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

735 lines
26 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, useState } from "react";
import Link from "next/link";
import {
ChevronLeft,
ChevronRight,
FileText,
FolderOpen,
History,
Loader2,
MessagesSquare,
RotateCcw,
Search,
Trash2,
} from "lucide-react";
import {
apiAdminContentCounts,
apiAdminContentPosts,
apiAdminContentComments,
apiAdminSoftDeletePost,
apiAdminRestorePost,
apiAdminPurgePost,
apiAdminSoftDeleteComment,
apiAdminRestoreComment,
apiAdminPurgeComment,
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 Avatar from "@/components/Avatar";
import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
import DeleteCommentModal from "@/components/DeleteCommentModal";
import CommentEditHistoryModal from "@/components/CommentEditHistoryModal";
const PAGE_SIZE = 15;
type Kind = "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 statusLabel(status: string, deleted: boolean) {
if (deleted) return "已删除";
if (status === "pending") return "待审";
if (status === "rejected") return "已拒绝";
if (status === "published") return "已发布";
return status;
}
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 Switch({
checked,
disabled,
onChange,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: () => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={onChange}
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
>
<span
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
/>
</button>
);
}
export default function ContentAdmin({
initialPosts,
initialPostsTotal,
initialCounts,
initError,
canConfigureSettings,
initialSettings,
}: {
initialPosts: AdminContentPost[];
initialPostsTotal: number;
initialCounts: AdminContentCounts;
initError: string;
canConfigureSettings: boolean;
initialSettings: PublicSettings;
}) {
const [kind, setKind] = useState<Kind>("posts");
const [status, setStatus] = useState<AdminContentStatus>("live");
const [counts, setCounts] = useState(initialCounts);
const [posts, setPosts] = useState(initialPosts);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [postsPage, setPostsPage] = useState(1);
const [comments, setComments] = useState<AdminContentComment[]>([]);
const [commentsTotal, setCommentsTotal] = useState(0);
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;
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);
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);
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();
};
useEffect(() => {
refreshCounts();
}, [refreshCounts]);
const switchKind = (k: Kind) => {
if (k === kind) return;
setKind(k);
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 = (e: React.FormEvent) => {
e.preventDefault();
const q = keyword.trim();
setAppliedQ(q);
if (kind === "posts") void loadPosts(1, status, q);
else void loadComments(1, status, q);
};
const runAction = (key: string, fn: () => Promise<void>, okMsg: string) => {
setActingKey(key);
fn()
.then(() => {
toast(okMsg, "ok");
reload(kind === "posts" ? postsPage : commentsPage);
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setActingKey(null));
};
const askConfirm = (
title: string,
message: string,
run: () => Promise<void>,
danger = false
) => setConfirm({ title, message, run, danger });
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 totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
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;
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;
return null;
};
return (
<div>
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<FolderOpen size={18} />
</span>
内容管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改
</p>
</div>
{canConfigureSettings && (
<div className="grid gap-3 mb-5">
<section className="panel p-4 sm:p-5 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
已过审用户后续免审
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed">
开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。
</p>
</div>
<Switch
checked={trustOn}
disabled={savingPolicy}
onChange={() => void toggleTrust()}
label="已过审用户后续免审"
/>
</section>
<section className="panel p-4 sm:p-5">
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
新用户发帖冷静期
</h2>
<p className="meta mt-1 text-[12.5px] leading-relaxed mb-3">
注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<input
type="number"
min={0}
max={168}
className="field w-28"
value={cooldown}
onChange={(e) => setCooldown(Number(e.target.value))}
aria-label="冷静期小时数"
/>
<span className="meta text-[12.5px]">小时</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingPolicy}
onClick={() => void saveCooldown()}
>
保存
</button>
</div>
</section>
</div>
)}
<div className="flex items-center gap-1.5 mb-3 flex-wrap" role="tablist" aria-label="内容类型">
{(
[
{ key: "posts" as Kind, label: "帖子", icon: <FileText size={14} />, count: counts.posts_live },
{
key: "comments" as Kind,
label: "评论",
icon: <MessagesSquare size={14} />,
count: counts.comments_live,
},
]
).map((t) => {
const active = kind === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchKind(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.icon}
{t.label}
<span className="text-[11px] tabular-nums" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}>
{t.count}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="状态筛选">
{STATUS_TABS.map((t) => {
const active = status === t.key;
const cnt = statusCount(t.key);
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchStatus(t.key)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12.5px] transition-colors"
style={
active
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-3)", background: "var(--panel)" }
}
>
{t.label}
{cnt != null && (
<span className="tabular-nums text-[11px] opacity-80">{cnt}</span>
)}
</button>
);
})}
</div>
<form onSubmit={applySearch} className="panel p-3 mb-4 flex gap-2 items-center" role="search">
<Search size={15} style={{ color: "var(--ink-3)" }} aria-hidden />
<input
className="field flex-1 !border-0 !shadow-none !bg-transparent py-1.5"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
aria-label="搜索内容"
/>
<button type="submit" className="btn btn-line btn-sm">
搜索
</button>
</form>
<div className="panel overflow-hidden">
{loading ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : error ? (
<p className="alert-error m-4">{error}</p>
) : kind === "posts" && posts.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无帖子</p>
) : kind === "comments" && comments.length === 0 ? (
<p className="meta text-center py-16 text-[13px]">暂无评论</p>
) : kind === "posts" ? (
posts.map((p) => {
const key = `p-${p.id}`;
const busy = actingKey === key;
return (
<div
key={p.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ 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">
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{
background: p.deleted ? "var(--danger-soft)" : "var(--tint)",
color: p.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(p.status, p.deleted)}
</span>
<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 shrink-0">
{p.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除帖子",
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
() =>
apiAdminPurgePost(p.id).then(() => {
toast("帖子已彻底删除", "ok");
reload(postsPage);
}),
true
)
}
>
<Trash2 size={14} /> 清除
</button>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => setStaffDeletePost(p)}
>
<Trash2 size={14} /> 删除
</button>
)}
</div>
</div>
);
})
) : (
comments.map((c) => {
const key = `c-${c.id}`;
const busy = actingKey === key;
return (
<div
key={c.id}
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
style={{ 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">
<span
className="text-[11px] px-2 py-0.5 rounded-full"
style={{
background: c.deleted ? "var(--danger-soft)" : "var(--tint)",
color: c.deleted ? "var(--danger)" : "var(--ink-3)",
}}
>
{statusLabel(c.status, c.deleted)}
</span>
<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 shrink-0">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHistoryCommentId(c.id)}
>
<History size={14} /> 历史
</button>
{c.deleted ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() =>
runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复")
}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
恢复
</button>
<button
type="button"
className="btn btn-danger btn-sm"
disabled={busy}
onClick={() =>
askConfirm(
"彻底删除评论",
"该评论将永久删除且不可恢复。",
() =>
apiAdminPurgeComment(c.id).then(() => {
toast("评论已彻底删除", "ok");
reload(commentsPage);
}),
true
)
}
>
<Trash2 size={14} /> 清除
</button>
</>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => setStaffDeleteComment(c)}
>
<Trash2 size={14} /> 删除
</button>
)}
</div>
</div>
);
})
)}
{totalPages > 1 && (
<div
className="flex items-center justify-center gap-2 px-4 py-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page <= 1 || loading}
onClick={() => {
const p = page - 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
<ChevronLeft size={14} /> 上一页
</button>
<span className="meta text-[12.5px] tabular-nums">
{page} / {totalPages}
</span>
<button
type="button"
className="btn btn-line btn-sm"
disabled={page >= totalPages || loading}
onClick={() => {
const p = page + 1;
if (kind === "posts") void loadPosts(p, status, appliedQ);
else void loadComments(p, status, appliedQ);
}}
>
下一页 <ChevronRight size={14} />
</button>
</div>
)}
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
danger={confirm?.danger}
busy={confirmBusy}
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);
reload(postsPage);
})
.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);
reload(commentsPage);
})
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => setStaffDeleteCommentBusy(false));
}}
/>
<CommentEditHistoryModal
open={historyCommentId != null}
commentId={historyCommentId}
onClose={() => setHistoryCommentId(null)}
/>
</div>
);
}