Files
jiang13-bbs/frontend/app/admin/moderation/ModerationAdmin.tsx
freefire a7b6421840 feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
2026-09-15 04:47:34 +08:00

458 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<Tab>("posts");
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
// 帖子队列
const [posts, setPosts] = useState<Post[]>(initialPosts);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [postsPage, setPostsPage] = useState(1);
// 评论队列
const [comments, setComments] = useState<PendingCommentItem[]>([]);
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<string | null>(null);
// 拒绝的二次内联确认(key: posts-<id> / comments-<id>,3 秒自动复原)
const [confirmKey, setConfirmKey] = useState<string | null>(null);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div>
{/* 页头 */}
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<ClipboardCheck size={18} />
</span>
内容审核
</h1>
<p className="meta mt-1.5 text-[12.5px]">
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容
</p>
</div>
{/* 队列切换 */}
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
{(
[
{ key: "posts" as Tab, label: "待审帖子", count: counts.posts, icon: <FileText size={14} /> },
{ key: "comments" as Tab, label: "待审评论", count: counts.comments, icon: <MessagesSquare size={14} /> },
]
).map((t) => {
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchTab(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.icon}
{t.label}
<span
className="text-[11px] tabular-nums rounded-full px-1.5 leading-[1.7]"
style={{
background: "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{t.count}
</span>
</button>
);
})}
</div>
{error && <p className="alert-error mb-4">{error}</p>}
<div aria-busy={loading} style={{ opacity: loading ? 0.6 : 1, transition: "opacity .15s" }}>
{tab === "posts" ? (
posts.length === 0 ? (
<EmptyQueue icon={<FileText size={22} />} text="暂无待审核帖子" />
) : (
<div className="flex flex-col gap-3">
{posts.map((p) => {
const key = `posts-${p.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div key={p.id} className="panel p-4 sm:p-5">
<div className="flex items-center gap-2 flex-wrap mb-2.5">
<Link
href={`/board/${p.board.id}`}
className="chip"
style={{ fontSize: 11.5 }}
>
{p.board.name}
</Link>
<span className="meta text-[11.5px]">{formatRelative(p.created_at)}</span>
</div>
<Link
href={`/post/${p.id}`}
target="_blank"
className="block text-[15.5px] font-extrabold tracking-tight leading-snug hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{p.title}
</Link>
<p
className="mt-1.5 text-[13px] leading-relaxed line-clamp-3"
style={{ color: "var(--ink-3)" }}
>
{excerpt(p.content) || "(无正文内容)"}
</p>
<div
className="mt-3.5 flex items-center justify-between gap-3 flex-wrap pt-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 min-w-0">
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={26} />
<span className="text-[12.5px] font-semibold truncate" style={{ color: "var(--ink-2)" }}>
{p.user.nickname}
</span>
<span className="meta text-[11.5px] truncate">@{p.user.username}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
disabled={busy}
onClick={() => act(key, "posts", p.id, true, postsPage, posts.length)}
className="btn btn-sm"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
通过
</button>
<button
type="button"
disabled={busy}
onClick={() => act(key, "posts", p.id, false, postsPage, posts.length)}
className="btn btn-sm"
style={{
background: confirming ? "var(--danger)" : "var(--danger-soft)",
color: confirming ? "var(--accent-on)" : "var(--danger)",
}}
>
{confirming ? "确认拒绝?" : <><X size={13} /> 拒绝</>}
</button>
</div>
</div>
</div>
);
})}
</div>
)
) : comments.length === 0 ? (
<EmptyQueue icon={<MessagesSquare size={22} />} text="暂无待审核评论" />
) : (
<div className="panel overflow-hidden">
{comments.map((c, idx) => {
const key = `comments-${c.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div
key={c.id}
className="p-4 sm:p-5 flex items-start gap-3.5"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar name={c.user.nickname || c.user.username} src={c.user.avatar || undefined} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{c.user.nickname}
</span>
<Link
href={`/board/${c.board.id}`}
className="chip"
style={{ fontSize: 11 }}
>
{c.board.name}
</Link>
<span className="meta text-[11.5px]">{formatRelative(c.created_at)}</span>
</div>
<p
className="mt-1.5 text-[13.5px] leading-relaxed rounded-xl px-3.5 py-2.5"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
{c.content}
</p>
<Link
href={`/post/${c.post_id}`}
target="_blank"
className="mt-1.5 block meta text-[12px] truncate hover:text-[var(--accent)] transition-colors"
>
所属帖子:《{c.post_title}》
</Link>
<div className="flex items-center gap-2 mt-3">
<button
type="button"
disabled={busy}
onClick={() => act(key, "comments", c.id, true, commentsPage, comments.length)}
className="btn btn-sm"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
通过
</button>
<button
type="button"
disabled={busy}
onClick={() => act(key, "comments", c.id, false, commentsPage, comments.length)}
className="btn btn-sm"
style={{
background: confirming ? "var(--danger)" : "var(--danger-soft)",
color: confirming ? "var(--accent-on)" : "var(--danger)",
}}
>
{confirming ? "确认拒绝?" : <><X size={13} /> 拒绝</>}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div
className="mt-4 flex items-center justify-between gap-2 panel px-4 sm:px-5 py-3"
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {tab === "posts" ? postsPage : commentsPage} / {totalPages} 页
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => (tab === "posts" ? goPosts(postsPage - 1) : goComments(commentsPage - 1))}
disabled={(tab === "posts" ? postsPage : commentsPage) <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => (tab === "posts" ? goPosts(postsPage + 1) : goComments(commentsPage + 1))}
disabled={(tab === "posts" ? postsPage : commentsPage) >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
);
}
function EmptyQueue({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="panel text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
{icon}
</span>
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
{text}
</p>
<p className="meta mt-1.5 text-[12.5px]">新提交的内容会按时间顺序出现在这里</p>
</div>
);
}