Files
jiang13-bbs/frontend/components/admin/AdminShell.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

609 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,
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 (
<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 urlStyle = useUrlStyle();
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={userPath(urlStyle, 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>
);
}