feat: 新增管理员用户管理功能与账号封禁体系

此提交完成了全站管理员后台核心功能开发:
1.  新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封
2.  重构认证链路,统一账号封禁错误码与处理逻辑
3.  新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态
4.  重构管理员后台路由与布局,统一权限校验逻辑
5.  优化通知轮询逻辑,页面切回前台时立即刷新未读计数
6.  合并原公告、外观管理页到统一后台面板
This commit is contained in:
2026-09-15 02:52:26 +08:00
parent 24c6dea38c
commit 6da4309453
20 changed files with 2248 additions and 105 deletions

View File

@@ -0,0 +1,169 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Megaphone,
Palette,
ShieldCheck,
ArrowLeft,
} from "lucide-react";
import type { User } from "@/lib/api";
import Avatar from "@/components/Avatar";
// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
const NAV_ITEMS: { href: string; label: string; icon: LucideIcon; exact?: boolean }[] = [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true },
{ href: "/admin/users", label: "用户管理", icon: Users },
{ href: "/admin/announcements", label: "公告管理", icon: Megaphone },
{ href: "/admin/appearance", label: "外观设置", icon: Palette },
];
function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
/**
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 管理员身份卡。
* 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
* 与 j13-bcard / 顶部 pill 保持一致。
*/
export function AdminSidebar({ user }: { user: User }) {
const pathname = usePathname();
return (
<div className="panel p-3">
{/* 品牌头 */}
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<ShieldCheck size={18} />
</span>
<span className="leading-tight min-w-0">
<span className="block text-[14.5px] font-extrabold tracking-tight truncate" style={{ color: "var(--ink)" }}>
管理面板
</span>
<span className="block text-[10.5px] font-semibold mt-0.5 tracking-wide" style={{ color: "var(--ink-3)" }}>
J13 ADMIN
</span>
</span>
</div>
{/* 导航 */}
<nav className="flex flex-col gap-1 pt-3 pb-2" aria-label="后台导航">
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
站点管理
</p>
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)" }
}
>
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
{item.label}
</Link>
);
})}
</nav>
{/* 返回前台 */}
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
<Link
href="/"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-semibold transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
>
<ArrowLeft size={16} className="shrink-0" style={{ color: "var(--ink-3)" }} />
返回站点
</Link>
</div>
{/* 管理员身份卡 */}
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
<div className="min-w-0 leading-tight">
<p className="text-[13px] font-bold truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="text-[11px] font-semibold truncate" style={{ color: "var(--ink-3)" }}>
@{user.username}
</p>
</div>
<span
className="ml-auto shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10.5px] font-bold leading-[1.6]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<ShieldCheck size={11} />
管理员
</span>
</div>
</div>
);
}
/**
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
*/
export function AdminNavMobile() {
const pathname = usePathname();
return (
<div
className="lg:hidden sticky z-30 -mx-4 sm:-mx-6 px-4 sm:px-6 py-2.5 mb-4"
style={{
top: 68,
background: "color-mix(in srgb, var(--bg) 88%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderBottom: "1px solid var(--line)",
}}
>
<nav className="flex items-center gap-2 overflow-x-auto" aria-label="后台导航">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }
}
>
<Icon size={14} />
{item.label}
</Link>
);
})}
<Link
href="/"
className="shrink-0 inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={{ color: "var(--ink-2)", background: "var(--panel)", border: "1px solid var(--line)" }}
>
<ArrowLeft size={14} />
返回站点
</Link>
</nav>
</div>
);
}

View File

