"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, BookOpen, CircleDollarSign, Columns3, PanelLeft, BarChart3, Award, Layers, Images, Link2, } 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"; import { useUrlStyle } from "@/components/SiteBrandProvider"; import { userPath } from "@/lib/urlStyle"; 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), }, { href: "/admin/library", label: "书库管理", icon: BookOpen, visible: (u) => isAdminOrAbove(u.role), }, { href: "/admin/friend-links", label: "友情链接", icon: Link2, visible: (u) => isSuperOrOwner(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 ( {count > 99 ? "99+" : count} ); } 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 (
{siteName} J13 ADMIN
); } 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 ( ); } function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { const urlStyle = useUrlStyle(); const [open, setOpen] = useState(false); const menuRef = useRef(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 (
{open && (

{user.nickname}

@{user.username}

setOpen(false)} className={itemCls} role="menuitem"> 个人主页 setOpen(false)} className={itemCls} role="menuitem"> 账号设置
)}
); } /** * 后台独立壳:全高品牌侧栏 + 细顶栏 + 可滚动主区。 * 不依赖公开站 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(null); const menuBtnRef = useRef(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 = ( <> ); return (
{/* 桌面侧栏:宽度断点由 CSS 控制,避免自定义 display 盖掉 Tailwind hidden */} {/* 移动抽屉 */} {drawer !== "closed" && (
)}
返回站点
{children}
); }