"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, apiAdminRestorePost, apiAdminPurgePost, apiAdminSoftDeleteComment, apiAdminRestoreComment, apiAdminPurgeComment, apiAdminApprovePost, apiAdminRejectPost, apiAdminApproveComment, apiAdminRejectComment, apiUpdateSiteSettings, type AdminContentComment, type AdminContentCounts, type AdminContentPost, type AdminContentStatus, type PublicSettings, } 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 CommentEditHistoryModal from "@/components/CommentEditHistoryModal"; import { AdminEmpty, AdminPageHeader, AdminPager, AdminSearchField, AdminSegmented, AdminStatusChip, AdminStepper, AdminSwitch, 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 PolicyChip({ title, hint, control, instant, }: { title: string; hint?: string; control: ReactNode; instant?: boolean; }) { return (
{title} {instant ? 立即生效 : null}
{control}
); } 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, canConfigureSettings, canPurge, viewerRole, initialSettings, }: { initialKind: ContentKind; initialStatus: AdminContentStatus; initialPosts: AdminContentPost[]; initialPostsTotal: number; initialComments: AdminContentComment[]; initialCommentsTotal: number; initialCounts: AdminContentCounts; initError: string; canConfigureSettings: boolean; canPurge: boolean; viewerRole: string; initialSettings: PublicSettings; }) { // 窄屏分段: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 [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish); const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours); const [postLinkNewTab, setPostLinkNewTab] = useState(initialSettings.post_link_new_tab !== false); const [savingPolicy, setSavingPolicy] = useState(false); 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 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 toggleTrust = async () => { if (!canConfigureSettings || savingPolicy) return; setSavingPolicy(true); try { const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn }); setTrustOn(res.trust_reviewed_publish); toast( res.trust_reviewed_publish ? "已开启过审免审(帖子与评论)" : "已关闭过审免审(帖子与评论将按规则待审)", "ok" ); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSavingPolicy(false); } }; const saveCooldown = async () => { if (!canConfigureSettings || savingPolicy) return; const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0)); setSavingPolicy(true); try { const res = await apiUpdateSiteSettings({ post_cooldown_hours: n }); setCooldown(res.post_cooldown_hours); toast( res.post_cooldown_hours === 0 ? "已关闭新用户发帖冷静期" : `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`, "ok" ); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSavingPolicy(false); } }; const togglePostLinkNewTab = async () => { if (!canConfigureSettings || savingPolicy) return; const next = !postLinkNewTab; setSavingPolicy(true); try { const res = await apiUpdateSiteSettings({ post_link_new_tab: next }); setPostLinkNewTab(res.post_link_new_tab !== false); toast( res.post_link_new_tab !== false ? "帖子与评论外链将在新标签打开" : "帖子与评论外链将在当前页打开", "ok" ); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { setSavingPolicy(false); } }; 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} } onClick={() => setHistoryCommentId(c.id)} /> {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 (
{canConfigureSettings && (
共用策略 过审免审与外链开关改完立即写入;冷静期需点保存。失败时恢复为已确认值。
void toggleTrust()} label="过审免审(帖子与评论)" /> } />
} /> void togglePostLinkNewTab()} label="外链新开(帖子与评论)" /> } />
)} {/* 窄屏:顶部分段切换双栏 */}
{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)); }} /> setHistoryCommentId(null)} /> ); }