Files
jiang13-bbs/frontend/app/admin/page.tsx
freefire a7b6421840 feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
2026-09-15 04:47:34 +08:00

342 lines
12 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.
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import {
Users,
PenLine,
MessageCircle,
Activity,
Megaphone,
Palette,
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";
export const metadata: Metadata = {
title: "管理仪表盘",
};
// 精确千分位(管理员视角需要真实数值,不用首页的紧凑 K/M)
const fmt = (n: number) => n.toLocaleString("zh-CN");
function greeting(hour: number) {
if (hour < 6) return "凌晨好";
if (hour < 12) return "上午好";
if (hour < 14) return "中午好";
if (hour < 18) return "下午好";
return "晚上好";
}
// 统计 Bento 卡:总量 + 今日增量,语义色与全站令牌一致
function StatCard({
label,
value,
foot,
icon,
color,
soft,
}: {
label: string;
value: string;
foot: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
<div className="panel p-4 sm:p-5">
<div className="flex items-start justify-between gap-2">
<p className="text-[12.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
</div>
<p
className="mt-3 text-[26px] sm:text-[30px] font-extrabold tabular-nums leading-none tracking-tight"
style={{ color: "var(--ink)" }}
>
{value}
</p>
<p className="mt-2 text-[11.5px] sm:text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>
{foot}
</p>
</div>
);
}
// 快捷管理入口:整卡可点(stretch-link),图标 + 标题 + 描述 + 箭头
function QuickLink({
href,
icon,
title,
desc,
soft,
color,
}: {
href: string;
icon: React.ReactNode;
title: string;
desc: string;
soft: string;
color: string;
}) {
return (
<Link href={href} className="panel hover-lift relative block p-5 group">
<div className="flex items-start gap-3.5">
<span
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
{title}
</p>
<p className="meta mt-1 text-[12.5px] leading-relaxed">{desc}</p>
</div>
<ChevronRight
size={17}
className="shrink-0 mt-1 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</div>
</Link>
);
}
function PanelHead({ title, extra }: { title: string; extra?: string }) {
return (
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</span>
{extra && (
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{extra}
</span>
)}
</div>
);
}
export default async function AdminDashboardPage() {
const cookie = authCookieHeader(await cookies());
// 与 layout 的 /me 请求共享 React cache;概览失败降级,不阻塞仪表盘骨架
const [me, overview] = await Promise.all([
getMeCached(cookie || undefined),
fetchOverview(cookie).catch(() => null),
]);
const now = new Date();
const dateLabel = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
}).format(now);
const stats = overview?.stats;
const boards = overview?.boards ?? [];
const newUsers = overview?.new_users ?? [];
const topBoards = [...boards].sort((a, b) => b.post_count - a.post_count).slice(0, 6);
const maxBoardCount = Math.max(1, ...topBoards.map((b) => b.post_count));
return (
<div>
{/* 页头 */}
<div className="mb-5">
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{greeting(now.getHours())},{me.user?.nickname}
</h1>
<p className="meta mt-1.5 text-[12.5px]">{dateLabel} · 这里是站点运营全貌</p>
</div>
{/* 统计 Bento */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<StatCard
label="注册用户"
value={stats ? fmt(stats.users) : "—"}
foot={stats ? `今日新增 +${fmt(stats.today_users)}` : "统计暂不可用"}
icon={<Users size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<StatCard
label="话题总数"
value={stats ? fmt(stats.posts) : "—"}
foot={stats ? `今日新发 +${fmt(stats.today_posts)}` : "统计暂不可用"}
icon={<PenLine size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<StatCard
label="回复总数"
value={stats ? fmt(stats.comments) : "—"}
foot={stats ? `今日新增 +${fmt(stats.today_comments)}` : "统计暂不可用"}
icon={<MessageCircle size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<StatCard
label="在线用户"
value={stats ? fmt(stats.online) : "—"}
foot={stats ? `近 5 分钟活跃 · 峰值 ${fmt(stats.peak_online)}` : "统计暂不可用"}
icon={<Activity size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
</div>
{/* 快捷入口(按角色权限过滤,与侧边栏一致;后端路由另有守卫) */}
<h2 className="sr-only">快捷管理</h2>
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4 mt-4">
{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>
{/* 成员与板块 */}
<div className="grid lg:grid-cols-2 gap-4 mt-4 items-start">
{/* 最新注册成员 */}
<div className="panel overflow-hidden">
<PanelHead title="最新注册成员" extra={stats ? `共 ${fmt(stats.users)} 人` : undefined} />
{newUsers.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无成员数据</p>
) : (
newUsers.slice(0, 6).map((u) => (
<Link
key={u.id}
href={`/u/${u.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[11.5px] font-semibold"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden="true" />
{formatRelative(u.created_at)}加入
</span>
</Link>
))
)}
</div>
{/* 板块分布 */}
<div className="panel overflow-hidden">
<PanelHead title="板块话题分布" extra={`${boards.length} 个板块`} />
{topBoards.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无板块数据</p>
) : (
topBoards.map((b) => (
<Link
key={b.id}
href={`/?board=${b.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--panel-2)", color: "var(--accent)" }}
aria-hidden="true"
>
<LayoutGrid size={14} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span className="text-[12px] font-bold tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
{fmt(b.post_count)}
</span>
</span>
{/* 相对占比迷你条(纯装饰) */}
<span
className="block mt-1.5 h-1 rounded-full overflow-hidden"
style={{ background: "var(--tint)" }}
aria-hidden="true"
>
<span
className="block h-full rounded-full"
style={{
width: `${Math.max(4, Math.round((b.post_count / maxBoardCount) * 100))}%`,
background: "var(--accent)",
}}
/>
</span>
</span>
</Link>
))
)}
</div>
</div>
</div>
);
}