"use client"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { FileText, FolderOpen, History, Loader2, MessagesSquare, RotateCcw, Trash2, } from "lucide-react"; import { apiAdminContentCounts, apiAdminContentPosts, apiAdminContentComments, apiAdminSoftDeletePost, apiAdminRestorePost, apiAdminPurgePost, apiAdminSoftDeleteComment, apiAdminRestoreComment, apiAdminPurgeComment, 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 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, AdminSwitch, contentStatusChip, } from "@/components/admin"; const PAGE_SIZE = 15; type Kind = "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; } export default function ContentAdmin({ initialPosts, initialPostsTotal, initialCounts, initError, canConfigureSettings, initialSettings, }: { initialPosts: AdminContentPost[]; initialPostsTotal: number; initialCounts: AdminContentCounts; initError: string; canConfigureSettings: boolean; initialSettings: PublicSettings; }) { const [kind, setKind] = useState("posts"); const [status, setStatus] = useState("live"); 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 [commentsPage, setCommentsPage] = useState(1); const [keyword, setKeyword] = useState(""); const [appliedQ, setAppliedQ] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(initError); 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; 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) => { setLoading(true); try { const res = await apiAdminContentPosts(page, st, q); setPosts(res.posts); setPostsTotal(res.total); setPostsPage(page); setError(""); } catch (e) { setError(e instanceof Error ? e.message : "获取帖子失败"); } finally { setLoading(false); } }, []); const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => { setLoading(true); try { const res = await apiAdminContentComments(page, st, q); setComments(res.comments); setCommentsTotal(res.total); setCommentsPage(page); setError(""); } catch (e) { setError(e instanceof Error ? e.message : "获取评论失败"); } finally { setLoading(false); } }, []); const reload = (page = 1) => { if (kind === "posts") void loadPosts(page, status, appliedQ); else void loadComments(page, status, appliedQ); refreshCounts(); }; useEffect(() => { refreshCounts(); }, [refreshCounts]); const switchKind = (k: Kind) => { if (k === kind) return; setKind(k); setError(""); if (k === "posts") void loadPosts(1, status, appliedQ); else void loadComments(1, status, appliedQ); }; 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); }; 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(""); 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"); reload(kind === "posts" ? postsPage : commentsPage); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setActingKey(null)); }; const askConfirm = ( title: string, message: string, run: () => Promise, danger = false ) => setConfirm({ title, message, run, danger }); 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; 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; return null; }; const goPage = (p: number) => { if (kind === "posts") void loadPosts(p, status, appliedQ); else void loadComments(p, status, appliedQ); }; return (
{canConfigureSettings && (
void toggleTrust()} label="已过审用户后续免审" /> } />
setCooldown(Number(e.target.value))} aria-label="冷静期小时数" /> 小时
)}
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, }))} />
{loading ? (
加载中…
) : error ? (

{error}

) : kind === "posts" && posts.length === 0 ? ( } title="暂无帖子" /> ) : kind === "comments" && comments.length === 0 ? ( } title="暂无评论" /> ) : kind === "posts" ? ( posts.map((p, idx) => { const key = `p-${p.id}`; const busy = actingKey === key; const chip = contentStatusChip(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} 评

{p.deleted ? ( <> runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复") } > {busy ? ( ) : ( )} askConfirm( "彻底删除帖子", `「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`, () => apiAdminPurgePost(p.id).then(() => { toast("帖子已彻底删除", "ok"); reload(postsPage); }), true ) } > ) : ( setStaffDeletePost(p)} > )}
); }) ) : ( comments.map((c, idx) => { const key = `c-${c.id}`; const busy = actingKey === key; const chip = contentStatusChip(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}`} )}

setHistoryCommentId(c.id)} > {c.deleted ? ( <> runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复") } > {busy ? ( ) : ( )} askConfirm( "彻底删除评论", "该评论将永久删除且不可恢复。", () => apiAdminPurgeComment(c.id).then(() => { toast("评论已彻底删除", "ok"); reload(commentsPage); }), 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); reload(postsPage); }) .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); reload(commentsPage); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteCommentBusy(false)); }} /> setHistoryCommentId(null)} />
); }