480 lines
18 KiB
TypeScript
480 lines
18 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import {
|
||
Tabs,
|
||
Card,
|
||
Avatar,
|
||
Form,
|
||
Input,
|
||
Button,
|
||
Upload,
|
||
Image,
|
||
Space,
|
||
App as AntdApp,
|
||
Modal,
|
||
} from 'antd';
|
||
import type { TabsProps } from 'antd';
|
||
import { UserOutlined, UploadOutlined } from '@ant-design/icons';
|
||
import { authService } from '@/services/auth';
|
||
import { userService } from '@/services/user';
|
||
import { storageService } from '@/services/storage';
|
||
import type { UserInfo } from '@/types/user';
|
||
import type { UploadFile } from 'antd/es/upload';
|
||
import type { RcFile } from 'antd/es/upload';
|
||
import dayjs from 'dayjs';
|
||
import './UserInfoPage.css';
|
||
|
||
/**
|
||
* 计算使用天数
|
||
* @param createdAt 创建时间
|
||
* @returns 使用天数字符串,如 "365天" 或 "1年30天"
|
||
*/
|
||
const calculateUsageDays = (createdAt: Date | string): string => {
|
||
const created = dayjs(createdAt);
|
||
const now = dayjs();
|
||
const days = now.diff(created, 'day');
|
||
|
||
if (days < 365) {
|
||
return `${days}天`;
|
||
}
|
||
|
||
const years = Math.floor(days / 365);
|
||
const remainingDays = days % 365;
|
||
return `${years}年${remainingDays}天`;
|
||
};
|
||
|
||
const UserInfoPage = () => {
|
||
const { message: messageApi } = AntdApp.useApp();
|
||
const [user, setUser] = useState<UserInfo | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [editing, setEditing] = useState(false);
|
||
const [avatarFileList, setAvatarFileList] = useState<UploadFile[]>([]);
|
||
const [passwordForm] = Form.useForm();
|
||
const [infoForm] = Form.useForm();
|
||
const isLoadingRef = useRef(false);
|
||
|
||
// 加载用户信息
|
||
const loadUserInfo = async () => {
|
||
// 防止重复请求
|
||
if (isLoadingRef.current) {
|
||
return;
|
||
}
|
||
|
||
const currentUser = authService.getUser();
|
||
if (!currentUser) {
|
||
messageApi.error('未找到用户信息');
|
||
return;
|
||
}
|
||
|
||
isLoadingRef.current = true;
|
||
setLoading(true);
|
||
try {
|
||
const userData = await userService.getUserById(currentUser.userId);
|
||
console.log('userData:', userData);
|
||
setUser(userData);
|
||
infoForm.setFieldsValue({
|
||
nickname: userData.nickname || '',
|
||
phone: userData.phone || '',
|
||
email: userData.email || '',
|
||
});
|
||
// 设置头像文件列表
|
||
if (userData.avatarUrl) {
|
||
setAvatarFileList([
|
||
{
|
||
uid: '-1',
|
||
name: 'avatar',
|
||
status: 'done',
|
||
url: userData.avatarUrl,
|
||
},
|
||
]);
|
||
}
|
||
} catch (error: any) {
|
||
messageApi.error(error.message || '加载用户信息失败');
|
||
} finally {
|
||
setLoading(false);
|
||
isLoadingRef.current = false;
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadUserInfo();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// 上传头像
|
||
const handleAvatarUpload = async (
|
||
file: RcFile,
|
||
onSuccess?: (response: any) => void,
|
||
onError?: (error: any) => void
|
||
) => {
|
||
setUploading(true);
|
||
try {
|
||
const response = await storageService.uploadAvatar(file);
|
||
// 更新用户头像
|
||
if (user) {
|
||
await userService.updateUser(user.userId, {
|
||
avatarUrl: response.url,
|
||
});
|
||
messageApi.success('头像上传成功');
|
||
await loadUserInfo();
|
||
onSuccess?.(response);
|
||
}
|
||
} catch (error: any) {
|
||
messageApi.error(error.message || '头像上传失败');
|
||
onError?.(error);
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
// 头像上传配置
|
||
const avatarUploadProps = {
|
||
name: 'file',
|
||
listType: 'picture' as const,
|
||
maxCount: 1,
|
||
fileList: avatarFileList,
|
||
accept: 'image/*',
|
||
customRequest: async (options: any) => {
|
||
const { file, onSuccess, onError } = options;
|
||
await handleAvatarUpload(file as RcFile, onSuccess, onError);
|
||
},
|
||
beforeUpload: (file: File) => {
|
||
const isImage = file.type.startsWith('image/');
|
||
if (!isImage) {
|
||
messageApi.error('只能上传图片文件!');
|
||
return false;
|
||
}
|
||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||
if (!isLt2M) {
|
||
messageApi.error('图片大小不能超过 2MB!');
|
||
return false;
|
||
}
|
||
return true;
|
||
},
|
||
onRemove: () => {
|
||
setAvatarFileList([]);
|
||
},
|
||
onChange: ({ fileList: newFileList }: { fileList: UploadFile[] }) => {
|
||
setAvatarFileList(newFileList);
|
||
},
|
||
};
|
||
|
||
// 进入编辑模式
|
||
const handleEdit = () => {
|
||
if (user) {
|
||
infoForm.setFieldsValue({
|
||
nickname: user.nickname || '',
|
||
phone: user.phone || '',
|
||
email: user.email || '',
|
||
});
|
||
setEditing(true);
|
||
}
|
||
};
|
||
|
||
// 取消编辑
|
||
const handleCancelEdit = () => {
|
||
setEditing(false);
|
||
infoForm.resetFields();
|
||
};
|
||
|
||
// 处理更新按钮点击(先验证表单,再显示确认框)
|
||
const handleUpdateClick = async () => {
|
||
try {
|
||
// 先验证表单
|
||
const values = await infoForm.validateFields();
|
||
if (!user) return;
|
||
|
||
// 验证通过,显示确认框
|
||
Modal.confirm({
|
||
title: '确认更新',
|
||
content: '确定要更新个人信息吗?',
|
||
onOk: async () => {
|
||
try {
|
||
await userService.updateUser(user.userId, {
|
||
nickname: values.nickname || undefined,
|
||
phone: values.phone || undefined,
|
||
email: values.email || undefined,
|
||
});
|
||
|
||
messageApi.success('个人信息更新成功');
|
||
setEditing(false);
|
||
await loadUserInfo();
|
||
} catch (error: any) {
|
||
messageApi.error(error.message || '更新失败');
|
||
}
|
||
},
|
||
});
|
||
} catch (error: any) {
|
||
// 验证失败,不显示确认框
|
||
if (error.errorFields) {
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 修改密码
|
||
const handleChangePassword = async () => {
|
||
try {
|
||
const values = await passwordForm.validateFields();
|
||
if (!user) return;
|
||
|
||
await userService.changePassword(user.userId, {
|
||
oldPassword: values.oldPassword,
|
||
newPassword: values.newPassword,
|
||
});
|
||
|
||
messageApi.success('密码修改成功');
|
||
passwordForm.resetFields();
|
||
} catch (error: any) {
|
||
if (error.errorFields) {
|
||
return;
|
||
}
|
||
messageApi.error(error.message || '密码修改失败');
|
||
}
|
||
};
|
||
|
||
if (!user) {
|
||
return <Card loading={loading}>加载中...</Card>;
|
||
}
|
||
|
||
// Tab配置
|
||
const tabItems: TabsProps['items'] = [
|
||
{
|
||
key: 'info',
|
||
label: '个人信息',
|
||
children: (
|
||
<div className="user-info-content">
|
||
<Form
|
||
form={infoForm}
|
||
layout="horizontal"
|
||
labelCol={{ span: 6 }}
|
||
wrapperCol={{ span: 18 }}
|
||
style={{ maxWidth: 600 }}
|
||
>
|
||
{/* 头像 */}
|
||
<Form.Item wrapperCol={{ span: 18, offset: 6 }}>
|
||
<div className="avatar-wrapper">
|
||
{user.avatarUrl ? (
|
||
<Image
|
||
src={user.avatarUrl}
|
||
alt="头像"
|
||
width={100}
|
||
height={100}
|
||
style={{ borderRadius: 8 }}
|
||
fallback="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f0f0f0'/%3E%3Cpath d='M50 33c9.4 0 17 7.6 17 17s-7.6 17-17 17-17-7.6-17-17 7.6-17 17-17zm0 40c11 0 33.5 5.5 33.5 16.5v8H16.5v-8C16.5 78.5 39 73 50 73z' fill='%23999'/%3E%3C/svg%3E"
|
||
/>
|
||
) : (
|
||
<Avatar size={100} icon={<UserOutlined />} />
|
||
)}
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
height: 32,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{editing ? (
|
||
<Upload {...avatarUploadProps}>
|
||
<Button
|
||
icon={<UploadOutlined />}
|
||
loading={uploading}
|
||
size="small"
|
||
>
|
||
上传头像
|
||
</Button>
|
||
</Upload>
|
||
) : (
|
||
<span
|
||
style={{ fontSize: 14, color: 'rgba(0, 0, 0, 0.65)' }}
|
||
>
|
||
头像
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Form.Item>
|
||
{/* 昵称 */}
|
||
<Form.Item
|
||
label="昵称"
|
||
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
|
||
>
|
||
{editing ? (
|
||
<Form.Item
|
||
name="nickname"
|
||
noStyle
|
||
rules={[{ max: 100, message: '昵称不能超过100个字符' }]}
|
||
>
|
||
<Input placeholder="请输入昵称" />
|
||
</Form.Item>
|
||
) : (
|
||
<span className="info-field-text">{user.nickname || '-'}</span>
|
||
)}
|
||
</Form.Item>
|
||
|
||
{/* 用户名(不可编辑) */}
|
||
<Form.Item label="用户名">
|
||
<span className="info-field-text">{user.username}</span>
|
||
</Form.Item>
|
||
|
||
{/* 电话 */}
|
||
<Form.Item
|
||
label="电话"
|
||
rules={[
|
||
{
|
||
pattern: /^[0-9+\-() ]+$/,
|
||
message: '电话号码格式不正确',
|
||
},
|
||
{ max: 20, message: '电话不能超过20个字符' },
|
||
]}
|
||
>
|
||
{editing ? (
|
||
<Form.Item
|
||
name="phone"
|
||
noStyle
|
||
rules={[
|
||
{
|
||
pattern: /^[0-9+\-() ]+$/,
|
||
message: '电话号码格式不正确',
|
||
},
|
||
{ max: 20, message: '电话不能超过20个字符' },
|
||
]}
|
||
>
|
||
<Input placeholder="请输入电话" />
|
||
</Form.Item>
|
||
) : (
|
||
<span className="info-field-text">{user.phone || '-'}</span>
|
||
)}
|
||
</Form.Item>
|
||
|
||
{/* 邮箱 */}
|
||
<Form.Item
|
||
label="邮箱"
|
||
rules={[
|
||
{ type: 'email', message: '邮箱格式不正确' },
|
||
{ max: 100, message: '邮箱不能超过100个字符' },
|
||
]}
|
||
>
|
||
{editing ? (
|
||
<Form.Item
|
||
name="email"
|
||
noStyle
|
||
rules={[
|
||
{ type: 'email', message: '邮箱格式不正确' },
|
||
{ max: 100, message: '邮箱不能超过100个字符' },
|
||
{ required: true, message: '请输入邮箱' },
|
||
]}
|
||
>
|
||
<Input placeholder="请输入邮箱" />
|
||
</Form.Item>
|
||
) : (
|
||
<span className="info-field-text">{user.email}</span>
|
||
)}
|
||
</Form.Item>
|
||
|
||
{/* 注册时间(不可编辑) */}
|
||
<Form.Item label="注册时间">
|
||
<span className="info-field-text">
|
||
{dayjs(user.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||
</span>
|
||
</Form.Item>
|
||
|
||
{/* 使用天数(不可编辑) */}
|
||
<Form.Item label="使用天数">
|
||
<span className="info-field-text">
|
||
{calculateUsageDays(user.createdAt)}
|
||
</span>
|
||
</Form.Item>
|
||
|
||
{/* 操作按钮 */}
|
||
<Form.Item wrapperCol={{ offset: 6, span: 18 }} style={{ marginTop: 24 }}>
|
||
{editing ? (
|
||
<Space>
|
||
<Button type="primary" onClick={handleUpdateClick}>
|
||
更新个人信息
|
||
</Button>
|
||
<Button onClick={handleCancelEdit}>取消</Button>
|
||
</Space>
|
||
) : (
|
||
<Button type="primary" onClick={handleEdit}>
|
||
编辑
|
||
</Button>
|
||
)}
|
||
</Form.Item>
|
||
</Form>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'password',
|
||
label: '修改密码',
|
||
children: (
|
||
<Form
|
||
form={passwordForm}
|
||
layout="horizontal"
|
||
labelCol={{ span: 6 }}
|
||
wrapperCol={{ span: 18 }}
|
||
style={{ maxWidth: 600 }}
|
||
onFinish={handleChangePassword}
|
||
>
|
||
<Form.Item
|
||
name="oldPassword"
|
||
label="原密码"
|
||
rules={[{ required: true, message: '请输入原密码' }]}
|
||
>
|
||
<Input.Password placeholder="请输入原密码" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="newPassword"
|
||
label="新密码"
|
||
rules={[
|
||
{ required: true, message: '请输入新密码' },
|
||
{ min: 6, message: '密码长度至少6位' },
|
||
{ max: 100, message: '密码长度不能超过100位' },
|
||
]}
|
||
>
|
||
<Input.Password placeholder="请输入新密码" />
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="confirmPassword"
|
||
label="确认密码"
|
||
dependencies={['newPassword']}
|
||
rules={[
|
||
{ required: true, message: '请确认新密码' },
|
||
({ getFieldValue }) => ({
|
||
validator(_, value) {
|
||
if (!value || getFieldValue('newPassword') === value) {
|
||
return Promise.resolve();
|
||
}
|
||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||
},
|
||
}),
|
||
]}
|
||
>
|
||
<Input.Password placeholder="请再次输入新密码" />
|
||
</Form.Item>
|
||
|
||
<Form.Item wrapperCol={{ offset: 6, span: 18 }}>
|
||
<Button type="primary" htmlType="submit">
|
||
修改密码
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="user-info-page">
|
||
<Card>
|
||
<Tabs defaultActiveKey="info" items={tabItems} />
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default UserInfoPage;
|