Files
invest-mind-store/apps/web/src/pages/user/UserInfoPage.tsx
T
2026-01-12 17:38:55 +08:00

480 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;