318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
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 { useLanguageStore } from '../store/languageStore';
|
|
import apiClient from '../utils/request';
|
|
|
|
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);
|
|
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
|
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);
|
|
|
|
useEffect(() => {
|
|
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 });
|
|
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}/profile`, {
|
|
...values,
|
|
avatar: avatarUrl,
|
|
passport: passportUrl,
|
|
driver_license: driverLicenseUrl
|
|
});
|
|
const data = response.data;
|
|
if (data.success) {
|
|
message.success(t('user.profileUpdated'));
|
|
if (user) {
|
|
setUser({ ...user, name: values.name, email: values.email, phone: values.phone, avatar: avatarUrl, passport: passportUrl, driverLicense: driverLicenseUrl });
|
|
}
|
|
} else {
|
|
message.error(data.message || t('user.updateFailed'));
|
|
}
|
|
} catch (error) {
|
|
message.error(t('user.updateRetry'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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(t('user.passwordUpdated'));
|
|
setPasswordModalVisible(false);
|
|
passwordForm.resetFields();
|
|
} else {
|
|
message.error(data.message || t('user.passwordUpdateFailed'));
|
|
}
|
|
} catch (error) {
|
|
message.error(t('user.passwordUpdateRetry'));
|
|
}
|
|
};
|
|
|
|
const handleAvatarUpload = async (options: any) => {
|
|
setUploading('avatar');
|
|
const url = await uploadToCos(options.file);
|
|
if (url) setAvatarUrl(url);
|
|
setUploading(null);
|
|
};
|
|
|
|
const handlePassportUpload = async (options: any) => {
|
|
setUploading('passport');
|
|
const url = await uploadToCos(options.file);
|
|
if (url) setPassportUrl(url);
|
|
setUploading(null);
|
|
};
|
|
|
|
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: isMobile ? 8 : 24 }}>
|
|
<div style={{ marginBottom: 24 }}>
|
|
<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
|
|
customRequest={handleAvatarUpload}
|
|
showUploadList={false}
|
|
accept="image/*"
|
|
>
|
|
{avatarUrl ? (
|
|
<Avatar size={128} src={avatarUrl} style={{ cursor: 'pointer' }} />
|
|
) : (
|
|
<Avatar size={128} icon={<UserOutlined />} style={{ cursor: 'pointer' }} />
|
|
)}
|
|
</Upload>
|
|
<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}>
|
|
<Row gutter={16}>
|
|
<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={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={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={colSpan}>
|
|
<Form.Item label={t('user.username')} disabled>
|
|
<Input value={user?.username} placeholder={t('user.username')} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<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={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={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, 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={t('user.changePassword')}
|
|
open={passwordModalVisible}
|
|
onCancel={() => setPasswordModalVisible(false)}
|
|
onOk={handlePasswordSubmit}
|
|
width={400}
|
|
>
|
|
<Form form={passwordForm} layout="vertical">
|
|
<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={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={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(t('user.passwordMismatch')));
|
|
}
|
|
})]}>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export default ProfilePage; |