"use client"; import { useCallback, useEffect, useRef, useState } 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 { 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"; import DeleteCommentModal from "@/components/DeleteCommentModal"; import CommentEditHistoryModal from "@/components/CommentEditHistoryModal"; import { AdminEmpty, AdminIconButton, AdminPageHeader, AdminPager, AdminSearchField, AdminSegmented, AdminSettingRow, AdminStatusChip, AdminStepper, AdminSwitch, contentStatusChip, } from "@/components/admin"; const PAGE_SIZE = 15; 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: "已删除" }, ]; function excerpt(md: string, max = 120): 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; } 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(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(initialComments); const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal); const [commentsPage, setCommentsPage] = useState(1); const [keyword, setKeyword] = useState(""); 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); const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours); 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 = ++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 { 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 { if (seq === loadSeq.current) setLoading(false); } }, []); const reload = (page = 1) => { if (kind === "posts") void loadPosts(page, status, appliedQ); else void loadComments(page, status, appliedQ); 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]); useEffect(() => { const off = realtime.on(RT_MODERATION_CHANGED, refreshCounts); return off; }, [refreshCounts]); const switchKind = (k: ContentKind) => { if (k === kind) return; setError(""); if (k === "posts") void loadPosts(1, status, appliedQ); else void loadComments(1, status, appliedQ); }; const switchStatus = (st: AdminContentStatus) => { if (st === status) return; setError(""); if (kind === "posts") void loadPosts(1, st, appliedQ); else void loadComments(1, st, appliedQ); }; const applySearch = () => { const q = keyword.trim(); if (kind === "posts") void loadPosts(1, status, q); else void loadComments(1, status, q); }; const clearSearch = () => { setKeyword(""); if (kind === "posts") void loadPosts(1, status, ""); else void loadComments(1, status, ""); }; const runAction = (key: string, fn: () => Promise, okMsg: string) => { setActingKey(key); fn() .then(() => { toast(okMsg, "ok"); reloadAfterItemChange(); }) .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: 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; 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 total = kind === "posts" ? postsTotal : commentsTotal; const page = kind === "posts" ? postsPage : commentsPage; const statusCount = (st: AdminContentStatus) => { 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 goPage = (p: number) => { if (kind === "posts") void loadPosts(p, status, appliedQ); else void loadComments(p, status, appliedQ); }; const empty = emptyCopy(kind, status); return (
{canConfigureSettings && (
void toggleTrust()} label="已过审用户后续免审" /> } />
} />
)}
ariaLabel="状态筛选" size="sm" value={status} onChange={switchStatus} options={STATUS_TABS.map((t) => ({ key: t.key, label: t.label, count: statusCount(t.key) ?? undefined, }))} />
{error ?

{error}

: null} {kind === "posts" && posts.length === 0 ? ( } title={empty.title} description={empty.description} /> ) : kind === "comments" && comments.length === 0 ? ( } 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 (
{chip.label} {p.board?.name} {formatRelative(p.created_at)}
{p.deleted ? (

{p.title}

) : ( {p.title} )}

{p.user?.nickname} · {p.comment_count} 评

{pending && ( runAction(key, () => apiAdminApprovePost(p.id), "已通过审核,内容已公开") } onReject={() => askReject("posts", p.id)} /> )} {p.deleted ? ( <> runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复") } > {busy ? ( ) : ( )} askConfirm( "彻底删除帖子", `「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`, () => apiAdminPurgePost(p.id).then(() => { toast("帖子已彻底删除", "ok"); reloadAfterItemChange(); }), true ) } > ) : ( setStaffDeletePost(p)} > )}
); }) ) : ( 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); return (
{chip.label} {c.board?.name} {formatRelative(c.created_at)}

{excerpt(c.content)}

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

{pending && ( runAction(key, () => apiAdminApproveComment(c.id), "已通过审核,内容已公开") } onReject={() => askReject("comments", c.id)} /> )} setHistoryCommentId(c.id)} > {c.deleted ? ( <> runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复") } > {busy ? ( ) : ( )} askConfirm( "彻底删除评论", "该评论将永久删除且不可恢复。", () => apiAdminPurgeComment(c.id).then(() => { toast("评论已彻底删除", "ok"); reloadAfterItemChange(); }), true ) } > ) : ( setStaffDeleteComment(c)} > )}
); }) )}
!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); reloadAfterItemChange(); }) .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); reloadAfterItemChange(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteCommentBusy(false)); }} /> setHistoryCommentId(null)} /> ); }