Files
jiang13-bbs/frontend/app/admin/content/ContentAdmin.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

957 lines
31 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,
apiCommentHistory,
apiAdminRestorePost,
apiAdminPurgePost,
apiAdminSoftDeleteComment,
apiAdminRestoreComment,
apiAdminPurgeComment,
apiAdminApprovePost,
apiAdminRejectPost,
apiAdminApproveComment,
apiAdminRejectComment,
type AdminContentComment,
type AdminContentCounts,
type AdminContentPost,
type AdminContentStatus,
} from "@/lib/api";
import { canModerateAuthor } from "@/lib/roles";
import { formatRelative } from "@/lib/format";
import { postPath } from "@/lib/urlStyle";
import { toast } from "@/lib/toast";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
import DeleteCommentModal from "@/components/DeleteCommentModal";
import EditHistoryModal from "@/components/EditHistoryModal";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
AdminStatusChip,
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 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,
canPurge,
viewerRole,
}: {
initialKind: ContentKind;
initialStatus: AdminContentStatus;
initialPosts: AdminContentPost[];
initialPostsTotal: number;
initialComments: AdminContentComment[];
initialCommentsTotal: number;
initialCounts: AdminContentCounts;
initError: string;
canPurge: boolean;
viewerRole: string;
}) {
const urlStyle = useUrlStyle();
// 窄屏分段: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 [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 [historyCommentContent, setHistoryCommentContent] = useState("");
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 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={postPath(urlStyle, 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 ? (
<>
{canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
<ContentAction
label="恢复"
ok
disabled={busy}
icon={
busy ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RotateCcw size={13} />
)
}
onClick={() =>
runAction(
key,
() => apiAdminRestorePost(p.id),
"帖子已恢复",
() => reloadPosts()
)
}
/>
) : null}
{canPurge ? (
<ContentAction
label="清除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() =>
askConfirm(
"彻底删除帖子",
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
() =>
apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok");
reloadPosts();
}),
true
)
}
/>
) : null}
</>
) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
<ContentAction
label="删除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() => setStaffDeletePost(p)}
/>
) : null}
</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={`${postPath(urlStyle, c.post_id)}#comment-${c.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}
{/* 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录 */}
{c.edited ? (
<ContentAction
label="历史"
icon={<History size={13} />}
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
/>
) : null}
{c.deleted ? (
<>
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
<ContentAction
label="恢复"
ok
disabled={busy}
icon={
busy ? (
<Loader2 size={13} className="animate-spin" />
) : (
<RotateCcw size={13} />
)
}
onClick={() =>
runAction(
key,
() => apiAdminRestoreComment(c.id),
"评论已恢复",
() => reloadComments()
)
}
/>
) : null}
{canPurge ? (
<ContentAction
label="清除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() =>
askConfirm(
"彻底删除评论",
"该评论将永久删除且不可恢复。",
() =>
apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok");
reloadComments();
}),
true
)
}
/>
) : null}
</>
) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
<ContentAction
label="删除"
danger
disabled={busy}
icon={<Trash2 size={13} />}
onClick={() => setStaffDeleteComment(c)}
/>
) : null}
</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="审核与管理帖子、评论。"
/>
{/* 窄屏:顶部分段切换双栏 */}
<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));
}}
/>
<EditHistoryModal
open={historyCommentId != null}
title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={(p, s) => {
if (historyCommentId == null) return Promise.reject(new Error("未选择评论"));
return apiCommentHistory(historyCommentId, p, s);
}}
onClose={() => setHistoryCommentId(null)}
/>
</div>
);
}