From 442af625f6088395e20fcc197fe4af1936785aff Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 17 Sep 2026 00:57:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin):=20=E5=90=8E=E5=8F=B0=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=88=87=E6=8D=A2=E4=BF=9D=E7=95=99=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=86=85=E5=AE=B9=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户档案、用户列表、内容筛选与公告页等数据到达后再换视图,避免滚动条闪动。 Co-authored-by: Cursor --- .../admin/announcements/AnnouncementAdmin.tsx | 28 ++-- frontend/app/admin/announcements/page.tsx | 13 +- frontend/app/admin/content/ContentAdmin.tsx | 21 ++- frontend/app/admin/users/UsersAdmin.tsx | 35 ++--- .../app/admin/users/[id]/UserAuditClient.tsx | 144 ++++++++++++------ frontend/app/admin/users/[id]/page.tsx | 45 +++++- frontend/lib/api.ts | 27 ++++ 7 files changed, 213 insertions(+), 100 deletions(-) diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index a496337..2909dbb 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Plus, Pencil, @@ -51,32 +51,30 @@ const EMPTY_FORM: FormState = { published: true, }; -export default function AnnouncementAdmin() { - const [list, setList] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(""); +export default function AnnouncementAdmin({ + initial, + initError, +}: { + initial: Announcement[]; + initError: string; +}) { + const [list, setList] = useState(initial); + const [loadError, setLoadError] = useState(initError); const [form, setForm] = useState(null); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const load = useCallback(async () => { - setLoading(true); try { const res = await apiAdminListAnnouncements(); setList(res.announcements); setLoadError(""); } catch (e) { setLoadError(e instanceof Error ? e.message : "加载公告失败"); - } finally { - setLoading(false); } }, []); - useEffect(() => { - load(); - }, [load]); - const startCreate = () => setForm({ ...EMPTY_FORM }); const startEdit = (a: Announcement) => setForm({ @@ -263,11 +261,7 @@ export default function AnnouncementAdmin() { 共 {list.length} 条 - {loading ? ( -
- 加载中… -
- ) : loadError ? ( + {loadError ? (

{loadError}

) : list.length === 0 ? ( ; } - return ; + + let initial: Announcement[] = []; + let initError = ""; + try { + const res = await fetchAdminAnnouncements(cookie || undefined); + initial = res.announcements ?? []; + } catch (e) { + initError = e instanceof Error ? e.message : "加载公告失败"; + } + + return ; } diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index 5a0d4fd..d1cd596 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Check, @@ -153,6 +153,7 @@ export default function ContentAdmin({ const [appliedQ, setAppliedQ] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(initError); + const loadSeq = useRef(0); const [actingKey, setActingKey] = useState(null); const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish); @@ -178,34 +179,44 @@ export default function ContentAdmin({ }, []); const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => { + const seq = ++loadSeq.current; setLoading(true); try { const res = await apiAdminContentPosts(page, st, q); + if (seq !== loadSeq.current) return; setPosts(res.posts); setPostsTotal(res.total); setPostsPage(page); setKind("posts"); + setStatus(st); + setAppliedQ(q); setError(""); } catch (e) { + if (seq !== loadSeq.current) return; setError(e instanceof Error ? e.message : "获取帖子失败"); } finally { - setLoading(false); + if (seq === loadSeq.current) setLoading(false); } }, []); const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => { + const seq = ++loadSeq.current; setLoading(true); try { const res = await apiAdminContentComments(page, st, q); + if (seq !== loadSeq.current) return; setComments(res.comments); setCommentsTotal(res.total); setCommentsPage(page); setKind("comments"); + setStatus(st); + setAppliedQ(q); setError(""); } catch (e) { + if (seq !== loadSeq.current) return; setError(e instanceof Error ? e.message : "获取评论失败"); } finally { - setLoading(false); + if (seq === loadSeq.current) setLoading(false); } }, []); @@ -239,7 +250,6 @@ export default function ContentAdmin({ const switchStatus = (st: AdminContentStatus) => { if (st === status) return; - setStatus(st); setError(""); if (kind === "posts") void loadPosts(1, st, appliedQ); else void loadComments(1, st, appliedQ); @@ -247,13 +257,12 @@ export default function ContentAdmin({ const applySearch = () => { const q = keyword.trim(); - setAppliedQ(q); if (kind === "posts") void loadPosts(1, status, q); else void loadComments(1, status, q); }; const clearSearch = () => { - setAppliedQ(""); + setKeyword(""); if (kind === "posts") void loadPosts(1, status, ""); else void loadComments(1, status, ""); }; diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx index bced067..ac276e6 100644 --- a/frontend/app/admin/users/UsersAdmin.tsx +++ b/frontend/app/admin/users/UsersAdmin.tsx @@ -10,7 +10,6 @@ import { ExternalLink, FolderSearch, History, - Loader2, Monitor, ShieldAlert, ShieldCheck, @@ -151,6 +150,9 @@ export default function UsersAdmin({ setUsers(res.users); setSummary(res.summary); setTotal(res.total); + setFilter(opts.filter); + setPage(opts.page); + setAppliedQ(opts.q); setError(""); } catch (e) { if (seq !== seqRef.current) return; @@ -166,8 +168,6 @@ export default function UsersAdmin({ debounceRef.current = setTimeout(() => { const q = keyword.trim(); if (q !== appliedQ) { - setAppliedQ(q); - setPage(1); load({ page: 1, filter, q }); } }, 400); @@ -195,8 +195,6 @@ export default function UsersAdmin({ const submitSearch = () => { const q = keyword.trim(); if (q === appliedQ) return; - setAppliedQ(q); - setPage(1); load({ page: 1, filter, q }); }; @@ -204,16 +202,12 @@ export default function UsersAdmin({ const clearSearch = () => { setKeyword(""); if (appliedQ !== "" || page !== 1) { - setAppliedQ(""); - setPage(1); load({ page: 1, filter, q: "" }); } }; const changeFilter = (f: UserFilter) => { if (f === filter) return; - setFilter(f); - setPage(1); load({ page: 1, filter: f, q: appliedQ }); }; @@ -221,7 +215,6 @@ export default function UsersAdmin({ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const next = Math.min(Math.max(1, p), totalPages); if (next === page) return; - setPage(next); load({ page: next, filter, q: appliedQ }); }; @@ -467,13 +460,9 @@ export default function UsersAdmin({ )} -
+
{error ? (

{error}

- ) : loading && users.length === 0 ? ( -
- 加载中… -
) : users.length === 0 ? ( } @@ -713,17 +702,15 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos } >
- {loading ? ( -
- 加载中… -
- ) : err ? ( + {err ? (

{err}

) : logs.length === 0 ? ( -
- -

暂无登录记录

-
+ loading ? null : ( +
+ +

暂无登录记录

+
+ ) ) : (
    {logs.map((log) => ( diff --git a/frontend/app/admin/users/[id]/UserAuditClient.tsx b/frontend/app/admin/users/[id]/UserAuditClient.tsx index c308e10..bbd339e 100644 --- a/frontend/app/admin/users/[id]/UserAuditClient.tsx +++ b/frontend/app/admin/users/[id]/UserAuditClient.tsx @@ -1,15 +1,8 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import Link from "next/link"; -import { - ArrowLeft, - ExternalLink, - FileText, - Loader2, - MessageCircle, - MessagesSquare, -} from "lucide-react"; +import { ArrowLeft, ExternalLink, FileText, MessageCircle, MessagesSquare } from "lucide-react"; import { apiAdminUserAuditComments, apiAdminUserAuditMessages, @@ -69,56 +62,121 @@ function StatusChips({ export default function UserAuditClient({ userId, initial, + initialPosts, + initialPostsTotal, + initError, }: { userId: number; initial: AdminUserAuditProfile; + initialPosts: AdminAuditPostItem[]; + initialPostsTotal: number; + initError: string; }) { const [profile] = useState(initial); const [tab, setTab] = useState("posts"); - const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [posts, setPosts] = useState([]); + const [error, setError] = useState(initError); + const [posts, setPosts] = useState(initialPosts); const [comments, setComments] = useState([]); const [messages, setMessages] = useState([]); - const [total, setTotal] = useState(0); + const [postsTotal, setPostsTotal] = useState(initialPostsTotal); + const [commentsTotal, setCommentsTotal] = useState(0); + const [messagesTotal, setMessagesTotal] = useState(0); + const [postsPage, setPostsPage] = useState(1); + const [commentsPage, setCommentsPage] = useState(1); + const [messagesPage, setMessagesPage] = useState(1); + const loadSeq = useRef(0); + const loadedRef = useRef({ posts: true, comments: false, messages: false }); const u = profile.user; - const load = useCallback( - async (t: TabKey, p: number) => { + const loadPosts = useCallback( + async (p: number) => { + const seq = ++loadSeq.current; setLoading(true); - setError(""); try { - if (t === "posts") { - const res = await apiAdminUserAuditPosts(userId, p); - setPosts(res.items || []); - setTotal(res.total); - } else if (t === "comments") { - const res = await apiAdminUserAuditComments(userId, p); - setComments(res.items || []); - setTotal(res.total); - } else { - const res = await apiAdminUserAuditMessages(userId, p); - setMessages(res.items || []); - setTotal(res.total); - } + const res = await apiAdminUserAuditPosts(userId, p); + if (seq !== loadSeq.current) return; + setPosts(res.items || []); + setPostsTotal(res.total); + setPostsPage(p); + loadedRef.current.posts = true; + setTab("posts"); + setError(""); } catch (e) { + if (seq !== loadSeq.current) return; setError(e instanceof Error ? e.message : "加载失败"); } finally { - setLoading(false); + if (seq === loadSeq.current) setLoading(false); } }, [userId] ); - useEffect(() => { - load(tab, page); - }, [tab, page, load]); + const loadComments = useCallback( + async (p: number) => { + const seq = ++loadSeq.current; + setLoading(true); + try { + const res = await apiAdminUserAuditComments(userId, p); + if (seq !== loadSeq.current) return; + setComments(res.items || []); + setCommentsTotal(res.total); + setCommentsPage(p); + loadedRef.current.comments = true; + setTab("comments"); + setError(""); + } catch (e) { + if (seq !== loadSeq.current) return; + setError(e instanceof Error ? e.message : "加载失败"); + } finally { + if (seq === loadSeq.current) setLoading(false); + } + }, + [userId] + ); + + const loadMessages = useCallback( + async (p: number) => { + const seq = ++loadSeq.current; + setLoading(true); + try { + const res = await apiAdminUserAuditMessages(userId, p); + if (seq !== loadSeq.current) return; + setMessages(res.items || []); + setMessagesTotal(res.total); + setMessagesPage(p); + loadedRef.current.messages = true; + setTab("messages"); + setError(""); + } catch (e) { + if (seq !== loadSeq.current) return; + setError(e instanceof Error ? e.message : "加载失败"); + } finally { + if (seq === loadSeq.current) setLoading(false); + } + }, + [userId] + ); const switchTab = (t: TabKey) => { - setTab(t); - setPage(1); + if (t === tab) return; + setError(""); + if (loadedRef.current[t]) { + loadSeq.current += 1; + setLoading(false); + setTab(t); + return; + } + if (t === "posts") void loadPosts(1); + else if (t === "comments") void loadComments(1); + else void loadMessages(1); + }; + + const goPage = (p: number) => { + if (tab === "posts") void loadPosts(p); + else if (tab === "comments") void loadComments(p); + else void loadMessages(p); }; const tabCount = (t: TabKey) => { @@ -127,6 +185,11 @@ export default function UserAuditClient({ return profile.messages_total; }; + const page = tab === "posts" ? postsPage : tab === "comments" ? commentsPage : messagesPage; + const total = tab === "posts" ? postsTotal : tab === "comments" ? commentsTotal : messagesTotal; + const empty = + tab === "posts" ? posts.length === 0 : tab === "comments" ? comments.length === 0 : messages.length === 0; + const emptyIcon = tab === "posts" ? ( @@ -232,14 +295,7 @@ export default function UserAuditClient({ {error}

    )} - {!error && loading && posts.length + comments.length + messages.length === 0 && ( -
    - -
    - )} - {!error && !loading && total === 0 && ( - - )} + {!error && empty && } {tab === "posts" && posts.map((p) => ( @@ -357,7 +413,7 @@ export default function UserAuditClient({ total={total} pageSize={PAGE_SIZE} loading={loading} - onChange={setPage} + onChange={goPage} />
diff --git a/frontend/app/admin/users/[id]/page.tsx b/frontend/app/admin/users/[id]/page.tsx index c39f1a1..9d8e69b 100644 --- a/frontend/app/admin/users/[id]/page.tsx +++ b/frontend/app/admin/users/[id]/page.tsx @@ -1,7 +1,11 @@ import type { Metadata } from "next"; import { cookies } from "next/headers"; import { authCookieHeader } from "@/lib/cookies"; -import { fetchAdminUserAudit } from "@/lib/api"; +import { + fetchAdminUserAudit, + fetchAdminUserAuditPosts, + type AdminAuditPostItem, +} from "@/lib/api"; import { getMeCached } from "@/lib/serverData"; import { isOwner, isSuperOrOwner } from "@/lib/roles"; import Forbidden from "../../Forbidden"; @@ -32,13 +36,38 @@ export default async function AdminUserAuditPage({ params }: PageProps) { return ; } - let profile; - try { - profile = await fetchAdminUserAudit(userId, cookie || undefined); - } catch (e) { - const msg = e instanceof Error ? e.message : "获取用户档案失败"; - return ; + const cookieOrUndef = cookie || undefined; + const [profileSettled, postsSettled] = await Promise.all([ + fetchAdminUserAudit(userId, cookieOrUndef) + .then((profile) => ({ ok: true as const, profile })) + .catch((e) => ({ + ok: false as const, + error: e instanceof Error ? e.message : "获取用户档案失败", + })), + fetchAdminUserAuditPosts(userId, 1, cookieOrUndef) + .then((res) => ({ ok: true as const, res })) + .catch((e) => ({ + ok: false as const, + error: e instanceof Error ? e.message : "获取帖子失败", + })), + ]); + + if (!profileSettled.ok) { + return ; } - return ; + const initialPosts: AdminAuditPostItem[] = postsSettled.ok ? postsSettled.res.items || [] : []; + const initialPostsTotal = postsSettled.ok ? postsSettled.res.total : 0; + const initError = postsSettled.ok ? "" : postsSettled.error; + const profile = profileSettled.profile; + + return ( + + ); } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 571c934..480f64f 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1245,6 +1245,19 @@ export async function fetchAnnouncementDetail( return res.json(); } +// SSR 后台公告列表(含草稿) +export async function fetchAdminAnnouncements( + cookieHeader?: string +): Promise<{ announcements: Announcement[] }> { + const res = await fetch( + `${API_BASE}/api/admin/announcements`, + ssrInit(cookieHeaders(cookieHeader)) + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "获取公告列表失败"); + return data as { announcements: Announcement[] }; +} + // 后台:公告列表(含草稿) export async function apiAdminListAnnouncements(): Promise<{ announcements: Announcement[] }> { const res = await fetchWithRefresh("/api/admin/announcements", { @@ -1524,6 +1537,20 @@ export async function fetchAdminUserAudit( return data as AdminUserAuditProfile; } +export async function fetchAdminUserAuditPosts( + id: number, + page = 1, + cookieHeader?: string +): Promise> { + const res = await fetch( + `${API_BASE}/api/admin/users/${id}/posts?page=${page}`, + ssrInit(cookieHeaders(cookieHeader)) + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "获取帖子失败"); + return data as AdminAuditListResponse; +} + export async function apiAdminUserAudit(id: number): Promise { const res = await fetchWithRefresh(`/api/admin/users/${id}`, { headers: clientHeaders(),