"use client"; import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import Link from "next/link"; import { Check, FileText, FolderOpen, History, Loader2, MessagesSquare, RotateCcw, Trash2, X, } from "lucide-react"; import { apiAdminContentCounts, apiAdminContentPosts, apiAdminContentComments, apiAdminSoftDeletePost, apiCommentHistory, apiAdminRestorePost, apiAdminPurgePost, apiAdminSoftDeleteComment, apiAdminRestoreComment, apiAdminPurgeComment, apiAdminApprovePost, apiAdminRejectPost, apiAdminApproveComment, apiAdminRejectComment, type AdminContentComment, type AdminContentCounts, type AdminContentPost, type AdminContentStatus, } from "@/lib/api"; import { canModerateAuthor } from "@/lib/roles"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; import Avatar from "@/components/Avatar"; import AutolinkText from "@/components/AutolinkText"; import ConfirmDialog from "@/components/ConfirmDialog"; import StaffDeletePostModal from "@/components/StaffDeletePostModal"; import DeleteCommentModal from "@/components/DeleteCommentModal"; import EditHistoryModal from "@/components/EditHistoryModal"; import { AdminEmpty, AdminPageHeader, AdminPager, AdminSearchField, AdminSegmented, AdminStatusChip, contentStatusChip, } from "@/components/admin"; const PAGE_SIZE = 15; const CLAMP_TOGGLE_CHARS = 90; export type ContentKind = "posts" | "comments"; const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [ { key: "live", label: "全部" }, { key: "published", label: "发布" }, { key: "pending", label: "待审" }, { key: "rejected", label: "拒绝" }, { key: "deleted", label: "删除" }, ]; /** 评论摘要:去 Markdown 标记,保留换行供 ClampText 判断 */ function commentPlain(md: string): string { return md .replace(/```[\s\S]*?```/g, " ") .replace(/[#>*_`~\-]/g, "") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .replace(/[ \t]{2,}/g, " ") .trim(); } function needsClampToggle(text: string): boolean { if (text.length > CLAMP_TOGGLE_CHARS) return true; const newlines = (text.match(/\n/g) || []).length; return newlines >= 2; } function ClampText({ text, className = "", style, }: { text: string; className?: string; style?: CSSProperties; }) { const [open, setOpen] = useState(false); const showToggle = needsClampToggle(text); const body = text || "(空)"; return (

{showToggle ? ( ) : null}
); } function ContentAction({ label, icon, onClick, disabled, danger, ok, }: { label: string; icon: ReactNode; onClick: () => void; disabled?: boolean; danger?: boolean; ok?: boolean; }) { const tone = danger ? " is-danger" : ok ? " is-ok" : ""; return ( ); } function isPendingRow(status: string, deleted: boolean) { return !deleted && status === "pending"; } function PendingActions({ busy, onApprove, onReject, }: { busy: boolean; onApprove: () => void; onReject: () => void; }) { return ( <> ); } function ColumnShell({ icon, title, count, pending, toolbar, body, pager, className = "", }: { icon: ReactNode; title: string; count?: number; pending?: number; toolbar?: ReactNode; body: ReactNode; pager?: ReactNode; className?: string; }) { return (
{icon}

{title}

{typeof count === "number" ? ( {count} ) : null} {pending && pending > 0 ? ( {pending} ) : null}
{toolbar}
{body}
{pager}
); } function statusCountFor( kind: "posts" | "comments", st: AdminContentStatus, counts: AdminContentCounts ): number | null { if (kind === "posts") { if (st === "live") return counts.posts_live; 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; } const pagerClass = "admin-content-col-pager px-3 sm:px-3.5"; export default function ContentAdmin({ initialKind, initialStatus, initialPosts, initialPostsTotal, initialComments, initialCommentsTotal, initialCounts, initError, canPurge, viewerRole, }: { initialKind: ContentKind; initialStatus: AdminContentStatus; initialPosts: AdminContentPost[]; initialPostsTotal: number; initialComments: AdminContentComment[]; initialCommentsTotal: number; initialCounts: AdminContentCounts; initError: string; canPurge: boolean; viewerRole: string; }) { // 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见 const [mobileKind, setMobileKind] = useState(initialKind); const [counts, setCounts] = useState(initialCounts); const [posts, setPosts] = useState(initialPosts); const [postsTotal, setPostsTotal] = useState(initialPostsTotal); const [postsPage, setPostsPage] = useState(1); const [postsStatus, setPostsStatus] = useState(initialStatus); const [postsKeyword, setPostsKeyword] = useState(""); const [postsQ, setPostsQ] = useState(""); const [postsLoading, setPostsLoading] = useState(false); const [postsError, setPostsError] = useState( initError && initialKind === "posts" ? initError : "" ); const postsSeq = useRef(0); const [comments, setComments] = useState(initialComments); const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal); const [commentsPage, setCommentsPage] = useState(1); const [commentsStatus, setCommentsStatus] = useState(initialStatus); const [commentsKeyword, setCommentsKeyword] = useState(""); const [commentsQ, setCommentsQ] = useState(""); const [commentsLoading, setCommentsLoading] = useState(false); const [commentsError, setCommentsError] = useState( initError && initialKind === "comments" ? initError : "" ); const commentsSeq = useRef(0); const [actingKey, setActingKey] = useState(null); const [confirm, setConfirm] = useState<{ title: string; message: string; danger?: boolean; confirmLabel?: string; run: () => Promise; } | null>(null); const [confirmBusy, setConfirmBusy] = useState(false); const [staffDeletePost, setStaffDeletePost] = useState(null); const [staffDeleteBusy, setStaffDeleteBusy] = useState(false); const [staffDeleteComment, setStaffDeleteComment] = useState(null); const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false); const [historyCommentId, setHistoryCommentId] = useState(null); const [historyCommentContent, setHistoryCommentContent] = useState(""); const refreshCounts = useCallback(() => { apiAdminContentCounts().then(setCounts).catch(() => {}); }, []); const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => { const seq = ++postsSeq.current; setPostsLoading(true); try { const res = await apiAdminContentPosts(page, st, q); if (seq !== postsSeq.current) return; setPosts(res.posts); setPostsTotal(res.total); setPostsPage(page); setPostsStatus(st); setPostsQ(q); setPostsError(""); } catch (e) { if (seq !== postsSeq.current) return; setPostsError(e instanceof Error ? e.message : "获取帖子失败"); } finally { if (seq === postsSeq.current) setPostsLoading(false); } }, []); const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => { const seq = ++commentsSeq.current; setCommentsLoading(true); try { const res = await apiAdminContentComments(page, st, q); if (seq !== commentsSeq.current) return; setComments(res.comments); setCommentsTotal(res.total); setCommentsPage(page); setCommentsStatus(st); setCommentsQ(q); setCommentsError(""); } catch (e) { if (seq !== commentsSeq.current) return; setCommentsError(e instanceof Error ? e.message : "获取评论失败"); } finally { if (seq === commentsSeq.current) setCommentsLoading(false); } }, []); useEffect(() => { refreshCounts(); }, [refreshCounts]); useEffect(() => { const off = realtime.on(RT_MODERATION_CHANGED, refreshCounts); return off; }, [refreshCounts]); const reloadPosts = (page?: number) => { const p = page ?? postsPage; const len = posts.length; void loadPosts(len <= 1 && p > 1 && page === undefined ? p - 1 : p, postsStatus, postsQ); refreshCounts(); }; const reloadComments = (page?: number) => { const p = page ?? commentsPage; const len = comments.length; void loadComments( len <= 1 && p > 1 && page === undefined ? p - 1 : p, commentsStatus, commentsQ ); refreshCounts(); }; const runAction = (key: string, fn: () => Promise, okMsg: string, after: () => void) => { setActingKey(key); fn() .then(() => { toast(okMsg, "ok"); after(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setActingKey(null)); }; const askConfirm = ( title: string, message: string, run: () => Promise, danger = false, confirmLabel?: string ) => setConfirm({ title, message, run, danger, confirmLabel }); const askReject = (kindNow: "posts" | "comments", id: number) => { askConfirm( kindNow === "posts" ? "拒绝这篇帖子?" : "拒绝这条评论?", "拒绝后作者将收到通知,内容不会公开。", async () => { if (kindNow === "posts") await apiAdminRejectPost(id); else await apiAdminRejectComment(id); toast("已拒绝,作者将收到通知", "ok"); if (kindNow === "posts") reloadPosts(); else reloadComments(); }, true, "确认拒绝" ); }; const postsToolbar = (
ariaLabel="帖子状态筛选" size="sm" value={postsStatus} onChange={(st) => { if (st === postsStatus) return; void loadPosts(1, st, postsQ); }} options={STATUS_TABS.map((t) => ({ key: t.key, label: t.label, count: statusCountFor("posts", t.key, counts) ?? undefined, }))} />
void loadPosts(1, postsStatus, postsKeyword.trim())} onClear={() => { setPostsKeyword(""); void loadPosts(1, postsStatus, ""); }} placeholder="搜索帖子标题…" ariaLabel="搜索帖子" />
); const commentsToolbar = (
ariaLabel="评论状态筛选" size="sm" value={commentsStatus} onChange={(st) => { if (st === commentsStatus) return; void loadComments(1, st, commentsQ); }} options={STATUS_TABS.map((t) => ({ key: t.key, label: t.label, count: statusCountFor("comments", t.key, counts) ?? undefined, }))} />
void loadComments(1, commentsStatus, commentsKeyword.trim())} onClear={() => { setCommentsKeyword(""); void loadComments(1, commentsStatus, ""); }} placeholder="搜索评论正文…" ariaLabel="搜索评论" />
); const postsBody = ( <> {postsError ?

{postsError}

: null} {postsLoading && posts.length === 0 ? (
) : posts.length === 0 ? ( } title={postsStatus === "pending" ? "暂无待审帖子" : "暂无帖子"} description={ postsStatus === "pending" ? "新提交的帖子会按时间出现在这里" : undefined } /> ) : ( 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 (
{p.user?.nickname || p.user?.username || "?"} {chip.label} {p.board?.name} {formatRelative(p.created_at)}
{p.deleted ? (

{p.title}

) : ( {p.title} )}

{p.comment_count} 评

{pending ? ( runAction( key, () => apiAdminApprovePost(p.id), "已通过审核,内容已公开", () => reloadPosts() ) } onReject={() => askReject("posts", p.id)} /> ) : null} {p.deleted ? ( <> {canModerateAuthor({ role: viewerRole }, p.user?.role) ? ( ) : ( ) } onClick={() => runAction( key, () => apiAdminRestorePost(p.id), "帖子已恢复", () => reloadPosts() ) } /> ) : null} {canPurge ? ( } onClick={() => askConfirm( "彻底删除帖子", `「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`, () => apiAdminPurgePost(p.id).then(() => { toast("帖子已彻底删除", "ok"); reloadPosts(); }), true ) } /> ) : null} ) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? ( } onClick={() => setStaffDeletePost(p)} /> ) : null}
); }) )} ); const commentsBody = ( <> {commentsError ?

{commentsError}

: null} {commentsLoading && comments.length === 0 ? (
) : comments.length === 0 ? ( } title={commentsStatus === "pending" ? "暂无待审评论" : "暂无评论"} description={ commentsStatus === "pending" ? "新提交的评论会按时间出现在这里" : undefined } /> ) : ( comments.map((c, idx) => { const key = `c-${c.id}`; const busy = actingKey === key; const chip = contentStatusChip(c.status, c.deleted); const pending = isPendingRow(c.status, c.deleted); const plain = commentPlain(c.content); return (
{c.user?.nickname || c.user?.username || "?"} {chip.label} {c.board?.name} {formatRelative(c.created_at)}

{c.deleted ? ( c.post_title ) : ( {c.post_title || `帖子 #${c.post_id}`} )}

{pending ? ( runAction( key, () => apiAdminApproveComment(c.id), "已通过审核,内容已公开", () => reloadComments() ) } onReject={() => askReject("comments", c.id)} /> ) : null} {/* 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录 */} {c.edited ? ( } onClick={() => { setHistoryCommentContent(c.content); setHistoryCommentId(c.id); }} /> ) : null} {c.deleted ? ( <> {canModerateAuthor({ role: viewerRole }, c.user?.role) ? ( ) : ( ) } onClick={() => runAction( key, () => apiAdminRestoreComment(c.id), "评论已恢复", () => reloadComments() ) } /> ) : null} {canPurge ? ( } onClick={() => askConfirm( "彻底删除评论", "该评论将永久删除且不可恢复。", () => apiAdminPurgeComment(c.id).then(() => { toast("评论已彻底删除", "ok"); reloadComments(); }), true ) } /> ) : null} ) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? ( } onClick={() => setStaffDeleteComment(c)} /> ) : null}
); }) )} ); const postsCol = ( } title="帖子" count={counts.posts_live} pending={counts.posts_pending} toolbar={postsToolbar} body={postsBody} pager={ void loadPosts(p, postsStatus, postsQ)} className={pagerClass} /> } className={mobileKind === "posts" ? "max-lg:flex" : "max-lg:hidden"} /> ); const commentsCol = ( } title="评论" count={counts.comments_live} pending={counts.comments_pending} toolbar={commentsToolbar} body={commentsBody} pager={ void loadComments(p, commentsStatus, commentsQ)} className={pagerClass} /> } className={mobileKind === "comments" ? "max-lg:flex" : "max-lg:hidden"} /> ); return (
{/* 窄屏:顶部分段切换双栏 */}
{postsCol} {commentsCol}
!confirmBusy && setConfirm(null)} onConfirm={() => { if (!confirm) return; setConfirmBusy(true); confirm .run() .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => { setConfirmBusy(false); setConfirm(null); }); }} /> !staffDeleteBusy && setStaffDeletePost(null)} onConfirm={(opts) => { if (!staffDeletePost) return; setStaffDeleteBusy(true); apiAdminSoftDeletePost(staffDeletePost.id, opts) .then(() => { toast("已移入回收站", "ok"); setStaffDeletePost(null); reloadPosts(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteBusy(false)); }} /> !staffDeleteCommentBusy && setStaffDeleteComment(null)} onConfirm={(opts) => { if (!staffDeleteComment) return; setStaffDeleteCommentBusy(true); apiAdminSoftDeleteComment(staffDeleteComment.id, opts) .then(() => { toast("已移入回收站", "ok"); setStaffDeleteComment(null); reloadComments(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteCommentBusy(false)); }} /> { if (historyCommentId == null) return Promise.reject(new Error("未选择评论")); return apiCommentHistory(historyCommentId, p, s); }} onClose={() => setHistoryCommentId(null)} />
); }