Files
jiang13-bbs/frontend/app/admin/moderation/ModerationAdmin.tsx

416 lines
14 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,
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";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSegmented,
} from "@/components/admin";
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 page = tab === "posts" ? postsPage : commentsPage;
const total = tab === "posts" ? postsTotal : commentsTotal;
return (
<div>
<AdminPageHeader
icon={ClipboardCheck}
title="内容审核"
description="普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。"
/>
{error && <p className="alert-error mb-4">{error}</p>}
<div
className="panel overflow-hidden"
aria-busy={loading}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity .15s" }}
>
<div className="admin-toolbar">
<AdminSegmented<Tab>
ariaLabel="审核队列"
value={tab}
onChange={switchTab}
options={[
{
key: "posts",
label: "待审帖子",
count: counts.posts,
icon: <FileText size={14} />,
},
{
key: "comments",
label: "待审评论",
count: counts.comments,
icon: <MessagesSquare size={14} />,
},
]}
/>
</div>
{tab === "posts" ? (
posts.length === 0 ? (
<AdminEmpty
icon={<FileText size={22} />}
title="暂无待审核帖子"
description="新提交的内容会按时间顺序出现在这里"
/>
) : (
posts.map((p, idx) => {
const key = `posts-${p.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div
key={p.id}
className="px-4 sm:px-5 py-4"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 flex-wrap mb-2">
<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-[15px] 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 flex items-center justify-between gap-3 flex-wrap">
<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 btn-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 btn-danger"
>
{confirming ? (
"确认拒绝?"
) : (
<>
<X size={13} /> 拒绝
</>
)}
</button>
</div>
</div>
</div>
);
})
)
) : comments.length === 0 ? (
<AdminEmpty
icon={<MessagesSquare size={22} />}
title="暂无待审核评论"
description="新提交的内容会按时间顺序出现在这里"
/>
) : (
comments.map((c, idx) => {
const key = `comments-${c.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div
key={c.id}
className="px-4 sm:px-5 py-4 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 btn-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 btn-danger"
>
{confirming ? (
"确认拒绝?"
) : (
<>
<X size={13} /> 拒绝
</>
)}
</button>
</div>
</div>
</div>
);
})
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={(p) => (tab === "posts" ? goPosts(p) : goComments(p))}
/>
</div>
</div>
);
}