feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系 - 实现实时权限快照加载与细粒度权限校验 - 新增内容审核队列与前后端页面 - 重构用户权限管理与站点管理逻辑 - 新增评论/帖子审核状态与通知推送 - 优化前端权限控制与角色徽章展示 - 修正数据库迁移与默认值问题
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user