feat: 实现完整的板块级RBAC内容审核系统

- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
This commit is contained in:
2026-09-15 04:47:34 +08:00
parent 6da4309453
commit a7b6421840
44 changed files with 3149 additions and 585 deletions

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
@@ -10,29 +11,114 @@ import {
Palette,
ShieldCheck,
ArrowLeft,
ClipboardCheck,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
import {
canModerateAny,
isAdminOrAbove,
isSuperOrOwner,
type RoleUserLike,
} from "@/lib/roles";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
exact?: boolean;
/** 按 /me 角色过滤可见性;最终权限以后端路由守卫为准 */
visible: (u: RoleUserLike) => boolean;
/** 是否携带待审核角标 */
pendingBadge?: boolean;
}
// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
const NAV_ITEMS: { href: string; label: string; icon: LucideIcon; exact?: boolean }[] = [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true },
{ href: "/admin/users", label: "用户管理", icon: Users },
{ href: "/admin/announcements", label: "公告管理", icon: Megaphone },
{ href: "/admin/appearance", label: "外观设置", icon: Palette },
const NAV_ITEMS: NavItem[] = [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
{
href: "/admin/moderation",
label: "内容审核",
icon: ClipboardCheck,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/users",
label: "用户权限",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观设置",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
];
function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
/** 待审核数量轮询(30 秒);仅内容审核入口可见时启用 */
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
if (!enabled) return;
let alive = true;
const load = () => {
apiAdminPendingCounts()
.then((c) => {
if (alive) setCounts(c);
})
.catch(() => {});
};
load();
const t = setInterval(load, 30_000);
const onVisible = () => {
if (document.visibilityState === "visible") load();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
alive = false;
clearInterval(t);
document.removeEventListener("visibilitychange", onVisible);
};
}, [enabled]);
return counts.posts + counts.comments;
}
function PendingBadge({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
className="ml-auto shrink-0 min-w-[20px] h-5 px-1.5 rounded-full inline-flex items-center justify-center text-[10.5px] font-extrabold tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
aria-label={`${count} 条待审核`}
>
{count > 99 ? "99+" : count}
</span>
);
}
/**
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 管理员身份卡。
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
* 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
* 与 j13-bcard / 顶部 pill 保持一致。
*/
export function AdminSidebar({ user }: { user: User }) {
const pathname = usePathname();
const items = NAV_ITEMS.filter((i) => i.visible(user));
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
return (
<div className="panel p-3">
@@ -59,7 +145,7 @@ export function AdminSidebar({ user }: { user: User }) {
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
站点管理
</p>
{NAV_ITEMS.map((item) => {
{items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
@@ -76,6 +162,7 @@ export function AdminSidebar({ user }: { user: User }) {
>
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
@@ -93,7 +180,7 @@ export function AdminSidebar({ user }: { user: User }) {
</Link>
</div>
{/* 管理员身份卡 */}
{/* 身份卡:按当前角色显示对应徽章(站长为皇冠标识) */}
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
<div className="min-w-0 leading-tight">
@@ -104,12 +191,8 @@ export function AdminSidebar({ user }: { user: User }) {
@{user.username}
</p>
</div>
<span
className="ml-auto shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10.5px] font-bold leading-[1.6]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<ShieldCheck size={11} />
管理员
<span className="ml-auto shrink-0">
<RoleBadge role={user.role} boardIds={user.board_ids} size={11} />
</span>
</div>
</div>
@@ -120,8 +203,10 @@ export function AdminSidebar({ user }: { user: User }) {
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
*/
export function AdminNavMobile() {
export function AdminNavMobile({ user }: { user: User }) {
const pathname = usePathname();
const items = NAV_ITEMS.filter((i) => i.visible(user));
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
return (
<div
@@ -135,7 +220,7 @@ export function AdminNavMobile() {
}}
>
<nav className="flex items-center gap-2 overflow-x-auto" aria-label="后台导航">
{NAV_ITEMS.map((item) => {
{items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
@@ -152,6 +237,14 @@ export function AdminNavMobile() {
>
<Icon size={14} />
{item.label}
{item.pendingBadge && pendingTotal > 0 && (
<span
className="min-w-[18px] h-[18px] px-1 rounded-full inline-flex items-center justify-center text-[10px] font-extrabold tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
{pendingTotal > 99 ? "99+" : pendingTotal}
</span>
)}
</Link>
);
})}

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
import { ShieldAlert } from "lucide-react";
// 子页面细粒度权限守卫的统一无权限视图(写接口仍由后端 403 兜底)
export default function Forbidden({
text = "你没有访问该管理功能的权限,如有需要请联系站长调整角色。",
}: {
text?: string;
}) {
return (
<div className="max-w-md mx-auto panel p-8 text-center">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
权限不足
</h1>
<p className="meta mt-2 text-[13px] leading-relaxed">{text}</p>
<Link href="/admin" className="btn btn-primary mt-6">
返回仪表盘
</Link>
</div>
);
}

View File

@@ -1,11 +1,21 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import AnnouncementAdmin from "./AnnouncementAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "站点公告管理",
};
// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担;本页只负责内容直出
export default function AdminAnnouncementsPage() {
// 公告管理仅管理员及以上可用(板块管理员无公告权;后端路由另有守卫)
export default async function AdminAnnouncementsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
}
return <AnnouncementAdmin />;
}

View File

@@ -1,14 +1,23 @@
import type { Metadata } from "next";
import { getPublicSettingsCached } from "@/lib/serverData";
import { cookies } from "next/headers";
import { getPublicSettingsCached, getMeCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import AppearanceAdmin from "./AppearanceAdmin";
export const metadata: Metadata = {
title: "外观设置",
};
// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担。
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动;
// 写操作仅超级管理员与站长可用(后端 PUT /settings 另有守卫)
export default async function AdminAppearancePage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="外观设置仅超级管理员和站长可操作。" />;
}
const { accent } = await getPublicSettingsCached();
return <AppearanceAdmin initialAccent={accent || ""} />;
}

View File

@@ -4,6 +4,7 @@ import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import { AdminSidebar, AdminNavMobile } from "./AdminNav";
// 整个 /admin 树不进入索引(子页面无需重复声明)
@@ -11,13 +12,13 @@ export const metadata: Metadata = {
robots: { index: false, follow: false },
};
// 权限以 Go 端 RequireAdmin 为唯一判定;这里仅决定管理界面是否直出,
// 非管理员访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (me.user?.role !== "admin") {
if (!me.user || !isStaff(me.user.role)) {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-6 lg:mt-10">
<span
@@ -39,7 +40,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
return (
<div>
<AdminNavMobile />
<AdminNavMobile user={me.user} />
<div className="flex items-start gap-6 xl:gap-7">
<aside className="hidden lg:block w-60 xl:w-64 shrink-0">
<div className="sticky" style={{ top: 84 }}>

View File

@@ -0,0 +1,457 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
Check,
ChevronLeft,
ChevronRight,
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";
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 totalPages = tab === "posts"
? Math.max(1, Math.ceil(postsTotal / PAGE_SIZE))
: Math.max(1, Math.ceil(commentsTotal / PAGE_SIZE));
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)" }}
>
<ClipboardCheck size={18} />
</span>
内容审核
</h1>
<p className="meta mt-1.5 text-[12.5px]">
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容
</p>
</div>
{/* 队列切换 */}
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
{(
[
{ key: "posts" as Tab, label: "待审帖子", count: counts.posts, icon: <FileText size={14} /> },
{ key: "comments" as Tab, label: "待审评论", count: counts.comments, icon: <MessagesSquare size={14} /> },
]
).map((t) => {
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
role="tab"
aria-selected={active}
onClick={() => switchTab(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold 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 rounded-full px-1.5 leading-[1.7]"
style={{
background: "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{t.count}
</span>
</button>
);
})}
</div>
{error && <p className="alert-error mb-4">{error}</p>}
<div aria-busy={loading} style={{ opacity: loading ? 0.6 : 1, transition: "opacity .15s" }}>
{tab === "posts" ? (
posts.length === 0 ? (
<EmptyQueue icon={<FileText size={22} />} text="暂无待审核帖子" />
) : (
<div className="flex flex-col gap-3">
{posts.map((p) => {
const key = `posts-${p.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div key={p.id} className="panel p-4 sm:p-5">
<div className="flex items-center gap-2 flex-wrap mb-2.5">
<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-[15.5px] font-extrabold 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.5 flex items-center justify-between gap-3 flex-wrap pt-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<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] font-semibold 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"
style={{ background: "var(--ok-soft)", color: "var(--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"
style={{
background: confirming ? "var(--danger)" : "var(--danger-soft)",
color: confirming ? "var(--accent-on)" : "var(--danger)",
}}
>
{confirming ? "确认拒绝?" : <><X size={13} /> 拒绝</>}
</button>
</div>
</div>
</div>
);
})}
</div>
)
) : comments.length === 0 ? (
<EmptyQueue icon={<MessagesSquare size={22} />} text="暂无待审核评论" />
) : (
<div className="panel overflow-hidden">
{comments.map((c, idx) => {
const key = `comments-${c.id}`;
const busy = actingKey === key;
const confirming = confirmKey === key;
return (
<div
key={c.id}
className="p-4 sm:p-5 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] font-bold 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"
style={{ background: "var(--ok-soft)", color: "var(--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"
style={{
background: confirming ? "var(--danger)" : "var(--danger-soft)",
color: confirming ? "var(--accent-on)" : "var(--danger)",
}}
>
{confirming ? "确认拒绝?" : <><X size={13} /> 拒绝</>}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div
className="mt-4 flex items-center justify-between gap-2 panel px-4 sm:px-5 py-3"
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {tab === "posts" ? postsPage : commentsPage} / {totalPages} 页
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => (tab === "posts" ? goPosts(postsPage - 1) : goComments(commentsPage - 1))}
disabled={(tab === "posts" ? postsPage : commentsPage) <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => (tab === "posts" ? goPosts(postsPage + 1) : goComments(commentsPage + 1))}
disabled={(tab === "posts" ? postsPage : commentsPage) >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
);
}
function EmptyQueue({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="panel text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
{icon}
</span>
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
{text}
</p>
<p className="meta mt-1.5 text-[12.5px]">新提交的内容会按时间顺序出现在这里</p>
</div>
);
}

View File

@@ -0,0 +1,43 @@
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}
/>
);
}

View File

@@ -11,10 +11,12 @@ import {
ChevronRight,
UserPlus,
LayoutGrid,
ClipboardCheck,
} from "lucide-react";
import { fetchOverview, type OverviewResponse } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isAdminOrAbove, isSuperOrOwner } from "@/lib/roles";
import Avatar from "@/components/Avatar";
import { formatRelative } from "@/lib/format";
@@ -204,33 +206,49 @@ export default async function AdminDashboardPage() {
/>
</div>
{/* 快捷入口 */}
{/* 快捷入口(按角色权限过滤,与侧边栏一致;后端路由另有守卫) */}
<h2 className="sr-only">快捷管理</h2>
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4 mt-4">
<QuickLink
href="/admin/users"
icon={<Users size={18} />}
title="用户管理"
desc="查看全站成员,授予管理员权限或封禁违规账号"
soft="var(--accent-soft)"
color="var(--accent)"
/>
<QuickLink
href="/admin/announcements"
icon={<Megaphone size={18} />}
title="公告管理"
desc="发布、编辑与下线站点公告,支持标签颜色与草稿状态"
soft="var(--clay-soft)"
color="var(--clay)"
/>
<QuickLink
href="/admin/appearance"
icon={<Palette size={18} />}
title="外观设置"
desc="配置全站主题强调色,实时预览后保存并对所有用户生效"
soft="var(--gold-soft)"
color="var(--gold)"
/>
{canModerateAny(me.user) && (
<QuickLink
href="/admin/moderation"
icon={<ClipboardCheck size={18} />}
title="内容审核"
desc="审核普通用户提交的帖子与评论,通过后内容公开可见"
soft="var(--ok-soft)"
color="var(--ok)"
/>
)}
{isAdminOrAbove(me.user?.role) && (
<QuickLink
href="/admin/announcements"
icon={<Megaphone size={18} />}
title="公告管理"
desc="发布、编辑与下线站点公告,支持标签颜色与草稿状态"
soft="var(--clay-soft)"
color="var(--clay)"
/>
)}
{isSuperOrOwner(me.user?.role) && (
<QuickLink
href="/admin/users"
icon={<Users size={18} />}
title="用户权限"
desc="查看全站成员,授予管理角色与板块授权,封禁违规账号"
soft="var(--accent-soft)"
color="var(--accent)"
/>
)}
{isSuperOrOwner(me.user?.role) && (
<QuickLink
href="/admin/appearance"
icon={<Palette size={18} />}
title="外观设置"
desc="配置全站主题强调色,实时预览后保存并对所有用户生效"
soft="var(--gold-soft)"
color="var(--gold)"
/>
)}
</div>
{/* 成员与板块 */}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@ import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminUsers, type AdminUsersResponse } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import UsersAdmin, { type UserFilter } from "./UsersAdmin";
export const metadata: Metadata = {
@@ -13,9 +15,9 @@ interface UsersPageProps {
searchParams: Promise<{ q?: string; filter?: string; page?: string }>;
}
// 筛选项映射为后端查询参数:管理员走 role,封禁走 status,其余不过滤
// 筛选项映射为后端查询参数:管理团队走 role=staff,封禁走 status,其余不过滤
function filterToQuery(filter: UserFilter): { role?: string; status?: string } {
if (filter === "admin") return { role: "admin" };
if (filter === "staff") return { role: "staff" };
if (filter === "banned") return { status: "banned" };
return {};
}
@@ -24,12 +26,17 @@ function filterToQuery(filter: UserFilter): { role?: string; status?: string } {
export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
const sp = await searchParams;
const q = sp.q?.trim() || "";
const filter: UserFilter = sp.filter === "admin" || sp.filter === "banned" ? sp.filter : "all";
const filter: UserFilter = sp.filter === "staff" || sp.filter === "banned" ? sp.filter : "all";
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
// 仅超级管理员与站长可管理用户与角色
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="用户与角色权限仅超级管理员和站长可操作。" />;
}
let initial: AdminUsersResponse | null = null;
let initError = "";
try {
@@ -44,6 +51,7 @@ export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
initError={initError}
initialQuery={{ q, filter, page }}
currentUserId={me.user?.id ?? 0}
currentUserRole={me.user?.role ?? ""}
/>
);
}