@@ -1,41 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import AnnouncementAdmin from "./AnnouncementAdmin";
export const metadata: Metadata = {
title: "站点公告管理",
robots: { index: false, follow: false },
};
// 权限以 Go 端 RequireAdmin 为唯一判定;这里仅决定管理界面是否直出,
// 非管理员访问时 SSR 直接给出无权限提示(所有写接口仍由后端 403 兜底)。
export default async function AdminAnnouncementsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (me.user?.role !== "admin") {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">仅站点管理员可以发布和管理站点公告。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担;本页只负责内容直出
export default function AdminAnnouncementsPage() {
return <AnnouncementAdmin />;
}

View File

@@ -1,43 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { getPublicSettingsCached } from "@/lib/serverData";
import AppearanceAdmin from "./AppearanceAdmin";
export const metadata: Metadata = {
title: "外观设置",
robots: { index: false, follow: false },
};
// 站点外观(主题色)管理:权限以 Go 端 RequireAdmin 为唯一判定,
// 非管理员 SSR 直出无权限提示,写接口仍由后端 403 兜底。
// 管理员鉴权与 noindex 由 app/admin/layout.tsx 统一承担。
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
export default async function AdminAppearancePage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (me.user?.role !== "admin") {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">仅站点管理员可以配置站点外观。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
// 主题色为公开设置:当前已保存值随 SSR 直出,避免面板初始闪动
const { accent } = await getPublicSettingsCached();
return <AppearanceAdmin initialAccent={accent || ""} />;
}

View File

@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { AdminSidebar, AdminNavMobile } from "./AdminNav";
// 整个 /admin 树不进入索引(子页面无需重复声明)
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
// 权限以 Go 端 RequireAdmin 为唯一判定;这里仅决定管理界面是否直出,
// 非管理员访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (me.user?.role !== "admin") {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-6 lg:mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
);
}
return (
<div>
<AdminNavMobile />
<div className="flex items-start gap-6 xl:gap-7">
<aside className="hidden lg:block w-60 xl:w-64 shrink-0">
<div className="sticky" style={{ top: 84 }}>
<AdminSidebar user={me.user} />
</div>
</aside>
<section className="flex-1 min-w-0">{children}</section>
</div>
</div>
);
}

