416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|