feat(admin): 完善站点设置、内容回收站与后台菜单分组

新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:56:27 +08:00
parent be47b38370
commit dd7bd010b8
25 changed files with 2193 additions and 323 deletions

View File

@@ -0,0 +1,696 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
FileText,
FolderOpen,
Loader2,
MessagesSquare,
RotateCcw,
Search,
Trash2,
} from "lucide-react";
import {
apiAdminContentCounts,
apiAdminContentPosts,
apiAdminContentComments,
apiAdminSoftDeletePost,
apiAdminRestorePost,
apiAdminPurgePost,
apiAdminSoftDeleteComment,
apiAdminRestoreComment,
apiAdminPurgeComment,
apiUpdateSiteSettings,
type AdminContentComment,
type AdminContentCounts,
type AdminContentPost,
type AdminContentStatus,
type PublicSettings,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import ConfirmDialog from "@/components/ConfirmDialog";
const PAGE_SIZE = 15;
type Kind = "posts" | "comments";
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
{ key: "live", label: "全部" },
{ key: "published", label: "已发布" },
{ key: "pending", label: "待审" },
{ key: "rejected", label: "已拒绝" },
{ key: "deleted", label: "已删除" },
];
function statusLabel(status: string, deleted: boolean) {
if (deleted) return "已删除";
if (status === "pending") return "待审";
if (status === "rejected") return "已拒绝";
if (status === "published") return "已发布";
return status;
}
function excerpt(md: string, max = 120): 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;
}
function Switch({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: () => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onChange}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
/>
</button>
);
}
export default function ContentAdmin({
initialPosts,
initialPostsTotal,
initialCounts,
initError,
canConfigureSettings,
initialSettings,
}: {
initialPosts: AdminContentPost[];
initialPostsTotal: number;
initialCounts: AdminContentCounts;
initError: string;
canConfigureSettings: boolean;
initialSettings: PublicSettings;
}) {
const [kind, setKind] = useState<Kind>("posts");
const [status, setStatus] = useState<AdminContentStatus>("live");
const [counts, setCounts] = useState(initialCounts);
const [posts, setPosts] = useState(initialPosts);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [postsPage, setPostsPage] = useState(1);
const [comments, setComments] = useState<AdminContentComment[]>([]);
const [commentsTotal, setCommentsTotal] = useState(0);
const [commentsPage, setCommentsPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingKey, setActingKey] = useState<string | null>(null);
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
const [savingPolicy, setSavingPolicy] = useState(false);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
danger?: boolean;
run: () => Promise<void>;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {});
}, []);
const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
setLoading(true);
try {
const res = await apiAdminContentPosts(page, st, q);
setPosts(res.posts);
setPostsTotal(res.total);
setPostsPage(page);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "获取帖子失败");
} finally {
setLoading(false);
}
}, []);
const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
setLoading(true);
try {
const res = await apiAdminContentComments(page, st, q);
setComments(res.comments);
setCommentsTotal(res.total);
setCommentsPage(page);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "获取评论失败");
} finally {
setLoading(false);
}
}, []);
const reload = (page = 1) => {
if (kind === "posts") void loadPosts(page, status, appliedQ);
else void loadComments(page, status, appliedQ);
refreshCounts();
};
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
const switchKind = (k: Kind) => {
if (k === kind) return;
setKind(k);
setError("");
if (k === "posts") void loadPosts(1, status, appliedQ);
else void loadComments(1, status, appliedQ);
};
const switchStatus = (st: AdminContentStatus) => {
if (st === status) return;
setStatus(st);
setError("");
if (kind === "posts") void loadPosts(1, st, appliedQ);
else void loadComments(1, st, appliedQ);
};
const applySearch = (e: React.FormEvent) => {
e.preventDefault();
const q = keyword.trim();
setAppliedQ(q);
if (kind === "posts") void loadPosts(1, status, q);
else void loadComments(1, status, q);
};
const runAction = (key: string, fn: () => Promise<void>, okMsg: string) => {
setActingKey(key);
fn()
.then(() => {
toast(okMsg, "ok");
reload(kind === "posts" ? postsPage : commentsPage);
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setActingKey(null));
};
const askConfirm = (
title: string,
message: string,
run: () => Promise<void>,
danger = false
) => setConfirm({ title, message, run, danger });
const toggleTrust = async () => {
if (!canConfigureSettings || savingPolicy) return;
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
setTrustOn(res.trust_reviewed_publish);
toast(res.trust_reviewed_publish ? "已开启过审免审" : "已关闭过审免审", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const saveCooldown = async () => {
if (!canConfigureSettings || savingPolicy) return;
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
setCooldown(res.post_cooldown_hours);
toast(
res.post_cooldown_hours === 0
? "已关闭新用户发帖冷静期"
: `冷静期已设为 ${res.post_cooldown_hours} 小时`,
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const total = kind === "posts" ? postsTotal : commentsTotal;
const page = kind === "posts" ? postsPage : commentsPage;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const statusCount = (st: AdminContentStatus) => {
if (kind === "posts") {
if (st === "live") return counts.posts_live;
if (st === "pending") return counts.posts_pending;
if (st === "rejected") return counts.posts_rejected;
if (st === "deleted") return counts.posts_deleted;
return null;
}
if (st === "live") return counts.comments_live;
if (st === "pending") return counts.comments_pending;
if (st === "rejected") return counts.comments_rejected;
if (st === "deleted") return counts.comments_deleted;
return null;
};
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)" }}
>
<FolderOpen size={18} />
</span>
内容管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改
</p>
</div>
{canConfigureSettings && (
<div className="grid gap-3 mb-5">
<section className="panel p-4 sm:p-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
已过审用户后续免审
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed">
开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。
</p>
</div>
<Switch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="已过审用户后续免审"
/>
</section>
<section className="panel p-4 sm:p-5">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
新用户发帖冷静期
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed mb-3">
注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="number"
min={0}
max={168}
className="field w-28"
value={cooldown}
onChange={(e) => setCooldown(Number(e.target.value))}
aria-label="冷静期小时数"
/>
<span className="meta text-[12.5px]">小时</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingPolicy}
onClick={() => void saveCooldown()}
>
保存
</button>
</div>
</section>
</div>
)}
<div className="flex items-center gap-1.5 mb-3 flex-wrap" role="tablist" aria-label="内容类型">
{(
[
{ key: "posts" as Kind, label: "帖子", icon: <FileText size={14} />, count: counts.posts_live },
{
key: "comments" as Kind,
label: "评论",
icon: <MessagesSquare size={14} />,
count: counts.comments_live,
},
]
).map((t) => {
const active = kind === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchKind(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" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}>
{t.count}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="状态筛选">
{STATUS_TABS.map((t) => {
const active = status === t.key;
const cnt = statusCount(t.key);
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchStatus(t.key)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12.5px] transition-colors"
style={
active
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-3)", background: "var(--panel)" }
}
>
{t.label}
{cnt != null && (
<span className="tabular-nums text-[11px] opacity-80">{cnt}</span>
)}
</button>
);
})}
</div>
<form onSubmit={applySearch} className="panel p-3 mb-4 flex gap-2 items-center" role="search">
<Search size={15} style={{ color: "var(--ink-3)" }} aria-hidden />
<input
className="field flex-1 !border-0 !shadow-none !bg-transparent py-1.5"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
aria-label="搜索内容"
/>
<button type="submit" className="btn btn-line btn-sm">
搜索
</button>
</form>
<div className="panel overflow-hidden">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : error ? (
<p className="alert-error m-4">{error}</p>
) : kind === "posts" && posts.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无帖子</p>
) : kind === "comments" && comments.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无评论</p>
) : kind === "posts" ? (
posts.map((p) => {
const key = `p-${p.id}`;
const busy = actingKey === key;
return (
<div
key={p.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar name={p.user?.nickname || p.user?.username || "?"} src={p.user?.avatar || undefined} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{
background: p.deleted ? "var(--danger-soft)" : "var(--tint)",
color: p.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(p.status, p.deleted)}
</span>
<span className="meta text-[11.5px]">{p.board?.name}</span>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(p.created_at)}</span>
</div>
{p.deleted ? (
<p className="mt-1 text-[14px]" style={{ color: "var(--ink-2)" }}>
{p.title}
</p>
) : (
<Link href={`/post/${p.id}`} className="mt-1 block text-[14px] hover:underline" style={{ color: "var(--ink)" }}>
{p.title}
</Link>
)}
<p className="meta text-[12px] mt-0.5">
{p.user?.nickname} · {p.comment_count} 评
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{p.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除帖子",
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
() =>
apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok");
reload(postsPage);
}),
true
)
}
>
<Trash2 size={14} /> 清除
</button>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"移入回收站",
`确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`,
() =>
apiAdminSoftDeletePost(p.id).then(() => {
toast("已移入回收站", "ok");
reload(postsPage);
}),
true
)
}
>
<Trash2 size={14} /> 删除
</button>
)}
</div>
</div>
);
})
) : (
comments.map((c) => {
const key = `c-${c.id}`;
const busy = actingKey === key;
return (
<div
key={c.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ 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-[11px] px-2 py-0.5 rounded-full"
style={{
background: c.deleted ? "var(--danger-soft)" : "var(--tint)",
color: c.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(c.status, c.deleted)}
</span>
<span className="meta text-[11.5px]">{c.board?.name}</span>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(c.created_at)}</span>
</div>
<p className="mt-1 text-[13.5px] leading-relaxed" style={{ color: "var(--ink)" }}>
{excerpt(c.content)}
</p>
<p className="meta text-[12px] mt-0.5">
{c.user?.nickname} ·{" "}
{c.deleted ? (
c.post_title
) : (
<Link href={`/post/${c.post_id}`} className="hover:underline" style={{ color: "var(--accent)" }}>
{c.post_title || `帖子 #${c.post_id}`}
</Link>
)}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{c.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除评论",
"该评论将永久删除且不可恢复。",
() =>
apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok");
reload(commentsPage);
}),
true
)
}
>
<Trash2 size={14} /> 清除
</button>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"移入回收站",
"确定软删这条评论吗?可在「已删除」中恢复。",
() =>
apiAdminSoftDeleteComment(c.id).then(() => {
toast("已移入回收站", "ok");
reload(commentsPage);
}),
true
)
}
>
<Trash2 size={14} /> 删除
</button>
)}
</div>
</div>
);
})
)}
{totalPages > 1 && (
<div
className="flex items-center justify-center gap-2 px-4 py-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page <= 1 || loading}
onClick={() => {
const p = page - 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
<ChevronLeft size={14} /> 上一页
</button>
<span className="meta text-[12.5px] tabular-nums">
{page} / {totalPages}
</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page >= totalPages || loading}
onClick={() => {
const p = page + 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
下一页 <ChevronRight size={14} />
</button>
</div>
)}
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
danger={confirm?.danger}
busy={confirmBusy}
confirmLabel={confirm?.danger ? "确认删除" : "确定"}
onCancel={() => !confirmBusy && setConfirm(null)}
onConfirm={() => {
if (!confirm) return;
setConfirmBusy(true);
confirm
.run()
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => {
setConfirmBusy(false);
setConfirm(null);
});
}}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import {
fetchAdminContentCounts,
fetchAdminContentPosts,
type AdminContentPost,
} from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import ContentAdmin from "./ContentAdmin";
export const metadata: Metadata = {
title: "内容管理",
};
export default async function AdminContentPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !canModerateAny(me.user)) {
return <Forbidden text="你所在的角色没有内容管理权限,或尚未被授权任何板块。" />;
}
let initialPosts: AdminContentPost[] = [];
let initialTotal = 0;
let initError = "";
const [postsSettled, counts] = await Promise.all([
fetchAdminContentPosts(1, "live", cookie || undefined)
.then((res) => ({ ok: true as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取内容列表失败",
})),
fetchAdminContentCounts(cookie || undefined),
]);
if (postsSettled.ok) {
initialPosts = postsSettled.res.posts;
initialTotal = postsSettled.res.total;
} else {
initError = postsSettled.error;
}
const settings = await getPublicSettingsCached();
return (
<ContentAdmin
initialPosts={initialPosts}
initialPostsTotal={initialTotal}
initialCounts={counts}
initError={initError}
canConfigureSettings={isSuperOrOwner(me.user.role)}
initialSettings={settings}
/>
);
}