diff --git a/.env b/.env index 26c53db..367975f 100644 --- a/.env +++ b/.env @@ -1,2 +1,5 @@ # 开发环境配置 -VITE_API_BASE_URL=http://127.0.0.1:8099 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/.env.production b/.env.production index 5cfb9c3..1e04027 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,2 @@ # 生产环境配置 -VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443 \ No newline at end of file +VITE_API_BASE_URL=https://if.u.mamamiyear.site:20443/api \ No newline at end of file diff --git a/package.json b/package.json index 2e73cd3..6f41f4f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/apis/config.ts b/src/apis/config.ts index 343fe5d..c1b48fb 100644 --- a/src/apis/config.ts +++ b/src/apis/config.ts @@ -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; \ No newline at end of file diff --git a/src/apis/index.ts b/src/apis/index.ts index b04df0c..2ec4549 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -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; \ No newline at end of file diff --git a/src/apis/request.ts b/src/apis/request.ts index 141f41c..697978d 100644 --- a/src/apis/request.ts +++ b/src/apis/request.ts @@ -60,6 +60,7 @@ export async function request( method, headers: requestHeaders, body: requestBody, + credentials: 'include', signal: controller.signal, }); diff --git a/src/apis/types.ts b/src/apis/types.ts index 1792a8d..8fe0c05 100644 --- a/src/apis/types.ts +++ b/src/apis/types.ts @@ -61,4 +61,53 @@ export interface PaginatedResponse { 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; } \ No newline at end of file diff --git a/src/apis/user.ts b/src/apis/user.ts new file mode 100644 index 0000000..e5867e8 --- /dev/null +++ b/src/apis/user.ts @@ -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 + */ +export async function sendCode(data: SendCodeRequest): Promise { + return post(API_ENDPOINTS.SEND_CODE, data); +} + +/** + * 用户注册 + * @param data 注册信息 + * @returns Promise + */ +export async function register(data: RegisterRequest): Promise { + return post(API_ENDPOINTS.REGISTER, data); +} + +/** + * 用户登录 + * @param data 登录信息 + * @returns Promise> + */ +export async function login(data: LoginRequest): Promise> { + return post>(API_ENDPOINTS.LOGIN, data); +} + +/** + * 用户登出 + * @returns Promise + */ +export async function logout(): Promise { + return del(API_ENDPOINTS.LOGOUT); +} + +/** + * 获取当前用户信息 + * @returns Promise> + */ +export async function getMe(): Promise> { + return get>(API_ENDPOINTS.ME); +} + +/** + * 更新用户信息 + * @param data 要更新的用户信息 + * @returns Promise> + */ +export async function updateMe(data: UpdateUserRequest): Promise> { + return put>(API_ENDPOINTS.ME, data); +} + +/** + * 用户注销 + * @returns Promise + */ +export async function deleteUser(): Promise { + return del(API_ENDPOINTS.DELETE_USER); +} + +/** + * 更新用户头像 + * @param data 要更新的用户头像 + * @returns Promise> + */ +export async function uploadAvatar(data: FormData): Promise> { + return put>(API_ENDPOINTS.AVATAR, data); +} + +export async function updatePhone(data: UpdatePhoneRequest): Promise> { + return put>(API_ENDPOINTS.UPDATE_PHONE, data); +} + +export async function updateEmail(data: UpdateEmailRequest): Promise> { + return put>(API_ENDPOINTS.UPDATE_EMAIL, data); +} \ No newline at end of file diff --git a/src/components/LayoutWrapper.tsx b/src/components/LayoutWrapper.tsx index 57c452a..10b6bee 100644 --- a/src/components/LayoutWrapper.tsx +++ b/src/components/LayoutWrapper.tsx @@ -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 = () => { } /> 菜单2的内容暂未实现} /> + } /> diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx new file mode 100644 index 0000000..9cae5c6 --- /dev/null +++ b/src/components/LoginModal.tsx @@ -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; + title: string; + username?: string; + usernameReadOnly?: boolean; + okText?: string; + hideSuccessMessage?: boolean; +} + +const LoginModal: React.FC = ({ 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 ( + + 取消 + , + , + ]} + > +
+ + + + + + +
+
+ ); +}; + +export default LoginModal; \ No newline at end of file diff --git a/src/components/RegisterModal.tsx b/src/components/RegisterModal.tsx new file mode 100644 index 0000000..1414436 --- /dev/null +++ b/src/components/RegisterModal.tsx @@ -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 = ({ open, onCancel }) => { + const [form] = Form.useForm(); + const { register, sendCode } = useAuth(); + const [step, setStep] = useState('register'); // 'register' | 'verify' + const [registerPayload, setRegisterPayload] = useState | 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 ( + +
+ {step === 'register' ? ( + <> + + + + + + + + + + + + + + + + + ) : ( + <> + + + + + + + + )} +
+
+ ); +}; + +export default RegisterModal; \ No newline at end of file diff --git a/src/components/SiderMenu.css b/src/components/SiderMenu.css index a304fec..d01263d 100644 --- a/src/components/SiderMenu.css +++ b/src/components/SiderMenu.css @@ -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); -} \ No newline at end of file +} +.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); } \ No newline at end of file diff --git a/src/components/SiderMenu.tsx b/src/components/SiderMenu.tsx index d274be3..6372716 100644 --- a/src/components/SiderMenu.tsx +++ b/src/components/SiderMenu.tsx @@ -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 = ({ 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 = ({ onNavigate, selectedKey, mobileOpen, onMob setCollapsed(isMobile); }, [isMobile]); - // 根据外部 selectedKey 同步选中态 React.useEffect(() => { if (selectedKey) { setSelectedKeys([selectedKey]); @@ -49,11 +55,39 @@ const SiderMenu: React.FC = ({ onNavigate, selectedKey, mobileOpen, onMob { key: 'menu1', label: '资源列表', icon: }, ]; - // 移动端:使用 Drawer 覆盖主内容 + const renderSiderHeader = (options?: { setOpen?: (v: boolean) => void; collapsed?: boolean }) => ( +
+ {isAuthenticated && user ? ( + <> +
+
+
+ {user.avatar_link ? ( + avatar + ) : ( + + )} +
+
+
{user.nickname}
+
+ + + ) : ( + <> + + + + )} +
+ ); + 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 = ({ onNavigate, selectedKey, mobileOpen, onMob rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }} styles={{ body: { padding: 0 }, header: { display: 'none' } }} > -
- -
-
单身管理
-
录入、展示与搜索你的单身资源
-
-
+ {renderSiderHeader({ setOpen })} = ({ onNavigate, selectedKey, mobileOpen, onMob onClick={({ key }) => { const k = String(key); setSelectedKeys([k]); - setOpen(false); // 选择后自动收起 + setOpen(false); onNavigate?.(k); }} items={items} /> + setIsLoginModalOpen(false)} + onOk={async (values) => { + await login(values); + setIsLoginModalOpen(false); + }} + title="登录" + /> + setIsRegisterModalOpen(false)} /> ); } - // PC 端:保持 Sider 行为不变 return ( setCollapsed(c)} - breakpoint="md" - collapsedWidth={64} - theme="dark" + onCollapse={(value) => setCollapsed(value)} + className="sider-menu" + width={240} > -
- - {!collapsed && ( -
-
单身管理
-
录入、展示与搜索你的单身资源
-
- )} -
+ {renderSiderHeader({ collapsed })} = ({ onNavigate, selectedKey, mobileOpen, onMob }} items={items} /> + setIsLoginModalOpen(false)} + onOk={async (values) => { + await login(values); + setIsLoginModalOpen(false); + }} + title="登录" + /> + setIsRegisterModalOpen(false)} /> ); }; diff --git a/src/components/UserProfile.tsx b/src/components/UserProfile.tsx new file mode 100644 index 0000000..7efcf46 --- /dev/null +++ b/src/components/UserProfile.tsx @@ -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() + const [completedCrop, setCompletedCrop] = React.useState() + const [modalVisible, setModalVisible] = React.useState(false) + const [uploading, setUploading] = React.useState(false) + const imgRef = React.useRef(null) + const previewCanvasRef = React.useRef(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 = { + 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 = {} + 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) { + 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 ( +
+ +
+ + {user?.avatar_link ? ( + avatar + ) : ( +
+ +
+ )} +
+

点击更换头像

+
+
+ + + + + } onClick={() => onEditClick('phone')}>编辑} + /> + + + } onClick={() => onEditClick('email')}>编辑} + /> + +
+ + + + + +
+ setModalVisible(false)} + okText="保存" + cancelText="取消" + confirmLoading={uploading} + maskClosable={!uploading} + closable={!uploading} + > + {imgSrc && ( + setCrop(c)} + onComplete={c => setCompletedCrop(c)} + aspect={1} + circularCrop + > + Crop me + + )} + + setEditVisible(false)} + footer={null} + confirmLoading={editLoading} + maskClosable={!editLoading} + closable={!editLoading} + > +
+ {editStep === 'input' ? ( + <> + {editType === 'phone' ? ( + + + + ) : ( + + + + )} + + + + + ) : ( + <> + + + + + + + + )} +
+
+ + 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('登录或注销失败'); + } + }} + /> +
+ ) +} + +export default UserProfile \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..697e37f --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -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; + logout: () => Promise; + clearLocalSession: () => void; + register: (data: RegisterRequest) => Promise; + sendCode: (data: SendCodeRequest) => Promise; + refreshUser: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [user, setUser] = useState(null); + const [token, setToken] = useState(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 {children}; +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 4ac1105..9b1aca5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( @@ -14,7 +15,9 @@ createRoot(document.getElementById('root')!).render( v7_startTransition: true, }} > - + + + , ) diff --git a/vite.config.ts b/vite.config.ts index f1d41c6..724d882 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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, + }, + }, + }, + } })