323
frontend/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,323 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import {
Users,
PenLine,
MessageCircle,
Activity,
Megaphone,
Palette,
ChevronRight,
UserPlus,
LayoutGrid,
} from "lucide-react";
import { fetchOverview, type OverviewResponse } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import Avatar from "@/components/Avatar";
import { formatRelative } from "@/lib/format";
export const metadata: Metadata = {
title: "管理仪表盘",
};
// 精确千分位(管理员视角需要真实数值,不用首页的紧凑 K/M)
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 "晚上好";
}
// 统计 Bento 卡:总量 + 今日增量,语义色与全站令牌一致
function StatCard({
label,
value,
foot,
icon,
color,
soft,
}: {
label: string;
value: string;
foot: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
<div className="panel p-4 sm:p-5">
<div className="flex items-start justify-between gap-2">
<p className="text-[12.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
</div>
<p
className="mt-3 text-[26px] sm:text-[30px] font-extrabold tabular-nums leading-none tracking-tight"
style={{ color: "var(--ink)" }}
>
{value}
</p>
<p className="mt-2 text-[11.5px] sm:text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>
{foot}
</p>
</div>
);
}
// 快捷管理入口:整卡可点(stretch-link),图标 + 标题 + 描述 + 箭头
function QuickLink({
href,
icon,
title,
desc,
soft,
color,
}: {
href: string;
icon: React.ReactNode;
title: string;
desc: string;
soft: string;
color: string;
}) {
return (
<Link href={href} className="panel hover-lift relative block p-5 group">
<div className="flex items-start gap-3.5">
<span
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-extrabold" style={{ color: "var(--ink)" }}>
{title}
</p>
<p className="meta mt-1 text-[12.5px] leading-relaxed">{desc}</p>
</div>
<ChevronRight
size={17}
className="shrink-0 mt-1 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</div>
</Link>
);
}
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>
);
}
export default async function AdminDashboardPage() {
const cookie = authCookieHeader(await cookies());
// 与 layout 的 /me 请求共享 React cache;概览失败降级,不阻塞仪表盘骨架
const [me, overview] = await Promise.all([
getMeCached(cookie || undefined),
fetchOverview(cookie).catch(() => 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>
{/* 统计 Bento */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<StatCard
label="注册用户"
value={stats ? fmt(stats.users) : "—"}
foot={stats ? `今日新增 +${fmt(stats.today_users)}` : "统计暂不可用"}
icon={<Users size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<StatCard
label="话题总数"
value={stats ? fmt(stats.posts) : "—"}
foot={stats ? `今日新发 +${fmt(stats.today_posts)}` : "统计暂不可用"}
icon={<PenLine size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<StatCard
label="回复总数"
value={stats ? fmt(stats.comments) : "—"}
foot={stats ? `今日新增 +${fmt(stats.today_comments)}` : "统计暂不可用"}
icon={<MessageCircle size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<StatCard
label="在线用户"
value={stats ? fmt(stats.online) : "—"}
foot={stats ? `近 5 分钟活跃 · 峰值 ${fmt(stats.peak_online)}` : "统计暂不可用"}
icon={<Activity size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
</div>
{/* 快捷入口 */}
<h2 className="sr-only">快捷管理</h2>
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4 mt-4">
<QuickLink
href="/admin/users"
icon={<Users size={18} />}
title="用户管理"
desc="查看全站成员,授予管理员权限或封禁违规账号"
soft="var(--accent-soft)"
color="var(--accent)"
/>
<QuickLink
href="/admin/announcements"
icon={<Megaphone size={18} />}
title="公告管理"
desc="发布、编辑与下线站点公告,支持标签颜色与草稿状态"
soft="var(--clay-soft)"
color="var(--clay)"
/>
<QuickLink
href="/admin/appearance"
icon={<Palette size={18} />}
title="外观设置"
desc="配置全站主题强调色,实时预览后保存并对所有用户生效"
soft="var(--gold-soft)"
color="var(--gold)"
/>
</div>
{/* 成员与板块 */}
<div className="grid lg:grid-cols-2 gap-4 mt-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>
))
)}
</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>
);
}

View File

@@ -0,0 +1,872 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
Ban,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
RotateCcw,
Search,
ShieldCheck,
ShieldOff,
Users as UsersIcon,
UserPlus,
X,
} from "lucide-react";
import {
apiAdminListUsers,
apiAdminSetUserBan,
apiAdminSetUserRole,
type AdminUser,
type AdminUserSummary,
type AdminUsersResponse,
} from "@/lib/api";
import { formatDate, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
export type UserFilter = "all" | "admin" | "banned";
const PAGE_SIZE = 20;
const FILTER_TABS: { key: UserFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "admin", label: "管理员" },
{ key: "banned", label: "已封禁" },
];
function filterToParams(filter: UserFilter): { role?: string; status?: string } {
if (filter === "admin") return { role: "admin" };
if (filter === "banned") return { status: "banned" };
return {};
}
// 待二次确认的危险操作种类
export type PendingKind = "promote" | "demote" | "ban" | "unban";
export interface PendingAction {
kind: PendingKind;
user: AdminUser;
}
// 确认弹窗的文案与视觉配置(说明需拼入用户名,故 desc 为函数)
const CONFIRM_CONFIG: Record<
PendingKind,
{
title: string;
desc: (u: AdminUser) => string;
confirmText: string;
tone: "accent" | "danger" | "ok";
icon: React.ReactNode;
}
> = {
promote: {
title: "设为管理员",
desc: (u) =>
`@${u.username} 将获得管理面板访问权限,可管理公告、外观设置与用户账号。`,
confirmText: "设为管理员",
tone: "accent",
icon: <ShieldCheck size={23} />,
},
demote: {
title: "取消管理员",
desc: (u) =>
`将管理员 @${u.username} 降级为普通用户。操作后该账号会被立即强制下线,需要重新登录。`,
confirmText: "确认降级",
tone: "danger",
icon: <ShieldOff size={23} />,
},
ban: {
title: "封禁账号",
desc: (u) =>
`封禁后 @${u.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`,
confirmText: "确认封禁",
tone: "danger",
icon: <Ban size={23} />,
},
unban: {
title: "解除封禁",
desc: (u) => `解除后 @${u.username} 可以重新登录,并恢复发帖、回复等全部功能。`,
confirmText: "解除封禁",
tone: "ok",
icon: <RotateCcw size={21} />,
},
};
// 汇总小卡
function MiniStat({
label,
value,
sub,
icon,
color,
soft,
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
<div className="panel p-4 flex items-center gap-3">
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: soft, color }}
aria-hidden="true"
>
{icon}
</span>
<div className="min-w-0 leading-tight">
<p className="text-[11.5px] font-semibold truncate" style={{ color: "var(--ink-3)" }}>
{label}
</p>
<p className="text-[19px] font-extrabold tabular-nums" style={{ color: "var(--ink)" }}>
{value}
{sub && (
<span className="ml-1.5 text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
{sub}
</span>
)}
</p>
</div>
</div>
);
}
// 角色/状态徽章
function Badge({
color,
soft,
icon,
children,
}: {
color: string;
soft: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-bold leading-[1.6] whitespace-nowrap"
style={{ background: soft, color }}
>
{icon}
{children}
</span>
);
}
export default function UsersAdmin({
initial,
initError,
initialQuery,
currentUserId,
}: {
initial: AdminUsersResponse | null;
initError: string;
initialQuery: { q: string; filter: UserFilter; page: number };
currentUserId: number;
}) {
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
const [summary, setSummary] = useState<AdminUserSummary>(
initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
);
const [total, setTotal] = useState(initial?.total ?? 0);
const [page, setPage] = useState(initialQuery.page);
const [filter, setFilter] = useState<UserFilter>(initialQuery.filter);
const [keyword, setKeyword] = useState(initialQuery.q);
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
// 待确认的危险操作(提权/降级/封禁/解封);null 表示弹窗关闭
const [pending, setPending] = useState<PendingAction | null>(null);
// 防止快速翻页/输入时旧响应覆盖新结果
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const urlFirstRun = useRef(true);
const router = useRouter();
const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
const seq = ++seqRef.current;
setLoading(true);
try {
const res = await apiAdminListUsers({
page: opts.page,
size: PAGE_SIZE,
q: opts.q || undefined,
...filterToParams(opts.filter),
});
if (seq !== seqRef.current) return;
setUsers(res.users);
setSummary(res.summary);
setTotal(res.total);
setError("");
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : "获取用户列表失败");
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, []);
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const q = keyword.trim();
if (q !== appliedQ) {
setAppliedQ(q);
setPage(1);
load({ page: 1, filter, q });
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
useEffect(() => {
if (urlFirstRun.current) {
urlFirstRun.current = false;
return;
}
const p = new URLSearchParams();
if (appliedQ) p.set("q", appliedQ);
if (filter !== "all") p.set("filter", filter);
if (page > 1) p.set("page", String(page));
const qs = p.toString();
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = keyword.trim();
if (q === appliedQ) return;
setAppliedQ(q);
setPage(1);
load({ page: 1, filter, q });
};
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
setAppliedQ("");
setPage(1);
load({ page: 1, filter, q: "" });
}
};
const changeFilter = (f: UserFilter) => {
if (f === filter) return;
setFilter(f);
setPage(1);
load({ page: 1, filter: f, q: appliedQ });
};
const goPage = (p: number) => {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const next = Math.min(Math.max(1, p), totalPages);
if (next === page) return;
setPage(next);
load({ page: next, filter, q: appliedQ });
};
// 危险操作不再使用浏览器原生 confirm,统一走站点风格的二次确认弹窗
const requestRoleChange = (u: AdminUser) => {
setPending({ kind: u.role === "admin" ? "demote" : "promote", user: u });
};
const requestBanChange = (u: AdminUser) => {
setPending({ kind: u.banned ? "unban" : "ban", user: u });
};
// 确认弹窗中点击"确认"后执行;成功关闭弹窗并重拉列表,
// 保证筛选结果集与汇总计数一致;失败保留弹窗,错误走 toast
const confirmPending = async () => {
const action = pending;
if (!action) return;
const { kind, user: u } = action;
setActingId(u.id);
try {
if (kind === "promote" || kind === "demote") {
await apiAdminSetUserRole(u.id, kind === "promote" ? "admin" : "user");
toast(kind === "promote" ? "已设为管理员" : "已取消管理员", "ok");
} else {
await apiAdminSetUserBan(u.id, kind === "ban");
toast(kind === "ban" ? "已封禁" : "已解封", "ok");
}
setPending(null);
await load({ page, filter, q: appliedQ });
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActingId(null);
}
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tabCount = (f: UserFilter) =>
f === "all" ? summary.total : f === "admin" ? summary.admins : summary.banned;
// 有关键词或非默认筛选时,空态文案与"无成员"区分
const hasFilter = appliedQ !== "" || filter !== "all";
// 行内操作按钮(圆形幽灵按钮,32px)
const ghostBtn =
"w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
const renderActions = (u: AdminUser, isSelf: boolean) => {
if (isSelf) {
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold whitespace-nowrap"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
);
}
const busy = actingId === u.id;
return (
<div className="flex items-center gap-1 justify-end">
<Link
href={`/u/${u.id}`}
title="查看个人主页"
aria-label={`查看 ${u.nickname} 的个人主页`}
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
>
<ExternalLink size={15} />
</Link>
<button
type="button"
title={u.role === "admin" ? "取消管理员" : "设为管理员"}
aria-label={u.role === "admin" ? `取消 ${u.username} 的管理员` : `将 ${u.username} 设为管理员`}
disabled={busy}
onClick={() => requestRoleChange(u)}
className={ghostBtn}
style={u.role === "admin" ? { color: "var(--accent)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent-soft)";
e.currentTarget.style.color = "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.role === "admin" ? "var(--accent)" : "var(--ink-3)";
}}
>
{busy ? <Loader2 size={15} className="animate-spin" /> : u.role === "admin" ? <ShieldOff size={15} /> : <ShieldCheck size={15} />}
</button>
<button
type="button"
title={u.banned ? "解封" : "封禁"}
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
disabled={busy}
onClick={() => requestBanChange(u)}
className={ghostBtn}
style={u.banned ? { color: "var(--danger)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--danger-soft)";
e.currentTarget.style.color = "var(--danger)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--ink-3)";
}}
>
{u.banned ? <RotateCcw size={15} /> : <Ban size={15} />}
</button>
</div>
);
};
const renderBadges = (u: AdminUser) => (
<div className="flex items-center gap-1.5 flex-wrap">
{u.role === "admin" ? (
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
管理员
</Badge>
) : (
<Badge color="var(--ink-3)" soft="var(--panel-2)" icon={<UsersIcon size={11} />}>
普通用户
</Badge>
)}
{u.banned && (
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
已封禁
</Badge>
)}
</div>
);
// 桌面端列模板(与表头一致)
const rowGrid =
"hidden md:grid md:grid-cols-[minmax(0,1.7fr)_88px_92px_104px_100px_108px_150px] gap-3 items-center px-4 sm:px-5 py-3.5";
return (
<div>
{/* 页头 */}
<div className="mb-5">
<h1
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
style={{ color: "var(--ink)" }}
>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UsersIcon size={18} />
</span>
用户管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">检索全部成员,管理管理员角色与封禁状态;操作即时生效</p>
</div>
{/* 汇总 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<MiniStat
label="注册用户"
value={String(summary.total)}
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
icon={<UsersIcon size={16} />}
color="var(--accent)"
soft="var(--accent-soft)"
/>
<MiniStat
label="管理员"
value={String(summary.admins)}
icon={<ShieldCheck size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<MiniStat
label="已封禁"
value={String(summary.banned)}
icon={<Ban size={16} />}
color="var(--danger)"
soft="var(--danger-soft)"
/>
<MiniStat
label="当前筛选"
value={String(total)}
sub="人"
icon={<UserPlus size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
</div>
{/* 搜索 + 筛选 */}
<div className="panel p-3 sm:p-4 mb-4">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<form onSubmit={submitSearch} className="relative flex-1" role="search">
<Search
size={15}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索用户名、昵称或邮箱…"
aria-label="搜索用户"
className="field pl-10 pr-9 py-2.5"
/>
{keyword && (
<button
type="button"
onClick={clearSearch}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
)}
</form>
<div className="flex items-center gap-1.5 flex-wrap" role="group" aria-label="用户筛选">
{FILTER_TABS.map((t) => {
const active = filter === t.key;
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => changeFilter(t.key)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] font-semibold transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)", background: "var(--panel-2)" }
}
>
{t.label}
<span
className="text-[11px] tabular-nums rounded-full px-1.5 leading-[1.7]"
style={{
background: active ? "var(--panel)" : "var(--panel)",
color: active ? "var(--nav-active-ink)" : "var(--ink-3)",
}}
>
{tabCount(t.key)}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* 用户列表 */}
<div className="panel overflow-hidden">
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2 flex-wrap"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
{appliedQ ? (
<>
搜索“<span style={{ color: "var(--accent)" }}>{appliedQ}</span>”
</>
) : filter === "admin" ? (
"管理员"
) : filter === "banned" ? (
"已封禁成员"
) : (
"全部成员"
)}
</span>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
共 {total} 人
</span>
</div>
{/* 桌面表头 */}
{users.length > 0 && (
<div
className={`${rowGrid} text-[11.5px] font-semibold uppercase tracking-wide`}
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span>成员</span>
<span>角色</span>
<span>状态</span>
<span>内容</span>
<span>注册</span>
<span>最近活跃</span>
<span className="text-right">操作</span>
</div>
)}
<div aria-busy={loading} style={{ opacity: loading ? 0.55 : 1, transition: "opacity .15s" }}>
{error ? (
<p className="alert-error m-4">{error}</p>
) : loading && users.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
<Loader2 size={16} className="animate-spin" /> 加载中…
</div>
) : users.length === 0 ? (
<div className="text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<UsersIcon size={22} />
</span>
<p className="font-bold text-[14.5px]" style={{ color: "var(--ink)" }}>
{hasFilter ? "没有匹配的成员" : "暂无成员"}
</p>
<p className="meta mt-1.5 text-[12.5px]">
{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
</p>
</div>
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
return (
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
{/* 桌面行 */}
<div className={rowGrid}>
<div className="flex items-center gap-3 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
</div>
<div>
{u.role === "admin" ? (
<Badge color="var(--accent)" soft="var(--accent-soft)" icon={<ShieldCheck size={11} />}>
管理员
</Badge>
) : (
<span className="text-[12.5px] font-semibold" style={{ color: "var(--ink-3)" }}>
普通用户
</span>
)}
</div>
<div>
{u.banned ? (
<Badge color="var(--danger)" soft="var(--danger-soft)" icon={<Ban size={11} />}>
已封禁
</Badge>
) : (
<span className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold" style={{ color: "var(--ok)" }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: "var(--ok)" }} aria-hidden="true" />
正常
</span>
)}
</div>
<p className="text-[12.5px] font-semibold tabular-nums" style={{ color: "var(--ink-2)" }}>
帖 {u.post_count}
<span className="mx-1" style={{ color: "var(--line-2)" }}>·</span>
评 {u.comment_count}
</p>
<p className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</p>
<p className="text-[12px]" style={{ color: "var(--ink-3)" }}>
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未活跃"}
</p>
<div className="flex justify-end">{renderActions(u, isSelf)}</div>
</div>
{/* 移动卡片 */}
<div className="md:hidden px-4 py-3.5">
<div className="flex items-center gap-3">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
<div className="min-w-0 flex-1 leading-tight">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-bold truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
{renderBadges(u)}
{isSelf && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] font-bold leading-[1.55]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
)}
</div>
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
</div>
</div>
<p className="mt-2.5 text-[11.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} · 活跃{" "}
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未"}
</p>
{!isSelf && (
<div className="flex items-center gap-2 mt-3">
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
<ExternalLink size={13} /> 主页
</Link>
<button
type="button"
disabled={actingId === u.id}
onClick={() => requestRoleChange(u)}
className="btn btn-line btn-sm"
style={u.role === "admin" ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
{actingId === u.id ? (
<Loader2 size={13} className="animate-spin" />
) : u.role === "admin" ? (
<ShieldOff size={13} />
) : (
<ShieldCheck size={13} />
)}
{u.role === "admin" ? "取消管理员" : "设为管理员"}
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => requestBanChange(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
</button>
</div>
)}
</div>
</div>
);
})
)}
</div>
{/* 分页 */}
{total > 0 && (
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between gap-2"
style={{ borderTop: "1px solid var(--line)" }}
>
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
第 {page} / {totalPages} 页 · 共 {total} 人
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => goPage(page - 1)}
disabled={page <= 1 || loading}
className="btn btn-line btn-sm"
>
<ChevronLeft size={14} /> 上一页
</button>
<button
type="button"
onClick={() => goPage(page + 1)}
disabled={page >= totalPages || loading}
className="btn btn-line btn-sm"
>
下一页 <ChevronRight size={14} />
</button>
</div>
</div>
)}
</div>
{/* 危险操作二次确认(替代浏览器原生 confirm,与站点视觉语言一致) */}
<ActionConfirmDialog
action={pending}
busy={actingId === pending?.user.id}
onCancel={() => setPending(null)}
onConfirm={confirmPending}
/>
</div>
);
}
// 危险操作确认弹窗:遮罩 + 居中卡片;ESC/点遮罩等同取消,执行中锁定所有关闭途径
function ActionConfirmDialog({
action,
busy,
onCancel,
onConfirm,
}: {
action: PendingAction | null;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
useEffect(() => {
if (!action) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [action, busy, onCancel]);
if (!action) return null;
const cfg = CONFIRM_CONFIG[action.kind];
const iconColor =
cfg.tone === "danger" ? "var(--danger)" : cfg.tone === "ok" ? "var(--ok)" : "var(--accent)";
const iconSoft =
cfg.tone === "danger"
? "var(--danger-soft)"
: cfg.tone === "ok"
? "var(--ok-soft)"
: "var(--accent-soft)";
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !busy && onCancel()}
>
<div
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="user-action-title"
aria-describedby="user-action-desc"
>
<div className="flex items-start gap-4">
<span
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
style={{ background: iconSoft, color: iconColor }}
aria-hidden="true"
>
{cfg.icon}
</span>
<div className="min-w-0 flex-1">
<h3
id="user-action-title"
className="text-[16px] font-extrabold tracking-tight"
style={{ color: "var(--ink)" }}
>
{cfg.title}
</h3>
<p id="user-action-desc" className="meta mt-1.5 text-[13px] leading-relaxed">
{cfg.desc(action.user)}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
type="button"
className="btn btn-line"
onClick={onCancel}
disabled={busy}
autoFocus
>
取消
</button>
{cfg.tone === "danger" ? (
<button
type="button"
className="btn btn-danger"
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
{cfg.confirmText}
</button>
) : (
<button
type="button"
className="btn"
style={
cfg.tone === "ok"
? { background: "var(--ok-soft)", color: "var(--ok)" }
: undefined
}
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
{cfg.confirmText}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminUsers, type AdminUsersResponse } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import UsersAdmin, { type UserFilter } from "./UsersAdmin";
export const metadata: Metadata = {
title: "用户管理",
};
interface UsersPageProps {
searchParams: Promise<{ q?: string; filter?: string; page?: string }>;
}
// 筛选项映射为后端查询参数:管理员走 role,封禁走 status,其余不过滤
function filterToQuery(filter: UserFilter): { role?: string; status?: string } {
if (filter === "admin") return { role: "admin" };
if (filter === "banned") return { status: "banned" };
return {};
}
// 管理员鉴权由 app/admin/layout.tsx 统一承担;列表首屏 SSR 直出,F5 无骨架闪动
export default async function AdminUsersPage({ searchParams }: UsersPageProps) {
const sp = await searchParams;
const q = sp.q?.trim() || "";
const filter: UserFilter = sp.filter === "admin" || sp.filter === "banned" ? sp.filter : "all";
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
let initial: AdminUsersResponse | null = null;
let initError = "";
try {
initial = await fetchAdminUsers({ page, q, ...filterToQuery(filter) }, cookie || undefined);
} catch (e) {
initError = e instanceof Error ? e.message : "获取用户列表失败";
}
return (
<UsersAdmin
initial={initial}
initError={initError}
initialQuery={{ q, filter, page }}
currentUserId={me.user?.id ?? 0}
/>
);
}