Files
jiang13-forum/frontend/src/pages/ProfilePage.tsx
freefire b451703642 完善论坛配置与发帖体验:TipTap 富文本、图片上传、修订历史、Feed 排序与后台参数管理。
同步更新 README 与 ROADMAP,反映最新功能与开发状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 04:11:38 +08:00

249 lines
8.7 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, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
});
const pwdSchema = (minLen: number) => z.object({
old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(minLen, `新密码至少 ${minLen}`),
confirm_password: z.string().min(1, '请确认新密码'),
}).refine(d => d.new_password === d.confirm_password, {
message: '两次输入的新密码不一致',
path: ['confirm_password'],
});
type NickValues = z.infer<typeof nickSchema>;
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
export default function ProfilePage() {
const nav = useNavigate();
const { user, loading: authLoading, refresh } = useAuth();
const [nickLoading, setNickLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const { limits } = useForumLimits();
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' },
});
const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema(limits.password_min_len)),
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
});
useEffect(() => {
if (!authLoading && !user) {
nav('/login');
}
}, [authLoading, user, nav]);
if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user) return null;
const onUpdateNick = async (values: NickValues) => {
setNickLoading(true);
try {
await api.updateNickname(values.nickname);
await refresh();
notify.success('昵称已更新');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '更新失败');
} finally {
setNickLoading(false);
}
};
const onUpdatePwd = async (values: PwdValues) => {
setPwdLoading(true);
try {
await api.updatePassword(values.old_password, values.new_password);
notify.success('密码已修改,请重新登录');
pwdForm.reset();
await api.logout();
nav('/login');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '修改失败');
} finally {
setPwdLoading(false);
}
};
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > limits.avatar_max_mb * 1024 * 1024) {
notify.error(`头像不能超过 ${limits.avatar_max_mb}MB`);
return;
}
setAvatarLoading(true);
try {
await api.uploadAvatar(file);
await refresh();
notify.success('头像已更新');
} catch (err: unknown) {
notify.error(err instanceof Error ? err.message : '上传失败');
} finally {
setAvatarLoading(false);
if (fileRef.current) fileRef.current.value = '';
}
};
return (
<div className="page-wrap">
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
<div className="profile-header">
<div className="profile-avatar-lg">
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]}
</div>
<div>
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1>
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div>
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>}
</div>
</div>
{user.role === 'admin' && (
<div className="section-card admin-entry-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => nav('/admin/boards')}>
<Settings />
</Button>
<Button onClick={() => nav('/admin/dashboard')}>
<LayoutDashboard />
</Button>
</div>
</div>
)}
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4">
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={nickLoading}></Button>
</form>
</Form>
</div>
<div className="section-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
JPGPNGGIFWebP 2MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={onAvatarChange}
/>
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
</Button>
</div>
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4">
<FormField
control={pwdForm.control}
name="old_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="输入当前密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="至少 6 位" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="再次输入新密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</form>
</Form>
</div>
</div>
</div>
);
}