Files
jiang13-bbs/frontend/app/admin/page.tsx
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

283 lines
9.9 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 {
UserPlus,
LayoutGrid,
ClipboardCheck,
ChevronRight,
MessageSquareText,
FileText,
Users,
} from "lucide-react";
import {
fetchOverview,
fetchAdminPendingCounts,
type PendingCounts,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import Avatar from "@/components/Avatar";
import { formatRelative } from "@/lib/format";
export const metadata: Metadata = {
title: "管理仪表盘",
};
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 "晚上好";
}
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>
);
}
/** 待办工作区:待审帖子 / 评论数量,整块进入审核页 */
function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
const total = counts.posts + counts.comments;
const idle = total === 0;
return (
<Link
href="/admin/moderation"
className="panel hover-lift relative block p-4 sm:p-5 mb-4 group"
>
<div className="flex items-start gap-3.5">
<span
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
style={{
background: idle ? "var(--tint)" : "var(--ok-soft)",
color: idle ? "var(--ink-3)" : "var(--ok)",
}}
aria-hidden="true"
>
<ClipboardCheck size={20} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
内容审核
</p>
{idle ? (
<span
className="text-[11.5px] font-semibold px-2 py-0.5 rounded-full"
style={{ background: "var(--tint)", color: "var(--ink-3)" }}
>
暂无待审
</span>
) : (
<span
className="text-[11.5px] font-semibold px-2 py-0.5 rounded-full tabular-nums"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
>
{fmt(total)} 项待处理
</span>
)}
</div>
<div className="mt-3 flex flex-wrap gap-3 sm:gap-5">
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<FileText size={14} style={{ color: "var(--ink-3)" }} aria-hidden="true" />
待审帖子
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
{fmt(counts.posts)}
</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<MessageSquareText size={14} style={{ color: "var(--ink-3)" }} aria-hidden="true" />
待审评论
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
{fmt(counts.comments)}
</strong>
</span>
</div>
</div>
<ChevronRight
size={18}
className="shrink-0 mt-1 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</div>
</Link>
);
}
export default async function AdminDashboardPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
const canMod = canModerateAny(me.user);
const canUsers = isSuperOrOwner(me.user?.role);
const [overview, pendingCounts] = await Promise.all([
fetchOverview(cookie).catch(() => null),
canMod
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
: Promise.resolve(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>
{canMod && pendingCounts && <PendingWorkPanel counts={pendingCounts} />}
<div className="grid lg:grid-cols-2 gap-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>
))
)}
{canUsers && (
<Link
href="/admin/users"
className="flex items-center justify-center gap-1.5 px-4 py-3 text-[13px] font-semibold transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden="true" />
前往用户权限
<ChevronRight size={14} aria-hidden="true" />
</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>
);
}