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:
@@ -8,6 +8,7 @@ import BatchRegister from './BatchRegister.tsx';
|
||||
import TopBar from './TopBar.tsx';
|
||||
import '../styles/base.css';
|
||||
import '../styles/layout.css';
|
||||
import UserProfile from './UserProfile.tsx';
|
||||
|
||||
const LayoutWrapper: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -105,6 +106,7 @@ const LayoutWrapper: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="/menu2" element={<div style={{ padding: 32, color: '#cbd5e1' }}>菜单2的内容暂未实现</div>} />
|
||||
<Route path="/user" element={<UserProfile />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
77
src/components/LoginModal.tsx
Normal file
77
src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, Button, message } from 'antd';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onOk: (values: any) => Promise<void>;
|
||||
title: string;
|
||||
username?: string;
|
||||
usernameReadOnly?: boolean;
|
||||
okText?: string;
|
||||
hideSuccessMessage?: boolean;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<Props> = ({ open, onCancel, onOk, title, username, usernameReadOnly, okText, hideSuccessMessage }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.setFieldsValue({ username });
|
||||
}
|
||||
}, [open, username, form]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const username: string = values.username;
|
||||
const password: string = values.password;
|
||||
const isEmail = /\S+@\S+\.\S+/.test(username);
|
||||
const payload = isEmail
|
||||
? { email: username, password }
|
||||
: { phone: username, password };
|
||||
await onOk(payload as any);
|
||||
if (!hideSuccessMessage) {
|
||||
message.success('登录成功');
|
||||
}
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
message.error('登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleLogin}>
|
||||
{okText || '登录'}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="手机号 / 邮箱"
|
||||
rules={[{ required: true, message: '请输入手机号 / 邮箱' }]}
|
||||
>
|
||||
<Input readOnly={usernameReadOnly} style={{ backgroundColor: usernameReadOnly ? '#f5f5f5' : '' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
112
src/components/RegisterModal.tsx
Normal file
112
src/components/RegisterModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, Button, message } from 'antd';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { RegisterRequest } from '../apis/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const RegisterModal: React.FC<Props> = ({ open, onCancel }) => {
|
||||
const [form] = Form.useForm();
|
||||
const { register, sendCode } = useAuth();
|
||||
const [step, setStep] = useState('register'); // 'register' | 'verify'
|
||||
const [registerPayload, setRegisterPayload] = useState<Partial<RegisterRequest> | null>(null);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
try {
|
||||
const values = await form.validateFields(['phone', 'email', 'nickname', 'password']);
|
||||
if (!values.phone && !values.email) {
|
||||
message.error('手机号和邮箱至少填写一个');
|
||||
return;
|
||||
}
|
||||
setRegisterPayload(values);
|
||||
|
||||
const target_type = values.phone ? 'phone' : 'email';
|
||||
const target = values.phone || values.email;
|
||||
|
||||
await sendCode({ target_type, target, scene: 'register' });
|
||||
message.success('验证码已发送');
|
||||
setStep('verify');
|
||||
} catch (error) {
|
||||
message.error('发送验证码失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await register({ ...registerPayload, ...values } as RegisterRequest);
|
||||
message.success('注册成功');
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
message.error('注册失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="注册"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{step === 'register' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSendCode} block>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="验证码"
|
||||
rules={[{ required: true, message: '请输入验证码' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleRegister} block>
|
||||
注册
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterModal;
|
||||
@@ -1,9 +1,14 @@
|
||||
/* 侧边菜单组件局部样式 */
|
||||
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); color: var(--text-invert); }
|
||||
.sider-header { border-bottom: 1px solid rgba(255,255,255,0.08); color: var(--text-invert); display: flex; align-items: center; justify-content: space-between; padding: 12px; }
|
||||
|
||||
/* 收起时图标水平居中(仅 PC 端 Sider 使用) */
|
||||
.sider-header.collapsed { justify-content: center; }
|
||||
|
||||
.sider-header.collapsed .sider-title,
|
||||
.sider-header.collapsed .sider-settings-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端汉堡触发按钮位置 */
|
||||
.mobile-menu-trigger {
|
||||
position: fixed;
|
||||
@@ -52,4 +57,12 @@
|
||||
}
|
||||
.mobile-menu-trigger:hover {
|
||||
background: rgba(16,185,129,0.35);
|
||||
}
|
||||
}
|
||||
.sider-user { display: inline-flex; align-items: center; gap: 12px; }
|
||||
.sider-avatar-container { display: inline-flex; align-items: center; justify-content: center; }
|
||||
.sider-avatar-frame { width: 40px; height: 40px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.6); box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; background: #ffffff; }
|
||||
.sider-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; }
|
||||
.sider-avatar-icon { font-size: 20px; color: #64748b; }
|
||||
.sider-title { font-weight: 600; color: #e5e7eb; }
|
||||
.sider-settings-btn { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; color: #e5e7eb; background: transparent; border: none; cursor: pointer; }
|
||||
.sider-settings-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
@@ -1,11 +1,14 @@
|
||||
import LoginModal from './LoginModal';
|
||||
import RegisterModal from './RegisterModal';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import React from 'react';
|
||||
import { Layout, Menu, Grid, Drawer, Button } from 'antd';
|
||||
import { HeartOutlined, FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { FormOutlined, UnorderedListOutlined, MenuOutlined, CopyOutlined, UserOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import './SiderMenu.css';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
// 新增:支持外部导航回调 + 受控选中态
|
||||
type Props = {
|
||||
onNavigate?: (key: string) => void;
|
||||
selectedKey?: string;
|
||||
@@ -14,6 +17,10 @@ type Props = {
|
||||
};
|
||||
|
||||
const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMobileToggle }) => {
|
||||
const [isLoginModalOpen, setIsLoginModalOpen] = React.useState(false);
|
||||
const [isRegisterModalOpen, setIsRegisterModalOpen] = React.useState(false);
|
||||
const { isAuthenticated, user, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
@@ -36,7 +43,6 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
setCollapsed(isMobile);
|
||||
}, [isMobile]);
|
||||
|
||||
// 根据外部 selectedKey 同步选中态
|
||||
React.useEffect(() => {
|
||||
if (selectedKey) {
|
||||
setSelectedKeys([selectedKey]);
|
||||
@@ -49,11 +55,39 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
{ key: 'menu1', label: '资源列表', icon: <UnorderedListOutlined /> },
|
||||
];
|
||||
|
||||
// 移动端:使用 Drawer 覆盖主内容
|
||||
const renderSiderHeader = (options?: { setOpen?: (v: boolean) => void; collapsed?: boolean }) => (
|
||||
<div className={`sider-header ${options?.collapsed ? 'collapsed' : ''}`}>
|
||||
{isAuthenticated && user ? (
|
||||
<>
|
||||
<div className="sider-user">
|
||||
<div className="sider-avatar-container">
|
||||
<div className="sider-avatar-frame">
|
||||
{user.avatar_link ? (
|
||||
<img src={user.avatar_link} alt="avatar" className="sider-avatar" />
|
||||
) : (
|
||||
<UserOutlined className="sider-avatar-icon" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sider-title">{user.nickname}</div>
|
||||
</div>
|
||||
<button type="button" className="sider-settings-btn" aria-label="设置" onClick={() => { navigate('/user'); options?.setOpen?.(false); }}>
|
||||
<SettingOutlined />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="primary" style={{ marginRight: 8 }} onClick={() => setIsLoginModalOpen(true)}>登录</Button>
|
||||
<Button onClick={() => setIsRegisterModalOpen(true)}>注册</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
const open = mobileOpen ?? internalMobileOpen;
|
||||
const setOpen = (v: boolean) => (onMobileToggle ? onMobileToggle(v) : setInternalMobileOpen(v));
|
||||
const showInternalTrigger = !onMobileToggle; // 若无外部控制,则显示内部按钮
|
||||
const showInternalTrigger = !onMobileToggle;
|
||||
return (
|
||||
<>
|
||||
{showInternalTrigger && (
|
||||
@@ -73,13 +107,7 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
rootStyle={{ top: topbarHeight, height: `calc(100% - ${topbarHeight}px)` }}
|
||||
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
|
||||
>
|
||||
<div className="sider-header">
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderSiderHeader({ setOpen })}
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
@@ -87,36 +115,36 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
onClick={({ key }) => {
|
||||
const k = String(key);
|
||||
setSelectedKeys([k]);
|
||||
setOpen(false); // 选择后自动收起
|
||||
setOpen(false);
|
||||
onNavigate?.(k);
|
||||
}}
|
||||
items={items}
|
||||
/>
|
||||
</Drawer>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onCancel={() => setIsLoginModalOpen(false)}
|
||||
onOk={async (values) => {
|
||||
await login(values);
|
||||
setIsLoginModalOpen(false);
|
||||
}}
|
||||
title="登录"
|
||||
/>
|
||||
<RegisterModal open={isRegisterModalOpen} onCancel={() => setIsRegisterModalOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// PC 端:保持 Sider 行为不变
|
||||
return (
|
||||
<Sider
|
||||
width={260}
|
||||
theme="dark"
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={(c) => setCollapsed(c)}
|
||||
breakpoint="md"
|
||||
collapsedWidth={64}
|
||||
theme="dark"
|
||||
onCollapse={(value) => setCollapsed(value)}
|
||||
className="sider-menu"
|
||||
width={240}
|
||||
>
|
||||
<div className={`sider-header ${collapsed ? 'collapsed' : ''}`}>
|
||||
<HeartOutlined style={{ fontSize: 22 }} />
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div className="sider-title">单身管理</div>
|
||||
<div className="sider-desc">录入、展示与搜索你的单身资源</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderSiderHeader({ collapsed })}
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
@@ -128,6 +156,16 @@ const SiderMenu: React.FC<Props> = ({ onNavigate, selectedKey, mobileOpen, onMob
|
||||
}}
|
||||
items={items}
|
||||
/>
|
||||
<LoginModal
|
||||
open={isLoginModalOpen}
|
||||
onCancel={() => setIsLoginModalOpen(false)}
|
||||
onOk={async (values) => {
|
||||
await login(values);
|
||||
setIsLoginModalOpen(false);
|
||||
}}
|
||||
title="登录"
|
||||
/>
|
||||
<RegisterModal open={isRegisterModalOpen} onCancel={() => setIsRegisterModalOpen(false)} />
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
418
src/components/UserProfile.tsx
Normal file
418
src/components/UserProfile.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React from 'react'
|
||||
import { Form, Input, Button, message, Card, Space, Upload, Modal } from 'antd'
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { updateMe, deleteUser, uploadAvatar, updatePhone, updateEmail } from '../apis'
|
||||
import { UserOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import LoginModal from './LoginModal';
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
function canvasPreview(
|
||||
image: HTMLImageElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
crop: Crop,
|
||||
) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('No 2d context')
|
||||
}
|
||||
|
||||
const scaleX = image.naturalWidth / image.width
|
||||
const scaleY = image.naturalHeight / image.height
|
||||
const pixelRatio = window.devicePixelRatio
|
||||
canvas.width = Math.floor(crop.width * scaleX * pixelRatio)
|
||||
canvas.height = Math.floor(crop.height * scaleY * pixelRatio)
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio)
|
||||
ctx.imageSmoothingQuality = 'high'
|
||||
|
||||
const cropX = crop.x * scaleX
|
||||
const cropY = crop.y * scaleY
|
||||
|
||||
const centerX = image.naturalWidth / 2
|
||||
const centerY = image.naturalHeight / 2
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(-cropX, -cropY)
|
||||
ctx.translate(centerX, centerY)
|
||||
ctx.translate(-centerX, -centerY)
|
||||
ctx.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
0,
|
||||
0,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
)
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const UserProfile: React.FC = () => {
|
||||
const { user, logout, refreshUser, sendCode, login, clearLocalSession } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [imgSrc, setImgSrc] = React.useState('')
|
||||
const [crop, setCrop] = React.useState<Crop>()
|
||||
const [completedCrop, setCompletedCrop] = React.useState<Crop>()
|
||||
const [modalVisible, setModalVisible] = React.useState(false)
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const imgRef = React.useRef<HTMLImageElement>(null)
|
||||
const previewCanvasRef = React.useRef<HTMLCanvasElement>(null)
|
||||
const [editVisible, setEditVisible] = React.useState(false)
|
||||
const [editType, setEditType] = React.useState<'phone' | 'email' | null>(null)
|
||||
const [editStep, setEditStep] = React.useState<'input' | 'verify'>('input')
|
||||
const [editLoading, setEditLoading] = React.useState(false)
|
||||
const [editForm] = Form.useForm()
|
||||
const [reauthVisible, setReauthVisible] = React.useState(false)
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
body: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
nickname: user?.nickname || '',
|
||||
avatar_link: user?.avatar_link || '',
|
||||
phone: user?.phone || '',
|
||||
email: user?.email || '',
|
||||
})
|
||||
}, [user, form])
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (values.nickname !== user?.nickname) payload.nickname = values.nickname
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
message.info('没有需要保存的变更')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const res = await updateMe(payload)
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('已保存')
|
||||
} else {
|
||||
message.error(res.error_info || '保存失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
navigate('/')
|
||||
} catch {
|
||||
message.error('登出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async () => {
|
||||
Modal.confirm({
|
||||
title: '确定注销吗?',
|
||||
content: '注销后所有数据均会被清理,且无法找回',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
setReauthVisible(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onSelectFile = (file: File) => {
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => {
|
||||
setImgSrc(reader.result?.toString() || '')
|
||||
setModalVisible(true)
|
||||
})
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const { width, height } = e.currentTarget
|
||||
const crop = centerCrop(
|
||||
makeAspectCrop(
|
||||
{
|
||||
unit: '%',
|
||||
width: 90,
|
||||
},
|
||||
1,
|
||||
width,
|
||||
height,
|
||||
),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
setCrop(crop)
|
||||
}
|
||||
|
||||
const uploadBlob = async (blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
message.error('图片处理失败')
|
||||
setUploading(false)
|
||||
setModalVisible(false)
|
||||
return
|
||||
}
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', new File([blob], 'avatar.png', { type: 'image/png' }))
|
||||
try {
|
||||
const res = await uploadAvatar(formData)
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('头像更新成功')
|
||||
} else {
|
||||
message.error(res.error_info || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
refreshUser()
|
||||
setUploading(false)
|
||||
setModalVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onOk = async () => {
|
||||
setUploading(true)
|
||||
if (completedCrop && previewCanvasRef.current && imgRef.current) {
|
||||
canvasPreview(imgRef.current, previewCanvasRef.current, completedCrop)
|
||||
const canvas = previewCanvasRef.current
|
||||
const MAX_SIZE = 128
|
||||
if (canvas.width > MAX_SIZE) {
|
||||
const resizeCanvas = document.createElement('canvas')
|
||||
const ctx = resizeCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
message.error('无法处理图片')
|
||||
setModalVisible(false)
|
||||
return
|
||||
}
|
||||
resizeCanvas.width = MAX_SIZE
|
||||
resizeCanvas.height = MAX_SIZE
|
||||
ctx.drawImage(canvas, 0, 0, MAX_SIZE, MAX_SIZE)
|
||||
resizeCanvas.toBlob((blob) => {
|
||||
uploadBlob(blob)
|
||||
}, 'image/png')
|
||||
} else {
|
||||
canvas.toBlob((blob) => {
|
||||
uploadBlob(blob)
|
||||
}, 'image/png')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onEditClick = (type: 'phone' | 'email') => {
|
||||
setEditType(type)
|
||||
setEditStep('input')
|
||||
setEditVisible(true)
|
||||
editForm.resetFields()
|
||||
}
|
||||
|
||||
const handleSendEditCode = async () => {
|
||||
try {
|
||||
const field = editType === 'phone' ? 'phone' : 'email'
|
||||
const values = await editForm.validateFields([field])
|
||||
const target = values[field]
|
||||
await sendCode({ target_type: field === 'phone' ? 'phone' : 'email', target, scene: 'update' })
|
||||
message.success('验证码已发送')
|
||||
setEditStep('verify')
|
||||
} catch {
|
||||
message.error('发送验证码失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitEdit = async () => {
|
||||
try {
|
||||
setEditLoading(true)
|
||||
const phoneOrEmail = editType === 'phone' ? await editForm.validateFields(['phone', 'code']) : await editForm.validateFields(['email', 'code'])
|
||||
if (editType === 'phone') {
|
||||
const res = await updatePhone({ phone: phoneOrEmail.phone, code: phoneOrEmail.code })
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('手机号已更新')
|
||||
setEditVisible(false)
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败')
|
||||
}
|
||||
} else if (editType === 'email') {
|
||||
const res = await updateEmail({ email: phoneOrEmail.email, code: phoneOrEmail.code })
|
||||
if (res.error_code === 0) {
|
||||
await refreshUser()
|
||||
message.success('邮箱已更新')
|
||||
setEditVisible(false)
|
||||
} else {
|
||||
message.error(res.error_info || '更新失败')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('更新失败')
|
||||
} finally {
|
||||
setEditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card style={{ width: '100%' }} styles={styles}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Upload
|
||||
beforeUpload={onSelectFile}
|
||||
showUploadList={false}
|
||||
accept='image/*'
|
||||
>
|
||||
{user?.avatar_link ? (
|
||||
<img src={user.avatar_link} alt="avatar" style={{ width: 96, height: 96, borderRadius: '50%', objectFit: 'cover', cursor: 'pointer' }} />
|
||||
) : (
|
||||
<div style={{ width: 96, height: 96, borderRadius: '50%', backgroundColor: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<UserOutlined style={{ fontSize: 48, color: '#999' }} />
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
<p style={{ fontSize: 12, color: '#999', textAlign: 'center', marginTop: 8 }}>点击更换头像</p>
|
||||
</div>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="nickname" label="昵称" rules={[{ required: true, message: '请输入昵称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input
|
||||
readOnly
|
||||
style={{ width: '100%', backgroundColor: '#f5f5f5' }}
|
||||
suffix={<Button type="text" icon={<EditOutlined />} onClick={() => onEditClick('phone')}>编辑</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input
|
||||
readOnly
|
||||
style={{ width: '100%', backgroundColor: '#f5f5f5' }}
|
||||
suffix={<Button type="text" icon={<EditOutlined />} onClick={() => onEditClick('email')}>编辑</Button>}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Button type="primary" onClick={onSave} loading={saving}>保存</Button>
|
||||
<Button onClick={onLogout}>登出</Button>
|
||||
<Button danger onClick={onDelete}>注销</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Modal
|
||||
title="裁剪头像"
|
||||
open={modalVisible}
|
||||
onOk={onOk}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
confirmLoading={uploading}
|
||||
maskClosable={!uploading}
|
||||
closable={!uploading}
|
||||
>
|
||||
{imgSrc && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={c => setCrop(c)}
|
||||
onComplete={c => setCompletedCrop(c)}
|
||||
aspect={1}
|
||||
circularCrop
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt="Crop me"
|
||||
src={imgSrc}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={editType === 'phone' ? '修改手机号' : editType === 'email' ? '修改邮箱' : ''}
|
||||
open={editVisible}
|
||||
onCancel={() => setEditVisible(false)}
|
||||
footer={null}
|
||||
confirmLoading={editLoading}
|
||||
maskClosable={!editLoading}
|
||||
closable={!editLoading}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
{editStep === 'input' ? (
|
||||
<>
|
||||
{editType === 'phone' ? (
|
||||
<Form.Item name="phone" label="新手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="email" label="新邮箱" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '请输入有效的邮箱地址' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSendEditCode} block>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleSubmitEdit} block loading={editLoading}>
|
||||
提交
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
objectFit: 'contain',
|
||||
width: completedCrop?.width,
|
||||
height: completedCrop?.height,
|
||||
}}
|
||||
/>
|
||||
<LoginModal
|
||||
open={reauthVisible}
|
||||
onCancel={() => setReauthVisible(false)}
|
||||
title="请输入密码以确认注销"
|
||||
okText="注销"
|
||||
username={user?.phone || user?.email}
|
||||
usernameReadOnly
|
||||
hideSuccessMessage
|
||||
onOk={async (values) => {
|
||||
try {
|
||||
await login(values);
|
||||
const res = await deleteUser();
|
||||
if (res.error_code === 0) {
|
||||
clearLocalSession();
|
||||
message.success('账户已注销');
|
||||
navigate('/');
|
||||
} else {
|
||||
message.error(res.error_info || '注销失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('登录或注销失败');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfile
|
||||
Reference in New Issue
Block a user