Files
jiang13-bbs/frontend/app/admin/content/ContentAdmin.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

1087 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import Link from "next/link";
import {
Check,
FileText,
FolderOpen,
History,
Loader2,
MessagesSquare,
RotateCcw,
Trash2,
X,
} from "lucide-react";
import {
apiAdminContentCounts,
apiAdminContentPosts,
apiAdminContentComments,
apiAdminSoftDeletePost,
apiAdminRestorePost,
apiAdminPurgePost,
apiAdminSoftDeleteComment,
apiAdminRestoreComment,
apiAdminPurgeComment,
apiAdminApprovePost,
apiAdminRejectPost,
apiAdminApproveComment,
apiAdminRejectComment,
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 { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
import DeleteCommentModal from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
AdminStatusChip,
AdminStepper,
AdminSwitch,
contentStatusChip,
} from "@/components/admin";
const PAGE_SIZE = 15;
const CLAMP_TOGGLE_CHARS = 90;
export type ContentKind = "posts" | "comments";
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
{ key: "live", label: "全部" },
{ key: "published", label: "发布" },
{ key: "pending", label: "待审" },
{ key: "rejected", label: "拒绝" },
{ key: "deleted", label: "删除" },
];
/** 评论摘要:去 Markdown 标记,保留换行供 ClampText 判断 */
function commentPlain(md: string): string {
return md
.replace(/```[\s\S]*?```/g, " ")
.replace(/[#>*_`~\-]/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
function needsClampToggle(text: string): boolean {
if (text.length > CLAMP_TOGGLE_CHARS) return true;
const newlines = (text.match(/\n/g) || []).length;
return newlines >= 2;
}
function ClampText({
text,
className = "",
style,
}: {
text: string;
className?: string;
style?: CSSProperties;
}) {
const [open, setOpen] = useState(false);
const showToggle = needsClampToggle(text);
const body = text || "(空)";
return (
<div className={className}>
<p
className={`text-[12.5px] leading-relaxed whitespace-pre-wrap break-words admin-content-clamp${open ? " is-open" : ""}`}
style={style}
{...(showToggle ? { "data-expandable": "" } : {})}
>
<AutolinkText text={body} />
</p>
{showToggle ? (
<button
type="button"
className="meta text-[11.5px] mt-0.5 hover:underline"
style={{ color: "var(--accent)" }}
onClick={() => setOpen((v) => !v)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
function ContentAction({
label,
icon,
onClick,
disabled,
danger,
ok,
}: {
label: string;
icon: ReactNode;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
ok?: boolean;
}) {
const tone = danger ? " is-danger" : ok ? " is-ok" : "";
return (
<button
type="button"
className={`admin-content-action${tone}`}
disabled={disabled}
onClick={onClick}
>
{icon}
{label}
</button>
);
}
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 PolicyChip({
title,
hint,
control,
instant,
}: {
title: string;
hint?: string;
control: ReactNode;
instant?: boolean;
}) {
return (
<div className="admin-content-policy-chip" data-tip={hint || undefined}>
<span className="admin-content-policy-chip-copy">
<span className="admin-content-policy-chip-label">{title}</span>
{instant ? <span className="admin-content-policy-chip-how">立即生效</span> : null}
</span>
<div className="admin-content-policy-chip-ctrl shrink-0 flex items-center gap-1.5">{control}</div>
</div>
);
}
function ColumnShell({
icon,
title,
count,
pending,
toolbar,
body,
pager,
className = "",
}: {
icon: ReactNode;
title: string;
count?: number;
pending?: number;
toolbar?: ReactNode;
body: ReactNode;
pager?: ReactNode;
className?: string;
}) {
return (
<section className={`panel admin-content-col ${className}`.trim()}>
<header
className="admin-content-col-head flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden>
{icon}
</span>
<h2 className="text-[14px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{title}
</h2>
{typeof count === "number" ? (
<span className="meta text-[12px] tabular-nums shrink-0">{count}</span>
) : null}
{pending && pending > 0 ? (
<span className="admin-status is-warn shrink-0" data-tip={`${pending} 条待审`}>
{pending}
</span>
) : null}
</header>
{toolbar}
<div className="admin-content-col-body">{body}</div>
{pager}
</section>
);
}
function statusCountFor(
kind: "posts" | "comments",
st: AdminContentStatus,
counts: AdminContentCounts
): number | null {
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;
if (st === "published") {
return Math.max(0, counts.posts_live - counts.posts_pending - counts.posts_rejected);
}
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;
if (st === "published") {
return Math.max(0, counts.comments_live - counts.comments_pending - counts.comments_rejected);
}
return null;
}
const pagerClass = "admin-content-col-pager px-3 sm:px-3.5";
export default function ContentAdmin({
initialKind,
initialStatus,
initialPosts,
initialPostsTotal,
initialComments,
initialCommentsTotal,
initialCounts,
initError,
canConfigureSettings,
initialSettings,
}: {
initialKind: ContentKind;
initialStatus: AdminContentStatus;
initialPosts: AdminContentPost[];
initialPostsTotal: number;
initialComments: AdminContentComment[];
initialCommentsTotal: number;
initialCounts: AdminContentCounts;
initError: string;
canConfigureSettings: boolean;
initialSettings: PublicSettings;
}) {
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
const [mobileKind, setMobileKind] = useState<ContentKind>(initialKind);
const [counts, setCounts] = useState(initialCounts);
const [posts, setPosts] = useState(initialPosts);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [postsPage, setPostsPage] = useState(1);
const [postsStatus, setPostsStatus] = useState<AdminContentStatus>(initialStatus);
const [postsKeyword, setPostsKeyword] = useState("");
const [postsQ, setPostsQ] = useState("");
const [postsLoading, setPostsLoading] = useState(false);
const [postsError, setPostsError] = useState(
initError && initialKind === "posts" ? initError : ""
);
const postsSeq = useRef(0);
const [comments, setComments] = useState(initialComments);
const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal);
const [commentsPage, setCommentsPage] = useState(1);
const [commentsStatus, setCommentsStatus] = useState<AdminContentStatus>(initialStatus);
const [commentsKeyword, setCommentsKeyword] = useState("");
const [commentsQ, setCommentsQ] = useState("");
const [commentsLoading, setCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState(
initError && initialKind === "comments" ? initError : ""
);
const commentsSeq = useRef(0);
const [actingKey, setActingKey] = useState<string | null>(null);
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
const [postLinkNewTab, setPostLinkNewTab] = useState(initialSettings.post_link_new_tab !== false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
danger?: boolean;
confirmLabel?: string;
run: () => Promise<void>;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [staffDeletePost, setStaffDeletePost] = useState<AdminContentPost | null>(null);
const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
const [staffDeleteComment, setStaffDeleteComment] = useState<AdminContentComment | null>(null);
const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState<number | null>(null);
const refreshCounts = useCallback(() => {
apiAdminContentCounts().then(setCounts).catch(() => {});
}, []);
const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
const seq = ++postsSeq.current;
setPostsLoading(true);
try {
const res = await apiAdminContentPosts(page, st, q);
if (seq !== postsSeq.current) return;
setPosts(res.posts);
setPostsTotal(res.total);
setPostsPage(page);
setPostsStatus(st);
setPostsQ(q);
setPostsError("");
} catch (e) {
if (seq !== postsSeq.current) return;
setPostsError(e instanceof Error ? e.message : "获取帖子失败");
} finally {
if (seq === postsSeq.current) setPostsLoading(false);
}
}, []);
const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
const seq = ++commentsSeq.current;
setCommentsLoading(true);
try {
const res = await apiAdminContentComments(page, st, q);
if (seq !== commentsSeq.current) return;
setComments(res.comments);
setCommentsTotal(res.total);
setCommentsPage(page);
setCommentsStatus(st);
setCommentsQ(q);
setCommentsError("");
} catch (e) {
if (seq !== commentsSeq.current) return;
setCommentsError(e instanceof Error ? e.message : "获取评论失败");
} finally {
if (seq === commentsSeq.current) setCommentsLoading(false);
}
}, []);
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
useEffect(() => {
const off = realtime.on(RT_MODERATION_CHANGED, refreshCounts);
return off;
}, [refreshCounts]);
const reloadPosts = (page?: number) => {
const p = page ?? postsPage;
const len = posts.length;
void loadPosts(len <= 1 && p > 1 && page === undefined ? p - 1 : p, postsStatus, postsQ);
refreshCounts();
};
const reloadComments = (page?: number) => {
const p = page ?? commentsPage;
const len = comments.length;
void loadComments(
len <= 1 && p > 1 && page === undefined ? p - 1 : p,
commentsStatus,
commentsQ
);
refreshCounts();
};
const runAction = (key: string, fn: () => Promise<void>, okMsg: string, after: () => void) => {
setActingKey(key);
fn()
.then(() => {
toast(okMsg, "ok");
after();
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setActingKey(null));
};
const askConfirm = (
title: string,
message: string,
run: () => Promise<void>,
danger = false,
confirmLabel?: string
) => setConfirm({ title, message, run, danger, confirmLabel });
const askReject = (kindNow: "posts" | "comments", id: number) => {
askConfirm(
kindNow === "posts" ? "拒绝这篇帖子?" : "拒绝这条评论?",
"拒绝后作者将收到通知,内容不会公开。",
async () => {
if (kindNow === "posts") await apiAdminRejectPost(id);
else await apiAdminRejectComment(id);
toast("已拒绝,作者将收到通知", "ok");
if (kindNow === "posts") reloadPosts();
else reloadComments();
},
true,
"确认拒绝"
);
};
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 togglePostLinkNewTab = async () => {
if (!canConfigureSettings || savingPolicy) return;
const next = !postLinkNewTab;
setSavingPolicy(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(
res.post_link_new_tab !== false
? "帖子与评论外链将在新标签打开"
: "帖子与评论外链将在当前页打开",
"ok"
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingPolicy(false);
}
};
const postsToolbar = (
<div className="admin-content-col-toolbar">
<div className="admin-content-status-scroll">
<AdminSegmented<AdminContentStatus>
ariaLabel="帖子状态筛选"
size="sm"
value={postsStatus}
onChange={(st) => {
if (st === postsStatus) return;
void loadPosts(1, st, postsQ);
}}
options={STATUS_TABS.map((t) => ({
key: t.key,
label: t.label,
count: statusCountFor("posts", t.key, counts) ?? undefined,
}))}
/>
</div>
<AdminSearchField
value={postsKeyword}
onChange={setPostsKeyword}
onSubmit={() => void loadPosts(1, postsStatus, postsKeyword.trim())}
onClear={() => {
setPostsKeyword("");
void loadPosts(1, postsStatus, "");
}}
placeholder="搜索帖子标题…"
ariaLabel="搜索帖子"
/>
</div>
);
const commentsToolbar = (
<div className="admin-content-col-toolbar">
<div className="admin-content-status-scroll">
<AdminSegmented<AdminContentStatus>
ariaLabel="评论状态筛选"
size="sm"
value={commentsStatus}
onChange={(st) => {
if (st === commentsStatus) return;
void loadComments(1, st, commentsQ);
}}
options={STATUS_TABS.map((t) => ({
key: t.key,
label: t.label,
count: statusCountFor("comments", t.key, counts) ?? undefined,
}))}
/>
</div>
<AdminSearchField
value={commentsKeyword}
onChange={setCommentsKeyword}
onSubmit={() => void loadComments(1, commentsStatus, commentsKeyword.trim())}
onClear={() => {
setCommentsKeyword("");
void loadComments(1, commentsStatus, "");
}}
placeholder="搜索评论正文…"
ariaLabel="搜索评论"
/>
</div>
);
const postsBody = (
<>
{postsError ? <p className="alert-error m-3 text-[12.5px]">{postsError}</p> : null}
{postsLoading && posts.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : posts.length === 0 ? (
<AdminEmpty
icon={<FileText size={20} />}
title={postsStatus === "pending" ? "暂无待审帖子" : "暂无帖子"}
description={
postsStatus === "pending" ? "新提交的帖子会按时间出现在这里" : undefined
}
/>
) : (
posts.map((p, idx) => {
const key = `p-${p.id}`;
const busy = actingKey === key;
const chip = contentStatusChip(p.status, p.deleted);
const pending = isPendingRow(p.status, p.deleted);
return (
<div
key={p.id}
className="flex items-start gap-2.5 px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar
name={p.user?.nickname || p.user?.username || "?"}
src={p.user?.avatar || undefined}
size={30}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{p.user?.nickname || p.user?.username || "?"}
</span>
<AdminStatusChip tone={chip.tone}>{chip.label}</AdminStatusChip>
<span className="meta text-[11px]">{p.board?.name}</span>
<span className="meta text-[11px] tabular-nums">
{formatRelative(p.created_at)}
</span>
</div>
{p.deleted ? (
<p
className="mt-1 text-[13px] leading-snug line-clamp-2"
style={{ color: "var(--ink-2)" }}
data-tip={p.title}
>
{p.title}
</p>
) : (
<Link
href={`/post/${p.id}`}
className="mt-1 block text-[13px] font-medium leading-snug hover:underline line-clamp-2"
style={{ color: "var(--ink)" }}
data-tip={p.title}
>
{p.title}
</Link>
)}
<p className="meta text-[11.5px] mt-0.5">{p.comment_count} 评</p>
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
{pending ? (
<PendingActions
busy={busy}
onApprove={() =>
runAction(
key,
() => apiAdminApprovePost(p.id),
"已通过审核,内容已公开",
() => reloadPosts()
)
}
onReject={() => askReject("posts", p.id)}
/>
) : null}
{p.deleted ? (
<>
<ContentAction
label="恢复"
ok
disabled={busy}
icon={
busy ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RotateCcw size={13} />
)
}
onClick={() =>
runAction(
key,
() => apiAdminRestorePost(p.id),
"帖子已恢复",
() => reloadPosts()
)
}
/>
<ContentAction
label="清除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() =>
askConfirm(
"彻底删除帖子",
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
() =>
apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok");
reloadPosts();
}),
true
)
}
/>
</>
) : (
<ContentAction
label="删除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() => setStaffDeletePost(p)}
/>
)}
</div>
</div>
</div>
);
})
)}
</>
);
const commentsBody = (
<>
{commentsError ? <p className="alert-error m-3 text-[12.5px]">{commentsError}</p> : null}
{commentsLoading && comments.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : comments.length === 0 ? (
<AdminEmpty
icon={<MessagesSquare size={20} />}
title={commentsStatus === "pending" ? "暂无待审评论" : "暂无评论"}
description={
commentsStatus === "pending" ? "新提交的评论会按时间出现在这里" : undefined
}
/>
) : (
comments.map((c, idx) => {
const key = `c-${c.id}`;
const busy = actingKey === key;
const chip = contentStatusChip(c.status, c.deleted);
const pending = isPendingRow(c.status, c.deleted);
const plain = commentPlain(c.content);
return (
<div
key={c.id}
className="flex items-start gap-2.5 px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar
name={c.user?.nickname || c.user?.username || "?"}
src={c.user?.avatar || undefined}
size={30}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{c.user?.nickname || c.user?.username || "?"}
</span>
<AdminStatusChip tone={chip.tone}>{chip.label}</AdminStatusChip>
<span className="meta text-[11px]">{c.board?.name}</span>
<span className="meta text-[11px] tabular-nums">
{formatRelative(c.created_at)}
</span>
</div>
<ClampText text={plain} className="mt-1" style={{ color: "var(--ink)" }} />
<p className="meta text-[11.5px] mt-0.5">
{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 className="flex items-center gap-1 mt-1.5 flex-wrap">
{pending ? (
<PendingActions
busy={busy}
onApprove={() =>
runAction(
key,
() => apiAdminApproveComment(c.id),
"已通过审核,内容已公开",
() => reloadComments()
)
}
onReject={() => askReject("comments", c.id)}
/>
) : null}
<ContentAction
label="历史"
icon={<History size={13} />}
onClick={() => setHistoryCommentId(c.id)}
/>
{c.deleted ? (
<>
<ContentAction
label="恢复"
ok
disabled={busy}
icon={
busy ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RotateCcw size={13} />
)
}
onClick={() =>
runAction(
key,
() => apiAdminRestoreComment(c.id),
"评论已恢复",
() => reloadComments()
)
}
/>
<ContentAction
label="清除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() =>
askConfirm(
"彻底删除评论",
"该评论将永久删除且不可恢复。",
() =>
apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok");
reloadComments();
}),
true
)
}
/>
</>
) : (
<ContentAction
label="删除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() => setStaffDeleteComment(c)}
/>
)}
</div>
</div>
</div>
);
})
)}
</>
);
const postsCol = (
<ColumnShell
icon={<FileText size={15} />}
title="帖子"
count={counts.posts_live}
pending={counts.posts_pending}
toolbar={postsToolbar}
body={postsBody}
pager={
<AdminPager
page={postsPage}
total={postsTotal}
pageSize={PAGE_SIZE}
loading={postsLoading}
onChange={(p) => void loadPosts(p, postsStatus, postsQ)}
className={pagerClass}
/>
}
className={mobileKind === "posts" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
const commentsCol = (
<ColumnShell
icon={<MessagesSquare size={15} />}
title="评论"
count={counts.comments_live}
pending={counts.comments_pending}
toolbar={commentsToolbar}
body={commentsBody}
pager={
<AdminPager
page={commentsPage}
total={commentsTotal}
pageSize={PAGE_SIZE}
loading={commentsLoading}
onChange={(p) => void loadComments(p, commentsStatus, commentsQ)}
className={pagerClass}
/>
}
className={mobileKind === "comments" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
return (
<div className="admin-content-page">
<AdminPageHeader
icon={FolderOpen}
title="内容管理"
description="审核与管理帖子、评论。共用策略作用于全站发帖与评论,与下方列表筛选无关。"
/>
{canConfigureSettings && (
<div className="admin-content-policy" role="group" aria-label="全站内容策略">
<div className="admin-content-policy-lead">
<span className="admin-content-policy-title">共用策略</span>
<span className="admin-content-policy-scope">
过审免审与外链开关改完立即写入;冷静期需点保存。失败时恢复为已确认值。
</span>
</div>
<div className="admin-content-policy-row">
<PolicyChip
title="过审免审"
instant
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。修改后立即生效。"
control={
<AdminSwitch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="过审免审(帖子与评论)"
/>
}
/>
<PolicyChip
title="新用户冷静期"
hint="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受此限制。需点保存后生效。"
control={
<div className="flex items-center gap-1.5">
<AdminStepper
value={cooldown}
onChange={setCooldown}
min={0}
max={168}
unit="小时"
ariaLabel="新用户发帖冷静期小时数"
disabled={savingPolicy}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingPolicy}
onClick={() => void saveCooldown()}
>
{savingPolicy ? "保存中…" : "保存"}
</button>
</div>
}
/>
<PolicyChip
title="外链新开"
instant
hint="帖子与评论中的外链在新标签打开。修改后立即生效。"
control={
<AdminSwitch
checked={postLinkNewTab}
disabled={savingPolicy}
onChange={() => void togglePostLinkNewTab()}
label="外链新开(帖子与评论)"
/>
}
/>
</div>
</div>
)}
{/* 窄屏:顶部分段切换双栏 */}
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="内容类型">
<button
type="button"
role="tab"
aria-selected={mobileKind === "posts"}
className="admin-content-kind-btn"
onClick={() => setMobileKind("posts")}
>
<FileText size={14} aria-hidden />
帖子
<span className="admin-content-kind-count">{counts.posts_live}</span>
{counts.posts_pending > 0 ? (
<span className="admin-status is-warn" data-tip={`${counts.posts_pending} 条待审`}>
{counts.posts_pending}
</span>
) : null}
</button>
<button
type="button"
role="tab"
aria-selected={mobileKind === "comments"}
className="admin-content-kind-btn"
onClick={() => setMobileKind("comments")}
>
<MessagesSquare size={14} aria-hidden />
评论
<span className="admin-content-kind-count">{counts.comments_live}</span>
{counts.comments_pending > 0 ? (
<span className="admin-status is-warn" data-tip={`${counts.comments_pending} 条待审`}>
{counts.comments_pending}
</span>
) : null}
</button>
</div>
<div className="admin-content-triptych">
{postsCol}
{commentsCol}
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
danger={confirm?.danger}
busy={confirmBusy}
confirmLabel={confirm?.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);
});
}}
/>
<StaffDeletePostModal
open={!!staffDeletePost}
title={staffDeletePost?.title}
busy={staffDeleteBusy}
onClose={() => !staffDeleteBusy && setStaffDeletePost(null)}
onConfirm={(opts) => {
if (!staffDeletePost) return;
setStaffDeleteBusy(true);
apiAdminSoftDeletePost(staffDeletePost.id, opts)
.then(() => {
toast("已移入回收站", "ok");
setStaffDeletePost(null);
reloadPosts();
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setStaffDeleteBusy(false));
}}
/>
<DeleteCommentModal
open={!!staffDeleteComment}
mode="staff"
busy={staffDeleteCommentBusy}
onClose={() => !staffDeleteCommentBusy && setStaffDeleteComment(null)}
onConfirm={(opts) => {
if (!staffDeleteComment) return;
setStaffDeleteCommentBusy(true);
apiAdminSoftDeleteComment(staffDeleteComment.id, opts)
.then(() => {
toast("已移入回收站", "ok");
setStaffDeleteComment(null);
reloadComments();
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setStaffDeleteCommentBusy(false));
}}
/>
<CommentEditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
onClose={() => setHistoryCommentId(null)}
/>
</div>
);
}