Files
jiang13-bbs/frontend/app/admin/content/ContentAdmin.tsx
freefire 442af625f6 fix(admin): 后台列表切换保留当前内容,去掉加载占位
用户档案、用户列表、内容筛选与公告页等数据到达后再换视图,避免滚动条闪动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 00:57:54 +08:00

779 lines
27 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 } 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 ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
import DeleteCommentModal from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
AdminSettingRow,
AdminStatusChip,
AdminStepper,
AdminSwitch,
contentStatusChip,
} from "@/components/admin";
const PAGE_SIZE = 15;
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: "已删除" },
];
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 isPendingRow(status: string, deleted: boolean) {
return !deleted && status === "pending";
}
function PendingActions({
busy,
onApprove,
onReject,
}: {
busy: boolean;
onApprove: () => void;
onReject: () => void;
}) {
return (
<>
<button type="button" className="btn btn-sm btn-ok" disabled={busy} onClick={onApprove}>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
通过
</button>
<button type="button" className="btn btn-sm btn-danger" disabled={busy} onClick={onReject}>
<X size={13} /> 拒绝
</button>
</>
);
}
function emptyCopy(kind: ContentKind, status: AdminContentStatus): { title: string; description?: string } {
if (status === "pending") {
return {
title: kind === "posts" ? "暂无待审帖子" : "暂无待审评论",
description: "新提交的内容会按时间顺序出现在这里,可通过或拒绝",
};
}
return { title: kind === "posts" ? "暂无帖子" : "暂无评论" };
}
export default function ContentAdmin({
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;
}) {
const [kind, setKind] = useState<ContentKind>(initialKind);
const [status, setStatus] = useState<AdminContentStatus>(initialStatus);
const [counts, setCounts] = useState(initialCounts);
const [posts, setPosts] = useState(initialPosts);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [postsPage, setPostsPage] = useState(1);
const [comments, setComments] = useState(initialComments);
const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal);
const [commentsPage, setCommentsPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const loadSeq = 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 [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 = ++loadSeq.current;
setLoading(true);
try {
const res = await apiAdminContentPosts(page, st, q);
if (seq !== loadSeq.current) return;
setPosts(res.posts);
setPostsTotal(res.total);
setPostsPage(page);
setKind("posts");
setStatus(st);
setAppliedQ(q);
setError("");
} catch (e) {
if (seq !== loadSeq.current) return;
setError(e instanceof Error ? e.message : "获取帖子失败");
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, []);
const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const res = await apiAdminContentComments(page, st, q);
if (seq !== loadSeq.current) return;
setComments(res.comments);
setCommentsTotal(res.total);
setCommentsPage(page);
setKind("comments");
setStatus(st);
setAppliedQ(q);
setError("");
} catch (e) {
if (seq !== loadSeq.current) return;
setError(e instanceof Error ? e.message : "获取评论失败");
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, []);
const reload = (page = 1) => {
if (kind === "posts") void loadPosts(page, status, appliedQ);
else void loadComments(page, status, appliedQ);
refreshCounts();
};
const reloadAfterItemChange = () => {
const p = kind === "posts" ? postsPage : commentsPage;
const len = kind === "posts" ? posts.length : comments.length;
reload(len <= 1 && p > 1 ? p - 1 : p);
};
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
useEffect(() => {
const off = realtime.on(RT_MODERATION_CHANGED, refreshCounts);
return off;
}, [refreshCounts]);
const switchKind = (k: ContentKind) => {
if (k === kind) return;
setError("");
if (k === "posts") void loadPosts(1, status, appliedQ);
else void loadComments(1, status, appliedQ);
};
const switchStatus = (st: AdminContentStatus) => {
if (st === status) return;
setError("");
if (kind === "posts") void loadPosts(1, st, appliedQ);
else void loadComments(1, st, appliedQ);
};
const applySearch = () => {
const q = keyword.trim();
if (kind === "posts") void loadPosts(1, status, q);
else void loadComments(1, status, q);
};
const clearSearch = () => {
setKeyword("");
if (kind === "posts") void loadPosts(1, status, "");
else void loadComments(1, status, "");
};
const runAction = (key: string, fn: () => Promise<void>, okMsg: string) => {
setActingKey(key);
fn()
.then(() => {
toast(okMsg, "ok");
reloadAfterItemChange();
})
.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: ContentKind, id: number) => {
askConfirm(
kindNow === "posts" ? "拒绝这篇帖子?" : "拒绝这条评论?",
"拒绝后作者将收到通知,内容不会公开。",
async () => {
if (kindNow === "posts") await apiAdminRejectPost(id);
else await apiAdminRejectComment(id);
toast("已拒绝,作者将收到通知", "ok");
reloadAfterItemChange();
},
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 total = kind === "posts" ? postsTotal : commentsTotal;
const page = kind === "posts" ? postsPage : commentsPage;
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;
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 goPage = (p: number) => {
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
};
const empty = emptyCopy(kind, status);
return (
<div>
<AdminPageHeader
icon={FolderOpen}
title="内容管理"
description="审核待发布内容,并处理已发布/已拒绝条目与回收站;策略开关仅超管/站长可改"
/>
{canConfigureSettings && (
<div className="flex flex-col gap-3 mb-5">
<AdminSettingRow
title="已过审用户后续免审"
description="开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。"
control={
<AdminSwitch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="已过审用户后续免审"
/>
}
/>
<AdminSettingRow
title="新用户发帖冷静期"
description="注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。"
control={
<div className="flex items-center gap-2.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>
}
/>
</div>
)}
<div className="panel overflow-hidden">
<div className="admin-content-toolbar">
<div className="admin-content-kind" role="tablist" aria-label="内容类型">
<button
type="button"
role="tab"
aria-selected={kind === "posts"}
className="admin-content-kind-btn"
onClick={() => switchKind("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" title={`${counts.posts_pending} 条待审`}>
{counts.posts_pending}
</span>
) : null}
</button>
<button
type="button"
role="tab"
aria-selected={kind === "comments"}
className="admin-content-kind-btn"
onClick={() => switchKind("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" title={`${counts.comments_pending} 条待审`}>
{counts.comments_pending}
</span>
) : null}
</button>
</div>
<span className="admin-content-split" aria-hidden />
<div className="admin-content-status">
<AdminSegmented<AdminContentStatus>
ariaLabel="状态筛选"
size="sm"
value={status}
onChange={switchStatus}
options={STATUS_TABS.map((t) => ({
key: t.key,
label: t.label,
count: statusCount(t.key) ?? undefined,
}))}
/>
</div>
<div className="admin-content-search">
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={applySearch}
onClear={clearSearch}
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
ariaLabel="搜索内容"
/>
</div>
</div>
{error ? <p className="alert-error m-4">{error}</p> : null}
{kind === "posts" && posts.length === 0 ? (
<AdminEmpty icon={<FileText size={22} />} title={empty.title} description={empty.description} />
) : kind === "comments" && comments.length === 0 ? (
<AdminEmpty icon={<MessagesSquare size={22} />} title={empty.title} description={empty.description} />
) : kind === "posts" ? (
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-3 px-4 sm:px-5 py-3.5"
style={idx === 0 ? undefined : { 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">
<AdminStatusChip tone={chip.tone}>{chip.label}</AdminStatusChip>
<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.5 shrink-0 flex-wrap justify-end">
{pending && (
<PendingActions
busy={busy}
onApprove={() =>
runAction(key, () => apiAdminApprovePost(p.id), "已通过审核,内容已公开")
}
onReject={() => askReject("posts", p.id)}
/>
)}
{p.deleted ? (
<>
<AdminIconButton
tip="恢复"
ariaLabel="恢复帖子"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复")
}
>
{busy ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
</AdminIconButton>
<AdminIconButton
tip="清除"
ariaLabel="彻底删除帖子"
tone="danger"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除帖子",
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
() =>
apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok");
reloadAfterItemChange();
}),
true
)
}
>
<Trash2 size={14} />
</AdminIconButton>
</>
) : (
<AdminIconButton
tip="删除"
ariaLabel="删除帖子"
disabled={busy}
onClick={() => setStaffDeletePost(p)}
>
<Trash2 size={14} />
</AdminIconButton>
)}
</div>
</div>
);
})
) : (
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);
return (
<div
key={c.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar
name={c.user?.nickname || c.user?.username || "?"}
src={c.user?.avatar || undefined}
size={34}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<AdminStatusChip tone={chip.tone}>{chip.label}</AdminStatusChip>
<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.5 shrink-0 flex-wrap justify-end">
{pending && (
<PendingActions
busy={busy}
onApprove={() =>
runAction(key, () => apiAdminApproveComment(c.id), "已通过审核,内容已公开")
}
onReject={() => askReject("comments", c.id)}
/>
)}
<AdminIconButton
tip="历史"
ariaLabel="查看编辑历史"
onClick={() => setHistoryCommentId(c.id)}
>
<History size={14} />
</AdminIconButton>
{c.deleted ? (
<>
<AdminIconButton
tip="恢复"
ariaLabel="恢复评论"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复")
}
>
{busy ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
</AdminIconButton>
<AdminIconButton
tip="清除"
ariaLabel="彻底删除评论"
tone="danger"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除评论",
"该评论将永久删除且不可恢复。",
() =>
apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok");
reloadAfterItemChange();
}),
true
)
}
>
<Trash2 size={14} />
</AdminIconButton>
</>
) : (
<AdminIconButton
tip="删除"
ariaLabel="删除评论"
disabled={busy}
onClick={() => setStaffDeleteComment(c)}
>
<Trash2 size={14} />
</AdminIconButton>
)}
</div>
</div>
);
})
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={goPage}
/>
</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);
reloadAfterItemChange();
})
.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);
reloadAfterItemChange();
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setStaffDeleteCommentBusy(false));
}}
/>
<CommentEditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
onClose={() => setHistoryCommentId(null)}
/>
</div>
);
}