- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
214 lines
6.7 KiB
TypeScript
214 lines
6.7 KiB
TypeScript
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/points" 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>
|
||
);
|
||
}
|