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:
2025-11-18 20:53:08 +08:00
parent 83f8091e15
commit c923244a68
17 changed files with 982 additions and 40 deletions

5
.env
View File

@@ -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

View File

@@ -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

View File

@@ -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": {

View File

@@ -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;

View File

@@ -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;

View File

@@ -60,6 +60,7 @@ export async function request<T = any>(
method,
headers: requestHeaders,
body: requestBody,
credentials: 'include',
signal: controller.signal,
});

View File

@@ -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
View 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);
}

View File

@@ -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>

View 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;

View 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;

View File

@@ -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); }

View File

@@ -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>
);
};

View 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

View 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;
};

View File

@@ -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>,
)

View File

@@ -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,
},
},
},
}
})