"use client"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { ChevronLeft, ChevronRight, FileText, FolderOpen, Loader2, MessagesSquare, RotateCcw, Search, 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"; 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 statusLabel(status: string, deleted: boolean) { if (deleted) return "已删除"; if (status === "pending") return "待审"; if (status === "rejected") return "已拒绝"; if (status === "published") return "已发布"; return status; } 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 Switch({ checked, disabled, onChange, label, }: { checked: boolean; disabled?: boolean; onChange: () => void; label: string; }) { return ( ); } 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 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 = (e: React.FormEvent) => { e.preventDefault(); const q = keyword.trim(); setAppliedQ(q); if (kind === "posts") void loadPosts(1, status, q); else void loadComments(1, status, q); }; 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 totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); 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; }; return (

内容管理

浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改

{canConfigureSettings && (

已过审用户后续免审

开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。

void toggleTrust()} label="已过审用户后续免审" />

新用户发帖冷静期

注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。

setCooldown(Number(e.target.value))} aria-label="冷静期小时数" /> 小时
)}
{( [ { key: "posts" as Kind, label: "帖子", icon: , count: counts.posts_live }, { key: "comments" as Kind, label: "评论", icon: , count: counts.comments_live, }, ] ).map((t) => { const active = kind === t.key; return ( ); })}
{STATUS_TABS.map((t) => { const active = status === t.key; const cnt = statusCount(t.key); return ( ); })}
setKeyword(e.target.value)} placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"} aria-label="搜索内容" />
{loading ? (
加载中…
) : error ? (

{error}

) : kind === "posts" && posts.length === 0 ? (

暂无帖子

) : kind === "comments" && comments.length === 0 ? (

暂无评论

) : kind === "posts" ? ( posts.map((p) => { const key = `p-${p.id}`; const busy = actingKey === key; return (
{statusLabel(p.status, p.deleted)} {p.board?.name} {formatRelative(p.created_at)}
{p.deleted ? (

{p.title}

) : ( {p.title} )}

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

{p.deleted ? ( <> ) : ( )}
); }) ) : ( comments.map((c) => { const key = `c-${c.id}`; const busy = actingKey === key; return (
{statusLabel(c.status, c.deleted)} {c.board?.name} {formatRelative(c.created_at)}

{excerpt(c.content)}

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

{c.deleted ? ( <> ) : ( )}
); }) )} {totalPages > 1 && (
{page} / {totalPages}
)}
!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)); }} />
); }