polish(admin): 抽出后台控件包并统一各页布局与交互
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user