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

281 lines
10 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 { useEffect, useState } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import {
ArrowLeft,
FileText,
Hash,
Heart,
Mail,
MessageCircle,
PenLine,
Settings,
Star,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import UserBadges from '../components/UserBadges';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem, UserActivityStats, UserPublic } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import { useSessionResource } from '../hooks/useSessionResource';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import ComposeMessageDialog from '../components/ComposeMessageDialog';
import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
import { usePageSEO } from '../hooks/usePageSEO';
import { loginPath } from '../utils/authRedirect';
import { canonicalRedirectPath, parsePermalinkID, userPath } from '../utils/permalink';
import NotFoundPage from './NotFoundPage';
import { InFlowSiteFooter } from '../components/SiteFooter';
type ProfileSnap = { profile: UserPublic; stats: UserActivityStats | null };
type PostsSnap = { posts: PostItem[]; total: number };
export default function UserProfilePage() {
const { id: idParam } = useParams();
const userId = parsePermalinkID(idParam);
const nav = useNavigate();
const location = useLocation();
const { user: me } = useAuth();
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [msgOpen, setMsgOpen] = useState(false);
const [notFound, setNotFound] = useState(false);
const [postPage, setPostPage] = useState(1);
const profileKey = userId && !Number.isNaN(userId) ? `user:${userId}` : null;
const { data: profileSnap, loading } = useSessionResource<ProfileSnap>(
profileKey,
() => api.userProfile(userId).then(d => ({ profile: d.user, stats: d.stats ?? null })),
{
enabled: !!profileKey,
onError: () => setNotFound(true),
},
);
const profile = profileSnap?.profile ?? null;
const stats = profileSnap?.stats ?? null;
const postsKey = profileKey && profile ? `${profileKey}:posts:${postPage}:${pageSize}` : null;
const { data: postsSnap, loading: postsLoading } = useSessionResource<PostsSnap>(
postsKey,
() => api.posts({ user_id: userId, 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 isSelf = !!me && me.id === userId;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
useEffect(() => {
if (!userId || Number.isNaN(userId)) {
setNotFound(true);
}
}, [userId]);
useEffect(() => {
setNotFound(false);
setPostPage(1);
}, [userId]);
useEffect(() => {
if (!userId || Number.isNaN(userId)) return;
const target = canonicalRedirectPath('user', userId, location.pathname, limits);
if (target) nav(target + location.search + location.hash, { replace: true });
}, [userId, location.pathname, location.search, location.hash, limits, nav]);
usePageSEO(profile ? {
title: `${profile.nickname} 的主页`,
description: profile.signature?.trim() || `${profile.nickname} 的主页`,
canonicalPath: userPath(profile.id, limits),
ogType: 'profile',
ogImage: profile.avatar || '',
jsonLd: {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
mainEntity: {
'@type': 'Person',
name: profile.nickname,
description: profile.signature?.trim() || undefined,
},
},
} : null);
if (loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (notFound || !profile) {
return (
<NotFoundPage
title="用户不存在"
description="该用户不存在,或账号不可访问。"
/>
);
}
const joinedAt = profile.created_at ? formatDateTime(profile.created_at) : '';
const signature = profile.signature?.trim() || '';
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>
<div className="profile-header-card profile-header-card--public">
<div className="profile-avatar-lg" aria-hidden>
{profile.avatar
? <img src={profile.avatar} alt="" loading="lazy" decoding="async" />
: profile.nickname[0]}
</div>
<div className="profile-header-info">
<div className="profile-header-main">
<div className="profile-name-row">
<h1 className="profile-display-name">{profile.nickname}</h1>
<UserBadges user={profile} compact={false} maxAchievement={6} />
{profile.banned && <Badge variant="destructive"></Badge>}
</div>
<div className="profile-username">@{profile.username}</div>
<div className="profile-id-row">
<span className="profile-id-chip" title="用户 ID">
<Hash size={13} aria-hidden />
UID {profile.id}
</span>
</div>
{signature ? (
<p className="profile-signature">{signature}</p>
) : (
<p className="profile-signature profile-signature--empty"></p>
)}
<dl className="profile-meta-list">
{joinedAt && (
<div>
<dt></dt>
<dd>{joinedAt}</dd>
</div>
)}
</dl>
</div>
<div className="profile-avatar-actions">
{isSelf ? (
<Button size="sm" variant="outline" onClick={() => nav('/profile?tab=settings')}>
<Settings size={14} />
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
if (!me) {
nav(loginPath(userPath(profile.id)));
return;
}
setMsgOpen(true);
}}
>
<Mail size={14} />
</Button>
)}
</div>
</div>
{!!profile.badges?.length && (
<div className="profile-badge-wall" aria-label="徽章墙">
<h3 className="profile-badge-wall-title"></h3>
<UserBadges user={profile} compact={false} maxAchievement={20} showLevel={false} />
</div>
)}
<div className="profile-stat-grid" aria-label="活动统计">
<div className="profile-stat">
<FileText size={16} aria-hidden />
<strong>{stats?.post_count ?? 0}</strong>
<span></span>
</div>
<div className="profile-stat">
<MessageCircle size={16} aria-hidden />
<strong>{stats?.comment_count ?? 0}</strong>
<span></span>
</div>
<div className="profile-stat">
<Heart size={16} aria-hidden />
<strong>{stats?.like_received ?? 0}</strong>
<span></span>
</div>
{isSelf && (
<button type="button" className="profile-stat" onClick={() => nav('/favorites')}>
<Star size={16} aria-hidden />
<strong>{stats?.favorite_count ?? 0}</strong>
<span></span>
</button>
)}
</div>
</div>
<div className="section-card-title profile-posts-heading">
{isSelf ? '我的帖子' : `${profile.nickname} 的帖子`}
{postTotal > 0 && <span className="profile-tab-count">{postTotal}</span>}
</div>
<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>{isSelf ? '还没有发布过帖子' : '暂无公开帖子'}</p>
{isSelf && <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>
</div>
<InFlowSiteFooter />
{!isSelf && profile && me && (
<ComposeMessageDialog
open={msgOpen}
onOpenChange={setMsgOpen}
toUserId={profile.id}
toNickname={profile.nickname}
onSent={() => nav(`/messages?peer=${profile.id}`)}
/>
)}
</div>
);
}