"use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef, useState } 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, } from "lucide-react"; import type { User } from "@/lib/api"; import { apiAdminPendingCounts, apiLogout } from "@/lib/api"; import { canModerateAny, isAdminOrAbove, 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 }, ], }, { title: "内容", items: [ { href: "/admin/content", label: "内容管理", icon: FolderOpen, visible: (u) => canModerateAny(u), pendingBadge: true, }, { href: "/admin/messages", label: "消息管理", icon: MessageCircle, visible: (u) => isStaff(u.role), }, { href: "/admin/announcements", label: "公告管理", icon: Megaphone, visible: (u) => isAdminOrAbove(u.role), }, ], }, { title: "成员与经济", items: [ { href: "/admin/users", label: "用户管理", icon: Users, visible: (u) => isSuperOrOwner(u.role), }, { href: "/admin/economy", label: "积分看板", icon: Coins, visible: (u) => isAdminOrAbove(u.role), }, ], }, { title: "站点", items: [ { href: "/admin/settings", label: "站点设置", icon: Globe, visible: (u) => isSuperOrOwner(u.role), }, { href: "/admin/appearance", label: "外观", icon: Palette, 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 [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(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"; 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); const [drawerOpen, setDrawerOpen] = useState(false); const brand = siteName.trim() || "姜十三论坛"; useEffect(() => { setUser(initialUser); }, [initialUser]); useEffect(() => onUserUpdate((patch) => { setUser((prev) => ({ ...prev, ...patch })); }), []); useEffect(() => onForceLogout(() => { setDrawerOpen(false); apiLogout() .catch(() => {}) .finally(() => { router.push("/"); router.refresh(); }); }), [router]); // 路由切换时关闭移动抽屉 useEffect(() => { setDrawerOpen(false); }, [pathname]); // 抽屉打开时锁 body 滚(壳层本身已 overflow:hidden,这里防焦点穿透) useEffect(() => { if (!drawerOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setDrawerOpen(false); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [drawerOpen]); 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 = ( <> setDrawerOpen(false)} /> ); return (
{/* 桌面侧栏:宽度断点由 CSS 控制,避免自定义 display 盖掉 Tailwind hidden */} {/* 移动抽屉 */} {drawerOpen && (
setDrawerOpen(false)} />
)}
返回站点
{children}
); }