Files
jiang13-bbs/frontend/components/admin/AdminShell.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

592 lines
17 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, useRouter } from "next/navigation";
import { useEffect, useRef, useState, useCallback } from "react";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Megaphone,
Palette,
ShieldCheck,
Coins,
Globe,
FolderOpen,
Menu,
MessageCircle,
X,
ArrowLeft,
LogOut,
User as UserIcon,
Settings,
ChevronDown,
FileText,
CircleDollarSign,
Columns3,
PanelLeft,
BarChart3,
Award,
Layers,
Images,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
import {
canModerateAny,
canAccessAdminMessages,
isAdminOrAbove,
isOwner,
isStaff,
isSuperOrOwner,
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { onForceLogout, onUserUpdate } from "@/lib/userEvents";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import NotificationBell from "@/components/NotificationBell";
import ThemeToggle from "@/components/ThemeToggle";
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
exact?: boolean;
visible: (u: RoleUserLike) => boolean;
pendingBadge?: boolean;
}
interface NavGroup {
title: string;
items: NavItem[];
}
// 分组导航:概览 → 内容 → 成员与经济 → 外观展示 → 系统
const NAV_GROUPS: NavGroup[] = [
{
title: "概览",
items: [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
{ href: "/admin/analytics", label: "访问统计", icon: BarChart3, visible: (u) => isSuperOrOwner(u.role) },
],
},
{
title: "内容",
items: [
{
href: "/admin/boards",
label: "板块管理",
icon: Columns3,
visible: (u) => isOwner(u.role),
},
{
href: "/admin/content",
label: "内容管理",
icon: FolderOpen,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/messages",
label: "消息管理",
icon: MessageCircle,
visible: (u) => canAccessAdminMessages(u),
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/pages",
label: "单页管理",
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "成员与经济",
items: [
{
href: "/admin/users",
label: "用户管理",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/points",
label: "积分管理",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/levels",
label: "等级管理",
icon: Layers,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/badges",
label: "徽章管理",
icon: Award,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "外观展示",
items: [
{
href: "/admin/appearance",
label: "外观",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/sidebar",
label: "侧栏管理",
icon: PanelLeft,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/media",
label: "媒体库",
icon: Images,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
{
title: "系统",
items: [
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];
function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
if (!enabled) return;
let alive = true;
const load = () => {
apiAdminPendingCounts()
.then((c) => {
if (alive) setCounts(c);
})
.catch(() => {});
};
load();
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
const offVisible = onDocumentVisible(load);
return () => {
alive = false;
off();
offVisible();
};
}, [enabled]);
return counts.posts + counts.comments;
}
function PendingBadge({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
className="ml-auto shrink-0 min-w-[20px] h-5 px-1.5 rounded-full inline-flex items-center justify-center text-[10.5px] font-medium tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
aria-label={`${count} 条待审核`}
>
{count > 99 ? "99+" : count}
</span>
);
}
function visibleGroups(user: RoleUserLike) {
return NAV_GROUPS.map((g) => ({
...g,
items: g.items.filter((i) => i.visible(user)),
})).filter((g) => g.items.length > 0);
}
function flatItems(user: RoleUserLike) {
return visibleGroups(user).flatMap((g) => g.items);
}
function AdminBrand({ siteName }: { siteName: string }) {
return (
<div className="admin-rail-brand">
<span className="admin-rail-brand-mark" aria-hidden>
<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)" }}>
{siteName}
</span>
<span
className="block text-[10.5px] mt-0.5 tracking-[0.12em] uppercase"
style={{ color: "var(--ink-3)" }}
>
J13 ADMIN
</span>
</span>
</div>
);
}
function AdminRailNav({
user,
onNavigate,
}: {
user: User;
onNavigate?: () => void;
}) {
const pathname = usePathname();
const groups = visibleGroups(user);
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
return (
<nav className="admin-rail-nav" aria-label="后台导航">
{groups.map((group) => (
<div key={group.title} className="admin-rail-group">
<p className="admin-rail-group-title">{group.title}</p>
<div className="flex flex-col gap-0.5">
{group.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}
onClick={onNavigate}
className={`admin-rail-link${active ? " is-active" : ""}`}
>
<Icon size={16} className="shrink-0" aria-hidden />
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
</div>
</div>
))}
</nav>
);
}
function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="admin-topbar-user"
style={{
borderColor: open ? "var(--accent)" : "var(--line)",
background: open ? "var(--accent-soft)" : "var(--panel)",
}}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
<span className="hidden sm:flex flex-col items-start min-w-0 leading-tight">
<span
className="text-[13px] font-medium max-w-[100px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
>
{user.nickname}
</span>
</span>
<span className="hidden md:inline-flex shrink-0">
<RoleBadge role={user.role} boardIds={user.board_ids} size={10} />
</span>
<ChevronDown
size={14}
className="shrink-0 transition-transform duration-200"
style={{
color: "var(--ink-3)",
transform: open ? "rotate(180deg)" : "none",
}}
/>
</button>
{open && (
<div
role="menu"
className="absolute right-0 mt-2 w-60 overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<div
className="flex items-center gap-3 px-4 py-3.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
<div className="min-w-0">
<p className="text-[14px] truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="meta text-xs truncate">@{user.username}</p>
</div>
</div>
<div className="p-1.5">
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<button
type="button"
onClick={() => {
setOpen(false);
onLogout();
}}
className={itemCls}
role="menuitem"
style={{ color: "var(--danger)" }}
>
<LogOut size={15} /> 退出登录
</button>
</div>
</div>
)}
</div>
);
}
/**
* 后台独立壳:全高品牌侧栏 + 细顶栏 + 可滚动主区。
* 不依赖公开站 Header/Footer;移动端用抽屉打开同源侧栏。
*/
export default function AdminShell({
user: initialUser,
siteName,
initialTheme = "light",
initialUnread = 0,
children,
}: {
user: User;
siteName: string;
initialTheme?: "light" | "dark";
initialUnread?: number;
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState(initialUser);
// 移动抽屉三态:closing 期间播放退场动画,结束后卸载
const [drawer, setDrawer] = useState<"closed" | "open" | "closing">("closed");
const drawerCloseBtnRef = useRef<HTMLButtonElement>(null);
const menuBtnRef = useRef<HTMLButtonElement>(null);
const wasDrawerOpenRef = useRef(false);
const brand = siteName.trim() || "姜十三论坛";
const drawerOpen = drawer !== "closed";
const closeDrawer = useCallback(() => {
setDrawer((prev) => {
if (prev !== "open") return prev;
// 减少动态偏好:跳过退场动画直接卸载
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return "closed";
return "closing";
});
}, []);
useEffect(() => {
setUser(initialUser);
}, [initialUser]);
useEffect(() => onUserUpdate((patch) => {
setUser((prev) => ({ ...prev, ...patch }));
}), []);
useEffect(() => onForceLogout(() => {
setDrawer("closed");
apiLogout()
.catch(() => {})
.finally(() => {
router.push("/");
router.refresh();
});
}), [router]);
// 路由切换时关闭移动抽屉
useEffect(() => {
closeDrawer();
}, [pathname, closeDrawer]);
// 退场动画结束后卸载;timeout 兜底防 animationend 丢失
useEffect(() => {
if (drawer !== "closing") return;
const t = setTimeout(() => setDrawer("closed"), 240);
return () => clearTimeout(t);
}, [drawer]);
// 抽屉打开时聚焦关闭按钮;关闭后焦点归还顶栏汉堡按钮
useEffect(() => {
if (drawer === "open") {
wasDrawerOpenRef.current = true;
drawerCloseBtnRef.current?.focus();
} else if (drawer === "closed" && wasDrawerOpenRef.current) {
wasDrawerOpenRef.current = false;
menuBtnRef.current?.focus();
}
}, [drawer]);
// 抽屉打开时监听 Esc 关闭
useEffect(() => {
if (drawer !== "open") return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeDrawer();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [drawer, closeDrawer]);
const handleLogout = async () => {
try {
await apiLogout();
} catch {
/* ignore */
}
router.push("/");
router.refresh();
};
const iconBtn =
"w-10 h-10 flex items-center justify-center rounded-full transition-colors";
const iconBtnStyle = {
border: "1px solid var(--line)",
background: "var(--panel)",
color: "var(--ink-2)",
} as const;
const railInner = (
<>
<AdminBrand siteName={brand} />
<AdminRailNav user={user} onNavigate={closeDrawer} />
</>
);
return (
<div data-admin-viewport className="admin-shell">
{/* 桌面侧栏:宽度断点由 CSS 控制,避免自定义 display 盖掉 Tailwind hidden */}
<aside className="admin-rail admin-rail-desktop" aria-label="管理侧栏">
{railInner}
</aside>
{/* 移动抽屉 */}
{drawer !== "closed" && (
<div
className={`admin-drawer lg:hidden${drawer === "closing" ? " is-closing" : ""}`}
role="dialog"
aria-modal="true"
aria-label="后台导航"
>
<button
type="button"
className="admin-drawer-backdrop"
aria-label="关闭导航"
onClick={closeDrawer}
/>
<aside className="admin-rail admin-drawer-panel">
<div className="flex items-center justify-between gap-2 mb-1">
<AdminBrand siteName={brand} />
<button
type="button"
ref={drawerCloseBtnRef}
className={iconBtn}
style={iconBtnStyle}
aria-label="关闭导航"
onClick={closeDrawer}
>
<X size={18} />
</button>
</div>
<AdminRailNav user={user} onNavigate={closeDrawer} />
</aside>
</div>
)}
<div className="admin-main-col">
<header className="admin-topbar">
<div className="admin-topbar-left">
<button
type="button"
ref={menuBtnRef}
className={`${iconBtn} lg:hidden`}
style={iconBtnStyle}
aria-label="打开导航"
aria-expanded={drawerOpen}
onClick={() => setDrawer("open")}
>
<Menu size={18} />
</button>
<Link href="/" className="admin-topbar-back">
<ArrowLeft size={15} aria-hidden />
返回站点
</Link>
</div>
<div className="admin-topbar-right">
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
<NotificationBell initialUnread={initialUnread} />
<AdminUserMenu user={user} onLogout={handleLogout} />
</div>
</header>
<main id="main" tabIndex={-1} className="admin-canvas outline-none">
{children}
</main>
</div>
</div>
);
}