"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 (
{showToggle ? (
) : null}
);
}
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 (
);
}
function isPendingRow(status: string, deleted: boolean) {
return !deleted && status === "pending";
}
function PendingActions({
busy,
onApprove,
onReject,
}: {
busy: boolean;
onApprove: () => void;
onReject: () => void;
}) {
return (
<>
>
);
}
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 (
{icon}
{title}
{typeof count === "number" ? (
{count}
) : null}
{pending && pending > 0 ? (
{pending}
) : null}
{toolbar}
{body}
{pager}
);
}
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(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(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(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(null);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
danger?: boolean;
confirmLabel?: string;
run: () => Promise;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [staffDeletePost, setStaffDeletePost] = useState(null);
const [staffDeleteBusy, setStaffDeleteBusy] = useState(false);
const [staffDeleteComment, setStaffDeleteComment] = useState(null);
const [staffDeleteCommentBusy, setStaffDeleteCommentBusy] = useState(false);
const [historyCommentId, setHistoryCommentId] = useState(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, 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,
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 = (
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,
}))}
/>
void loadPosts(1, postsStatus, postsKeyword.trim())}
onClear={() => {
setPostsKeyword("");
void loadPosts(1, postsStatus, "");
}}
placeholder="搜索帖子标题…"
ariaLabel="搜索帖子"
/>
);
const commentsToolbar = (
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,
}))}
/>
void loadComments(1, commentsStatus, commentsKeyword.trim())}
onClear={() => {
setCommentsKeyword("");
void loadComments(1, commentsStatus, "");
}}
placeholder="搜索评论正文…"
ariaLabel="搜索评论"
/>
);
const postsBody = (
<>
{postsError ? {postsError}
: null}
{postsLoading && posts.length === 0 ? (
) : posts.length === 0 ? (
}
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 (
{p.user?.nickname || p.user?.username || "?"}
{chip.label}
{p.board?.name}
{formatRelative(p.created_at)}
{p.deleted ? (
{p.title}
) : (
{p.title}
)}
{p.comment_count} 评
{pending ? (
runAction(
key,
() => apiAdminApprovePost(p.id),
"已通过审核,内容已公开",
() => reloadPosts()
)
}
onReject={() => askReject("posts", p.id)}
/>
) : null}
{p.deleted ? (
<>
{canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
) : (
)
}
onClick={() =>
runAction(
key,
() => apiAdminRestorePost(p.id),
"帖子已恢复",
() => reloadPosts()
)
}
/>
) : null}
{canPurge ? (
}
onClick={() =>
askConfirm(
"彻底删除帖子",
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
() =>
apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok");
reloadPosts();
}),
true
)
}
/>
) : null}
>
) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
}
onClick={() => setStaffDeletePost(p)}
/>
) : null}
);
})
)}
>
);
const commentsBody = (
<>
{commentsError ? {commentsError}
: null}
{commentsLoading && comments.length === 0 ? (
) : comments.length === 0 ? (
}
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 (
{c.user?.nickname || c.user?.username || "?"}
{chip.label}
{c.board?.name}
{formatRelative(c.created_at)}
{c.deleted ? (
c.post_title
) : (
{c.post_title || `帖子 #${c.post_id}`}
)}
{pending ? (
runAction(
key,
() => apiAdminApproveComment(c.id),
"已通过审核,内容已公开",
() => reloadComments()
)
}
onReject={() => askReject("comments", c.id)}
/>
) : null}
{/* 未编辑(无修订快照)的评论不展示历史入口,避免打开空记录 */}
{c.edited ? (
}
onClick={() => {
setHistoryCommentContent(c.content);
setHistoryCommentId(c.id);
}}
/>
) : null}
{c.deleted ? (
<>
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
) : (
)
}
onClick={() =>
runAction(
key,
() => apiAdminRestoreComment(c.id),
"评论已恢复",
() => reloadComments()
)
}
/>
) : null}
{canPurge ? (
}
onClick={() =>
askConfirm(
"彻底删除评论",
"该评论将永久删除且不可恢复。",
() =>
apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok");
reloadComments();
}),
true
)
}
/>
) : null}
>
) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
}
onClick={() => setStaffDeleteComment(c)}
/>
) : null}
);
})
)}
>
);
const postsCol = (
}
title="帖子"
count={counts.posts_live}
pending={counts.posts_pending}
toolbar={postsToolbar}
body={postsBody}
pager={
void loadPosts(p, postsStatus, postsQ)}
className={pagerClass}
/>
}
className={mobileKind === "posts" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
const commentsCol = (
}
title="评论"
count={counts.comments_live}
pending={counts.comments_pending}
toolbar={commentsToolbar}
body={commentsBody}
pager={
void loadComments(p, commentsStatus, commentsQ)}
className={pagerClass}
/>
}
className={mobileKind === "comments" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
return (
{/* 窄屏:顶部分段切换双栏 */}
{postsCol}
{commentsCol}
!confirmBusy && setConfirm(null)}
onConfirm={() => {
if (!confirm) return;
setConfirmBusy(true);
confirm
.run()
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => {
setConfirmBusy(false);
setConfirm(null);
});
}}
/>
!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));
}}
/>
!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));
}}
/>
{
if (historyCommentId == null) return Promise.reject(new Error("未选择评论"));
return apiCommentHistory(historyCommentId, p, s);
}}
onClose={() => setHistoryCommentId(null)}
/>
);
}