feat: support user management
- sign up and delete account with email or phone - sign in and sign out - update nickname and avatar - update account phone or email
This commit is contained in:
5
.env
5
.env
@@ -1,2 +1,5 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8099
|
||||
# VITE_API_BASE_URL=http://localhost:8099/api
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_HTTPS_KEY_PATH=./certs/localhost-key.pem
|
||||
VITE_HTTPS_CERT_PATH=./certs/localhost.pem
|
||||
@@ -1,2 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443
|
||||
VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443/api
|
||||
@@ -15,6 +15,7 @@
|
||||
"antd": "^5.27.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// API 配置
|
||||
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8099',
|
||||
BASE_URL: import.meta.env.VITE_API_BASE_URL,
|
||||
TIMEOUT: 10000,
|
||||
HEADERS: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -18,4 +18,14 @@ export const API_ENDPOINTS = {
|
||||
PEOPLE: '/people',
|
||||
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
||||
PEOPLE_REMARK_BY_ID: (id: string) => `/people/${id}/remark`,
|
||||
// 用户相关
|
||||
SEND_CODE: '/user/send_code',
|
||||
REGISTER: '/user',
|
||||
LOGIN: '/user/login',
|
||||
LOGOUT: '/user/me/login',
|
||||
ME: '/user/me',
|
||||
AVATAR: '/user/me/avatar',
|
||||
DELETE_USER: '/user/me',
|
||||
UPDATE_PHONE: '/user/me/phone',
|
||||
UPDATE_EMAIL: '/user/me/email',
|
||||
} as const;
|
||||
@@ -9,11 +9,13 @@ export * from './types';
|
||||
export * from './input';
|
||||
export * from './upload';
|
||||
export * from './people';
|
||||
export * from './user';
|
||||
|
||||
// 默认导出所有API函数
|
||||
import * as inputApi from './input';
|
||||
import * as uploadApi from './upload';
|
||||
import * as peopleApi from './people';
|
||||
import * as userApi from './user';
|
||||
|
||||
export const api = {
|
||||
// 文本输入相关
|
||||
@@ -24,6 +26,9 @@ export const api = {
|
||||
|
||||
// 人员管理相关
|
||||
people: peopleApi,
|
||||
|
||||
// 用户管理相关
|
||||
user: userApi,
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -60,6 +60,7 @@ export async function request<T = any>(
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: requestBody,
|
||||
credentials: 'include',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -61,4 +61,53 @@ export interface PaginatedResponse<T> {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// 用户相关类型
|
||||
|
||||
export interface SendCodeRequest {
|
||||
target_type: 'phone' | 'email';
|
||||
target: string;
|
||||
scene: 'register' | 'update';
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
nickname?: string;
|
||||
avatar_link?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
created_at: string;
|
||||
nickname: string;
|
||||
avatar_link?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
nickname?: string;
|
||||
avatar_link?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePhoneRequest {
|
||||
phone: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmailRequest {
|
||||
email: string;
|
||||
code: string;
|
||||
}
|
||||
81
src/apis/user.ts
Normal file
81
src/apis/user.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { get, post, put, del } from './request';
|
||||
import { API_ENDPOINTS } from './config';
|
||||
import type { SendCodeRequest, RegisterRequest, LoginRequest, User, ApiResponse, UpdateUserRequest, UpdatePhoneRequest, UpdateEmailRequest } from './types';
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* @param phone 手机号
|
||||
* @param usage 用途
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function sendCode(data: SendCodeRequest): Promise<ApiResponse> {
|
||||
return post<ApiResponse>(API_ENDPOINTS.SEND_CODE, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* @param data 注册信息
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function register(data: RegisterRequest): Promise<ApiResponse> {
|
||||
return post<ApiResponse>(API_ENDPOINTS.REGISTER, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param data 登录信息
|
||||
* @returns Promise<ApiResponse<{token: string}>>
|
||||
*/
|
||||
export async function login(data: LoginRequest): Promise<ApiResponse<{token: string}>> {
|
||||
return post<ApiResponse<{token: string}>>(API_ENDPOINTS.LOGIN, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function logout(): Promise<ApiResponse> {
|
||||
return del<ApiResponse>(API_ENDPOINTS.LOGOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* @returns Promise<ApiResponse<User>>
|
||||
*/
|
||||
export async function getMe(): Promise<ApiResponse<User>> {
|
||||
return get<ApiResponse<User>>(API_ENDPOINTS.ME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
* @param data 要更新的用户信息
|
||||
* @returns Promise<ApiResponse<User>>
|
||||
*/
|
||||
export async function updateMe(data: UpdateUserRequest): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.ME, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注销
|
||||
* @returns Promise<ApiResponse>
|
||||
*/
|
||||
export async function deleteUser(): Promise<ApiResponse> {
|
||||
return del<ApiResponse>(API_ENDPOINTS.DELETE_USER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户头像
|
||||
* @param data 要更新的用户头像
|
||||
* @returns Promise<ApiResponse<User>>
|
||||
*/
|
||||
export async function uploadAvatar(data: FormData): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.AVATAR, data);
|
||||
}
|
||||
|
||||
export async function updatePhone(data: UpdatePhoneRequest): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.UPDATE_PHONE, data);
|
||||
}
|
||||
|
||||
export async function updateEmail(data: UpdateEmailRequest): Promise<ApiResponse<User>> {
|
||||
return put<ApiResponse<User>>(API_ENDPOINTS.UPDATE_EMAIL, data);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import BatchRegister from './BatchRegister.tsx';
|
||||
import TopBar from './TopBar.tsx';
|
||||
import '../styles/base.css';
|
||||
import '../styles/layout.css';
|
||||
import UserProfile from './UserProfile.tsx';
|
||||
|
||||
const LayoutWrapper: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -105,6 +106,7 @@ const LayoutWrapper: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>菜单2的内容暂未实现</div>} />
|
||||
<Route path="/user" element={<UserProfile />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
77
src/components/LoginModal.tsx
Normal file
77
src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, Button, message } from 'antd';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onOk: (values: any) => Promise<void>;
|
||||
title: string;
|
||||
username?: string;
|
||||
usernameReadOnly?: boolean;
|
||||
okText?: string;
|
||||
hideSuccessMessage?: boolean;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<Props> = ({ open, onCancel, onOk, title, username, usernameReadOnly, okText, hideSuccessMessage }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.setFieldsValue({ username });
|
||||
}
|
||||
}, [open, username, form]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const username: string = values.username;
|
||||
const password: string = values.password;
|
||||
const isEmail = /\S+@\S+\.\S+/.test(username);
|
||||
const payload = isEmail
|
||||
? { email: username, password }
|
||||
: { phone: username, password };
|
||||
await onOk(payload as any);
|
||||
if (!hideSuccessMessage) {
|
||||
message.success('登录成功');
|
||||
}
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
message.error('登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleLogin}>
|
||||
{okText || '登录'}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="手机号 / 邮箱"
|
||||
rules={[{ required: true, message: '请输入手机号 / 邮箱' }]}
|
||||
>
|
||||
<Input readOnly={usernameReadOnly} style={{ backgroundColor: usernameReadOnly ? '#f5f5f5' : '' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
112
src/components/RegisterModal.tsx
Normal file
112
src/components/RegisterModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, Button, message } from 'antd';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { RegisterRequest } from '../apis/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const RegisterModal: React.FC<Props> = ({ open, onCancel }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { register, sendCode } = useAuth();
|
||||
const [step, setStep] = useState('register'); // 'register' | 'verify'
|
||||
const [registerPayload, setRegisterPayload] = useState<Partial<RegisterRequest> | null>(null);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const values = await form.validateFields(['phone', 'email', 'nickname', 'password']);
|
||||
if (!values.phone && !values.email) {
|
||||
message.error('手机号和邮箱至少填写一个');
|
||||
return;
|
||||
}
|
||||
setRegisterPayload(values);
|
||||
|
||||
const target_type = values.phone ? 'phone' : 'email';
|
||||
const target = values.phone || values.email;
|
||||
|
||||
await sendCode({ target_type, target, scene: 'register' });
|
||||
message.success('验证码已发送');
|
||||
setStep('verify');
|
||||
} catch (error) {
|
||||
message.error('发送验证码失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await register({ ...registerPayload, ...values } as RegisterRequest);
|
||||
message.success('注册成功');
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
message.error('注册失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="注册"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{step === 'register' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSendCode} block>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleRegister} block>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterModal;
|
||||
@@ -1,9 +1,14 @@
|
||||
/* 侧边菜单组件局部样式 */
|
||||
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); color: var(--text-invert); }
|
||||
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); color: var(--text-invert); display: flex; align-items: center; justify-content: space-between; padding: 12px; }
|
||||
|
||||
/* 收起时图标水平居中(仅 PC 端 Sider 使用) */
|
||||
.sider-header.collapsed { justify-content: center; }
|
||||
|
||||
.sider-header.collapsed .sider-title,
|
||||
.sider-header.collapsed .sider-settings-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端汉堡触发按钮位置 */
|
||||
.mobile-menu-trigger {
|
||||
position: fixed;
|
||||
@@ -52,4 +57,12 @@
|
||||
}
|
||||
.mobile-menu-trigger:hover {
|
||||
background: rgba(16,185,129,0.35);
|
||||
}
|
||||
}
|
||||
.sider-user { display: inline-flex; align-items: center; gap: 12px; }
|
||||
.sider-avatar-container { display: inline-flex; align-items: center; justify-content: center; }
|
||||
.sider-avatar-frame { width: 40px; height: 40px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.6); box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; background: #ffffff; }
|
||||
.sider-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; }
|
||||
.sider-avatar-icon { font-size: 20px; color: #64748b; }
|
||||
.sider-title { font-weight: 600; color: #e5e7eb; }
|
||||
.sider-settings-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; color: #e5e7eb; background: transparent; border: none; cursor: pointer; }
|
||||
.sider-settings-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
@@ -1,11 +1,14 @@
|
||||
import LoginModal from './LoginModal';
|
||||
import RegisterModal from './RegisterModal';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import React from 'react';
|
||||
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
||||
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined, UserOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import './SiderMenu.css';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
// 新增:支持外部导航回调 + 受控选中态
|
||||
type Props = {
|
||||
onNavigate?: (key: string) => void;
|
||||
selectedKey?: string;
|
||||
@@ -14,6 +17,10 @@ type Props = {
|
||||
};
|
||||
|
||||
const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMobileToggle }) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = React.useState(false);
|
||||
const [isRegisterModalOpen, setIsRegisterModalOpen] = React.useState(false);
|
||||
const { isAuthenticated, user, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
@@ -36,7 +43,6 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
setCollapsed(isMobile);
|
||||
}, [isMobile]);
|
||||
|
||||
// 根据外部 selectedKey 同步选中态
|
||||
React.useEffect(() => {
|
||||
if (selectedKey) {
|
||||
setSelectedKeys([selectedKey]);
|
||||
@@ -49,11 +55,39 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
|
||||
];
|
||||
|
||||
// 移动端:使用 Drawer 覆盖主内容
|
||||
const renderSiderHeader = (options?: { setOpen?: (v: boolean) => void; collapsed?: boolean }) => (
|
||||
<div className={`sider-header ${options?.collapsed ? 'collapsed' : ''}`}>
|
||||
{isAuthenticated && user ? (
|
||||
<>
|
||||
<div className="sider-user">
|
||||
<div className="sider-avatar-container">
|
||||
<div className="sider-avatar-frame">
|
||||
{user.avatar_link ? (
|
||||
<img src={user.avatar_link} alt="avatar" className="sider-avatar" />
|
||||
) : (
|
||||
<UserOutlined className="sider-avatar-icon" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sider-title">{user.nickname}</div>
|
||||
</div>
|
||||
<button type="button" className="sider-settings-btn" aria-label="设置" onClick={() => { navigate('/user'); options?.setOpen?.(false); }}>
|
||||
<SettingOutlined />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="primary" style={{ marginRight: 8 }} onClick={() => setIsLoginModalOpen(true)}>登录</Button>
|
||||
<Button onClick={() => setIsRegisterModalOpen(true)}>注册</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
const open = mobileOpen ?? internalMobileOpen;
|
||||
const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
|
||||
const showInternalTrigger = !onMobileToggle; // 若无外部控制,则显示内部按钮
|
||||
const showInternalTrigger = !onMobileToggle;
|
||||
return (
|
||||
<>
|
||||
{showInternalTrigger && (
|
||||
@@ -73,13 +107,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
|
||||
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
|
||||
>
|
||||
<div className="sider-header">
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderSiderHeader({ setOpen })}
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
@@ -87,36 +115,36 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
onClick={({ key }) => {
|
||||
const k = String(key);
|
||||
setSelectedKeys([k]);
|
||||
setOpen(false); // 选择后自动收起
|
||||
setOpen(false);
|
||||
onNavigate?.(k);
|
||||
}}
|
||||
items={items}
|
||||
/>
|
||||
</Drawer>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onCancel={() => setIsLoginModalOpen(false)}
|
||||
onOk={async (values) => {
|
||||
await login(values);
|
||||
setIsLoginModalOpen(false);
|
||||
}}
|
||||
title="登录"
|
||||
/>
|
||||
<RegisterModal open={isRegisterModalOpen} onCancel={() => setIsRegisterModalOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// PC 端:保持 Sider 行为不变
|
||||
return (
|
||||
<Sider
|
||||
width={260}
|
||||
theme="dark"
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={(c) => setCollapsed(c)}
|
||||
breakpoint="md"
|
||||
collapsedWidth={64}
|
||||
theme="dark"
|
||||
onCollapse={(value) => setCollapsed(value)}
|
||||
className="sider-menu"
|
||||
width={240}
|
||||
>
|
||||
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSiderHeader({ collapsed })}
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
@@ -128,6 +156,16 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
}}
|
||||
items={items}
|
||||
/>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onCancel={() => setIsLoginModalOpen(false)}
|
||||
onOk={async (values) => {
|
||||
await login(values);
|
||||
setIsLoginModalOpen(false);
|
||||
}}
|
||||
title="登录"
|
||||
/>
|
||||
<RegisterModal open={isRegisterModalOpen} onCancel={() => setIsRegisterModalOpen(false)} />
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
418
src/components/UserProfile.tsx
Normal file
418
src/components/UserProfile.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React from 'react'
|
||||
import { Form, Input, Button, message, Card, Space, Upload, Modal } from 'antd'
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { updateMe, deleteUser, uploadAvatar, updatePhone, updateEmail } from '../apis'
|
||||
import { UserOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import LoginModal from './LoginModal';
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
function canvasPreview(
|
||||
image: HTMLImageElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
crop: Crop,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('No 2d context')
|
||||
}
|
||||
|
||||
const scaleX = image.naturalWidth / image.width
|
||||
const scaleY = image.naturalHeight / image.height
|
||||
const pixelRatio = window.devicePixelRatio
|
||||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio)
|
||||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio)
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio)
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
|
||||
const cropX = crop.x * scaleX
|
||||
const cropY = crop.y * scaleY
|
||||
|
||||
const centerX = image.naturalWidth / 2
|
||||
const centerY = image.naturalHeight / 2
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(-cropX, -cropY)
|
||||
ctx.translate(centerX, centerY)
|
||||
ctx.translate(-centerX, -centerY)
|
||||
ctx.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
)
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const UserProfile: React.FC = () => {
|
||||
const { user, logout, refreshUser, sendCode, login, clearLocalSession } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [imgSrc, setImgSrc] = React.useState('')
|
||||
const [crop, setCrop] = React.useState<Crop>()
|
||||
const [completedCrop, setCompletedCrop] = React.useState<Crop>()
|
||||
const [modalVisible, setModalVisible] = React.useState(false)
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const imgRef = React.useRef<HTMLImageElement>(null)
|
||||
const previewCanvasRef = React.useRef<HTMLCanvasElement>(null)
|
||||
const [editVisible, setEditVisible] = React.useState(false)
|
||||
const [editType, setEditType] = React.useState<'phone' | 'email' | null>(null)
|
||||
const [editStep, setEditStep] = React.useState<'input' | 'verify'>('input')
|
||||
const [editLoading, setEditLoading] = React.useState(false)
|
||||
const [editForm] = Form.useForm()
|
||||
const [reauthVisible, setReauthVisible] = React.useState(false)
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
body: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
nickname: user?.nickname || '',
|
||||
avatar_link: user?.avatar_link || '',
|
||||
phone: user?.phone || '',
|
||||
email: user?.email || '',
|
||||
})
|
||||
}, [user, form])
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (values.nickname !== user?.nickname) payload.nickname = values.nickname
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
message.info('没有需要保存的变更')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const res = await updateMe(payload)
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('已保存')
|
||||
} else {
|
||||
message.error(res.error_info || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
navigate('/')
|
||||
} catch {
|
||||
message.error('登出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async () => {
|
||||
Modal.confirm({
|
||||
title: '确定注销吗?',
|
||||
content: '注销后所有数据均会被清理,且无法找回',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
setReauthVisible(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onSelectFile = (file: File) => {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
setImgSrc(reader.result?.toString() || '')
|
||||
setModalVisible(true)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const { width, height } = e.currentTarget
|
||||
const crop = centerCrop(
|
||||
makeAspectCrop(
|
||||
{
|
||||
unit: '%',
|
||||
width: 90,
|
||||
},
|
||||
1,
|
||||
width,
|
||||
height,
|
||||
),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
setCrop(crop)
|
||||
}
|
||||
|
||||
const uploadBlob = async (blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
message.error('图片处理失败')
|
||||
setUploading(false)
|
||||
setModalVisible(false)
|
||||
return
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', new File([blob], 'avatar.png', { type: 'image/png' }))
|
||||
try {
|
||||
const res = await uploadAvatar(formData)
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('头像更新成功')
|
||||
} else {
|
||||
message.error(res.error_info || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
refreshUser()
|
||||
setUploading(false)
|
||||
setModalVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
setUploading(true)
|
||||
if (completedCrop && previewCanvasRef.current && imgRef.current) {
|
||||
canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop)
|
||||
const canvas = previewCanvasRef.current
|
||||
const MAX_SIZE = 128
|
||||
if (canvas.width > MAX_SIZE) {
|
||||
const resizeCanvas = document.createElement('canvas')
|
||||
const ctx = resizeCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
message.error('无法处理图片')
|
||||
setModalVisible(false)
|
||||
return
|
||||
}
|
||||
resizeCanvas.width = MAX_SIZE
|
||||
resizeCanvas.height = MAX_SIZE
|
||||
ctx.drawImage(canvas, 0, 0, MAX_SIZE, MAX_SIZE)
|
||||
resizeCanvas.toBlob((blob) => {
|
||||
uploadBlob(blob)
|
||||
}, 'image/png')
|
||||
} else {
|
||||
canvas.toBlob((blob) => {
|
||||
uploadBlob(blob)
|
||||
}, 'image/png')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onEditClick = (type: 'phone' | 'email') => {
|
||||
setEditType(type)
|
||||
setEditStep('input')
|
||||
setEditVisible(true)
|
||||
editForm.resetFields()
|
||||
}
|
||||
|
||||
const handleSendEditCode = async () => {
|
||||
try {
|
||||
const field = editType === 'phone' ? 'phone' : 'email'
|
||||
const values = await editForm.validateFields([field])
|
||||
const target = values[field]
|
||||
await sendCode({ target_type: field === 'phone' ? 'phone' : 'email', target, scene: 'update' })
|
||||
message.success('验证码已发送')
|
||||
setEditStep('verify')
|
||||
} catch {
|
||||
message.error('发送验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitEdit = async () => {
|
||||
try {
|
||||
setEditLoading(true)
|
||||
const phoneOrEmail = editType === 'phone' ? await editForm.validateFields(['phone', 'code']) : await editForm.validateFields(['email', 'code'])
|
||||
if (editType === 'phone') {
|
||||
const res = await updatePhone({ phone: phoneOrEmail.phone, code: phoneOrEmail.code })
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('手机号已更新')
|
||||
setEditVisible(false)
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败')
|
||||
}
|
||||
} else if (editType === 'email') {
|
||||
const res = await updateEmail({ email: phoneOrEmail.email, code: phoneOrEmail.code })
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('邮箱已更新')
|
||||
setEditVisible(false)
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('更新失败')
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card style={{ width: '100%' }} styles={styles}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Upload
|
||||
beforeUpload={onSelectFile}
|
||||
showUploadList={false}
|
||||
accept='image/*'
|
||||
>
|
||||
{user?.avatar_link ? (
|
||||
<img src={user.avatar_link} alt="avatar" style={{ width: 96, height: 96, borderRadius: '50%', objectFit: 'cover', cursor: 'pointer' }} />
|
||||
) : (
|
||||
<div style={{ width: 96, height: 96, borderRadius: '50%', backgroundColor: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<UserOutlined style={{ fontSize: 48, color: '#999' }} />
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
<p style={{ fontSize: 12, color: '#999', textAlign: 'center', marginTop: 8 }}>点击更换头像</p>
|
||||
</div>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="nickname" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input
|
||||
readOnly
|
||||
style={{ width: '100%', backgroundColor: '#f5f5f5' }}
|
||||
suffix={<Button type="text" icon={<EditOutlined />} onClick={() => onEditClick('phone')}>编辑</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input
|
||||
readOnly
|
||||
style={{ width: '100%', backgroundColor: '#f5f5f5' }}
|
||||
suffix={<Button type="text" icon={<EditOutlined />} onClick={() => onEditClick('email')}>编辑</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Button type="primary" onClick={onSave} loading={saving}>保存</Button>
|
||||
<Button onClick={onLogout}>登出</Button>
|
||||
<Button danger onClick={onDelete}>注销</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Modal
|
||||
title="裁剪头像"
|
||||
open={modalVisible}
|
||||
onOk={onOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={uploading}
|
||||
maskClosable={!uploading}
|
||||
closable={!uploading}
|
||||
>
|
||||
{imgSrc && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={c => setCrop(c)}
|
||||
onComplete={c => setCompletedCrop(c)}
|
||||
aspect={1}
|
||||
circularCrop
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imgSrc}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={editType === 'phone' ? '修改手机号' : editType === 'email' ? '修改邮箱' : ''}
|
||||
open={editVisible}
|
||||
onCancel={() => setEditVisible(false)}
|
||||
footer={null}
|
||||
confirmLoading={editLoading}
|
||||
maskClosable={!editLoading}
|
||||
closable={!editLoading}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
{editStep === 'input' ? (
|
||||
<>
|
||||
{editType === 'phone' ? (
|
||||
<Form.Item name="phone" label="新手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="email" label="新邮箱" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '请输入有效的邮箱地址' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSendEditCode} block>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSubmitEdit} block loading={editLoading}>
|
||||
提交
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
objectFit: 'contain',
|
||||
width: completedCrop?.width,
|
||||
height: completedCrop?.height,
|
||||
}}
|
||||
/>
|
||||
<LoginModal
|
||||
open={reauthVisible}
|
||||
onCancel={() => setReauthVisible(false)}
|
||||
title="请输入密码以确认注销"
|
||||
okText="注销"
|
||||
username={user?.phone || user?.email}
|
||||
usernameReadOnly
|
||||
hideSuccessMessage
|
||||
onOk={async (values) => {
|
||||
try {
|
||||
await login(values);
|
||||
const res = await deleteUser();
|
||||
if (res.error_code === 0) {
|
||||
clearLocalSession();
|
||||
message.success('账户已注销');
|
||||
navigate('/');
|
||||
} else {
|
||||
message.error(res.error_info || '注销失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('登录或注销失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfile
|
||||
106
src/contexts/AuthContext.tsx
Normal file
106
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { login as apiLogin, register as apiRegister, sendCode as apiSendCode, getMe as apiGetMe, logout as apiLogout } from '../apis';
|
||||
import type { LoginRequest, RegisterRequest, User, SendCodeRequest } from '../apis/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (data: LoginRequest) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearLocalSession: () => void;
|
||||
register: (data: RegisterRequest) => Promise<void>;
|
||||
sendCode: (data: SendCodeRequest) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
|
||||
useEffect(() => {
|
||||
const validateSession = async () => {
|
||||
try {
|
||||
const response = await apiGetMe();
|
||||
if (response.data) {
|
||||
setUser(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
};
|
||||
validateSession();
|
||||
}, [token]);
|
||||
|
||||
const login = async (data: LoginRequest) => {
|
||||
const response = await apiLogin(data);
|
||||
if (response.data?.token) {
|
||||
const newToken = response.data.token;
|
||||
setToken(newToken);
|
||||
localStorage.setItem('token', newToken);
|
||||
}
|
||||
try {
|
||||
const me = await apiGetMe();
|
||||
if (me.data) {
|
||||
setUser(me.data);
|
||||
}
|
||||
} catch (e) { void e; }
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await apiLogout();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const clearLocalSession = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const register = async (data: RegisterRequest) => {
|
||||
await apiRegister(data);
|
||||
// 注册后可以根据业务需求选择是否自动登录
|
||||
};
|
||||
|
||||
const sendCode = async (data: SendCodeRequest) => {
|
||||
await apiSendCode(data);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const me = await apiGetMe();
|
||||
if (me.data) {
|
||||
setUser(me.data);
|
||||
}
|
||||
} catch (e) { void e; }
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: !!token || !!user,
|
||||
login,
|
||||
logout,
|
||||
clearLocalSession,
|
||||
register,
|
||||
sendCode,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import 'antd/dist/reset.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './contexts/AuthContext.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
@@ -14,7 +15,9 @@ createRoot(document.getElementById('root')!).render(
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import fs from 'node:fs'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Vite 默认支持环境变量,无需额外配置
|
||||
// 环境变量加载优先级:
|
||||
// .env.local > .env.[mode] > .env
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const keyPath = env.VITE_HTTPS_KEY_PATH
|
||||
const certPath = env.VITE_HTTPS_CERT_PATH
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
// Vite 默认支持环境变量,无需额外配置
|
||||
// 环境变量加载优先级:
|
||||
// .env.local > .env.[mode] > .env
|
||||
server: {
|
||||
https: keyPath && certPath
|
||||
? {
|
||||
key: fs.readFileSync(keyPath),
|
||||
cert: fs.readFileSync(certPath),
|
||||
}
|
||||
: undefined,
|
||||
host: 'localhost',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8099',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user