feat(settings): add user profile and password management

This commit is contained in:
2025-10-21 11:40:47 +08:00
parent 73510a3b74
commit 6ca5e235b4
13 changed files with 1330 additions and 20 deletions

View File

@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
IconButton,
InputAdornment,
Typography,
Alert
} from '@mui/material';
import { Visibility, VisibilityOff, Close } from '@mui/icons-material';
import { useSnackbar } from '@/hooks/useSnackbar';
import logger from '@/utils/logger';
interface ChangePasswordDialogProps {
open: boolean;
onClose: () => void;
changeUserPassword: (data: { password: string; new_password: string }) => Promise<void>;
}
interface PasswordFormData {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
/**
* 修改密码对话框
*/
function ChangePasswordDialog({ open, onClose, changeUserPassword }: ChangePasswordDialogProps) {
const { showMessage } = useSnackbar();
const [formData, setFormData] = useState<PasswordFormData>({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Partial<PasswordFormData>>({});
// 重置表单
const resetForm = () => {
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
setShowPasswords({
current: false,
new: false,
confirm: false
});
};
// 处理关闭
const handleClose = () => {
resetForm();
onClose();
};
// 处理输入变化
const handleInputChange = (field: keyof PasswordFormData) => (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setFormData(prev => ({
...prev,
[field]: value
}));
// 清除对应字段的错误
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: undefined
}));
}
};
// 切换密码可见性
const togglePasswordVisibility = (field: 'current' | 'new' | 'confirm') => {
setShowPasswords(prev => ({
...prev,
[field]: !prev[field]
}));
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<PasswordFormData> = {};
if (!formData.currentPassword.trim()) {
newErrors.currentPassword = '请输入当前密码';
}
if (!formData.newPassword.trim()) {
newErrors.newPassword = '请输入新密码';
} else if (formData.newPassword.length < 6) {
newErrors.newPassword = '新密码长度至少6位';
}
if (!formData.confirmPassword.trim()) {
newErrors.confirmPassword = '请确认新密码';
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = '两次输入的密码不一致';
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = '新密码不能与当前密码相同';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 提交修改
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
setLoading(true);
try {
await changeUserPassword({
password: formData.currentPassword,
new_password: formData.newPassword
});
showMessage.success('密码修改成功');
handleClose();
} catch (error: any) {
logger.error('修改密码失败:', error);
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: { borderRadius: 2 }
}}
>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" component="div">
</Typography>
<IconButton
onClick={handleClose}
size="small"
sx={{ color: 'text.secondary' }}
>
<Close />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, pt: 1 }}>
<Alert severity="info" sx={{ mb: 1 }}>
6
</Alert>
{/* 当前密码 */}
<TextField
fullWidth
label="当前密码"
type={showPasswords.current ? 'text' : 'password'}
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={!!errors.currentPassword}
helperText={errors.currentPassword}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => togglePasswordVisibility('current')}
edge="end"
size="small"
>
{showPasswords.current ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
{/* 新密码 */}
<TextField
fullWidth
label="新密码"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={!!errors.newPassword}
helperText={errors.newPassword || '密码长度至少6位'}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => togglePasswordVisibility('new')}
edge="end"
size="small"
>
{showPasswords.new ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
{/* 确认新密码 */}
<TextField
fullWidth
label="确认新密码"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => togglePasswordVisibility('confirm')}
edge="end"
size="small"
>
{showPasswords.confirm ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}}
/>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, gap: 1 }}>
<Button
onClick={handleClose}
variant="outlined"
disabled={loading}
>
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={loading}
sx={{ minWidth: 100 }}
>
{loading ? '修改中...' : '确认修改'}
</Button>
</DialogActions>
</Dialog>
);
}
export default ChangePasswordDialog;

View File

