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; type SigValues = z.infer>; type PwdValues = z.infer>; 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(null); const [avatarPreview, setAvatarPreview] = useState(null); const [cropOpen, setCropOpen] = useState(false); const [cropImageSrc, setCropImageSrc] = useState(null); const [cropFileName, setCropFileName] = useState(''); const [dragOver, setDragOver] = useState(false); const [idCopied, setIdCopied] = useState(false); const fileRef = useRef(null); const dragCounter = useRef(0); const copyTimer = useRef>(); const sigSectionRef = useRef(null); const pendingFocusSig = useRef(false); const { limits } = useForumLimits(); const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20; const nickForm = useForm({ resolver: zodResolver(nickSchema), values: { nickname: user?.nickname ?? '' }, }); const sigMax = limits.signature_max > 0 ? limits.signature_max : 200; const sigForm = useForm({ resolver: zodResolver(sigSchema(sigMax)), values: { signature: user?.signature ?? '' }, }); const pwdForm = useForm({ 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( 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
; } 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) => { 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 (

个人中心

{dragOver && (
松开以上传头像
)}

{user.nickname}

@{user.username}
UID {user.id}
{user.signature?.trim() ? ( ) : ( )}
{pendingAvatar && (
已裁剪,待保存
)}
{statsLoading ? '—' : (stats?.comment_count ?? 0)} 评论
{statsLoading ? '—' : (stats?.like_received ?? 0)} 获赞
{user.role === 'admin' && (
站长
)}
{tabs.map(t => ( ))}
{tab === 'posts' && (
{postsLoading ? (
) : posts.length === 0 ? (

还没有发布过帖子

) : ( <>
{posts.map(post => ( openForumPost(nav, id, limits.open_posts_in_new_tab)} /> ))}
{totalPages > 1 && ( )} )}
)} {tab === 'points' && (
)} {tab === 'settings' && (
展示资料

昵称与个性签名会显示在公开主页和帖子旁

( 昵称 )} />
头像可在上方点击或拖拽更换;支持 JPG / PNG / GIF / WebP,裁剪后不超过 {limits.avatar_max_mb}MB
( 个性签名