"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,
} 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/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/ads",
label: "推广与赞助",
icon: CircleDollarSign,
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),
},
],
},
{
title: "站点",
items: [
{
href: "/admin/boards",
label: "板块管理",
icon: Columns3,
visible: (u) => isOwner(u.role),
},
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/sidebar",
label: "侧栏管理",
icon: PanelLeft,
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(--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}
);
}