feat(admin): 合并内容审核到内容管理,并固定后台侧栏
待审与内容库同一页处理;筛选切换去掉加载占位,避免页面闪动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ClipboardCheck,
|
|
||||||
Coins,
|
Coins,
|
||||||
Globe,
|
Globe,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -54,18 +53,12 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
title: "内容",
|
title: "内容",
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
href: "/admin/moderation",
|
|
||||||
label: "内容审核",
|
|
||||||
icon: ClipboardCheck,
|
|
||||||
visible: (u) => canModerateAny(u),
|
|
||||||
pendingBadge: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "/admin/content",
|
href: "/admin/content",
|
||||||
label: "内容管理",
|
label: "内容管理",
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
visible: (u) => canModerateAny(u),
|
visible: (u) => canModerateAny(u),
|
||||||
|
pendingBadge: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/announcements",
|
href: "/admin/announcements",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
Check,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
History,
|
History,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiAdminContentCounts,
|
apiAdminContentCounts,
|
||||||
@@ -21,6 +23,10 @@ import {
|
|||||||
apiAdminSoftDeleteComment,
|
apiAdminSoftDeleteComment,
|
||||||
apiAdminRestoreComment,
|
apiAdminRestoreComment,
|
||||||
apiAdminPurgeComment,
|
apiAdminPurgeComment,
|
||||||
|
apiAdminApprovePost,
|
||||||
|
apiAdminRejectPost,
|
||||||
|
apiAdminApproveComment,
|
||||||
|
apiAdminRejectComment,
|
||||||
apiUpdateSiteSettings,
|
apiUpdateSiteSettings,
|
||||||
type AdminContentComment,
|
type AdminContentComment,
|
||||||
type AdminContentCounts,
|
type AdminContentCounts,
|
||||||
@@ -30,6 +36,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
||||||
@@ -44,13 +51,14 @@ import {
|
|||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminSettingRow,
|
AdminSettingRow,
|
||||||
AdminStatusChip,
|
AdminStatusChip,
|
||||||
|
AdminStepper,
|
||||||
AdminSwitch,
|
AdminSwitch,
|
||||||
contentStatusChip,
|
contentStatusChip,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
type Kind = "posts" | "comments";
|
export type ContentKind = "posts" | "comments";
|
||||||
|
|
||||||
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
|
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
|
||||||
{ key: "live", label: "全部" },
|
{ key: "live", label: "全部" },
|
||||||
@@ -70,31 +78,75 @@ function excerpt(md: string, max = 120): string {
|
|||||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
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({
|
export default function ContentAdmin({
|
||||||
|
initialKind,
|
||||||
|
initialStatus,
|
||||||
initialPosts,
|
initialPosts,
|
||||||
initialPostsTotal,
|
initialPostsTotal,
|
||||||
|
initialComments,
|
||||||
|
initialCommentsTotal,
|
||||||
initialCounts,
|
initialCounts,
|
||||||
initError,
|
initError,
|
||||||
canConfigureSettings,
|
canConfigureSettings,
|
||||||
initialSettings,
|
initialSettings,
|
||||||
}: {
|
}: {
|
||||||
|
initialKind: ContentKind;
|
||||||
|
initialStatus: AdminContentStatus;
|
||||||
initialPosts: AdminContentPost[];
|
initialPosts: AdminContentPost[];
|
||||||
initialPostsTotal: number;
|
initialPostsTotal: number;
|
||||||
|
initialComments: AdminContentComment[];
|
||||||
|
initialCommentsTotal: number;
|
||||||
initialCounts: AdminContentCounts;
|
initialCounts: AdminContentCounts;
|
||||||
initError: string;
|
initError: string;
|
||||||
canConfigureSettings: boolean;
|
canConfigureSettings: boolean;
|
||||||
initialSettings: PublicSettings;
|
initialSettings: PublicSettings;
|
||||||
}) {
|
}) {
|
||||||
const [kind, setKind] = useState<Kind>("posts");
|
const [kind, setKind] = useState<ContentKind>(initialKind);
|
||||||
const [status, setStatus] = useState<AdminContentStatus>("live");
|
const [status, setStatus] = useState<AdminContentStatus>(initialStatus);
|
||||||
const [counts, setCounts] = useState(initialCounts);
|
const [counts, setCounts] = useState(initialCounts);
|
||||||
|
|
||||||
const [posts, setPosts] = useState(initialPosts);
|
const [posts, setPosts] = useState(initialPosts);
|
||||||
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
|
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
|
||||||
const [postsPage, setPostsPage] = useState(1);
|
const [postsPage, setPostsPage] = useState(1);
|
||||||
|
|
||||||
const [comments, setComments] = useState<AdminContentComment[]>([]);
|
const [comments, setComments] = useState(initialComments);
|
||||||
const [commentsTotal, setCommentsTotal] = useState(0);
|
const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal);
|
||||||
const [commentsPage, setCommentsPage] = useState(1);
|
const [commentsPage, setCommentsPage] = useState(1);
|
||||||
|
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
@@ -111,6 +163,7 @@ export default function ContentAdmin({
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
confirmLabel?: string;
|
||||||
run: () => Promise<void>;
|
run: () => Promise<void>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
@@ -131,6 +184,7 @@ export default function ContentAdmin({
|
|||||||
setPosts(res.posts);
|
setPosts(res.posts);
|
||||||
setPostsTotal(res.total);
|
setPostsTotal(res.total);
|
||||||
setPostsPage(page);
|
setPostsPage(page);
|
||||||
|
setKind("posts");
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "获取帖子失败");
|
setError(e instanceof Error ? e.message : "获取帖子失败");
|
||||||
@@ -146,6 +200,7 @@ export default function ContentAdmin({
|
|||||||
setComments(res.comments);
|
setComments(res.comments);
|
||||||
setCommentsTotal(res.total);
|
setCommentsTotal(res.total);
|
||||||
setCommentsPage(page);
|
setCommentsPage(page);
|
||||||
|
setKind("comments");
|
||||||
setError("");
|
setError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "获取评论失败");
|
setError(e instanceof Error ? e.message : "获取评论失败");
|
||||||
@@ -160,13 +215,23 @@ export default function ContentAdmin({
|
|||||||
refreshCounts();
|
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(() => {
|
useEffect(() => {
|
||||||
refreshCounts();
|
refreshCounts();
|
||||||
}, [refreshCounts]);
|
}, [refreshCounts]);
|
||||||
|
|
||||||
const switchKind = (k: Kind) => {
|
useEffect(() => {
|
||||||
|
const off = realtime.on(RT_MODERATION_CHANGED, refreshCounts);
|
||||||
|
return off;
|
||||||
|
}, [refreshCounts]);
|
||||||
|
|
||||||
|
const switchKind = (k: ContentKind) => {
|
||||||
if (k === kind) return;
|
if (k === kind) return;
|
||||||
setKind(k);
|
|
||||||
setError("");
|
setError("");
|
||||||
if (k === "posts") void loadPosts(1, status, appliedQ);
|
if (k === "posts") void loadPosts(1, status, appliedQ);
|
||||||
else void loadComments(1, status, appliedQ);
|
else void loadComments(1, status, appliedQ);
|
||||||
@@ -198,7 +263,7 @@ export default function ContentAdmin({
|
|||||||
fn()
|
fn()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast(okMsg, "ok");
|
toast(okMsg, "ok");
|
||||||
reload(kind === "posts" ? postsPage : commentsPage);
|
reloadAfterItemChange();
|
||||||
})
|
})
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||||
.finally(() => setActingKey(null));
|
.finally(() => setActingKey(null));
|
||||||
@@ -208,8 +273,24 @@ export default function ContentAdmin({
|
|||||||
title: string,
|
title: string,
|
||||||
message: string,
|
message: string,
|
||||||
run: () => Promise<void>,
|
run: () => Promise<void>,
|
||||||
danger = false
|
danger = false,
|
||||||
) => setConfirm({ title, message, run, danger });
|
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 () => {
|
const toggleTrust = async () => {
|
||||||
if (!canConfigureSettings || savingPolicy) return;
|
if (!canConfigureSettings || savingPolicy) return;
|
||||||
@@ -254,12 +335,18 @@ export default function ContentAdmin({
|
|||||||
if (st === "pending") return counts.posts_pending;
|
if (st === "pending") return counts.posts_pending;
|
||||||
if (st === "rejected") return counts.posts_rejected;
|
if (st === "rejected") return counts.posts_rejected;
|
||||||
if (st === "deleted") return counts.posts_deleted;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
if (st === "live") return counts.comments_live;
|
if (st === "live") return counts.comments_live;
|
||||||
if (st === "pending") return counts.comments_pending;
|
if (st === "pending") return counts.comments_pending;
|
||||||
if (st === "rejected") return counts.comments_rejected;
|
if (st === "rejected") return counts.comments_rejected;
|
||||||
if (st === "deleted") return counts.comments_deleted;
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,12 +355,14 @@ export default function ContentAdmin({
|
|||||||
else void loadComments(p, status, appliedQ);
|
else void loadComments(p, status, appliedQ);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const empty = emptyCopy(kind, status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
title="内容管理"
|
title="内容管理"
|
||||||
description="浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改"
|
description="审核待发布内容,并处理已发布/已拒绝条目与回收站;策略开关仅超管/站长可改"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{canConfigureSettings && (
|
{canConfigureSettings && (
|
||||||
@@ -294,88 +383,104 @@ export default function ContentAdmin({
|
|||||||
<AdminSettingRow
|
<AdminSettingRow
|
||||||
title="新用户发帖冷静期"
|
title="新用户发帖冷静期"
|
||||||
description="注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。"
|
description="注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。"
|
||||||
>
|
control={
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<input
|
<AdminStepper
|
||||||
type="number"
|
value={cooldown}
|
||||||
min={0}
|
onChange={setCooldown}
|
||||||
max={168}
|
min={0}
|
||||||
className="field w-28"
|
max={168}
|
||||||
value={cooldown}
|
unit="小时"
|
||||||
onChange={(e) => setCooldown(Number(e.target.value))}
|
ariaLabel="冷静期小时数"
|
||||||
aria-label="冷静期小时数"
|
disabled={savingPolicy}
|
||||||
/>
|
/>
|
||||||
<span className="meta text-[12.5px]">小时</span>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="btn btn-line btn-sm"
|
||||||
className="btn btn-line btn-sm"
|
disabled={savingPolicy}
|
||||||
disabled={savingPolicy}
|
onClick={() => void saveCooldown()}
|
||||||
onClick={() => void saveCooldown()}
|
>
|
||||||
>
|
保存
|
||||||
保存
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
}
|
||||||
</AdminSettingRow>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
<div className="admin-toolbar">
|
<div className="admin-content-toolbar">
|
||||||
<AdminSegmented<Kind>
|
<div className="admin-content-kind" role="tablist" aria-label="内容类型">
|
||||||
ariaLabel="内容类型"
|
<button
|
||||||
value={kind}
|
type="button"
|
||||||
onChange={switchKind}
|
role="tab"
|
||||||
options={[
|
aria-selected={kind === "posts"}
|
||||||
{
|
className="admin-content-kind-btn"
|
||||||
key: "posts",
|
onClick={() => switchKind("posts")}
|
||||||
label: "帖子",
|
>
|
||||||
icon: <FileText size={14} />,
|
<FileText size={14} aria-hidden />
|
||||||
count: counts.posts_live,
|
帖子
|
||||||
},
|
<span className="admin-content-kind-count">{counts.posts_live}</span>
|
||||||
{
|
{counts.posts_pending > 0 ? (
|
||||||
key: "comments",
|
<span className="admin-status is-warn" title={`${counts.posts_pending} 条待审`}>
|
||||||
label: "评论",
|
{counts.posts_pending}
|
||||||
icon: <MessagesSquare size={14} />,
|
</span>
|
||||||
count: counts.comments_live,
|
) : null}
|
||||||
},
|
</button>
|
||||||
]}
|
<button
|
||||||
/>
|
type="button"
|
||||||
<AdminSegmented<AdminContentStatus>
|
role="tab"
|
||||||
ariaLabel="状态筛选"
|
aria-selected={kind === "comments"}
|
||||||
size="sm"
|
className="admin-content-kind-btn"
|
||||||
value={status}
|
onClick={() => switchKind("comments")}
|
||||||
onChange={switchStatus}
|
>
|
||||||
options={STATUS_TABS.map((t) => ({
|
<MessagesSquare size={14} aria-hidden />
|
||||||
key: t.key,
|
评论
|
||||||
label: t.label,
|
<span className="admin-content-kind-count">{counts.comments_live}</span>
|
||||||
count: statusCount(t.key) ?? undefined,
|
{counts.comments_pending > 0 ? (
|
||||||
}))}
|
<span className="admin-status is-warn" title={`${counts.comments_pending} 条待审`}>
|
||||||
/>
|
{counts.comments_pending}
|
||||||
<AdminSearchField
|
</span>
|
||||||
value={keyword}
|
) : null}
|
||||||
onChange={setKeyword}
|
</button>
|
||||||
onSubmit={applySearch}
|
</div>
|
||||||
onClear={clearSearch}
|
<span className="admin-content-split" aria-hidden />
|
||||||
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
|
<div className="admin-content-status">
|
||||||
ariaLabel="搜索内容"
|
<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>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{error ? <p className="alert-error m-4">{error}</p> : null}
|
||||||
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
|
{kind === "posts" && posts.length === 0 ? (
|
||||||
<Loader2 size={16} className="animate-spin" /> 加载中…
|
<AdminEmpty icon={<FileText size={22} />} title={empty.title} description={empty.description} />
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<p className="alert-error m-4">{error}</p>
|
|
||||||
) : kind === "posts" && posts.length === 0 ? (
|
|
||||||
<AdminEmpty icon={<FileText size={22} />} title="暂无帖子" />
|
|
||||||
) : kind === "comments" && comments.length === 0 ? (
|
) : kind === "comments" && comments.length === 0 ? (
|
||||||
<AdminEmpty icon={<MessagesSquare size={22} />} title="暂无评论" />
|
<AdminEmpty icon={<MessagesSquare size={22} />} title={empty.title} description={empty.description} />
|
||||||
) : kind === "posts" ? (
|
) : kind === "posts" ? (
|
||||||
posts.map((p, idx) => {
|
posts.map((p, idx) => {
|
||||||
const key = `p-${p.id}`;
|
const key = `p-${p.id}`;
|
||||||
const busy = actingKey === key;
|
const busy = actingKey === key;
|
||||||
const chip = contentStatusChip(p.status, p.deleted);
|
const chip = contentStatusChip(p.status, p.deleted);
|
||||||
|
const pending = isPendingRow(p.status, p.deleted);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -412,7 +517,16 @@ export default function ContentAdmin({
|
|||||||
{p.user?.nickname} · {p.comment_count} 评
|
{p.user?.nickname} · {p.comment_count} 评
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<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 ? (
|
{p.deleted ? (
|
||||||
<>
|
<>
|
||||||
<AdminIconButton
|
<AdminIconButton
|
||||||
@@ -441,7 +555,7 @@ export default function ContentAdmin({
|
|||||||
() =>
|
() =>
|
||||||
apiAdminPurgePost(p.id).then(() => {
|
apiAdminPurgePost(p.id).then(() => {
|
||||||
toast("帖子已彻底删除", "ok");
|
toast("帖子已彻底删除", "ok");
|
||||||
reload(postsPage);
|
reloadAfterItemChange();
|
||||||
}),
|
}),
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
@@ -469,6 +583,7 @@ export default function ContentAdmin({
|
|||||||
const key = `c-${c.id}`;
|
const key = `c-${c.id}`;
|
||||||
const busy = actingKey === key;
|
const busy = actingKey === key;
|
||||||
const chip = contentStatusChip(c.status, c.deleted);
|
const chip = contentStatusChip(c.status, c.deleted);
|
||||||
|
const pending = isPendingRow(c.status, c.deleted);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -506,7 +621,16 @@ export default function ContentAdmin({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<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
|
<AdminIconButton
|
||||||
tip="历史"
|
tip="历史"
|
||||||
ariaLabel="查看编辑历史"
|
ariaLabel="查看编辑历史"
|
||||||
@@ -542,7 +666,7 @@ export default function ContentAdmin({
|
|||||||
() =>
|
() =>
|
||||||
apiAdminPurgeComment(c.id).then(() => {
|
apiAdminPurgeComment(c.id).then(() => {
|
||||||
toast("评论已彻底删除", "ok");
|
toast("评论已彻底删除", "ok");
|
||||||
reload(commentsPage);
|
reloadAfterItemChange();
|
||||||
}),
|
}),
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
@@ -582,7 +706,7 @@ export default function ContentAdmin({
|
|||||||
message={confirm?.message || ""}
|
message={confirm?.message || ""}
|
||||||
danger={confirm?.danger}
|
danger={confirm?.danger}
|
||||||
busy={confirmBusy}
|
busy={confirmBusy}
|
||||||
confirmLabel={confirm?.danger ? "确认删除" : "确定"}
|
confirmLabel={confirm?.confirmLabel || (confirm?.danger ? "确认删除" : "确定")}
|
||||||
onCancel={() => !confirmBusy && setConfirm(null)}
|
onCancel={() => !confirmBusy && setConfirm(null)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (!confirm) return;
|
if (!confirm) return;
|
||||||
@@ -609,7 +733,7 @@ export default function ContentAdmin({
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
toast("已移入回收站", "ok");
|
toast("已移入回收站", "ok");
|
||||||
setStaffDeletePost(null);
|
setStaffDeletePost(null);
|
||||||
reload(postsPage);
|
reloadAfterItemChange();
|
||||||
})
|
})
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||||
.finally(() => setStaffDeleteBusy(false));
|
.finally(() => setStaffDeleteBusy(false));
|
||||||
@@ -628,7 +752,7 @@ export default function ContentAdmin({
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
toast("已移入回收站", "ok");
|
toast("已移入回收站", "ok");
|
||||||
setStaffDeleteComment(null);
|
setStaffDeleteComment(null);
|
||||||
reload(commentsPage);
|
reloadAfterItemChange();
|
||||||
})
|
})
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||||
.finally(() => setStaffDeleteCommentBusy(false));
|
.finally(() => setStaffDeleteCommentBusy(false));
|
||||||
|
|||||||
@@ -4,18 +4,39 @@ import { authCookieHeader } from "@/lib/cookies";
|
|||||||
import {
|
import {
|
||||||
fetchAdminContentCounts,
|
fetchAdminContentCounts,
|
||||||
fetchAdminContentPosts,
|
fetchAdminContentPosts,
|
||||||
|
fetchAdminContentComments,
|
||||||
|
type AdminContentComment,
|
||||||
type AdminContentPost,
|
type AdminContentPost,
|
||||||
|
type AdminContentStatus,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
import ContentAdmin from "./ContentAdmin";
|
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "内容管理",
|
title: "内容管理",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminContentPage() {
|
const STATUSES: AdminContentStatus[] = ["live", "published", "pending", "rejected", "deleted"];
|
||||||
|
|
||||||
|
function parseStatus(v?: string): AdminContentStatus {
|
||||||
|
return v && STATUSES.includes(v as AdminContentStatus) ? (v as AdminContentStatus) : "live";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKind(v?: string): ContentKind {
|
||||||
|
return v === "comments" ? "comments" : "posts";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentPageProps {
|
||||||
|
searchParams: Promise<{ status?: string; kind?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminContentPage({ searchParams }: ContentPageProps) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const initialStatus = parseStatus(sp.status);
|
||||||
|
const initialKind = parseKind(sp.kind);
|
||||||
|
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !canModerateAny(me.user)) {
|
if (!me.user || !canModerateAny(me.user)) {
|
||||||
@@ -23,30 +44,49 @@ export default async function AdminContentPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let initialPosts: AdminContentPost[] = [];
|
let initialPosts: AdminContentPost[] = [];
|
||||||
let initialTotal = 0;
|
let initialPostsTotal = 0;
|
||||||
|
let initialComments: AdminContentComment[] = [];
|
||||||
|
let initialCommentsTotal = 0;
|
||||||
let initError = "";
|
let initError = "";
|
||||||
const [postsSettled, counts] = await Promise.all([
|
const [listSettled, counts] = await Promise.all([
|
||||||
fetchAdminContentPosts(1, "live", cookie || undefined)
|
initialKind === "comments"
|
||||||
.then((res) => ({ ok: true as const, res }))
|
? fetchAdminContentComments(1, initialStatus, cookie || undefined)
|
||||||
.catch((e) => ({
|
.then((res) => ({ ok: true as const, kind: "comments" as const, res }))
|
||||||
ok: false as const,
|
.catch((e) => ({
|
||||||
error: e instanceof Error ? e.message : "获取内容列表失败",
|
ok: false as const,
|
||||||
})),
|
error: e instanceof Error ? e.message : "获取内容列表失败",
|
||||||
|
}))
|
||||||
|
: fetchAdminContentPosts(1, initialStatus, cookie || undefined)
|
||||||
|
.then((res) => ({ ok: true as const, kind: "posts" as const, res }))
|
||||||
|
.catch((e) => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: e instanceof Error ? e.message : "获取内容列表失败",
|
||||||
|
})),
|
||||||
fetchAdminContentCounts(cookie || undefined),
|
fetchAdminContentCounts(cookie || undefined),
|
||||||
]);
|
]);
|
||||||
if (postsSettled.ok) {
|
if (listSettled.ok) {
|
||||||
initialPosts = postsSettled.res.posts;
|
if (listSettled.kind === "posts") {
|
||||||
initialTotal = postsSettled.res.total;
|
initialPosts = listSettled.res.posts;
|
||||||
|
initialPostsTotal = listSettled.res.total;
|
||||||
|
} else {
|
||||||
|
initialComments = listSettled.res.comments;
|
||||||
|
initialCommentsTotal = listSettled.res.total;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
initError = postsSettled.error;
|
initError = listSettled.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentAdmin
|
<ContentAdmin
|
||||||
|
key={`${initialKind}-${initialStatus}`}
|
||||||
|
initialKind={initialKind}
|
||||||
|
initialStatus={initialStatus}
|
||||||
initialPosts={initialPosts}
|
initialPosts={initialPosts}
|
||||||
initialPostsTotal={initialTotal}
|
initialPostsTotal={initialPostsTotal}
|
||||||
|
initialComments={initialComments}
|
||||||
|
initialCommentsTotal={initialCommentsTotal}
|
||||||
initialCounts={counts}
|
initialCounts={counts}
|
||||||
initError={initError}
|
initError={initError}
|
||||||
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
<div>
|
<div>
|
||||||
<AdminNavMobile user={me.user} />
|
<AdminNavMobile user={me.user} />
|
||||||
<div className="flex items-start gap-6 xl:gap-7">
|
<div className="flex items-start gap-6 xl:gap-7">
|
||||||
<aside className="hidden lg:block w-60 xl:w-64 shrink-0">
|
{/* sticky 必须钉在 aside 自身:内层与侧栏同高时等于没钉住,会跟着主栏一起滚 */}
|
||||||
<div className="sticky" style={{ top: 84 }}>
|
<aside
|
||||||
<AdminSidebar user={me.user} />
|
className="hidden lg:block w-60 xl:w-64 shrink-0 sticky self-start z-20 max-h-[calc(100vh-6.5rem)] overflow-y-auto"
|
||||||
</div>
|
style={{ top: 84 }}
|
||||||
|
>
|
||||||
|
<AdminSidebar user={me.user} />
|
||||||
</aside>
|
</aside>
|
||||||
<section className="flex-1 min-w-0">{children}</section>
|
<section className="flex-1 min-w-0">{children}</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
ClipboardCheck,
|
|
||||||
FileText,
|
|
||||||
Loader2,
|
|
||||||
MessagesSquare,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
apiAdminPendingPosts,
|
|
||||||
apiAdminPendingComments,
|
|
||||||
apiAdminPendingCounts,
|
|
||||||
apiAdminApprovePost,
|
|
||||||
apiAdminRejectPost,
|
|
||||||
apiAdminApproveComment,
|
|
||||||
apiAdminRejectComment,
|
|
||||||
type PendingCommentItem,
|
|
||||||
type PendingCounts,
|
|
||||||
type Post,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import { formatRelative } from "@/lib/format";
|
|
||||||
import { toast } from "@/lib/toast";
|
|
||||||
import Avatar from "@/components/Avatar";
|
|
||||||
import {
|
|
||||||
AdminEmpty,
|
|
||||||
AdminPageHeader,
|
|
||||||
AdminPager,
|
|
||||||
AdminSegmented,
|
|
||||||
} from "@/components/admin";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
|
||||||
|
|
||||||
type Tab = "posts" | "comments";
|
|
||||||
|
|
||||||
// 粗略去除 Markdown 标记,生成纯文本摘要
|
|
||||||
function excerpt(md: string, max = 160): 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ModerationAdmin({
|
|
||||||
initialPosts,
|
|
||||||
initialPostsTotal,
|
|
||||||
initError,
|
|
||||||
}: {
|
|
||||||
initialPosts: Post[];
|
|
||||||
initialPostsTotal: number;
|
|
||||||
initError: string;
|
|
||||||
}) {
|
|
||||||
const [tab, setTab] = useState<Tab>("posts");
|
|
||||||
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
|
|
||||||
|
|
||||||
// 帖子队列
|
|
||||||
const [posts, setPosts] = useState<Post[]>(initialPosts);
|
|
||||||
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
|
|
||||||
const [postsPage, setPostsPage] = useState(1);
|
|
||||||
|
|
||||||
// 评论队列
|
|
||||||
const [comments, setComments] = useState<PendingCommentItem[]>([]);
|
|
||||||
const [commentsTotal, setCommentsTotal] = useState(0);
|
|
||||||
const [commentsPage, setCommentsPage] = useState(1);
|
|
||||||
const [commentsLoaded, setCommentsLoaded] = useState(false);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(initError);
|
|
||||||
const [actingKey, setActingKey] = useState<string | null>(null);
|
|
||||||
// 拒绝的二次内联确认(key: posts-<id> / comments-<id>,3 秒自动复原)
|
|
||||||
const [confirmKey, setConfirmKey] = useState<string | null>(null);
|
|
||||||
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const refreshCounts = useCallback(() => {
|
|
||||||
apiAdminPendingCounts()
|
|
||||||
.then(setCounts)
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshCounts();
|
|
||||||
}, [refreshCounts]);
|
|
||||||
|
|
||||||
const loadPosts = useCallback(async (page: number) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiAdminPendingPosts(page);
|
|
||||||
setPosts(res.posts);
|
|
||||||
setPostsTotal(res.total);
|
|
||||||
setError("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "获取待审帖子失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadComments = useCallback(async (page: number) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiAdminPendingComments(page);
|
|
||||||
setComments(res.comments);
|
|
||||||
setCommentsTotal(res.total);
|
|
||||||
setError("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "获取待审评论失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const switchTab = (t: Tab) => {
|
|
||||||
if (t === tab) return;
|
|
||||||
setTab(t);
|
|
||||||
setError("");
|
|
||||||
setConfirmKey(null);
|
|
||||||
if (t === "comments" && !commentsLoaded) {
|
|
||||||
setCommentsLoaded(true);
|
|
||||||
loadComments(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goPosts = (p: number) => {
|
|
||||||
const next = Math.min(Math.max(1, p), Math.max(1, Math.ceil(postsTotal / PAGE_SIZE)));
|
|
||||||
setPostsPage(next);
|
|
||||||
loadPosts(next);
|
|
||||||
};
|
|
||||||
const goComments = (p: number) => {
|
|
||||||
const next = Math.min(Math.max(1, p), Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE)));
|
|
||||||
setCommentsPage(next);
|
|
||||||
loadComments(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const armConfirm = (key: string) => {
|
|
||||||
setConfirmKey(key);
|
|
||||||
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
|
||||||
confirmTimer.current = setTimeout(() => setConfirmKey(null), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 本地移除已处理项;当页清空且不在第一页时回退一页
|
|
||||||
const afterAction = (kind: Tab, removedPage: number, removedPageItems: number, remaining: number) => {
|
|
||||||
refreshCounts();
|
|
||||||
setConfirmKey(null);
|
|
||||||
if (remaining === 0 && removedPage > 1) {
|
|
||||||
const prev = removedPage - 1;
|
|
||||||
if (kind === "posts") {
|
|
||||||
setPostsPage(prev);
|
|
||||||
loadPosts(prev);
|
|
||||||
} else {
|
|
||||||
setCommentsPage(prev);
|
|
||||||
loadComments(prev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const act = async (
|
|
||||||
key: string,
|
|
||||||
kind: "posts" | "comments",
|
|
||||||
id: number,
|
|
||||||
approve: boolean,
|
|
||||||
page: number,
|
|
||||||
pageItems: number
|
|
||||||
) => {
|
|
||||||
// 首次点拒绝进入确认态,不执行
|
|
||||||
if (!approve && confirmKey !== key) {
|
|
||||||
armConfirm(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActingKey(key);
|
|
||||||
try {
|
|
||||||
if (kind === "posts") {
|
|
||||||
if (approve) await apiAdminApprovePost(id);
|
|
||||||
else await apiAdminRejectPost(id);
|
|
||||||
setPosts((list) => list.filter((p) => p.id !== id));
|
|
||||||
setPostsTotal((t) => Math.max(0, t - 1));
|
|
||||||
} else {
|
|
||||||
if (approve) await apiAdminApproveComment(id);
|
|
||||||
else await apiAdminRejectComment(id);
|
|
||||||
setComments((list) => list.filter((c) => c.id !== id));
|
|
||||||
setCommentsTotal((t) => Math.max(0, t - 1));
|
|
||||||
}
|
|
||||||
toast(approve ? "已通过审核,内容已公开" : "已拒绝,作者将收到通知", "ok");
|
|
||||||
afterAction(kind, page, pageItems, pageItems - 1);
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : "操作失败");
|
|
||||||
} finally {
|
|
||||||
setActingKey(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const page = tab === "posts" ? postsPage : commentsPage;
|
|
||||||
const total = tab === "posts" ? postsTotal : commentsTotal;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<AdminPageHeader
|
|
||||||
icon={ClipboardCheck}
|
|
||||||
title="内容审核"
|
|
||||||
description="普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <p className="alert-error mb-4">{error}</p>}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="panel overflow-hidden"
|
|
||||||
aria-busy={loading}
|
|
||||||
style={{ opacity: loading ? 0.6 : 1, transition: "opacity .15s" }}
|
|
||||||
>
|
|
||||||
<div className="admin-toolbar">
|
|
||||||
<AdminSegmented<Tab>
|
|
||||||
ariaLabel="审核队列"
|
|
||||||
value={tab}
|
|
||||||
onChange={switchTab}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
key: "posts",
|
|
||||||
label: "待审帖子",
|
|
||||||
count: counts.posts,
|
|
||||||
icon: <FileText size={14} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "comments",
|
|
||||||
label: "待审评论",
|
|
||||||
count: counts.comments,
|
|
||||||
icon: <MessagesSquare size={14} />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "posts" ? (
|
|
||||||
posts.length === 0 ? (
|
|
||||||
<AdminEmpty
|
|
||||||
icon={<FileText size={22} />}
|
|
||||||
title="暂无待审核帖子"
|
|
||||||
description="新提交的内容会按时间顺序出现在这里"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
posts.map((p, idx) => {
|
|
||||||
const key = `posts-${p.id}`;
|
|
||||||
const busy = actingKey === key;
|
|
||||||
const confirming = confirmKey === key;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className="px-4 sm:px-5 py-4"
|
|
||||||
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
|
||||||
<Link
|
|
||||||
href={`/?board=${p.board.id}`}
|
|
||||||
className="chip"
|
|
||||||
style={{ fontSize: 11.5 }}
|
|
||||||
>
|
|
||||||
{p.board.name}
|
|
||||||
</Link>
|
|
||||||
<span className="meta text-[11.5px]">{formatRelative(p.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/post/${p.id}`}
|
|
||||||
target="_blank"
|
|
||||||
className="block text-[15px] font-semibold tracking-tight leading-snug hover:text-[var(--accent)] transition-colors"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{p.title}
|
|
||||||
</Link>
|
|
||||||
<p
|
|
||||||
className="mt-1.5 text-[13px] leading-relaxed line-clamp-3"
|
|
||||||
style={{ color: "var(--ink-3)" }}
|
|
||||||
>
|
|
||||||
{excerpt(p.content) || "(无正文内容)"}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Avatar
|
|
||||||
name={p.user.nickname || p.user.username}
|
|
||||||
src={p.user.avatar || undefined}
|
|
||||||
size={26}
|
|
||||||
/>
|
|
||||||
<span className="text-[12.5px] truncate" style={{ color: "var(--ink-2)" }}>
|
|
||||||
{p.user.nickname}
|
|
||||||
</span>
|
|
||||||
<span className="meta text-[11.5px] truncate">@{p.user.username}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => act(key, "posts", p.id, true, postsPage, posts.length)}
|
|
||||||
className="btn btn-sm btn-ok"
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
|
|
||||||
通过
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => act(key, "posts", p.id, false, postsPage, posts.length)}
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
>
|
|
||||||
{confirming ? (
|
|
||||||
"确认拒绝?"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<X size={13} /> 拒绝
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
) : comments.length === 0 ? (
|
|
||||||
<AdminEmpty
|
|
||||||
icon={<MessagesSquare size={22} />}
|
|
||||||
title="暂无待审核评论"
|
|
||||||
description="新提交的内容会按时间顺序出现在这里"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
comments.map((c, idx) => {
|
|
||||||
const key = `comments-${c.id}`;
|
|
||||||
const busy = actingKey === key;
|
|
||||||
const confirming = confirmKey === key;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={c.id}
|
|
||||||
className="px-4 sm:px-5 py-4 flex items-start gap-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">
|
|
||||||
<span className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
|
|
||||||
{c.user.nickname}
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
href={`/?board=${c.board.id}`}
|
|
||||||
className="chip"
|
|
||||||
style={{ fontSize: 11 }}
|
|
||||||
>
|
|
||||||
{c.board.name}
|
|
||||||
</Link>
|
|
||||||
<span className="meta text-[11.5px]">{formatRelative(c.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="mt-1.5 text-[13.5px] leading-relaxed rounded-xl px-3.5 py-2.5"
|
|
||||||
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
|
|
||||||
>
|
|
||||||
{c.content}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={`/post/${c.post_id}`}
|
|
||||||
target="_blank"
|
|
||||||
className="mt-1.5 block meta text-[12px] truncate hover:text-[var(--accent)] transition-colors"
|
|
||||||
>
|
|
||||||
所属帖子:《{c.post_title}》
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() =>
|
|
||||||
act(key, "comments", c.id, true, commentsPage, comments.length)
|
|
||||||
}
|
|
||||||
className="btn btn-sm btn-ok"
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />}
|
|
||||||
通过
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() =>
|
|
||||||
act(key, "comments", c.id, false, commentsPage, comments.length)
|
|
||||||
}
|
|
||||||
className="btn btn-sm btn-danger"
|
|
||||||
>
|
|
||||||
{confirming ? (
|
|
||||||
"确认拒绝?"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<X size={13} /> 拒绝
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AdminPager
|
|
||||||
page={page}
|
|
||||||
total={total}
|
|
||||||
pageSize={PAGE_SIZE}
|
|
||||||
loading={loading}
|
|
||||||
onChange={(p) => (tab === "posts" ? goPosts(p) : goComments(p))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
|
||||||
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
|
|
||||||
import { getMeCached } from "@/lib/serverData";
|
|
||||||
import { canModerateAny } from "@/lib/roles";
|
|
||||||
import Forbidden from "../Forbidden";
|
|
||||||
import ModerationAdmin from "./ModerationAdmin";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "内容审核",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 鉴权由 app/admin/layout.tsx 统一承担(RequireStaff + 板块隔离由后端兜底);
|
|
||||||
// 帖子待审队列首屏 SSR 直出,评论队列由客户端进入时拉取
|
|
||||||
export default async function ModerationPage() {
|
|
||||||
const cookie = authCookieHeader(await cookies());
|
|
||||||
const me = await getMeCached(cookie || undefined);
|
|
||||||
|
|
||||||
if (!me.user || !canModerateAny(me.user)) {
|
|
||||||
return <Forbidden text="你所在的角色没有内容审核权限,或尚未被授权任何板块。" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
let initialPosts: Post[] = [];
|
|
||||||
let initialTotal = 0;
|
|
||||||
let initError = "";
|
|
||||||
try {
|
|
||||||
const res = await fetchAdminPendingPosts(1, cookie || undefined);
|
|
||||||
initialPosts = res.posts;
|
|
||||||
initialTotal = res.total;
|
|
||||||
} catch (e) {
|
|
||||||
initError = e instanceof Error ? e.message : "获取待审核内容失败";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModerationAdmin
|
|
||||||
initialPosts={initialPosts}
|
|
||||||
initialPostsTotal={initialTotal}
|
|
||||||
initError={initError}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -56,14 +56,18 @@ function PanelHead({ title, extra }: { title: string; extra?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待办:待审帖子 / 评论,整块进入审核页 */
|
/** 待办:待审帖子 / 评论,进入内容管理的待审筛选 */
|
||||||
function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
|
function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
|
||||||
const total = counts.posts + counts.comments;
|
const total = counts.posts + counts.comments;
|
||||||
const idle = total === 0;
|
const idle = total === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/moderation"
|
href={
|
||||||
|
counts.posts > 0 || counts.comments === 0
|
||||||
|
? "/admin/content?status=pending"
|
||||||
|
: "/admin/content?status=pending&kind=comments"
|
||||||
|
}
|
||||||
className="panel hover-lift relative block p-4 sm:p-5 mb-4 group"
|
className="panel hover-lift relative block p-4 sm:p-5 mb-4 group"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3.5">
|
<div className="flex items-start gap-3.5">
|
||||||
@@ -80,7 +84,7 @@ function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
内容审核
|
待审内容
|
||||||
</p>
|
</p>
|
||||||
{idle ? (
|
{idle ? (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1872,6 +1872,102 @@ a.j13-mention-token:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 内容管理工具条:类型 / 状态 / 搜索同一行,避免三行左对齐或等分顶栏留白 */
|
||||||
|
.admin-content-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.admin-content-toolbar {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.admin-content-kind {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.admin-content-kind-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.admin-content-kind-btn:hover:not([aria-selected="true"]) {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.admin-content-kind-btn[aria-selected="true"] {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.admin-content-kind-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.admin-content-kind-count {
|
||||||
|
min-width: 1.15rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.65;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.admin-content-kind-btn[aria-selected="true"] .admin-content-kind-count {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.admin-content-split {
|
||||||
|
display: none;
|
||||||
|
width: 1px;
|
||||||
|
height: 1.5rem;
|
||||||
|
background: var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.admin-content-status {
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.admin-content-search {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
min-width: 11rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.admin-content-split {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.admin-content-search {
|
||||||
|
flex-basis: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
|
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
|
||||||
.j13-like {
|
.j13-like {
|
||||||
border-color: var(--line-2);
|
border-color: var(--line-2);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
|||||||
if (n.type === "mention" && n.room_id) {
|
if (n.type === "mention" && n.room_id) {
|
||||||
router.push(`/chat/${n.room_id}`);
|
router.push(`/chat/${n.room_id}`);
|
||||||
} else if (n.type === "pending_review") {
|
} else if (n.type === "pending_review") {
|
||||||
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/moderation");
|
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
||||||
} else {
|
} else {
|
||||||
router.push(`/post/${n.post_id}`);
|
router.push(`/post/${n.post_id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
|||||||
if (n.type === "mention" && n.room_id) {
|
if (n.type === "mention" && n.room_id) {
|
||||||
router.push(`/chat/${n.room_id}`);
|
router.push(`/chat/${n.room_id}`);
|
||||||
} else if (n.type === "pending_review") {
|
} else if (n.type === "pending_review") {
|
||||||
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/moderation");
|
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
||||||
} else {
|
} else {
|
||||||
router.push(`/post/${n.post_id}`);
|
router.push(`/post/${n.post_id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1387,20 +1387,12 @@ export async function fetchAdminUsers(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSR 待审核帖子首页(转发管理团队 cookie 直连后端);权限不足时抛错由页面层处理
|
/** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */
|
||||||
export async function fetchAdminPendingPosts(
|
export interface PendingCounts {
|
||||||
page = 1,
|
posts: number;
|
||||||
cookieHeader?: string
|
comments: number;
|
||||||
): Promise<{ posts: Post[]; total: number; page: number }> {
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE}/api/admin/moderation/pending-posts?page=${page}`,
|
|
||||||
ssrInit(cookieHeaders(cookieHeader))
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error("获取待审核帖子失败");
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** SSR 待审数量(帖子 + 评论);失败时返回零,不阻塞仪表盘 */
|
|
||||||
export async function fetchAdminPendingCounts(
|
export async function fetchAdminPendingCounts(
|
||||||
cookieHeader?: string
|
cookieHeader?: string
|
||||||
): Promise<PendingCounts> {
|
): Promise<PendingCounts> {
|
||||||
@@ -1622,49 +1614,7 @@ export async function apiListBoards(): Promise<{ boards: Board[] }> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 内容审核队列 =====
|
// ===== 内容审核动作 =====
|
||||||
|
|
||||||
export interface PendingCommentItem {
|
|
||||||
id: number;
|
|
||||||
post_id: number;
|
|
||||||
post_title: string;
|
|
||||||
board_id: number;
|
|
||||||
board: Board;
|
|
||||||
content: string;
|
|
||||||
created_at: string;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PendingCounts {
|
|
||||||
posts: number;
|
|
||||||
comments: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiAdminPendingPosts(page = 1): Promise<{
|
|
||||||
posts: Post[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
}> {
|
|
||||||
const res = await fetchWithRefresh(`/api/admin/moderation/pending-posts?page=${page}`, {
|
|
||||||
headers: clientHeaders(),
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data.error || "获取待审帖子失败");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiAdminPendingComments(page = 1): Promise<{
|
|
||||||
comments: PendingCommentItem[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
}> {
|
|
||||||
const res = await fetchWithRefresh(`/api/admin/moderation/pending-comments?page=${page}`, {
|
|
||||||
headers: clientHeaders(),
|
|
||||||
});
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data.error || "获取待审评论失败");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiAdminPendingCounts(): Promise<PendingCounts> {
|
export async function apiAdminPendingCounts(): Promise<PendingCounts> {
|
||||||
const res = await fetchWithRefresh("/api/admin/moderation/counts", {
|
const res = await fetchWithRefresh("/api/admin/moderation/counts", {
|
||||||
@@ -1862,6 +1812,19 @@ export async function fetchAdminContentPosts(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminContentComments(
|
||||||
|
page = 1,
|
||||||
|
status: AdminContentStatus = "live",
|
||||||
|
cookieHeader?: string
|
||||||
|
): Promise<{ comments: AdminContentComment[]; total: number; page: number }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/admin/content/comments?page=${page}&status=${status}`,
|
||||||
|
ssrInit(cookieHeaders(cookieHeader))
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error("获取评论列表失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 群聊(二期) =====
|
// ===== 群聊(二期) =====
|
||||||
|
|
||||||
// 群聊房间基础模型(与后端 model.ChatRoom 对齐)
|
// 群聊房间基础模型(与后端 model.ChatRoom 对齐)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function canModerateBoard(
|
|||||||
return (user.board_ids ?? []).includes(boardId);
|
return (user.board_ids ?? []).includes(boardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否拥有任意板块的内容审核权(后台"内容审核"入口) */
|
/** 是否拥有任意板块的内容审核权(后台「内容管理」入口) */
|
||||||
export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
|
export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (isAdminOrAbove(user.role)) return true;
|
if (isAdminOrAbove(user.role)) return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user