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",
|
"antd": "^5.27.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
"react-router-dom": "^6.30.1"
|
"react-router-dom": "^6.30.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// API 配置
|
// API 配置
|
||||||
|
|
||||||
export const API_CONFIG = {
|
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,
|
TIMEOUT: 10000,
|
||||||
HEADERS: {
|
HEADERS: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -18,4 +18,14 @@ export const API_ENDPOINTS = {
|
|||||||
PEOPLE: '/people',
|
PEOPLE: '/people',
|
||||||
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
PEOPLE_BY_ID: (id: string) => `/people/${id}`,
|
||||||
PEOPLE_REMARK_BY_ID: (id: string) => `/people/${id}/remark`,
|
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;
|
} as const;
|
||||||
@@ -9,11 +9,13 @@ export * from './types';
|
|||||||
export * from './input';
|
export * from './input';
|
||||||
export * from './upload';
|
export * from './upload';
|
||||||
export * from './people';
|
export * from './people';
|
||||||
|
export * from './user';
|
||||||
|
|
||||||
// 默认导出所有API函数
|
// 默认导出所有API函数
|
||||||
import * as inputApi from './input';
|
import * as inputApi from './input';
|
||||||
import * as uploadApi from './upload';
|
import * as uploadApi from './upload';
|
||||||
import * as peopleApi from './people';
|
import * as peopleApi from './people';
|
||||||
|
import * as userApi from './user';
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// 文本输入相关
|
// 文本输入相关
|
||||||
@@ -24,6 +26,9 @@ export const api = {
|
|||||||
|
|
||||||
// 人员管理相关
|
// 人员管理相关
|
||||||
people: peopleApi,
|
people: peopleApi,
|
||||||
|
|
||||||
|
// 用户管理相关
|
||||||
|
user: userApi,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
@@ -60,6 +60,7 @@ export async function request<T = any>(
|
|||||||
method,
|
method,
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
|
credentials: 'include',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,3 +62,52 @@ export interface PaginatedResponse<T> {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: 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 TopBar from './TopBar.tsx';
|
||||||
import '../styles/base.css';
|
import '../styles/base.css';
|
||||||
import '../styles/layout.css';
|
import '../styles/layout.css';
|
||||||
|
import UserProfile from './UserProfile.tsx';
|
||||||
|
|
||||||
const LayoutWrapper: React.FC = () => {
|
const LayoutWrapper: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
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="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>菜单2的内容暂未实现</div>} />
|
||||||
|
<Route path="/user" element={<UserProfile />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</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 使用) */
|
/* 收起时图标水平居中(仅 PC 端 Sider 使用) */
|
||||||
.sider-header.collapsed { justify-content: center; }
|
.sider-header.collapsed { justify-content: center; }
|
||||||
|
|
||||||
|
.sider-header.collapsed .sider-title,
|
||||||
|
.sider-header.collapsed .sider-settings-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 移动端汉堡触发按钮位置 */
|
/* 移动端汉堡触发按钮位置 */
|
||||||
.mobile-menu-trigger {
|
.mobile-menu-trigger {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -53,3 +58,11 @@
|
|||||||
.mobile-menu-trigger:hover {
|
.mobile-menu-trigger:hover {
|
||||||
background: rgba(16,185,129,0.35);
|
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 React from 'react';
|
||||||
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
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 './SiderMenu.css';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|
||||||
// 新增:支持外部导航回调 + 受控选中态
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onNavigate?: (key: string) => void;
|
onNavigate?: (key: string) => void;
|
||||||
selectedKey?: string;
|
selectedKey?: string;
|
||||||
@@ -14,6 +17,10 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMobileToggle }) => {
|
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 screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const [collapsed, setCollapsed] = React.useState(false);
|
const [collapsed, setCollapsed] = React.useState(false);
|
||||||
@@ -36,7 +43,6 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
|||||||
setCollapsed(isMobile);
|
setCollapsed(isMobile);
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
// 根据外部 selectedKey 同步选中态
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (selectedKey) {
|
if (selectedKey) {
|
||||||
setSelectedKeys([selectedKey]);
|
setSelectedKeys([selectedKey]);
|
||||||
@@ -49,11 +55,39 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
|||||||
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
|
{ 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) {
|
if (isMobile) {
|
||||||
const open = mobileOpen ?? internalMobileOpen;
|
const open = mobileOpen ?? internalMobileOpen;
|
||||||
const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
|
const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
|
||||||
const showInternalTrigger = !onMobileToggle; // 若无外部控制,则显示内部按钮
|
const showInternalTrigger = !onMobileToggle;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showInternalTrigger && (
|
{showInternalTrigger && (
|
||||||
@@ -73,13 +107,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
|||||||
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
|
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
|
||||||
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
|
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
|
||||||
>
|
>
|
||||||
<div className="sider-header">
|
{renderSiderHeader({ setOpen })}
|
||||||
<HeartOutlined style={{ fontSize: 22 }} />
|
|
||||||
<div>
|
|
||||||
<div className="sider-title">单身管理</div>
|
|
||||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
@@ -87,36 +115,36 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
|||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
const k = String(key);
|
const k = String(key);
|
||||||
setSelectedKeys([k]);
|
setSelectedKeys([k]);
|
||||||
setOpen(false); // 选择后自动收起
|
setOpen(false);
|
||||||
onNavigate?.(k);
|
onNavigate?.(k);
|
||||||
}}
|
}}
|
||||||
items={items}
|
items={items}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</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 (
|
return (
|
||||||
<Sider
|
<Sider
|
||||||
width={260}
|
theme="dark"
|
||||||
collapsible
|
collapsible
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onCollapse={(c) => setCollapsed(c)}
|
onCollapse={(value) => setCollapsed(value)}
|
||||||
breakpoint="md"
|
className="sider-menu"
|
||||||
collapsedWidth={64}
|
width={240}
|
||||||
theme="dark"
|
|
||||||
>
|
>
|
||||||
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
|
{renderSiderHeader({ collapsed })}
|
||||||
<HeartOutlined style={{ fontSize: 22 }} />
|
|
||||||
{!collapsed && (
|
|
||||||
<div>
|
|
||||||
<div className="sider-title">单身管理</div>
|
|
||||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Menu
|
<Menu
|
||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
@@ -128,6 +156,16 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
|||||||
}}
|
}}
|
||||||
items={items}
|
items={items}
|
||||||
/>
|
/>
|
||||||
|
<LoginModal
|
||||||
|
open={isLoginModalOpen}
|
||||||
|
onCancel={() => setIsLoginModalOpen(false)}
|
||||||
|
onOk={async (values) => {
|
||||||
|
await login(values);
|
||||||
|
setIsLoginModalOpen(false);
|
||||||
|
}}
|
||||||
|
title="登录"
|
||||||
|
/>
|
||||||
|
<RegisterModal open={isRegisterModalOpen} onCancel={() => setIsRegisterModalOpen(false)} />
|
||||||
</Sider>
|
</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 'antd/dist/reset.css'
|
||||||
import './styles/base.css'
|
import './styles/base.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
@@ -14,7 +15,9 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
v7_startTransition: true,
|
v7_startTransition: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
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()],
|
plugins: [react()],
|
||||||
// Vite 默认支持环境变量,无需额外配置
|
// Vite 默认支持环境变量,无需额外配置
|
||||||
// 环境变量加载优先级:
|
// 环境变量加载优先级:
|
||||||
// .env.local > .env.[mode] > .env
|
// .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