feat(settings): add user profile and password management
This commit is contained in:
272
src/pages/setting/components/ChangePasswordDialog.tsx
Normal file
272
src/pages/setting/components/ChangePasswordDialog.tsx
Normal 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;
|
||||
278
src/pages/setting/components/ProfileForm.tsx
Normal file
278
src/pages/setting/components/ProfileForm.tsx
Normal 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 }}>
|
||||
支持 JPG、PNG 格式,文件大小不超过 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user