diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx index 556dd69..fce7a8d 100644 --- a/frontend/app/admin/AdminNav.tsx +++ b/frontend/app/admin/AdminNav.tsx @@ -11,7 +11,6 @@ import { Palette, ShieldCheck, ArrowLeft, - ClipboardCheck, Coins, Globe, FolderOpen, @@ -54,18 +53,12 @@ const NAV_GROUPS: NavGroup[] = [ { title: "内容", items: [ - { - href: "/admin/moderation", - label: "内容审核", - icon: ClipboardCheck, - visible: (u) => canModerateAny(u), - pendingBadge: true, - }, { href: "/admin/content", label: "内容管理", icon: FolderOpen, visible: (u) => canModerateAny(u), + pendingBadge: true, }, { href: "/admin/announcements", diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index 744dee1..5a0d4fd 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { + Check, FileText, FolderOpen, History, @@ -10,6 +11,7 @@ import { MessagesSquare, RotateCcw, Trash2, + X, } from "lucide-react"; import { apiAdminContentCounts, @@ -21,6 +23,10 @@ import { apiAdminSoftDeleteComment, apiAdminRestoreComment, apiAdminPurgeComment, + apiAdminApprovePost, + apiAdminRejectPost, + apiAdminApproveComment, + apiAdminRejectComment, apiUpdateSiteSettings, type AdminContentComment, type AdminContentCounts, @@ -30,6 +36,7 @@ import { } from "@/lib/api"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; +import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; import Avatar from "@/components/Avatar"; import ConfirmDialog from "@/components/ConfirmDialog"; import StaffDeletePostModal from "@/components/StaffDeletePostModal"; @@ -44,13 +51,14 @@ import { AdminSegmented, AdminSettingRow, AdminStatusChip, + AdminStepper, AdminSwitch, contentStatusChip, } from "@/components/admin"; const PAGE_SIZE = 15; -type Kind = "posts" | "comments"; +export type ContentKind = "posts" | "comments"; const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [ { key: "live", label: "全部" }, @@ -70,31 +78,75 @@ function excerpt(md: string, max = 120): string { return text.length > max ? `${text.slice(0, max)}…` : text; } +function isPendingRow(status: string, deleted: boolean) { + return !deleted && status === "pending"; +} + +function PendingActions({ + busy, + onApprove, + onReject, +}: { + busy: boolean; + onApprove: () => void; + onReject: () => void; +}) { + return ( + <> + + + + ); +} + +function emptyCopy(kind: ContentKind, status: AdminContentStatus): { title: string; description?: string } { + if (status === "pending") { + return { + title: kind === "posts" ? "暂无待审帖子" : "暂无待审评论", + description: "新提交的内容会按时间顺序出现在这里,可通过或拒绝", + }; + } + return { title: kind === "posts" ? "暂无帖子" : "暂无评论" }; +} + export default function ContentAdmin({ + initialKind, + initialStatus, initialPosts, initialPostsTotal, + initialComments, + initialCommentsTotal, initialCounts, initError, canConfigureSettings, initialSettings, }: { + initialKind: ContentKind; + initialStatus: AdminContentStatus; initialPosts: AdminContentPost[]; initialPostsTotal: number; + initialComments: AdminContentComment[]; + initialCommentsTotal: number; initialCounts: AdminContentCounts; initError: string; canConfigureSettings: boolean; initialSettings: PublicSettings; }) { - const [kind, setKind] = useState("posts"); - const [status, setStatus] = useState("live"); + const [kind, setKind] = useState(initialKind); + const [status, setStatus] = useState(initialStatus); const [counts, setCounts] = useState(initialCounts); const [posts, setPosts] = useState(initialPosts); const [postsTotal, setPostsTotal] = useState(initialPostsTotal); const [postsPage, setPostsPage] = useState(1); - const [comments, setComments] = useState([]); - const [commentsTotal, setCommentsTotal] = useState(0); + const [comments, setComments] = useState(initialComments); + const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal); const [commentsPage, setCommentsPage] = useState(1); const [keyword, setKeyword] = useState(""); @@ -111,6 +163,7 @@ export default function ContentAdmin({ title: string; message: string; danger?: boolean; + confirmLabel?: string; run: () => Promise; } | null>(null); const [confirmBusy, setConfirmBusy] = useState(false); @@ -131,6 +184,7 @@ export default function ContentAdmin({ setPosts(res.posts); setPostsTotal(res.total); setPostsPage(page); + setKind("posts"); setError(""); } catch (e) { setError(e instanceof Error ? e.message : "获取帖子失败"); @@ -146,6 +200,7 @@ export default function ContentAdmin({ setComments(res.comments); setCommentsTotal(res.total); setCommentsPage(page); + setKind("comments"); setError(""); } catch (e) { setError(e instanceof Error ? e.message : "获取评论失败"); @@ -160,13 +215,23 @@ export default function ContentAdmin({ refreshCounts(); }; + const reloadAfterItemChange = () => { + const p = kind === "posts" ? postsPage : commentsPage; + const len = kind === "posts" ? posts.length : comments.length; + reload(len <= 1 && p > 1 ? p - 1 : p); + }; + useEffect(() => { refreshCounts(); }, [refreshCounts]); - const switchKind = (k: Kind) => { + useEffect(() => { + const off = realtime.on(RT_MODERATION_CHANGED, refreshCounts); + return off; + }, [refreshCounts]); + + const switchKind = (k: ContentKind) => { if (k === kind) return; - setKind(k); setError(""); if (k === "posts") void loadPosts(1, status, appliedQ); else void loadComments(1, status, appliedQ); @@ -198,7 +263,7 @@ export default function ContentAdmin({ fn() .then(() => { toast(okMsg, "ok"); - reload(kind === "posts" ? postsPage : commentsPage); + reloadAfterItemChange(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setActingKey(null)); @@ -208,8 +273,24 @@ export default function ContentAdmin({ title: string, message: string, run: () => Promise, - danger = false - ) => setConfirm({ title, message, run, danger }); + danger = false, + confirmLabel?: string + ) => setConfirm({ title, message, run, danger, confirmLabel }); + + const askReject = (kindNow: ContentKind, id: number) => { + askConfirm( + kindNow === "posts" ? "拒绝这篇帖子?" : "拒绝这条评论?", + "拒绝后作者将收到通知,内容不会公开。", + async () => { + if (kindNow === "posts") await apiAdminRejectPost(id); + else await apiAdminRejectComment(id); + toast("已拒绝,作者将收到通知", "ok"); + reloadAfterItemChange(); + }, + true, + "确认拒绝" + ); + }; const toggleTrust = async () => { if (!canConfigureSettings || savingPolicy) return; @@ -254,12 +335,18 @@ export default function ContentAdmin({ if (st === "pending") return counts.posts_pending; if (st === "rejected") return counts.posts_rejected; if (st === "deleted") return counts.posts_deleted; + if (st === "published") { + return Math.max(0, counts.posts_live - counts.posts_pending - counts.posts_rejected); + } return null; } if (st === "live") return counts.comments_live; if (st === "pending") return counts.comments_pending; if (st === "rejected") return counts.comments_rejected; if (st === "deleted") return counts.comments_deleted; + if (st === "published") { + return Math.max(0, counts.comments_live - counts.comments_pending - counts.comments_rejected); + } return null; }; @@ -268,12 +355,14 @@ export default function ContentAdmin({ else void loadComments(p, status, appliedQ); }; + const empty = emptyCopy(kind, status); + return (
{canConfigureSettings && ( @@ -294,88 +383,104 @@ export default function ContentAdmin({ -
- setCooldown(Number(e.target.value))} - aria-label="冷静期小时数" - /> - 小时 - -
-
+ control={ +
+ + +
+ } + />
)}
-
- - ariaLabel="内容类型" - value={kind} - onChange={switchKind} - options={[ - { - key: "posts", - label: "帖子", - icon: , - count: counts.posts_live, - }, - { - key: "comments", - label: "评论", - icon: , - count: counts.comments_live, - }, - ]} - /> - - ariaLabel="状态筛选" - size="sm" - value={status} - onChange={switchStatus} - options={STATUS_TABS.map((t) => ({ - key: t.key, - label: t.label, - count: statusCount(t.key) ?? undefined, - }))} - /> - +
+
+ + +
+ +
+ + ariaLabel="状态筛选" + size="sm" + value={status} + onChange={switchStatus} + options={STATUS_TABS.map((t) => ({ + key: t.key, + label: t.label, + count: statusCount(t.key) ?? undefined, + }))} + /> +
+
+ +
- {loading ? ( -
- 加载中… -
- ) : error ? ( -

{error}

- ) : kind === "posts" && posts.length === 0 ? ( - } title="暂无帖子" /> + {error ?

{error}

: null} + {kind === "posts" && posts.length === 0 ? ( + } title={empty.title} description={empty.description} /> ) : kind === "comments" && comments.length === 0 ? ( - } title="暂无评论" /> + } title={empty.title} description={empty.description} /> ) : kind === "posts" ? ( posts.map((p, idx) => { const key = `p-${p.id}`; const busy = actingKey === key; const chip = contentStatusChip(p.status, p.deleted); + const pending = isPendingRow(p.status, p.deleted); return (
-
+
+ {pending && ( + + runAction(key, () => apiAdminApprovePost(p.id), "已通过审核,内容已公开") + } + onReject={() => askReject("posts", p.id)} + /> + )} {p.deleted ? ( <> apiAdminPurgePost(p.id).then(() => { toast("帖子已彻底删除", "ok"); - reload(postsPage); + reloadAfterItemChange(); }), true ) @@ -469,6 +583,7 @@ export default function ContentAdmin({ const key = `c-${c.id}`; const busy = actingKey === key; const chip = contentStatusChip(c.status, c.deleted); + const pending = isPendingRow(c.status, c.deleted); return (
-
+
+ {pending && ( + + runAction(key, () => apiAdminApproveComment(c.id), "已通过审核,内容已公开") + } + onReject={() => askReject("comments", c.id)} + /> + )} apiAdminPurgeComment(c.id).then(() => { toast("评论已彻底删除", "ok"); - reload(commentsPage); + reloadAfterItemChange(); }), true ) @@ -582,7 +706,7 @@ export default function ContentAdmin({ message={confirm?.message || ""} danger={confirm?.danger} busy={confirmBusy} - confirmLabel={confirm?.danger ? "确认删除" : "确定"} + confirmLabel={confirm?.confirmLabel || (confirm?.danger ? "确认删除" : "确定")} onCancel={() => !confirmBusy && setConfirm(null)} onConfirm={() => { if (!confirm) return; @@ -609,7 +733,7 @@ export default function ContentAdmin({ .then(() => { toast("已移入回收站", "ok"); setStaffDeletePost(null); - reload(postsPage); + reloadAfterItemChange(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteBusy(false)); @@ -628,7 +752,7 @@ export default function ContentAdmin({ .then(() => { toast("已移入回收站", "ok"); setStaffDeleteComment(null); - reload(commentsPage); + reloadAfterItemChange(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteCommentBusy(false)); diff --git a/frontend/app/admin/content/page.tsx b/frontend/app/admin/content/page.tsx index ef9dae6..4ef6025 100644 --- a/frontend/app/admin/content/page.tsx +++ b/frontend/app/admin/content/page.tsx @@ -4,18 +4,39 @@ import { authCookieHeader } from "@/lib/cookies"; import { fetchAdminContentCounts, fetchAdminContentPosts, + fetchAdminContentComments, + type AdminContentComment, type AdminContentPost, + type AdminContentStatus, } from "@/lib/api"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { canModerateAny, isSuperOrOwner } from "@/lib/roles"; import Forbidden from "../Forbidden"; -import ContentAdmin from "./ContentAdmin"; +import ContentAdmin, { type ContentKind } from "./ContentAdmin"; export const metadata: Metadata = { title: "内容管理", }; -export default async function AdminContentPage() { +const STATUSES: AdminContentStatus[] = ["live", "published", "pending", "rejected", "deleted"]; + +function parseStatus(v?: string): AdminContentStatus { + return v && STATUSES.includes(v as AdminContentStatus) ? (v as AdminContentStatus) : "live"; +} + +function parseKind(v?: string): ContentKind { + return v === "comments" ? "comments" : "posts"; +} + +interface ContentPageProps { + searchParams: Promise<{ status?: string; kind?: string }>; +} + +export default async function AdminContentPage({ searchParams }: ContentPageProps) { + const sp = await searchParams; + const initialStatus = parseStatus(sp.status); + const initialKind = parseKind(sp.kind); + const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); if (!me.user || !canModerateAny(me.user)) { @@ -23,30 +44,49 @@ export default async function AdminContentPage() { } let initialPosts: AdminContentPost[] = []; - let initialTotal = 0; + let initialPostsTotal = 0; + let initialComments: AdminContentComment[] = []; + let initialCommentsTotal = 0; let initError = ""; - const [postsSettled, counts] = await Promise.all([ - fetchAdminContentPosts(1, "live", cookie || undefined) - .then((res) => ({ ok: true as const, res })) - .catch((e) => ({ - ok: false as const, - error: e instanceof Error ? e.message : "获取内容列表失败", - })), + const [listSettled, counts] = await Promise.all([ + initialKind === "comments" + ? fetchAdminContentComments(1, initialStatus, cookie || undefined) + .then((res) => ({ ok: true as const, kind: "comments" as const, res })) + .catch((e) => ({ + ok: false as const, + error: e instanceof Error ? e.message : "获取内容列表失败", + })) + : fetchAdminContentPosts(1, initialStatus, cookie || undefined) + .then((res) => ({ ok: true as const, kind: "posts" as const, res })) + .catch((e) => ({ + ok: false as const, + error: e instanceof Error ? e.message : "获取内容列表失败", + })), fetchAdminContentCounts(cookie || undefined), ]); - if (postsSettled.ok) { - initialPosts = postsSettled.res.posts; - initialTotal = postsSettled.res.total; + if (listSettled.ok) { + if (listSettled.kind === "posts") { + initialPosts = listSettled.res.posts; + initialPostsTotal = listSettled.res.total; + } else { + initialComments = listSettled.res.comments; + initialCommentsTotal = listSettled.res.total; + } } else { - initError = postsSettled.error; + initError = listSettled.error; } const settings = await getPublicSettingsCached(); return (
-
diff --git a/frontend/app/admin/moderation/ModerationAdmin.tsx b/frontend/app/admin/moderation/ModerationAdmin.tsx deleted file mode 100644 index d7c4094..0000000 --- a/frontend/app/admin/moderation/ModerationAdmin.tsx +++ /dev/null @@ -1,415 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import Link from "next/link"; -import { - Check, - ClipboardCheck, - FileText, - Loader2, - MessagesSquare, - X, -} from "lucide-react"; -import { - apiAdminPendingPosts, - apiAdminPendingComments, - apiAdminPendingCounts, - apiAdminApprovePost, - apiAdminRejectPost, - apiAdminApproveComment, - apiAdminRejectComment, - type PendingCommentItem, - type PendingCounts, - type Post, -} from "@/lib/api"; -import { formatRelative } from "@/lib/format"; -import { toast } from "@/lib/toast"; -import Avatar from "@/components/Avatar"; -import { - AdminEmpty, - AdminPageHeader, - AdminPager, - AdminSegmented, -} from "@/components/admin"; - -const PAGE_SIZE = 15; - -type Tab = "posts" | "comments"; - -// 粗略去除 Markdown 标记,生成纯文本摘要 -function excerpt(md: string, max = 160): string { - const text = md - .replace(/```[\s\S]*?```/g, " ") - .replace(/[#>*_`~\-]/g, "") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\s+/g, " ") - .trim(); - return text.length > max ? `${text.slice(0, max)}…` : text; -} - -export default function ModerationAdmin({ - initialPosts, - initialPostsTotal, - initError, -}: { - initialPosts: Post[]; - initialPostsTotal: number; - initError: string; -}) { - const [tab, setTab] = useState("posts"); - const [counts, setCounts] = useState({ posts: initialPostsTotal, comments: 0 }); - - // 帖子队列 - const [posts, setPosts] = useState(initialPosts); - const [postsTotal, setPostsTotal] = useState(initialPostsTotal); - const [postsPage, setPostsPage] = useState(1); - - // 评论队列 - const [comments, setComments] = useState([]); - const [commentsTotal, setCommentsTotal] = useState(0); - const [commentsPage, setCommentsPage] = useState(1); - const [commentsLoaded, setCommentsLoaded] = useState(false); - - const [loading, setLoading] = useState(false); - const [error, setError] = useState(initError); - const [actingKey, setActingKey] = useState(null); - // 拒绝的二次内联确认(key: posts- / comments-,3 秒自动复原) - const [confirmKey, setConfirmKey] = useState(null); - const confirmTimer = useRef | null>(null); - - const refreshCounts = useCallback(() => { - apiAdminPendingCounts() - .then(setCounts) - .catch(() => {}); - }, []); - - useEffect(() => { - refreshCounts(); - }, [refreshCounts]); - - const loadPosts = useCallback(async (page: number) => { - setLoading(true); - try { - const res = await apiAdminPendingPosts(page); - setPosts(res.posts); - setPostsTotal(res.total); - setError(""); - } catch (e) { - setError(e instanceof Error ? e.message : "获取待审帖子失败"); - } finally { - setLoading(false); - } - }, []); - - const loadComments = useCallback(async (page: number) => { - setLoading(true); - try { - const res = await apiAdminPendingComments(page); - setComments(res.comments); - setCommentsTotal(res.total); - setError(""); - } catch (e) { - setError(e instanceof Error ? e.message : "获取待审评论失败"); - } finally { - setLoading(false); - } - }, []); - - const switchTab = (t: Tab) => { - if (t === tab) return; - setTab(t); - setError(""); - setConfirmKey(null); - if (t === "comments" && !commentsLoaded) { - setCommentsLoaded(true); - loadComments(1); - } - }; - - const goPosts = (p: number) => { - const next = Math.min(Math.max(1, p), Math.max(1, Math.ceil(postsTotal / PAGE_SIZE))); - setPostsPage(next); - loadPosts(next); - }; - const goComments = (p: number) => { - const next = Math.min(Math.max(1, p), Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE))); - setCommentsPage(next); - loadComments(next); - }; - - const armConfirm = (key: string) => { - setConfirmKey(key); - if (confirmTimer.current) clearTimeout(confirmTimer.current); - confirmTimer.current = setTimeout(() => setConfirmKey(null), 3000); - }; - - // 本地移除已处理项;当页清空且不在第一页时回退一页 - const afterAction = (kind: Tab, removedPage: number, removedPageItems: number, remaining: number) => { - refreshCounts(); - setConfirmKey(null); - if (remaining === 0 && removedPage > 1) { - const prev = removedPage - 1; - if (kind === "posts") { - setPostsPage(prev); - loadPosts(prev); - } else { - setCommentsPage(prev); - loadComments(prev); - } - } - }; - - const act = async ( - key: string, - kind: "posts" | "comments", - id: number, - approve: boolean, - page: number, - pageItems: number - ) => { - // 首次点拒绝进入确认态,不执行 - if (!approve && confirmKey !== key) { - armConfirm(key); - return; - } - setActingKey(key); - try { - if (kind === "posts") { - if (approve) await apiAdminApprovePost(id); - else await apiAdminRejectPost(id); - setPosts((list) => list.filter((p) => p.id !== id)); - setPostsTotal((t) => Math.max(0, t - 1)); - } else { - if (approve) await apiAdminApproveComment(id); - else await apiAdminRejectComment(id); - setComments((list) => list.filter((c) => c.id !== id)); - setCommentsTotal((t) => Math.max(0, t - 1)); - } - toast(approve ? "已通过审核,内容已公开" : "已拒绝,作者将收到通知", "ok"); - afterAction(kind, page, pageItems, pageItems - 1); - } catch (e) { - toast(e instanceof Error ? e.message : "操作失败"); - } finally { - setActingKey(null); - } - }; - - const page = tab === "posts" ? postsPage : commentsPage; - const total = tab === "posts" ? postsTotal : commentsTotal; - - return ( -
- - - {error &&

{error}

} - -
-
- - ariaLabel="审核队列" - value={tab} - onChange={switchTab} - options={[ - { - key: "posts", - label: "待审帖子", - count: counts.posts, - icon: , - }, - { - key: "comments", - label: "待审评论", - count: counts.comments, - icon: , - }, - ]} - /> -
- - {tab === "posts" ? ( - posts.length === 0 ? ( - } - title="暂无待审核帖子" - description="新提交的内容会按时间顺序出现在这里" - /> - ) : ( - posts.map((p, idx) => { - const key = `posts-${p.id}`; - const busy = actingKey === key; - const confirming = confirmKey === key; - return ( -
-
- - {p.board.name} - - {formatRelative(p.created_at)} -
- - {p.title} - -

- {excerpt(p.content) || "(无正文内容)"} -

-
-
- - - {p.user.nickname} - - @{p.user.username} -
-
- - -
-
-
- ); - }) - ) - ) : comments.length === 0 ? ( - } - title="暂无待审核评论" - description="新提交的内容会按时间顺序出现在这里" - /> - ) : ( - comments.map((c, idx) => { - const key = `comments-${c.id}`; - const busy = actingKey === key; - const confirming = confirmKey === key; - return ( -
- -
-
- - {c.user.nickname} - - - {c.board.name} - - {formatRelative(c.created_at)} -
-

- {c.content} -

- - 所属帖子:《{c.post_title}》 - -
- - -
-
-
- ); - }) - )} - - (tab === "posts" ? goPosts(p) : goComments(p))} - /> -
-
- ); -} diff --git a/frontend/app/admin/moderation/page.tsx b/frontend/app/admin/moderation/page.tsx deleted file mode 100644 index 427b96c..0000000 --- a/frontend/app/admin/moderation/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Metadata } from "next"; -import { cookies } from "next/headers"; -import { authCookieHeader } from "@/lib/cookies"; -import { fetchAdminPendingPosts, type Post } from "@/lib/api"; -import { getMeCached } from "@/lib/serverData"; -import { canModerateAny } from "@/lib/roles"; -import Forbidden from "../Forbidden"; -import ModerationAdmin from "./ModerationAdmin"; - -export const metadata: Metadata = { - title: "内容审核", -}; - -// 鉴权由 app/admin/layout.tsx 统一承担(RequireStaff + 板块隔离由后端兜底); -// 帖子待审队列首屏 SSR 直出,评论队列由客户端进入时拉取 -export default async function ModerationPage() { - const cookie = authCookieHeader(await cookies()); - const me = await getMeCached(cookie || undefined); - - if (!me.user || !canModerateAny(me.user)) { - return ; - } - - let initialPosts: Post[] = []; - let initialTotal = 0; - let initError = ""; - try { - const res = await fetchAdminPendingPosts(1, cookie || undefined); - initialPosts = res.posts; - initialTotal = res.total; - } catch (e) { - initError = e instanceof Error ? e.message : "获取待审核内容失败"; - } - - return ( - - ); -} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 822ffcc..7c5c38e 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -56,14 +56,18 @@ function PanelHead({ title, extra }: { title: string; extra?: string }) { ); } -/** 待办:待审帖子 / 评论,整块进入审核页 */ +/** 待办:待审帖子 / 评论,进入内容管理的待审筛选 */ function PendingWorkPanel({ counts }: { counts: PendingCounts }) { const total = counts.posts + counts.comments; const idle = total === 0; return ( 0 || counts.comments === 0 + ? "/admin/content?status=pending" + : "/admin/content?status=pending&kind=comments" + } className="panel hover-lift relative block p-4 sm:p-5 mb-4 group" >
@@ -80,7 +84,7 @@ function PendingWorkPanel({ counts }: { counts: PendingCounts }) {

- 内容审核 + 待审内容

{idle ? ( { - const res = await fetch( - `${API_BASE}/api/admin/moderation/pending-posts?page=${page}`, - ssrInit(cookieHeaders(cookieHeader)) - ); - if (!res.ok) throw new Error("获取待审核帖子失败"); - return res.json(); +/** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */ +export interface PendingCounts { + posts: number; + comments: number; } -/** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */ export async function fetchAdminPendingCounts( cookieHeader?: string ): Promise { @@ -1622,49 +1614,7 @@ export async function apiListBoards(): Promise<{ boards: Board[] }> { return res.json(); } -// ===== 内容审核队列 ===== - -export interface PendingCommentItem { - id: number; - post_id: number; - post_title: string; - board_id: number; - board: Board; - content: string; - created_at: string; - user: User; -} - -export interface PendingCounts { - posts: number; - comments: number; -} - -export async function apiAdminPendingPosts(page = 1): Promise<{ - posts: Post[]; - total: number; - page: number; -}> { - const res = await fetchWithRefresh(`/api/admin/moderation/pending-posts?page=${page}`, { - headers: clientHeaders(), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || "获取待审帖子失败"); - return data; -} - -export async function apiAdminPendingComments(page = 1): Promise<{ - comments: PendingCommentItem[]; - total: number; - page: number; -}> { - const res = await fetchWithRefresh(`/api/admin/moderation/pending-comments?page=${page}`, { - headers: clientHeaders(), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || "获取待审评论失败"); - return data; -} +// ===== 内容审核动作 ===== export async function apiAdminPendingCounts(): Promise { const res = await fetchWithRefresh("/api/admin/moderation/counts", { @@ -1862,6 +1812,19 @@ export async function fetchAdminContentPosts( return res.json(); } +export async function fetchAdminContentComments( + page = 1, + status: AdminContentStatus = "live", + cookieHeader?: string +): Promise<{ comments: AdminContentComment[]; total: number; page: number }> { + const res = await fetch( + `${API_BASE}/api/admin/content/comments?page=${page}&status=${status}`, + ssrInit(cookieHeaders(cookieHeader)) + ); + if (!res.ok) throw new Error("获取评论列表失败"); + return res.json(); +} + // ===== 群聊(二期) ===== // 群聊房间基础模型(与后端 model.ChatRoom 对齐) diff --git a/frontend/lib/roles.ts b/frontend/lib/roles.ts index 33df3fa..ff0e575 100644 --- a/frontend/lib/roles.ts +++ b/frontend/lib/roles.ts @@ -69,7 +69,7 @@ export function canModerateBoard( return (user.board_ids ?? []).includes(boardId); } -/** 是否拥有任意板块的内容审核权(后台"内容审核"入口) */ +/** 是否拥有任意板块的内容审核权(后台「内容管理」入口) */ export function canModerateAny(user: RoleUserLike | null | undefined): boolean { if (!user) return false; if (isAdminOrAbove(user.role)) return true;