Files
jiang13-bbs/frontend/app/admin/moderation/ModerationAdmin.tsx
freefire dd7bd010b8 feat(admin): 完善站点设置、内容回收站与后台菜单分组
新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:56:27 +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] 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-semibold 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] 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] 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="text-[14.5px]" style={{ color: "var(--ink)" }}>
{text}
</p>
<p className="meta mt-1.5 text-[12.5px]">新提交的内容会按时间顺序出现在这里</p>
</div>
);
}