- 新增站长/超级管理员/管理员/板块管理员四级角色体系 - 实现实时权限快照加载与细粒度权限校验 - 新增内容审核队列与前后端页面 - 重构用户权限管理与站点管理逻辑 - 新增评论/帖子审核状态与通知推送 - 优化前端权限控制与角色徽章展示 - 修正数据库迁移与默认值问题
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|