feat: 新增管理员用户管理功能与账号封禁体系
此提交完成了全站管理员后台核心功能开发: 1. 新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封 2. 重构认证链路,统一账号封禁错误码与处理逻辑 3. 新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态 4. 重构管理员后台路由与布局,统一权限校验逻辑 5. 优化通知轮询逻辑,页面切回前台时立即刷新未读计数 6. 合并原公告、外观管理页到统一后台面板
This commit is contained in:
169
frontend/app/admin/AdminNav.tsx
Normal file
169
frontend/app/admin/AdminNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 || ""} />;
|
||||
}
|
||||
|
||||
53
frontend/app/admin/layout.tsx
Normal file
53
frontend/app/admin/layout.tsx
Normal 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
323
frontend/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
872
frontend/app/admin/users/UsersAdmin.tsx
Normal file
872
frontend/app/admin/users/UsersAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
frontend/app/admin/users/page.tsx
Normal file
49
frontend/app/admin/users/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user