备份:PWA配置前的完整版本

包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
This commit is contained in:
a273825743
2026-06-13 12:44:48 +08:00
parent 5fd19822a2
commit 706dcc24eb
83 changed files with 26590 additions and 13379 deletions
+189 -186
View File
@@ -1,14 +1,15 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal } from 'antd';
import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined } from '@ant-design/icons';
import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal, Image, Popconfirm } from 'antd';
import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined, EyeOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { useAuthStore } from '../store/authStore';
import useFormDraft from '../hooks/useFormDraft';
import { useLanguageStore } from '../store/languageStore';
import apiClient from '../utils/request';
const { Title, Paragraph } = Typography;
const { Title, Paragraph, Text } = Typography;
const ProfilePage: React.FC = () => {
const { user, setUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const [loading, setLoading] = useState(false);
@@ -16,96 +17,79 @@ const ProfilePage: React.FC = () => {
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(user?.avatar);
const [passportUrl, setPassportUrl] = useState<string | undefined>(user?.passport);
const [driverLicenseUrl, setDriverLicenseUrl] = useState<string | undefined>(user?.driverLicense);
const [uploading, setUploading] = useState<string | null>(null);
const [previewVisible, setPreviewVisible] = useState(false);
const [previewTitle, setPreviewTitle] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [isMobile, setIsMobile] = useState(false);
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form,
storageKey: 'profile_edit',
onRestore: (data) => {
if (data.avatarUrl !== undefined) setAvatarUrl(data.avatarUrl)
if (data.passportUrl !== undefined) setPassportUrl(data.passportUrl)
if (data.driverLicenseUrl !== undefined) setDriverLicenseUrl(data.driverLicenseUrl)
},
})
const handleFormChange = useCallback(() => {
saveDraft({ avatarUrl, passportUrl, driverLicenseUrl })
}, [saveDraft, avatarUrl, passportUrl, driverLicenseUrl])
// 页面级 beforeunload 保护
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (form.isFieldsTouched()) {
e.preventDefault()
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [form])
// 页面加载时检查草稿
useEffect(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未保存的个人信息修改,是否恢复?',
okText: '恢复草稿',
cancelText: '放弃草稿',
onOk: () => {
restoreDraft()
},
onCancel: () => {
clearDraft()
},
})
}
}, [])
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
if (user) {
form.setFieldsValue({
name: user.name,
phone: user.phone,
email: user.email
});
form.setFieldsValue({ name: user.name, phone: user.phone, email: user.email });
setAvatarUrl(user.avatar);
setPassportUrl(user.passport);
setDriverLicenseUrl(user.driverLicense);
}
const fetchProfile = async () => {
try {
const res = await apiClient.get('/users/me');
if (res.data.success) {
const d = res.data.data;
form.setFieldsValue({ name: d.name, phone: d.phone, email: d.email });
setAvatarUrl(d.avatar);
setPassportUrl(d.passport);
setDriverLicenseUrl(d.driverLicense);
if (user) {
setUser({ ...user, name: d.name, email: d.email, phone: d.phone, avatar: d.avatar, passport: d.passport, driverLicense: d.driverLicense });
}
}
} catch (e) {}
};
fetchProfile();
}, [user, form]);
const uploadToCos = async (file: File): Promise<string | null> => {
try {
const formData = new FormData();
formData.append('file', file);
const res = await apiClient.post('/upload/single', formData);
if (res.data.success) return res.data.data.url;
message.error(t('user.uploadFailed'));
return null;
} catch (e) {
message.error(t('user.uploadFailed'));
return null;
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const response = await apiClient.put(`/users/${user?.id}`, {
const response = await apiClient.put(`/users/${user?.id}/profile`, {
...values,
avatar: avatarUrl,
passport: passportUrl,
driverLicense: driverLicenseUrl
driver_license: driverLicenseUrl
});
const data = response.data;
if (data.success) {
message.success('个人信息已更新');
// 更新authStore中的用户信息
message.success(t('user.profileUpdated'));
if (user) {
const updatedUser = {
...user,
name: values.name,
email: values.email,
phone: values.phone,
avatar: avatarUrl
};
setUser(updatedUser);
setUser({ ...user, name: values.name, email: values.email, phone: values.phone, avatar: avatarUrl, passport: passportUrl, driverLicense: driverLicenseUrl });
}
} else {
message.error(data.message || '更新失败');
message.error(data.message || t('user.updateFailed'));
}
} catch (error) {
console.error('提交失败:', error);
message.error('更新失败,请重试');
message.error(t('user.updateRetry'));
} finally {
setLoading(false);
}
@@ -114,200 +98,219 @@ const ProfilePage: React.FC = () => {
const handlePasswordSubmit = async () => {
try {
const values = await passwordForm.validateFields();
const response = await apiClient.put(`/users/${user?.id}/password`, {
currentPassword: values.currentPassword,
newPassword: values.newPassword
});
const data = response.data;
if (data.success) {
message.success('密码已更新');
message.success(t('user.passwordUpdated'));
setPasswordModalVisible(false);
passwordForm.resetFields();
} else {
message.error(data.message || '密码更新失败');
message.error(data.message || t('user.passwordUpdateFailed'));
}
} catch (error) {
console.error('提交失败:', error);
message.error('密码更新失败,请重试');
message.error(t('user.passwordUpdateRetry'));
}
};
const handleAvatarChange = (info: any) => {
if (info.file.status === 'done') {
setAvatarUrl(URL.createObjectURL(info.file.originFileObj));
message.success('头像上传成功');
} else if (info.file.status === 'error') {
message.error('头像上传失败');
}
const handleAvatarUpload = async (options: any) => {
setUploading('avatar');
const url = await uploadToCos(options.file);
if (url) setAvatarUrl(url);
setUploading(null);
};
const handlePassportChange = (info: any) => {
if (info.file.status === 'done') {
setPassportUrl(URL.createObjectURL(info.file.originFileObj));
message.success('护照上传成功');
} else if (info.file.status === 'error') {
message.error('护照上传失败');
}
const handlePassportUpload = async (options: any) => {
setUploading('passport');
const url = await uploadToCos(options.file);
if (url) setPassportUrl(url);
setUploading(null);
};
const handleDriverLicenseChange = (info: any) => {
if (info.file.status === 'done') {
setDriverLicenseUrl(URL.createObjectURL(info.file.originFileObj));
message.success('驾照上传成功');
} else if (info.file.status === 'error') {
message.error('驾照上传失败');
}
const handleDriverLicenseUpload = async (options: any) => {
setUploading('driverLicense');
const url = await uploadToCos(options.file);
if (url) setDriverLicenseUrl(url);
setUploading(null);
};
const showPreview = (title: string, url: string) => {
setPreviewTitle(title);
setPreviewUrl(url);
setPreviewVisible(true);
};
const colSpan = isMobile ? 24 : 12;
return (
<div style={{ padding: 24 }}>
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3}></Title>
<Paragraph type="secondary"></Paragraph>
<Title level={3}>{t('user.profile')}</Title>
<Paragraph type="secondary">{t('user.managingProfile')}</Paragraph>
</div>
<Card>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Space direction="vertical" style={{ alignItems: 'center' }}>
<Upload
name="avatar"
listType="picture-circle"
customRequest={handleAvatarUpload}
showUploadList={false}
onChange={handleAvatarChange}
maxCount={1}
accept="image/*"
>
{avatarUrl ? (
<Avatar size={128} src={avatarUrl} />
<Avatar size={128} src={avatarUrl} style={{ cursor: 'pointer' }} />
) : (
<Avatar size={128} icon={<UserOutlined />} />
<Avatar size={128} icon={<UserOutlined />} style={{ cursor: 'pointer' }} />
)}
</Upload>
<Typography.Text></Typography.Text>
<Typography.Text strong>{user?.name || user?.username}</Typography.Text>
<Typography.Text type="secondary">{user?.role === 'admin' ? '管理员' : user?.role === 'manager' ? '经理' : '普通用户'}</Typography.Text>
<Text>{t('user.clickToChangeAvatar')}</Text>
<Text strong>{user?.name || user?.username}</Text>
<Text type="secondary">{user?.role === 'admin' ? t('user.admin') : user?.role === 'manager' ? t('user.manager') : t('user.employee')}</Text>
</Space>
</div>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
<Col span={colSpan}>
<Form.Item label={t('user.name')} name="name" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<Input placeholder={t('common.inputPlaceholder')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone" rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="请输入手机号" prefix={<PhoneOutlined />} />
<Col span={colSpan}>
<Form.Item label={t('user.phone')} name="phone" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<Input placeholder={t('common.inputPlaceholder')} prefix={<PhoneOutlined />} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="邮箱" name="email" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '请输入正确的邮箱地址' }]}>
<Input placeholder="请输入邮箱" prefix={<MailOutlined />} />
<Col span={colSpan}>
<Form.Item label={t('user.email')} name="email" rules={[{ required: true, message: t('common.inputPlaceholder') }, { type: 'email', message: 'Invalid email' }]}>
<Input placeholder="email@example.com" prefix={<MailOutlined />} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="用户名" disabled>
<Input value={user?.username} placeholder="用户名" />
<Col span={colSpan}>
<Form.Item label={t('user.username')} disabled>
<Input value={user?.username} placeholder={t('user.username')} />
</Form.Item>
</Col>
</Row>
<div style={{ marginBottom: 24 }}>
<Title level={4}></Title>
<div style={{ marginBottom: 16, marginTop: 8 }}>
<Title level={4}>{t('user.idDocument')}</Title>
<Text type="secondary">{t('user.idDocTip')}</Text>
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="护照">
<Upload
name="passport"
listType="picture"
showUploadList={false}
onChange={handlePassportChange}
maxCount={1}
>
<Card
style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9' }}
>
{passportUrl ? (
<img src={passportUrl} alt="护照" style={{ maxWidth: '100%', maxHeight: 200 }} />
) : (
<Space direction="vertical" style={{ alignItems: 'center' }}>
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<Typography.Text></Typography.Text>
</Space>
)}
</Card>
</Upload>
</Form.Item>
<Col span={colSpan}>
<Card
size="small"
title={t('user.passport')}
extra={passportUrl ? (
<Space size="small">
<Button size="small" icon={<EyeOutlined />} onClick={() => showPreview(t('user.passport'), passportUrl)}>{t('common.view')}</Button>
<Upload customRequest={handlePassportUpload} showUploadList={false} accept="image/*">
<Button size="small" icon={<ReloadOutlined />} loading={uploading === 'passport'}>{t('user.replace')}</Button>
</Upload>
<Popconfirm title={t('user.deletePassportConfirm')} okText={t('common.confirm')} cancelText={t('common.cancel')} onConfirm={() => setPassportUrl(undefined)}>
<Button size="small" danger icon={<DeleteOutlined />}>{t('common.delete')}</Button>
</Popconfirm>
</Space>
) : null}
style={{ marginBottom: 16 }}
>
{passportUrl ? (
<div style={{ cursor: 'pointer', textAlign: 'center' }} onClick={() => showPreview(t('user.passport'), passportUrl)}>
<img src={passportUrl} alt={t('user.passport')} style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 4, objectFit: 'contain' }} />
</div>
) : (
<Upload customRequest={handlePassportUpload} showUploadList={false} accept="image/*">
<div style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9', borderRadius: 4, cursor: 'pointer' }}>
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadPassport')}</Text></div>
</div>
</Upload>
)}
</Card>
</Col>
<Col span={12}>
<Form.Item label="驾照">
<Upload
name="driverLicense"
listType="picture"
showUploadList={false}
onChange={handleDriverLicenseChange}
maxCount={1}
>
<Card
style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9' }}
>
{driverLicenseUrl ? (
<img src={driverLicenseUrl} alt="驾照" style={{ maxWidth: '100%', maxHeight: 200 }} />
) : (
<Space direction="vertical" style={{ alignItems: 'center' }}>
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<Typography.Text></Typography.Text>
</Space>
)}
</Card>
</Upload>
</Form.Item>
<Col span={colSpan}>
<Card
size="small"
title={t('user.driverLicense')}
extra={driverLicenseUrl ? (
<Space size="small">
<Button size="small" icon={<EyeOutlined />} onClick={() => showPreview(t('user.driverLicense'), driverLicenseUrl)}>{t('common.view')}</Button>
<Upload customRequest={handleDriverLicenseUpload} showUploadList={false} accept="image/*">
<Button size="small" icon={<ReloadOutlined />} loading={uploading === 'driverLicense'}>{t('user.replace')}</Button>
</Upload>
<Popconfirm title={t('user.deleteDriverLicenseConfirm')} okText={t('common.confirm')} cancelText={t('common.cancel')} onConfirm={() => setDriverLicenseUrl(undefined)}>
<Button size="small" danger icon={<DeleteOutlined />}>{t('common.delete')}</Button>
</Popconfirm>
</Space>
) : null}
style={{ marginBottom: 16 }}
>
{driverLicenseUrl ? (
<div style={{ cursor: 'pointer', textAlign: 'center' }} onClick={() => showPreview(t('user.driverLicense'), driverLicenseUrl)}>
<img src={driverLicenseUrl} alt={t('user.driverLicense')} style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 4, objectFit: 'contain' }} />
</div>
) : (
<Upload customRequest={handleDriverLicenseUpload} showUploadList={false} accept="image/*">
<div style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9', borderRadius: 4, cursor: 'pointer' }}>
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadDriverLicense')}</Text></div>
</div>
</Upload>
)}
</Card>
</Col>
</Row>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
<Button style={{ marginLeft: 16 }} onClick={() => setPasswordModalVisible(true)}>
</Button>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24, gap: 8 }}>
<Button type="primary" htmlType="submit" loading={loading}>{t('user.saveProfile')}</Button>
<Button onClick={() => setPasswordModalVisible(true)}>{t('user.changePassword')}</Button>
</div>
</Form>
</Card>
<Modal
title="修改密码"
title={t('user.changePassword')}
open={passwordModalVisible}
onCancel={() => setPasswordModalVisible(false)}
onOk={handlePasswordSubmit}
width={400}
>
<Form form={passwordForm} layout="vertical">
<Form.Item label="当前密码" name="currentPassword" rules={[{ required: true, message: '请输入当前密码' }]}>
<Input.Password placeholder="请输入当前密码" prefix={<LockOutlined />} />
<Form.Item label={t('user.currentPassword')} name="currentPassword" rules={[{ required: true, message: t('user.currentPasswordPlaceholder') }]}>
<Input.Password placeholder={t('user.currentPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度至少为6位' }]}>
<Input.Password placeholder="请输入新密码" prefix={<LockOutlined />} />
<Form.Item label={t('user.newPassword')} name="newPassword" rules={[{ required: true, message: t('user.newPasswordPlaceholder') }, { min: 6, message: t('user.passwordMinLen') }]}>
<Input.Password placeholder={t('user.newPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
<Form.Item label="确认新密码" name="confirmPassword" rules={[{ required: true, message: '请确认新密码' }, ({ getFieldValue }) => ({
<Form.Item label={t('user.confirmPassword')} name="confirmPassword" rules={[{ required: true, message: t('user.confirmPasswordPlaceholder') }, ({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
if (!value || getFieldValue('newPassword') === value) return Promise.resolve();
return Promise.reject(new Error(t('user.passwordMismatch')));
}
})]}>
<Input.Password placeholder="请确认新密码" prefix={<LockOutlined />} />
<Input.Password placeholder={t('user.confirmPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item>
</Form>
</Modal>
<Modal
title={previewTitle}
open={previewVisible}
footer={null}
onCancel={() => setPreviewVisible(false)}
width={isMobile ? '95%' : 600}
centered
>
<div style={{ textAlign: 'center' }}>
<img src={previewUrl} alt={previewTitle} style={{ maxWidth: '100%', maxHeight: '70vh', objectFit: 'contain' }} />
</div>
</Modal>
</div>
);
};