Files
jiang13-bbs/frontend/app/admin/content/ContentAdmin.tsx

1075 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,
}: {
title: string;
hint?: string;
control: ReactNode;
}) {
return (
<div className="admin-content-policy-chip" data-tip={hint || undefined}>
<span className="admin-content-policy-chip-label">{title}</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="全站内容策略">
<span className="admin-content-policy-title">共用策略</span>
<div className="admin-content-policy-row">
<PolicyChip
title="过审免审"
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()}
>
保存
</button>
</div>
}
/>
<PolicyChip
title="外链新开"
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>
);
}