polish(admin): 抽出后台控件包并统一各页布局与交互

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 19:03:21 +08:00
parent cfc6c1403b
commit f73d2a89e8
23 changed files with 1720 additions and 1412 deletions

View File

@@ -4,8 +4,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
Check,
ChevronLeft,
ChevronRight,
ClipboardCheck,
FileText,
Loader2,
@@ -27,6 +25,12 @@ import {
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;
@@ -190,198 +194,106 @@ export default function ModerationAdmin({
}
};
const totalPages = tab === "posts"
? Math.max(1, Math.ceil(postsTotal / PAGE_SIZE))
: Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE));
const page = tab === "posts" ? postsPage : commentsPage;
const total = tab === "posts" ? postsTotal : commentsTotal;
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>
<AdminPageHeader
icon={ClipboardCheck}
title="内容审核"
description="普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。"
/>
{error && <p className="alert-error mb-4">{error}</p>}
<div aria-busy={loading} style={{ opacity: loading ? 0.6 : 1, transition: "opacity .15s" }}>
<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 ? (
<EmptyQueue icon={<FileText size={22} />} text="暂无待审核帖子" />
<AdminEmpty
icon={<FileText size={22} />}
title="暂无待审核帖子"
description="新提交的内容会按时间顺序出现在这里"
/>
) : (
<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}`;
posts.map((p, idx) => {
const key = `posts-${p.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"
key={p.id}
className="px-4 sm:px-5 py-4"
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>
<div className="flex items-center gap-2 flex-wrap mb-2">
<Link
href={`/post/${c.post_id}`}
target="_blank"
className="mt-1.5 block meta text-[12px] truncate hover:text-[var(--accent)] transition-colors"
href={`/?board=${p.board.id}`}
className="chip"
style={{ fontSize: 11.5 }}
>
所属帖子:《{c.post_title}》
{p.board.name}
</Link>
<div className="flex items-center gap-2 mt-3">
<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, "comments", c.id, true, commentsPage, comments.length)}
className="btn btn-sm"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
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} />}
通过
@@ -389,69 +301,115 @@ export default function ModerationAdmin({
<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)",
}}
onClick={() => act(key, "posts", p.id, false, postsPage, posts.length)}
className="btn btn-sm btn-danger"
>
{confirming ? "确认拒绝?" : <><X size={13} /> 拒绝</>}
{confirming ? (
"确认拒绝?"
) : (
<>
<X size={13} /> 拒绝
</>
)}
</button>
</div>
</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>
{/* 分页 */}
{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>
);
}