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

199 lines
6.0 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 } 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 [overview, pendingCounts, analytics] = await Promise.all([
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 = overview?.new_users ?? [];
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={`独立访客 UV ${fmt(analytics.today_uv)}`}
icon={<Eye size={16} />}
/>
<AdminStatCard
label="今日 UV"
value={fmt(analytics.today_uv)}
hint="独立访客"
icon={<Users size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<AdminStatCard
label="今日内容"
value={fmt(stats?.today_posts ?? 0)}
sub="帖"
hint={`评论 ${fmt(stats?.today_comments ?? 0)} · 注册 ${fmt(stats?.today_users ?? 0)}`}
icon={<FileText size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="在线"
value={fmt(stats?.online ?? 0)}
hint={`峰值 ${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>
);
}