Files
jiang13-bbs/frontend/app/admin/page.tsx

214 lines
6.7 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 {
ClipboardCheck,
ChevronRight,
Eye,
FileText,
MessageSquareText,
LayoutDashboard,
Coins,
Users,
Activity,
} from "lucide-react";
import {
fetchOverview,
fetchAdminPendingCounts,
fetchAdminAnalytics,
type PendingCounts,
type AnalyticsOverview,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove, ROLES } from "@/lib/roles";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import DashboardClient from "./DashboardClient";
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 PendingStrip({ counts }: { counts: PendingCounts }) {
const total = counts.posts + counts.comments;
const idle = total === 0;
const href =
counts.posts > 0 || counts.comments === 0
? "/admin/content?status=pending"
: "/admin/content?status=pending&kind=comments";
return (
<Link
href={href}
className="panel hover-lift mb-4 px-4 sm:px-5 py-3 flex items-center gap-3 group"
>
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{
background: idle ? "var(--tint)" : "var(--ok-soft)",
color: idle ? "var(--ink-3)" : "var(--ok)",
}}
aria-hidden
>
<ClipboardCheck size={18} />
</span>
<div className="min-w-0 flex-1 flex items-center gap-3 sm:gap-5 flex-wrap">
<span className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
待审内容
</span>
{idle ? (
<span className="admin-status is-muted">暂无待审</span>
) : (
<span className="admin-status is-ok tabular-nums">{fmt(total)} 项</span>
)}
<span className="inline-flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<FileText size={13} style={{ color: "var(--ink-3)" }} aria-hidden />
帖子
<strong className="tabular-nums font-bold" style={{ color: "var(--ink)" }}>
{fmt(counts.posts)}
</strong>
</span>
<span className="inline-flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<MessageSquareText size={13} style={{ color: "var(--ink-3)" }} aria-hidden />
评论
<strong className="tabular-nums font-bold" style={{ color: "var(--ink)" }}>
{fmt(counts.comments)}
</strong>
</span>
</div>
<ChevronRight
size={16}
className="shrink-0 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</Link>
);
}
const emptyAnalytics = (): AnalyticsOverview => ({
range_days: 7,
today_pv: 0,
today_uv: 0,
visits: [],
posts_series: [],
comments_series: [],
registers_series: [],
boards: [],
post_types: [],
top_viewed: [],
});
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 canEconomy = isAdminOrAbove(me.user?.role);
const boardScoped = me.user?.role === ROLES.BOARD_ADMIN;
const [overview, pendingCounts, analytics] = await Promise.all([
boardScoped
? Promise.resolve(null)
: fetchOverview(cookie).catch(() => null),
canMod
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
: Promise.resolve(null),
fetchAdminAnalytics(cookie, "7d").catch(() => emptyAnalytics()),
]);
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 newUsers = boardScoped ? [] : overview?.new_users ?? [];
// 板管:今日内容用授权板 analytics 序列今日点;隐藏全站注册/在线卡的误导数字
const todayPosts = boardScoped
? analytics.posts_series[analytics.posts_series.length - 1]?.count ?? 0
: stats?.today_posts ?? 0;
const todayComments = boardScoped
? analytics.comments_series[analytics.comments_series.length - 1]?.count ?? 0
: stats?.today_comments ?? 0;
return (
<div>
<AdminPageHeader
icon={LayoutDashboard}
title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`}
description={`${dateLabel} · 站点管理`}
actions={
canEconomy ? (
<Link href="/admin/economy" className="btn btn-ghost text-[13px] gap-1.5">
<Coins size={15} aria-hidden />
积分看板
</Link>
) : undefined
}
/>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<AdminStatCard
label="今日 PV"
value={fmt(analytics.today_pv)}
hint={boardScoped ? "仅全站可见(板管不展示)" : `独立访客 UV ${fmt(analytics.today_uv)}`}
icon={<Eye size={16} />}
/>
<AdminStatCard
label="今日 UV"
value={fmt(analytics.today_uv)}
hint={boardScoped ? "板管仪表盘不含全站 UV" : "独立访客"}
icon={<Users size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<AdminStatCard
label="今日内容"
value={fmt(todayPosts)}
sub="帖"
hint={
boardScoped
? `评论 ${fmt(todayComments)} · 授权板块`
: `评论 ${fmt(todayComments)} · 注册 ${fmt(stats?.today_users ?? 0)}`
}
icon={<FileText size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="在线"
value={boardScoped ? "—" : fmt(stats?.online ?? 0)}
hint={boardScoped ? "板管不展示全站在线" : `峰值 ${fmt(stats?.peak_online ?? 0)}`}
icon={<Activity size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
</div>
{canMod && pendingCounts && <PendingStrip counts={pendingCounts} />}
<DashboardClient
initialAnalytics={analytics}
newUsers={newUsers}
totalUsers={stats?.users}
canUsers={canUsers}
/>
</div>
);
}