1075 lines
35 KiB
TypeScript
1075 lines
35 KiB
TypeScript
"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>
|
||
);
|
||
}
|