feat(admin): 合并内容审核到内容管理,并固定后台侧栏

待审与内容库同一页处理;筛选切换去掉加载占位,避免页面闪动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 00:10:23 +08:00
parent 011a610113
commit a0a9aeed66
12 changed files with 398 additions and 633 deletions

View File

@@ -11,7 +11,6 @@ import {
Palette, Palette,
ShieldCheck, ShieldCheck,
ArrowLeft, ArrowLeft,
ClipboardCheck,
Coins, Coins,
Globe, Globe,
FolderOpen, FolderOpen,
@@ -54,18 +53,12 @@ const NAV_GROUPS: NavGroup[] = [
{ {
title: "内容", title: "内容",
items: [ items: [
{
href: "/admin/moderation",
label: "内容审核",
icon: ClipboardCheck,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{ {
href: "/admin/content", href: "/admin/content",
label: "内容管理", label: "内容管理",
icon: FolderOpen, icon: FolderOpen,
visible: (u) => canModerateAny(u), visible: (u) => canModerateAny(u),
pendingBadge: true,
}, },
{ {
href: "/admin/announcements", href: "/admin/announcements",

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
Check,
FileText, FileText,
FolderOpen, FolderOpen,
History, History,
@@ -10,6 +11,7 @@ import {
MessagesSquare, MessagesSquare,
RotateCcw, RotateCcw,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
apiAdminContentCounts, apiAdminContentCounts,
@@ -21,6 +23,10 @@ import {
apiAdminSoftDeleteComment, apiAdminSoftDeleteComment,
apiAdminRestoreComment, apiAdminRestoreComment,
apiAdminPurgeComment, apiAdminPurgeComment,
apiAdminApprovePost,
apiAdminRejectPost,
apiAdminApproveComment,
apiAdminRejectComment,
apiUpdateSiteSettings, apiUpdateSiteSettings,
type AdminContentComment, type AdminContentComment,
type AdminContentCounts, type AdminContentCounts,
@@ -30,6 +36,7 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { formatRelative } from "@/lib/format"; import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal"; import StaffDeletePostModal from "@/components/StaffDeletePostModal";
@@ -44,13 +51,14 @@ import {
AdminSegmented, AdminSegmented,
AdminSettingRow, AdminSettingRow,
AdminStatusChip, AdminStatusChip,
AdminStepper,
AdminSwitch, AdminSwitch,
contentStatusChip, contentStatusChip,
} from "@/components/admin"; } from "@/components/admin";
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
type Kind = "posts" | "comments"; export type ContentKind = "posts" | "comments";
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [ const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
{ key: "live", label: "全部" }, { key: "live", label: "全部" },
@@ -70,31 +78,75 @@ function excerpt(md: string, max = 120): string {
return text.length > max ? `${text.slice(0, max)}…` : text; return text.length > max ? `${text.slice(0, max)}…` : text;
} }
function isPendingRow(status: string, deleted: boolean) {
return !deleted && status === "pending";
}
function PendingActions({
busy,
onApprove,
onReject,
}: {
busy: boolean;
onApprove: () => void;
onReject: () => void;
}) {
return (
<>
<button type="button" className="btn btn-sm btn-ok" disabled={busy} onClick={onApprove}>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
通过
</button>
<button type="button" className="btn btn-sm btn-danger" disabled={busy} onClick={onReject}>
<X size={13} /> 拒绝
</button>
</>
);
}
function emptyCopy(kind: ContentKind, status: AdminContentStatus): { title: string; description?: string } {
if (status === "pending") {
return {
title: kind === "posts" ? "暂无待审帖子" : "暂无待审评论",
description: "新提交的内容会按时间顺序出现在这里,可通过或拒绝",
};
}
return { title: kind === "posts" ? "暂无帖子" : "暂无评论" };
}
export default function ContentAdmin({ export default function ContentAdmin({
initialKind,
initialStatus,
initialPosts, initialPosts,
initialPostsTotal, initialPostsTotal,
initialComments,
initialCommentsTotal,
initialCounts, initialCounts,
initError, initError,
canConfigureSettings, canConfigureSettings,
initialSettings, initialSettings,
}: { }: {
initialKind: ContentKind;
initialStatus: AdminContentStatus;
initialPosts: AdminContentPost[]; initialPosts: AdminContentPost[];
initialPostsTotal: number; initialPostsTotal: number;
initialComments: AdminContentComment[];
initialCommentsTotal: number;
initialCounts: AdminContentCounts; initialCounts: AdminContentCounts;
initError: string; initError: string;
canConfigureSettings: boolean; canConfigureSettings: boolean;
initialSettings: PublicSettings; initialSettings: PublicSettings;
}) { }) {
const [kind, setKind] = useState<Kind>("posts"); const [kind, setKind] = useState<ContentKind>(initialKind);
const [status, setStatus] = useState<AdminContentStatus>("live"); const [status, setStatus] = useState<AdminContentStatus>(initialStatus);
const [counts, setCounts] = useState(initialCounts); const [counts, setCounts] = useState(initialCounts);
const [posts, setPosts] = useState(initialPosts); const [posts, setPosts] = useState(initialPosts);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal); const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [postsPage, setPostsPage] = useState(1); const [postsPage, setPostsPage] = useState(1);
const [comments, setComments] = useState<AdminContentComment[]>([]); const [comments, setComments] = useState(initialComments);
const [commentsTotal, setCommentsTotal] = useState(0); const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal);
const [commentsPage, setCommentsPage] = useState(1); const [commentsPage, setCommentsPage] = useState(1);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
@@ -111,6 +163,7 @@ export default function ContentAdmin({
title: string; title: string;
message: string; message: string;
danger?: boolean; danger?: boolean;
confirmLabel?: string;
run: () => Promise<void>; run: () => Promise<void>;
} | null>(null); } | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false); const [confirmBusy, setConfirmBusy] = useState(false);
@@ -131,6 +184,7 @@ export default function ContentAdmin({
setPosts(res.posts); setPosts(res.posts);
setPostsTotal(res.total); setPostsTotal(res.total);
setPostsPage(page); setPostsPage(page);
setKind("posts");
setError(""); setError("");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "获取帖子失败"); setError(e instanceof Error ? e.message : "获取帖子失败");
@@ -146,6 +200,7 @@ export default function ContentAdmin({
setComments(res.comments); setComments(res.comments);
setCommentsTotal(res.total); setCommentsTotal(res.total);
setCommentsPage(page); setCommentsPage(page);
setKind("comments");
setError(""); setError("");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "获取评论失败"); setError(e instanceof Error ? e.message : "获取评论失败");
@@ -160,13 +215,23 @@ export default function ContentAdmin({
refreshCounts(); refreshCounts();
}; };
const reloadAfterItemChange = () => {
const p = kind === "posts" ? postsPage : commentsPage;
const len = kind === "posts" ? posts.length : comments.length;
reload(len <= 1 && p > 1 ? p - 1 : p);
};
useEffect(() => { useEffect(() => {
refreshCounts(); refreshCounts();
}, [refreshCounts]); }, [refreshCounts]);
const switchKind = (k: Kind) => { useEffect(() => {
const off = realtime.on(RT_MODERATION_CHANGED, refreshCounts);
return off;
}, [refreshCounts]);
const switchKind = (k: ContentKind) => {
if (k === kind) return; if (k === kind) return;
setKind(k);
setError(""); setError("");
if (k === "posts") void loadPosts(1, status, appliedQ); if (k === "posts") void loadPosts(1, status, appliedQ);
else void loadComments(1, status, appliedQ); else void loadComments(1, status, appliedQ);
@@ -198,7 +263,7 @@ export default function ContentAdmin({
fn() fn()
.then(() => { .then(() => {
toast(okMsg, "ok"); toast(okMsg, "ok");
reload(kind === "posts" ? postsPage : commentsPage); reloadAfterItemChange();
}) })
.catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setActingKey(null)); .finally(() => setActingKey(null));
@@ -208,8 +273,24 @@ export default function ContentAdmin({
title: string, title: string,
message: string, message: string,
run: () => Promise<void>, run: () => Promise<void>,
danger = false danger = false,
) => setConfirm({ title, message, run, danger }); confirmLabel?: string
) => setConfirm({ title, message, run, danger, confirmLabel });
const askReject = (kindNow: ContentKind, id: number) => {
askConfirm(
kindNow === "posts" ? "拒绝这篇帖子?" : "拒绝这条评论?",
"拒绝后作者将收到通知,内容不会公开。",
async () => {
if (kindNow === "posts") await apiAdminRejectPost(id);
else await apiAdminRejectComment(id);
toast("已拒绝,作者将收到通知", "ok");
reloadAfterItemChange();
},
true,
"确认拒绝"
);
};
const toggleTrust = async () => { const toggleTrust = async () => {
if (!canConfigureSettings || savingPolicy) return; if (!canConfigureSettings || savingPolicy) return;
@@ -254,12 +335,18 @@ export default function ContentAdmin({
if (st === "pending") return counts.posts_pending; if (st === "pending") return counts.posts_pending;
if (st === "rejected") return counts.posts_rejected; if (st === "rejected") return counts.posts_rejected;
if (st === "deleted") return counts.posts_deleted; if (st === "deleted") return counts.posts_deleted;
if (st === "published") {
return Math.max(0, counts.posts_live - counts.posts_pending - counts.posts_rejected);
}
return null; return null;
} }
if (st === "live") return counts.comments_live; if (st === "live") return counts.comments_live;
if (st === "pending") return counts.comments_pending; if (st === "pending") return counts.comments_pending;
if (st === "rejected") return counts.comments_rejected; if (st === "rejected") return counts.comments_rejected;
if (st === "deleted") return counts.comments_deleted; if (st === "deleted") return counts.comments_deleted;
if (st === "published") {
return Math.max(0, counts.comments_live - counts.comments_pending - counts.comments_rejected);
}
return null; return null;
}; };
@@ -268,12 +355,14 @@ export default function ContentAdmin({
else void loadComments(p, status, appliedQ); else void loadComments(p, status, appliedQ);
}; };
const empty = emptyCopy(kind, status);
return ( return (
<div> <div>
<AdminPageHeader <AdminPageHeader
icon={FolderOpen} icon={FolderOpen}
title="内容管理" title="内容管理"
description="浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改" description="审核待发布内容,并处理已发布/已拒绝条目与回收站;策略开关仅超管/站长可改"
/> />
{canConfigureSettings && ( {canConfigureSettings && (
@@ -294,88 +383,104 @@ export default function ContentAdmin({
<AdminSettingRow <AdminSettingRow
title="新用户发帖冷静期" title="新用户发帖冷静期"
description="注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。" description="注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。"
> control={
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex items-center gap-2.5">
<input <AdminStepper
type="number" value={cooldown}
min={0} onChange={setCooldown}
max={168} min={0}
className="field w-28" max={168}
value={cooldown} unit="小时"
onChange={(e) => setCooldown(Number(e.target.value))} ariaLabel="冷静期小时数"
aria-label="冷静期小时数" disabled={savingPolicy}
/> />
<span className="meta text-[12.5px]">小时</span> <button
<button type="button"
type="button" className="btn btn-line btn-sm"
className="btn btn-line btn-sm" disabled={savingPolicy}
disabled={savingPolicy} onClick={() => void saveCooldown()}
onClick={() => void saveCooldown()} >
> 保存
保存 </button>
</button> </div>
</div> }
</AdminSettingRow> />
</div> </div>
)} )}
<div className="panel overflow-hidden"> <div className="panel overflow-hidden">
<div className="admin-toolbar"> <div className="admin-content-toolbar">
<AdminSegmented<Kind> <div className="admin-content-kind" role="tablist" aria-label="内容类型">
ariaLabel="内容类型" <button
value={kind} type="button"
onChange={switchKind} role="tab"
options={[ aria-selected={kind === "posts"}
{ className="admin-content-kind-btn"
key: "posts", onClick={() => switchKind("posts")}
label: "帖子", >
icon: <FileText size={14} />, <FileText size={14} aria-hidden />
count: counts.posts_live, 帖子
}, <span className="admin-content-kind-count">{counts.posts_live}</span>
{ {counts.posts_pending > 0 ? (
key: "comments", <span className="admin-status is-warn" title={`${counts.posts_pending} 条待审`}>
label: "评论", {counts.posts_pending}
icon: <MessagesSquare size={14} />, </span>
count: counts.comments_live, ) : null}
}, </button>
]} <button
/> type="button"
<AdminSegmented<AdminContentStatus> role="tab"
ariaLabel="状态筛选" aria-selected={kind === "comments"}
size="sm" className="admin-content-kind-btn"
value={status} onClick={() => switchKind("comments")}
onChange={switchStatus} >
options={STATUS_TABS.map((t) => ({ <MessagesSquare size={14} aria-hidden />
key: t.key, 评论
label: t.label, <span className="admin-content-kind-count">{counts.comments_live}</span>
count: statusCount(t.key) ?? undefined, {counts.comments_pending > 0 ? (
}))} <span className="admin-status is-warn" title={`${counts.comments_pending} 条待审`}>
/> {counts.comments_pending}
<AdminSearchField </span>
value={keyword} ) : null}
onChange={setKeyword} </button>
onSubmit={applySearch} </div>
onClear={clearSearch} <span className="admin-content-split" aria-hidden />
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"} <div className="admin-content-status">
ariaLabel="搜索内容" <AdminSegmented<AdminContentStatus>
/> ariaLabel="状态筛选"
size="sm"
value={status}
onChange={switchStatus}
options={STATUS_TABS.map((t) => ({
key: t.key,
label: t.label,
count: statusCount(t.key) ?? undefined,
}))}
/>
</div>
<div className="admin-content-search">
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={applySearch}
onClear={clearSearch}
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
ariaLabel="搜索内容"
/>
</div>
</div> </div>
{loading ? ( {error ? <p className="alert-error m-4">{error}</p> : null}
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]"> {kind === "posts" && posts.length === 0 ? (
<Loader2 size={16} className="animate-spin" /> 加载中… <AdminEmpty icon={<FileText size={22} />} title={empty.title} description={empty.description} />
</div>
) : error ? (
<p className="alert-error m-4">{error}</p>
) : kind === "posts" && posts.length === 0 ? (
<AdminEmpty icon={<FileText size={22} />} title="暂无帖子" />
) : kind === "comments" && comments.length === 0 ? ( ) : kind === "comments" && comments.length === 0 ? (
<AdminEmpty icon={<MessagesSquare size={22} />} title="暂无评论" /> <AdminEmpty icon={<MessagesSquare size={22} />} title={empty.title} description={empty.description} />
) : kind === "posts" ? ( ) : kind === "posts" ? (
posts.map((p, idx) => { posts.map((p, idx) => {
const key = `p-${p.id}`; const key = `p-${p.id}`;
const busy = actingKey === key; const busy = actingKey === key;
const chip = contentStatusChip(p.status, p.deleted); const chip = contentStatusChip(p.status, p.deleted);
const pending = isPendingRow(p.status, p.deleted);
return ( return (
<div <div
key={p.id} key={p.id}
@@ -412,7 +517,16 @@ export default function ContentAdmin({
{p.user?.nickname} · {p.comment_count} 评 {p.user?.nickname} · {p.comment_count} 评
</p> </p>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
{pending && (
<PendingActions
busy={busy}
onApprove={() =>
runAction(key, () => apiAdminApprovePost(p.id), "已通过审核,内容已公开")
}
onReject={() => askReject("posts", p.id)}
/>
)}
{p.deleted ? ( {p.deleted ? (
<> <>
<AdminIconButton <AdminIconButton
@@ -441,7 +555,7 @@ export default function ContentAdmin({
() => () =>
apiAdminPurgePost(p.id).then(() => { apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok"); toast("帖子已彻底删除", "ok");
reload(postsPage); reloadAfterItemChange();
}), }),
true true
) )
@@ -469,6 +583,7 @@ export default function ContentAdmin({
const key = `c-${c.id}`; const key = `c-${c.id}`;
const busy = actingKey === key; const busy = actingKey === key;
const chip = contentStatusChip(c.status, c.deleted); const chip = contentStatusChip(c.status, c.deleted);
const pending = isPendingRow(c.status, c.deleted);
return ( return (
<div <div
key={c.id} key={c.id}
@@ -506,7 +621,16 @@ export default function ContentAdmin({
)} )}
</p> </p>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
{pending && (
<PendingActions
busy={busy}
onApprove={() =>
runAction(key, () => apiAdminApproveComment(c.id), "已通过审核,内容已公开")
}
onReject={() => askReject("comments", c.id)}
/>
)}
<AdminIconButton <AdminIconButton
tip="历史" tip="历史"
ariaLabel="查看编辑历史" ariaLabel="查看编辑历史"
@@ -542,7 +666,7 @@ export default function ContentAdmin({
() => () =>
apiAdminPurgeComment(c.id).then(() => { apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok"); toast("评论已彻底删除", "ok");
reload(commentsPage); reloadAfterItemChange();
}), }),
true true
) )
@@ -582,7 +706,7 @@ export default function ContentAdmin({
message={confirm?.message || ""} message={confirm?.message || ""}
danger={confirm?.danger} danger={confirm?.danger}
busy={confirmBusy} busy={confirmBusy}
confirmLabel={confirm?.danger ? "确认删除" : "确定"} confirmLabel={confirm?.confirmLabel || (confirm?.danger ? "确认删除" : "确定")}
onCancel={() => !confirmBusy && setConfirm(null)} onCancel={() => !confirmBusy && setConfirm(null)}
onConfirm={() => { onConfirm={() => {
if (!confirm) return; if (!confirm) return;
@@ -609,7 +733,7 @@ export default function ContentAdmin({
.then(() => { .then(() => {
toast("已移入回收站", "ok"); toast("已移入回收站", "ok");
setStaffDeletePost(null); setStaffDeletePost(null);
reload(postsPage); reloadAfterItemChange();
}) })
.catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setStaffDeleteBusy(false)); .finally(() => setStaffDeleteBusy(false));
@@ -628,7 +752,7 @@ export default function ContentAdmin({
.then(() => { .then(() => {
toast("已移入回收站", "ok"); toast("已移入回收站", "ok");
setStaffDeleteComment(null); setStaffDeleteComment(null);
reload(commentsPage); reloadAfterItemChange();
}) })
.catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setStaffDeleteCommentBusy(false)); .finally(() => setStaffDeleteCommentBusy(false));

View File

@@ -4,18 +4,39 @@ import { authCookieHeader } from "@/lib/cookies";
import { import {
fetchAdminContentCounts, fetchAdminContentCounts,
fetchAdminContentPosts, fetchAdminContentPosts,
fetchAdminContentComments,
type AdminContentComment,
type AdminContentPost, type AdminContentPost,
type AdminContentStatus,
} from "@/lib/api"; } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles"; import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden"; import Forbidden from "../Forbidden";
import ContentAdmin from "./ContentAdmin"; import ContentAdmin, { type ContentKind } from "./ContentAdmin";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "内容管理", title: "内容管理",
}; };
export default async function AdminContentPage() { const STATUSES: AdminContentStatus[] = ["live", "published", "pending", "rejected", "deleted"];
function parseStatus(v?: string): AdminContentStatus {
return v && STATUSES.includes(v as AdminContentStatus) ? (v as AdminContentStatus) : "live";
}
function parseKind(v?: string): ContentKind {
return v === "comments" ? "comments" : "posts";
}
interface ContentPageProps {
searchParams: Promise<{ status?: string; kind?: string }>;
}
export default async function AdminContentPage({ searchParams }: ContentPageProps) {
const sp = await searchParams;
const initialStatus = parseStatus(sp.status);
const initialKind = parseKind(sp.kind);
const cookie = authCookieHeader(await cookies()); const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined); const me = await getMeCached(cookie || undefined);
if (!me.user || !canModerateAny(me.user)) { if (!me.user || !canModerateAny(me.user)) {
@@ -23,30 +44,49 @@ export default async function AdminContentPage() {
} }
let initialPosts: AdminContentPost[] = []; let initialPosts: AdminContentPost[] = [];
let initialTotal = 0; let initialPostsTotal = 0;
let initialComments: AdminContentComment[] = [];
let initialCommentsTotal = 0;
let initError = ""; let initError = "";
const [postsSettled, counts] = await Promise.all([ const [listSettled, counts] = await Promise.all([
fetchAdminContentPosts(1, "live", cookie || undefined) initialKind === "comments"
.then((res) => ({ ok: true as const, res })) ? fetchAdminContentComments(1, initialStatus, cookie || undefined)
.catch((e) => ({ .then((res) => ({ ok: true as const, kind: "comments" as const, res }))
ok: false as const, .catch((e) => ({
error: e instanceof Error ? e.message : "获取内容列表失败", ok: false as const,
})), error: e instanceof Error ? e.message : "获取内容列表失败",
}))
: fetchAdminContentPosts(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, kind: "posts" as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取内容列表失败",
})),
fetchAdminContentCounts(cookie || undefined), fetchAdminContentCounts(cookie || undefined),
]); ]);
if (postsSettled.ok) { if (listSettled.ok) {
initialPosts = postsSettled.res.posts; if (listSettled.kind === "posts") {
initialTotal = postsSettled.res.total; initialPosts = listSettled.res.posts;
initialPostsTotal = listSettled.res.total;
} else {
initialComments = listSettled.res.comments;
initialCommentsTotal = listSettled.res.total;
}
} else { } else {
initError = postsSettled.error; initError = listSettled.error;
} }
const settings = await getPublicSettingsCached(); const settings = await getPublicSettingsCached();
return ( return (
<ContentAdmin <ContentAdmin
key={`${initialKind}-${initialStatus}`}
initialKind={initialKind}
initialStatus={initialStatus}
initialPosts={initialPosts} initialPosts={initialPosts}
initialPostsTotal={initialTotal} initialPostsTotal={initialPostsTotal}
initialComments={initialComments}
initialCommentsTotal={initialCommentsTotal}
initialCounts={counts} initialCounts={counts}
initError={initError} initError={initError}
canConfigureSettings={isSuperOrOwner(me.user.role)} canConfigureSettings={isSuperOrOwner(me.user.role)}

View File

@@ -42,10 +42,12 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<div> <div>
<AdminNavMobile user={me.user} /> <AdminNavMobile user={me.user} />
<div className="flex items-start gap-6 xl:gap-7"> <div className="flex items-start gap-6 xl:gap-7">
<aside className="hidden lg:block w-60 xl:w-64 shrink-0"> {/* sticky 必须钉在 aside 自身:内层与侧栏同高时等于没钉住,会跟着主栏一起滚 */}
<div className="sticky" style={{ top: 84 }}> <aside
<AdminSidebar user={me.user} /> className="hidden lg:block w-60 xl:w-64 shrink-0 sticky self-start z-20 max-h-[calc(100vh-6.5rem)] overflow-y-auto"
</div> style={{ top: 84 }}
>
<AdminSidebar user={me.user} />
</aside> </aside>
<section className="flex-1 min-w-0">{children}</section> <section className="flex-1 min-w-0">{children}</section>
</div> </div>

View File

@@ -1,415 +0,0 @@
"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>
);
}

View File

@@ -1,42 +0,0 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ModerationAdmin from "./ModerationAdmin";
export const metadata: Metadata = {
title: "内容审核",
};
// 鉴权由 app/admin/layout.tsx 统一承担(RequireStaff + 板块隔离由后端兜底);
// 帖子待审队列首屏 SSR 直出,评论队列由客户端进入时拉取
export default async function ModerationPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !canModerateAny(me.user)) {
return <Forbidden text="你所在的角色没有内容审核权限,或尚未被授权任何板块。" />;
}
let initialPosts: Post[] = [];
let initialTotal = 0;
let initError = "";
try {
const res = await fetchAdminPendingPosts(1, cookie || undefined);
initialPosts = res.posts;
initialTotal = res.total;
} catch (e) {
initError = e instanceof Error ? e.message : "获取待审核内容失败";
}
return (
<ModerationAdmin
initialPosts={initialPosts}
initialPostsTotal={initialTotal}
initError={initError}
/>
);
}

View File

@@ -56,14 +56,18 @@ function PanelHead({ title, extra }: { title: string; extra?: string }) {
); );
} }
/** 待办:待审帖子 / 评论,整块进入审核页 */ /** 待办:待审帖子 / 评论,进入内容管理的待审筛选 */
function PendingWorkPanel({ counts }: { counts: PendingCounts }) { function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
const total = counts.posts + counts.comments; const total = counts.posts + counts.comments;
const idle = total === 0; const idle = total === 0;
return ( return (
<Link <Link
href="/admin/moderation" href={
counts.posts > 0 || counts.comments === 0
? "/admin/content?status=pending"
: "/admin/content?status=pending&kind=comments"
}
className="panel hover-lift relative block p-4 sm:p-5 mb-4 group" className="panel hover-lift relative block p-4 sm:p-5 mb-4 group"
> >
<div className="flex items-start gap-3.5"> <div className="flex items-start gap-3.5">
@@ -80,7 +84,7 @@ function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}> <p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
内容审核 待审内容
</p> </p>
{idle ? ( {idle ? (
<span <span

View File

@@ -1872,6 +1872,102 @@ a.j13-mention-token:hover {
} }
} }
/* 内容管理工具条:类型 / 状态 / 搜索同一行,避免三行左对齐或等分顶栏留白 */
.admin-content-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
@media (min-width: 640px) {
.admin-content-toolbar {
padding: 0.75rem 1.25rem;
}
}
.admin-content-kind {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem;
border-radius: 999px;
background: var(--panel-2);
flex-shrink: 0;
}
.admin-content-kind-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4rem 0.8rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--ink-3);
font-size: 13px;
font-weight: 600;
line-height: 1.3;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.admin-content-kind-btn:hover:not([aria-selected="true"]) {
color: var(--ink);
}
.admin-content-kind-btn[aria-selected="true"] {
background: var(--panel);
color: var(--ink);
box-shadow: 0 0 0 1px var(--line);
}
.admin-content-kind-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.admin-content-kind-count {
min-width: 1.15rem;
padding: 0 0.35rem;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.65;
background: var(--panel);
color: var(--ink-3);
}
.admin-content-kind-btn[aria-selected="true"] .admin-content-kind-count {
background: var(--accent-soft);
color: var(--accent);
}
.admin-content-split {
display: none;
width: 1px;
height: 1.5rem;
background: var(--line);
flex-shrink: 0;
}
.admin-content-status {
flex: 1 1 14rem;
min-width: 0;
}
.admin-content-search {
flex: 1 1 12rem;
max-width: 18rem;
min-width: 11rem;
margin-left: auto;
}
@media (min-width: 768px) {
.admin-content-split {
display: block;
}
}
@media (max-width: 767px) {
.admin-content-search {
flex-basis: 100%;
max-width: none;
margin-left: 0;
}
}
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */ /* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
.j13-like { .j13-like {
border-color: var(--line-2); border-color: var(--line-2);

View File

@@ -24,7 +24,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
if (n.type === "mention" && n.room_id) { if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`); router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") { } else if (n.type === "pending_review") {
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/moderation"); router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
} else { } else {
router.push(`/post/${n.post_id}`); router.push(`/post/${n.post_id}`);
} }

View File

@@ -103,7 +103,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "mention" && n.room_id) { if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`); router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") { } else if (n.type === "pending_review") {
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/moderation"); router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
} else { } else {
router.push(`/post/${n.post_id}`); router.push(`/post/${n.post_id}`);
} }

View File

@@ -1387,20 +1387,12 @@ export async function fetchAdminUsers(
return res.json(); return res.json();
} }
// SSR 待审核帖子首页(转发管理团队 cookie 直连后端);权限不足时抛错由页面层处理 /** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */
export async function fetchAdminPendingPosts( export interface PendingCounts {
page = 1, posts: number;
cookieHeader?: string comments: number;
): Promise<{ posts: Post[]; total: number; page: number }> {
const res = await fetch(
`${API_BASE}/api/admin/moderation/pending-posts?page=${page}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (!res.ok) throw new Error("获取待审核帖子失败");
return res.json();
} }
/** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */
export async function fetchAdminPendingCounts( export async function fetchAdminPendingCounts(
cookieHeader?: string cookieHeader?: string
): Promise<PendingCounts> { ): Promise<PendingCounts> {
@@ -1622,49 +1614,7 @@ export async function apiListBoards(): Promise<{ boards: Board[] }> {
return res.json(); return res.json();
} }
// ===== 内容审核队列 ===== // ===== 内容审核动作 =====
export interface PendingCommentItem {
id: number;
post_id: number;
post_title: string;
board_id: number;
board: Board;
content: string;
created_at: string;
user: User;
}
export interface PendingCounts {
posts: number;
comments: number;
}
export async function apiAdminPendingPosts(page = 1): Promise<{
posts: Post[];
total: number;
page: number;
}> {
const res = await fetchWithRefresh(`/api/admin/moderation/pending-posts?page=${page}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取待审帖子失败");
return data;
}
export async function apiAdminPendingComments(page = 1): Promise<{
comments: PendingCommentItem[];
total: number;
page: number;
}> {
const res = await fetchWithRefresh(`/api/admin/moderation/pending-comments?page=${page}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取待审评论失败");
return data;
}
export async function apiAdminPendingCounts(): Promise<PendingCounts> { export async function apiAdminPendingCounts(): Promise<PendingCounts> {
const res = await fetchWithRefresh("/api/admin/moderation/counts", { const res = await fetchWithRefresh("/api/admin/moderation/counts", {
@@ -1862,6 +1812,19 @@ export async function fetchAdminContentPosts(
return res.json(); return res.json();
} }
export async function fetchAdminContentComments(
page = 1,
status: AdminContentStatus = "live",
cookieHeader?: string
): Promise<{ comments: AdminContentComment[]; total: number; page: number }> {
const res = await fetch(
`${API_BASE}/api/admin/content/comments?page=${page}&status=${status}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (!res.ok) throw new Error("获取评论列表失败");
return res.json();
}
// ===== 群聊(二期) ===== // ===== 群聊(二期) =====
// 群聊房间基础模型(与后端 model.ChatRoom 对齐) // 群聊房间基础模型(与后端 model.ChatRoom 对齐)

View File

@@ -69,7 +69,7 @@ export function canModerateBoard(
return (user.board_ids ?? []).includes(boardId); return (user.board_ids ?? []).includes(boardId);
} }
/** 是否拥有任意板块的内容审核权(后台"内容审核"入口) */ /** 是否拥有任意板块的内容审核权(后台「内容管理」入口) */
export function canModerateAny(user: RoleUserLike | null | undefined): boolean { export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
if (!user) return false; if (!user) return false;
if (isAdminOrAbove(user.role)) return true; if (isAdminOrAbove(user.role)) return true;