Files
jiang13-forum/frontend/src/pages/ProfilePage.tsx
freefire 9e134916a4 fix: 软刷新齐套前保留旧画面,避免一点击就卸光
Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
2026-09-01 04:56:10 +08:00

748 lines
26 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, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
ArrowLeft,
Camera,
Check,
Coins,
Copy,
FileText,
Hash,
Heart,
LayoutDashboard,
MessageCircle,
PenLine,
Settings,
Star,
Upload,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import UserBadges from '../components/UserBadges';
import PointsWalletPanel from '../components/PointsWalletPanel';
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 { useNoIndexSEO } from '../hooks/usePageSEO';
import { api } from '../api/client';
import type { PostItem, UserActivityStats } from '../api/types';
import { useForumLimits } from '../hooks/useForumLimits';
import AvatarCropDialog from '../components/AvatarCropDialog';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
import { useSessionResource } from '../hooks/useSessionResource';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
});
const sigSchema = (maxLen: number) => z.object({
signature: z.string().max(maxLen > 0 ? maxLen : 512, `签名不能超过 ${maxLen || 512}`),
});
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 SigValues = z.infer<ReturnType<typeof sigSchema>>;
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
type ProfileTab = 'posts' | 'points' | 'settings' | 'security';
function parseTab(raw: string | null): ProfileTab {
if (raw === 'settings' || raw === 'security' || raw === 'points' || raw === 'posts') return raw;
return 'posts';
}
export default function ProfilePage() {
const nav = useNavigate();
const [params, setParams] = useSearchParams();
const tab = parseTab(params.get('tab'));
const { user, loading: authLoading, refresh, logout } = useAuth();
useNoIndexSEO('个人中心');
const [nickLoading, setNickLoading] = useState(false);
const [sigLoading, setSigLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [cropOpen, setCropOpen] = useState(false);
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [idCopied, setIdCopied] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
const sigSectionRef = useRef<HTMLDivElement>(null);
const pendingFocusSig = useRef(false);
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' },
});
const sigMax = limits.signature_max > 0 ? limits.signature_max : 200;
const sigForm = useForm<SigValues>({
resolver: zodResolver(sigSchema(sigMax)),
values: { signature: user?.signature ?? '' },
});
const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema(limits.password_min_len)),
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
});
const setTab = (next: ProfileTab) => {
const nextParams = new URLSearchParams(params);
if (next === 'posts') nextParams.delete('tab');
else nextParams.set('tab', next);
setParams(nextParams, { replace: true });
};
const focusSignatureField = useCallback(() => {
sigSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => {
sigSectionRef.current?.querySelector('textarea')?.focus();
}, 80);
}, []);
/** 顶部签名 /「编辑资料」:跳到资料 Tab 并聚焦签名框 */
const goEditSignature = () => {
if (tab !== 'settings') {
pendingFocusSig.current = true;
setTab('settings');
return;
}
focusSignatureField();
};
const goEditProfile = () => {
if (tab !== 'settings') {
setTab('settings');
return;
}
window.scrollTo({ top: 0, behavior: 'smooth' });
};
useEffect(() => {
if (tab !== 'settings' || !pendingFocusSig.current) return;
pendingFocusSig.current = false;
const t = window.setTimeout(focusSignatureField, 40);
return () => window.clearTimeout(t);
}, [tab, focusSignatureField]);
useEffect(() => {
if (!authLoading && !user) {
nav(loginPath('/profile'));
}
}, [authLoading, user, nav]);
useEffect(() => {
return () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
};
}, [avatarPreview]);
useEffect(() => {
return () => {
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
};
}, [cropImageSrc]);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
const { data: stats = null, loading: statsLoading } = useSessionResource<UserActivityStats | null>(
user ? `profile:stats:${user.id}` : null,
() => api.profileStats().then(d => d.stats ?? null),
{ enabled: !!user },
);
const [postPage, setPostPage] = useState(1);
const postsKey = user && tab === 'posts' ? `profile:posts:${user.id}:${postPage}:${pageSize}` : null;
const { data: postsSnap, loading: postsLoading } = useSessionResource<{ posts: PostItem[]; total: number }>(
postsKey,
() => api.posts({ user_id: user!.id, page: postPage, size: pageSize, sort: 'latest' })
.then(d => ({ posts: Array.isArray(d.posts) ? d.posts : [], total: d.total ?? 0 })),
{
enabled: !!postsKey,
onError: (e) => notify.error(e instanceof Error ? e.message : '加载帖子失败'),
},
);
const posts = postsSnap?.posts ?? [];
const postTotal = postsSnap?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
const closeCropDialog = useCallback((open: boolean) => {
if (!open) {
setCropOpen(false);
setCropImageSrc(prev => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setCropFileName('');
if (fileRef.current) fileRef.current.value = '';
}
}, []);
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 onUpdateSig = async (values: SigValues) => {
setSigLoading(true);
try {
await api.updateSignature(values.signature);
await refresh();
notify.success('签名已更新');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '更新失败');
} finally {
setSigLoading(false);
}
};
const onUpdatePwd = async (values: PwdValues) => {
setPwdLoading(true);
try {
await api.updatePassword(values.old_password, values.new_password);
notify.success('密码已修改,请重新登录');
pwdForm.reset();
await logout();
nav('/login');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '修改失败');
} finally {
setPwdLoading(false);
}
};
const clearPendingAvatar = () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
setPendingAvatar(null);
setAvatarPreview(null);
if (fileRef.current) fileRef.current.value = '';
};
const openCropForFile = (file: File) => {
const err = validateAvatarFile(file);
if (err) {
notify.error(err);
if (fileRef.current) fileRef.current.value = '';
return;
}
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
setCropFileName(file.name);
setCropImageSrc(URL.createObjectURL(file));
setCropOpen(true);
};
const onAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) openCropForFile(file);
};
const onCropConfirm = (file: File) => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
setPendingAvatar(file);
setAvatarPreview(URL.createObjectURL(file));
};
const onSaveAvatar = async () => {
if (!pendingAvatar) return;
setAvatarLoading(true);
try {
await api.uploadAvatar(pendingAvatar);
await refresh();
clearPendingAvatar();
notify.success('头像已更新');
} catch (err: unknown) {
notify.error(err instanceof Error ? err.message : '上传失败');
} finally {
setAvatarLoading(false);
}
};
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current += 1;
if (e.dataTransfer.types.includes('Files')) {
setDragOver(true);
}
};
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current -= 1;
if (dragCounter.current <= 0) {
dragCounter.current = 0;
setDragOver(false);
}
};
const onDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragOver(false);
if (avatarLoading) return;
const file = e.dataTransfer.files?.[0];
if (file) openCropForFile(file);
};
const copyUserId = async () => {
try {
await navigator.clipboard.writeText(String(user.id));
setIdCopied(true);
notify.success('已复制用户 ID');
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setIdCopied(false), 1600);
} catch {
notify.error('复制失败,请手动选择');
}
};
const displayAvatar = avatarPreview ?? user.avatar;
const joinedAt = user.created_at ? formatDateTime(user.created_at) : '';
const tabs: { key: ProfileTab; label: string; count?: number }[] = [
{ key: 'posts', label: '我的帖子', count: stats?.post_count },
{ key: 'points', label: '积分' },
{ key: 'settings', label: '资料' },
{ key: 'security', label: '安全' },
];
return (
<div className="page-wrap">
<div className="page-inner-wide page-inner-wide--profile">
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
<h1 className="page-title mb-4"></h1>
<div
className={`profile-header-card${dragOver ? ' profile-header-card--dragover' : ''}`}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{dragOver && (
<div className="profile-drop-overlay">
<Upload size={28} strokeWidth={1.5} />
<span></span>
</div>
)}
<input
ref={fileRef}
type="file"
accept={AVATAR_ACCEPT}
className="sr-only"
onChange={onAvatarSelect}
/>
<button
type="button"
className="profile-avatar-btn"
title="点击或拖拽图片到此处更换头像"
disabled={avatarLoading}
onClick={() => fileRef.current?.click()}
>
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
{displayAvatar
? <img src={displayAvatar} alt="" loading="lazy" decoding="async" />
: user.nickname[0]}
<span className="profile-avatar-overlay">
{avatarLoading
? <Spinner size="sm" className="text-white" />
: <Camera size={22} strokeWidth={1.75} />}
</span>
</div>
</button>
<div className="profile-header-info">
<div className="profile-header-main">
<div className="profile-name-row">
<h2 className="profile-display-name">{user.nickname}</h2>
<UserBadges user={user} compact={false} maxAchievement={6} />
</div>
<div className="profile-username">@{user.username}</div>
<div className="profile-id-row">
<span className="profile-id-chip" title="用户 ID">
<Hash size={13} aria-hidden />
UID {user.id}
</span>
<button
type="button"
className="profile-id-copy"
onClick={copyUserId}
aria-label="复制用户 ID"
>
{idCopied ? <Check size={14} /> : <Copy size={14} />}
{idCopied ? '已复制' : '复制'}
</button>
<button
type="button"
className="profile-id-copy"
onClick={() => nav(userPath(user.id))}
>
</button>
<button
type="button"
className="profile-id-copy"
onClick={goEditProfile}
>
<PenLine size={13} aria-hidden />
</button>
</div>
{user.signature?.trim() ? (
<button
type="button"
className="profile-signature profile-signature--editable"
onClick={goEditSignature}
title="编辑个性签名"
>
<span className="profile-signature-text">{user.signature}</span>
<span className="profile-signature-edit">
<PenLine size={13} aria-hidden />
</span>
</button>
) : (
<button
type="button"
className="profile-signature profile-signature--empty profile-signature--editable"
onClick={goEditSignature}
>
<PenLine size={14} aria-hidden />
</button>
)}
</div>
{pendingAvatar && (
<div className="profile-avatar-actions">
<span className="profile-avatar-hint"></span>
<Button size="sm" loading={avatarLoading} onClick={onSaveAvatar}>
</Button>
<Button
size="sm"
variant="ghost"
disabled={avatarLoading}
onClick={clearPendingAvatar}
aria-label="取消"
>
<X size={16} />
</Button>
</div>
)}
</div>
<div className="profile-stat-grid" aria-label="活动统计">
<button type="button" className="profile-stat" onClick={() => setTab('posts')}>
<FileText size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.post_count ?? 0)}</strong>
<span></span>
</button>
<div className="profile-stat">
<MessageCircle size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.comment_count ?? 0)}</strong>
<span></span>
</div>
<button type="button" className="profile-stat" onClick={() => nav('/favorites')}>
<Star size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.favorite_count ?? 0)}</strong>
<span></span>
</button>
<div className="profile-stat">
<Heart size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.like_received ?? 0)}</strong>
<span></span>
</div>
</div>
</div>
<AvatarCropDialog
open={cropOpen}
imageSrc={cropImageSrc}
fileName={cropFileName}
maxMb={limits.avatar_max_mb}
onOpenChange={closeCropDialog}
onConfirm={onCropConfirm}
/>
{user.role === 'admin' && (
<div className="admin-entry-bar">
<span className="admin-entry-bar-label">
<Settings size={14} aria-hidden />
</span>
<div className="admin-entry-bar-actions">
<Button size="sm" variant="outline" onClick={() => nav('/admin/boards')}>
</Button>
<Button size="sm" onClick={() => nav('/admin/dashboard')}>
<LayoutDashboard size={14} />
</Button>
</div>
</div>
)}
<div className="profile-tabs" role="tablist" aria-label="个人中心分区">
{tabs.map(t => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={`profile-tab${tab === t.key ? ' active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.key === 'points' && <Coins size={14} aria-hidden className="profile-tab-icon" />}
{t.label}
{typeof t.count === 'number' && (
<span className="profile-tab-count">{t.count}</span>
)}
</button>
))}
</div>
{tab === 'posts' && (
<div className="profile-panel">
{postsLoading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : posts.length === 0 ? (
<div className="empty-state">
<PenLine className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<Button onClick={() => nav('/compose')}></Button>
</div>
) : (
<>
<div className="content-surface">
{posts.map(post => (
<PostListItem
key={post.id}
post={post}
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
/>
))}
</div>
{totalPages > 1 && (
<FeedPagination
page={postPage}
totalPages={totalPages}
postTotal={postTotal}
loading={postsLoading}
onPageChange={setPostPage}
/>
)}
</>
)}
</div>
)}
{tab === 'points' && (
<div className="profile-panel profile-panel--points">
<PointsWalletPanel />
</div>
)}
{tab === 'settings' && (
<div className="section-card">
<div className="section-card-title"></div>
<p className="profile-settings-lead">
</p>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
JPG / PNG / GIF / WebP {limits.avatar_max_mb}MB
</span>
<Button type="submit" loading={nickLoading}></Button>
</div>
</form>
</Form>
<div className="profile-form-divider" />
<div ref={sigSectionRef} id="profile-signature">
<Form {...sigForm}>
<form onSubmit={sigForm.handleSubmit(onUpdateSig)} className="profile-form">
<FormField
control={sigForm.control}
name="signature"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
rows={3}
maxLength={sigMax}
placeholder="写一句介绍自己的话,会显示在公开主页与个人中心顶部"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
{(sigForm.watch('signature') || '').length}/{sigMax}
</span>
<Button type="submit" loading={sigLoading}></Button>
</div>
</form>
</Form>
</div>
<div className="profile-form-divider" />
<div className="section-card-title section-card-title--sub"></div>
<div className="profile-form profile-form--readonly">
<div className="profile-readonly-field">
<Label htmlFor="profile-readonly-id"> ID</Label>
<Input id="profile-readonly-id" value={String(user.id)} disabled />
</div>
<div className="profile-readonly-field">
<Label htmlFor="profile-readonly-username"></Label>
<Input id="profile-readonly-username" value={user.username} disabled />
</div>
<div className="profile-readonly-field">
<Label htmlFor="profile-readonly-email"></Label>
<Input id="profile-readonly-email" value={user.email || '未设置'} disabled />
</div>
{joinedAt && (
<div className="profile-readonly-field">
<Label htmlFor="profile-readonly-joined"></Label>
<Input id="profile-readonly-joined" value={joinedAt} disabled />
</div>
)}
<p className="profile-form-hint"> ID </p>
</div>
</div>
)}
{tab === 'security' && (
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
<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={`至少 ${limits.password_min_len}`} {...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>
)}
/>
<div className="profile-form-footer profile-form-footer--end">
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</div>
</form>
</Form>
</div>
)}
</div>
<InFlowSiteFooter />
</div>
);
}