@@ -0,0 +1,278 @@
import React, { useState, useRef } from 'react';
import {
Box,
TextField,
Button,
Avatar,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
Grid,
Paper,
IconButton,
Tooltip
} from '@mui/material';
import { PhotoCamera, Edit } from '@mui/icons-material';
import { useProfileSetting } from '@/hooks/setting-hooks';
import { useMessage } from '@/hooks/useSnackbar';
import type { IUserInfo } from '@/interfaces/database/user-setting';
import { TimezoneList } from '@/constants/setting';
// 语言选项
const languageOptions = [
{ value: 'Chinese', label: '简体中文' },
{ value: 'English', label: 'English' },
{ value: 'Spanish', label: 'Español' },
{ value: 'French', label: 'Français' },
{ value: 'German', label: 'Deutsch' },
{ value: 'Japanese', label: '日本語' },
{ value: 'Korean', label: '한국어' },
{ value: 'Vietnamese', label: 'Tiếng Việt' }
];
// 时区选项
const timezoneOptions = TimezoneList.map(x => ({ value: x, label: x }));
interface ProfileFormProps {
userInfo: IUserInfo | null;
onSubmit: (data: Partial<IUserInfo>) => Promise<void>;
}
/**
* 个人信息表单
*/
function ProfileForm({ userInfo, onSubmit }: ProfileFormProps) {
const showMessage = useMessage();
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<Partial<IUserInfo>>({
nickname: userInfo?.nickname || '',
avatar: userInfo?.avatar || null,
language: userInfo?.language || 'Chinese',
timezone: userInfo?.timezone || 'UTC+8\tAsia/Shanghai',
email: userInfo?.email || ''
});
// 更新表单数据
React.useEffect(() => {
if (userInfo) {
setFormData({
nickname: userInfo.nickname || '',
avatar: userInfo.avatar || null,
language: userInfo.language || 'Chinese',
timezone: userInfo.timezone || 'UTC+8\tAsia/Shanghai',
email: userInfo.email || ''
});
}
}, [userInfo]);
// 处理输入变化
const handleInputChange = (field: keyof IUserInfo) => (event: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[field]: event.target.value
}));
};
// 处理选择变化
const handleSelectChange = (field: keyof IUserInfo) => (event: any) => {
setFormData(prev => ({
...prev,
[field]: event.target.value
}));
};
// 处理头像上传
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// 检查文件类型
if (!file.type.startsWith('image/')) {
showMessage.error('请选择图片文件');
return;
}
// 检查文件大小 (限制为2MB)
if (file.size > 2 * 1024 * 1024) {
showMessage.error('图片大小不能超过2MB');
return;
}
// 转换为base64
const reader = new FileReader();
reader.onload = (e) => {
const base64String = e.target?.result as string;
setFormData(prev => ({
...prev,
avatar: base64String
}));
};
reader.readAsDataURL(file);
}
};
// 触发文件选择
const triggerFileSelect = () => {
fileInputRef.current?.click();
};
// 保存用户信息
const handleSave = async () => {
try {
if (!formData.nickname?.trim()) {
showMessage.error('用户名不能为空');
return;
}
const updateData: Partial<IUserInfo> = {
nickname: formData.nickname,
avatar: formData.avatar,
language: formData.language,
timezone: formData.timezone,
email: formData.email
};
await onSubmit(updateData);
showMessage.success('个人信息更新成功');
} catch (error) {
console.error('更新用户信息失败:', error);
showMessage.error('更新失败,请重试');
}
};
return (
<Paper elevation={0} sx={{ p: 3, backgroundColor: 'transparent' }}>
<Typography variant="h6" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
</Typography>
<Grid container spacing={3}>
{/* 头像部分 */}
<Grid size={12}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
src={formData.avatar}
sx={{
width: 80,
height: 80,
border: '2px solid',
borderColor: 'divider'
}}
>
{formData.nickname?.charAt(0)?.toUpperCase()}
</Avatar>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
</Typography>
<Tooltip title="上传头像">
<IconButton
color="primary"
onClick={triggerFileSelect}
sx={{
border: '1px solid',
borderColor: 'primary.main',
'&:hover': {
backgroundColor: 'primary.main',
color: 'white'
}
}}
>
<PhotoCamera />
</IconButton>
</Tooltip>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
style={{ display: 'none' }}
/>
<Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 1 }}>
JPGPNG 2MB
</Typography>
</Box>
</Box>
</Grid>
{/* 用户名 */}
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="用户名"
value={formData.nickname}
onChange={handleInputChange('nickname')}
variant="outlined"
required
/>
</Grid>
{/* 邮箱 (只读) */}
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="邮箱"
value={formData.email}
variant="outlined"
disabled
helperText="邮箱地址不可修改"
/>
</Grid>
{/* 语言 */}
<Grid size={{ xs: 12, sm: 6 }}>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={formData.language}
label="语言"
onChange={handleSelectChange('language')}
>
{languageOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 时区 */}
<Grid size={{ xs: 12, sm: 6 }}>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={formData.timezone}
label="时区"
onChange={handleSelectChange('timezone')}
>
{timezoneOptions.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* 保存按钮 */}
<Grid size={12}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
variant="contained"
onClick={handleSave}
sx={{ minWidth: 120 }}
>
</Button>
</Box>
</Grid>
</Grid>
</Paper>
);
}
export default ProfileForm;

View File

@@ -1,8 +1,67 @@
import React, { useState } from "react";
import { Box, Button, Divider, Typography } from "@mui/material";
import { Lock } from "@mui/icons-material";
import ProfileForm from "./components/ProfileForm";
import ChangePasswordDialog from "./components/ChangePasswordDialog";
import { useProfileSetting } from "@/hooks/setting-hooks";
import logger from "@/utils/logger";
function ProfileSetting() {
const { userInfo, updateUserInfo: updateUserInfoFunc, changeUserPassword: changeUserPasswordFunc } = useProfileSetting();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
logger.debug('userInfo', userInfo);
const handleOpenPasswordDialog = () => {
setPasswordDialogOpen(true);
};
const handleClosePasswordDialog = () => {
setPasswordDialogOpen(false);
};
return (
<div>
<h1>Profile Setting</h1>
</div>
<Box sx={{ maxWidth: 800, mx: 'auto', p: 3 }}>
{/* 个人资料表单 */}
<ProfileForm userInfo={userInfo} onSubmit={updateUserInfoFunc} />
{/* 分割线 */}
<Divider sx={{ my: 4 }} />
{/* 密码修改部分 */}
<Box sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
</Typography>
<Button
variant="outlined"
startIcon={<Lock />}
onClick={handleOpenPasswordDialog}
sx={{
minWidth: 140,
borderColor: 'primary.main',
color: 'primary.main',
'&:hover': {
backgroundColor: 'primary.main',
color: 'white'
}
}}
>
</Button>
</Box>
{/* 修改密码对话框 */}
<ChangePasswordDialog
open={passwordDialogOpen}
onClose={handleClosePasswordDialog}
changeUserPassword={changeUserPasswordFunc}
/>
</Box>
);
}