Files
jiang13-bbs/frontend/components/admin/AdminShell.tsx
freefire a83276060f feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 19:52:06 +08:00

501 lines
14 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 } 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,
} 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),
},
{
href: "/admin/pages",
label: "单页",
icon: FileText,
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 (
<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 [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(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
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={`/u/${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);
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 = (
<>
<AdminBrand siteName={brand} />
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
</>
);
return (
<div data-admin-viewport className="admin-shell">
{/* 桌面侧栏:宽度断点由 CSS 控制,避免自定义 display 盖掉 Tailwind hidden */}
<aside className="admin-rail admin-rail-desktop" aria-label="管理侧栏">
{railInner}
</aside>
{/* 移动抽屉 */}
{drawerOpen && (
<div className="admin-drawer lg:hidden" role="dialog" aria-modal="true" aria-label="后台导航">
<button
type="button"
className="admin-drawer-backdrop"
aria-label="关闭导航"
onClick={() => setDrawerOpen(false)}
/>
<aside className="admin-rail admin-drawer-panel">
<div className="flex items-center justify-between gap-2 mb-1">
<AdminBrand siteName={brand} />
<button
type="button"
className={iconBtn}
style={iconBtnStyle}
aria-label="关闭导航"
onClick={() => setDrawerOpen(false)}
>
<X size={18} />
</button>
</div>
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
</aside>
</div>
)}
<div className="admin-main-col">
<header className="admin-topbar">
<div className="admin-topbar-left">
<button
type="button"
className={`${iconBtn} lg:hidden`}
style={iconBtnStyle}
aria-label="打开导航"
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(true)}
>
<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>
);
}