新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
611 lines
17 KiB
TypeScript
611 lines
17 KiB
TypeScript
"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,
|
||
hasPerm,
|
||
isAdminOrAbove,
|
||
isOwner,
|
||
isStaff,
|
||
isSuperOrOwner,
|
||
PERMS,
|
||
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) => hasPerm(u, PERMS.ANNOUNCEMENTS),
|
||
},
|
||
{
|
||
href: "/admin/pages",
|
||
label: "单页管理",
|
||
icon: FileText,
|
||
visible: (u) => hasPerm(u, PERMS.PAGES),
|
||
},
|
||
{
|
||
href: "/admin/library",
|
||
label: "书库管理",
|
||
icon: BookOpen,
|
||
visible: (u) => hasPerm(u, PERMS.LIBRARY),
|
||
},
|
||
{
|
||
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) => hasPerm(u, PERMS.ADS),
|
||
},
|
||
],
|
||
},
|
||
{
|
||
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>
|
||
);
|
||
}
|