770 lines
26 KiB
TypeScript
770 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, 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 [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) => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await apiAdminContentPosts(page, st, q);
|
||
setPosts(res.posts);
|
||
setPostsTotal(res.total);
|
||
setPostsPage(page);
|
||
setKind("posts");
|
||
setError("");
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "获取帖子失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await apiAdminContentComments(page, st, q);
|
||
setComments(res.comments);
|
||
setCommentsTotal(res.total);
|
||
setCommentsPage(page);
|
||
setKind("comments");
|
||
setError("");
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "获取评论失败");
|
||
} finally {
|
||
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;
|
||
setStatus(st);
|
||
setError("");
|
||
if (kind === "posts") void loadPosts(1, st, appliedQ);
|
||
else void loadComments(1, st, appliedQ);
|
||
};
|
||
|
||
const applySearch = () => {
|
||
const q = keyword.trim();
|
||
setAppliedQ(q);
|
||
if (kind === "posts") void loadPosts(1, status, q);
|
||
else void loadComments(1, status, q);
|
||
};
|
||
|
||
const clearSearch = () => {
|
||
setAppliedQ("");
|
||
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>
|
||
);
|
||
}
|