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

170 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}