"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Check, ChevronLeft, ChevronRight, ClipboardCheck, FileText, Loader2, MessagesSquare, X, } from "lucide-react"; import { apiAdminPendingPosts, apiAdminPendingComments, apiAdminPendingCounts, apiAdminApprovePost, apiAdminRejectPost, apiAdminApproveComment, apiAdminRejectComment, type PendingCommentItem, type PendingCounts, type Post, } from "@/lib/api"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; const PAGE_SIZE = 15; type Tab = "posts" | "comments"; // 粗略去除 Markdown 标记,生成纯文本摘要 function excerpt(md: string, max = 160): 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 ModerationAdmin({ initialPosts, initialPostsTotal, initError, }: { initialPosts: Post[]; initialPostsTotal: number; initError: string; }) { const [tab, setTab] = useState("posts"); const [counts, setCounts] = useState({ posts: initialPostsTotal, comments: 0 }); // 帖子队列 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 [commentsLoaded, setCommentsLoaded] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(initError); const [actingKey, setActingKey] = useState(null); // 拒绝的二次内联确认(key: posts- / comments-,3 秒自动复原) const [confirmKey, setConfirmKey] = useState(null); const confirmTimer = useRef | null>(null); const refreshCounts = useCallback(() => { apiAdminPendingCounts() .then(setCounts) .catch(() => {}); }, []); useEffect(() => { refreshCounts(); }, [refreshCounts]); const loadPosts = useCallback(async (page: number) => { setLoading(true); try { const res = await apiAdminPendingPosts(page); setPosts(res.posts); setPostsTotal(res.total); setError(""); } catch (e) { setError(e instanceof Error ? e.message : "获取待审帖子失败"); } finally { setLoading(false); } }, []); const loadComments = useCallback(async (page: number) => { setLoading(true); try { const res = await apiAdminPendingComments(page); setComments(res.comments); setCommentsTotal(res.total); setError(""); } catch (e) { setError(e instanceof Error ? e.message : "获取待审评论失败"); } finally { setLoading(false); } }, []); const switchTab = (t: Tab) => { if (t === tab) return; setTab(t); setError(""); setConfirmKey(null); if (t === "comments" && !commentsLoaded) { setCommentsLoaded(true); loadComments(1); } }; const goPosts = (p: number) => { const next = Math.min(Math.max(1, p), Math.max(1, Math.ceil(postsTotal / PAGE_SIZE))); setPostsPage(next); loadPosts(next); }; const goComments = (p: number) => { const next = Math.min(Math.max(1, p), Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE))); setCommentsPage(next); loadComments(next); }; const armConfirm = (key: string) => { setConfirmKey(key); if (confirmTimer.current) clearTimeout(confirmTimer.current); confirmTimer.current = setTimeout(() => setConfirmKey(null), 3000); }; // 本地移除已处理项;当页清空且不在第一页时回退一页 const afterAction = (kind: Tab, removedPage: number, removedPageItems: number, remaining: number) => { refreshCounts(); setConfirmKey(null); if (remaining === 0 && removedPage > 1) { const prev = removedPage - 1; if (kind === "posts") { setPostsPage(prev); loadPosts(prev); } else { setCommentsPage(prev); loadComments(prev); } } }; const act = async ( key: string, kind: "posts" | "comments", id: number, approve: boolean, page: number, pageItems: number ) => { // 首次点拒绝进入确认态,不执行 if (!approve && confirmKey !== key) { armConfirm(key); return; } setActingKey(key); try { if (kind === "posts") { if (approve) await apiAdminApprovePost(id); else await apiAdminRejectPost(id); setPosts((list) => list.filter((p) => p.id !== id)); setPostsTotal((t) => Math.max(0, t - 1)); } else { if (approve) await apiAdminApproveComment(id); else await apiAdminRejectComment(id); setComments((list) => list.filter((c) => c.id !== id)); setCommentsTotal((t) => Math.max(0, t - 1)); } toast(approve ? "已通过审核,内容已公开" : "已拒绝,作者将收到通知", "ok"); afterAction(kind, page, pageItems, pageItems - 1); } catch (e) { toast(e instanceof Error ? e.message : "操作失败"); } finally { setActingKey(null); } }; const totalPages = tab === "posts" ? Math.max(1, Math.ceil(postsTotal / PAGE_SIZE)) : Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE)); return (
{/* 页头 */}

内容审核

普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。

{/* 队列切换 */}
{( [ { key: "posts" as Tab, label: "待审帖子", count: counts.posts, icon: }, { key: "comments" as Tab, label: "待审评论", count: counts.comments, icon: }, ] ).map((t) => { const active = tab === t.key; return ( ); })}
{error &&

{error}

}
{tab === "posts" ? ( posts.length === 0 ? ( } text="暂无待审核帖子" /> ) : (
{posts.map((p) => { const key = `posts-${p.id}`; const busy = actingKey === key; const confirming = confirmKey === key; return (
{p.board.name} {formatRelative(p.created_at)}
{p.title}

{excerpt(p.content) || "(无正文内容)"}

{p.user.nickname} @{p.user.username}
); })}
) ) : comments.length === 0 ? ( } text="暂无待审核评论" /> ) : (
{comments.map((c, idx) => { const key = `comments-${c.id}`; const busy = actingKey === key; const confirming = confirmKey === key; return (
{c.user.nickname} {c.board.name} {formatRelative(c.created_at)}

{c.content}

所属帖子:《{c.post_title}》
); })}
)}
{/* 分页 */} {totalPages > 1 && (
第 {tab === "posts" ? postsPage : commentsPage} / {totalPages} 页
)}
); } function EmptyQueue({ icon, text }: { icon: React.ReactNode; text: string }) { return (
{icon}

{text}

新提交的内容会按时间顺序出现在这里

); }