Files
jiang13-bbs/frontend/app/admin/page.tsx
freefire a0a9aeed66 feat(admin): 合并内容审核到内容管理,并固定后台侧栏
待审与内容库同一页处理;筛选切换去掉加载占位,避免页面闪动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 00:10:23 +08:00

297 lines
10 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,
LayoutDashboard,
Coins,
} from "lucide-react";
import {
fetchOverview,
fetchAdminPendingCounts,
type PendingCounts,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
import Avatar from "@/components/Avatar";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
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-medium 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={
counts.posts > 0 || counts.comments === 0
? "/admin/content?status=pending"
: "/admin/content?status=pending&kind=comments"
}
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
>
<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-semibold" style={{ color: "var(--ink)" }}>
待审内容
</p>
{idle ? (
<span
className="admin-status is-muted"
>
暂无待审
</span>
) : (
<span className="admin-status is-ok tabular-nums">
{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 />
待审帖子
<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 />
待审评论
<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 canEconomy = isAdminOrAbove(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>
<AdminPageHeader
icon={LayoutDashboard}
title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`}
description={`${dateLabel} · 站点管理`}
/>
{canMod && pendingCounts && <PendingWorkPanel counts={pendingCounts} />}
{canEconomy && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<Link href="/admin/economy" className="block hover-lift rounded-[var(--r)]">
<AdminStatCard
label="积分看板"
value="查看"
hint="余额合计、悬赏托管与流水"
icon={<Coins size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
</Link>
{stats && (
<AdminStatCard
label="注册成员"
value={fmt(stats.users)}
hint="全站用户总量"
icon={<Users size={16} />}
/>
)}
</div>
)}
<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] 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]"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden />
{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] transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden />
前往用户权限
<ChevronRight size={14} aria-hidden />
</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
>
<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] truncate" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span
className="text-[12px] 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
>
<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>
);
}