Files
2026-07-20 16:42:33 +08:00

253 lines
9.4 KiB
TypeScript

import {
Bot,
Heart,
LayoutDashboard,
PackageSearch,
PanelLeft,
PanelRight,
ServerCog,
ShieldCheck,
UserRoundPen,
WandSparkles,
Wrench,
X
} from "lucide-react";
import { type ComponentType, type ReactNode, type TouchEvent, useEffect, useRef, useState } from "react";
import type { PageId, PageParams, PageRoute } from "../contracts/page";
import type { CurrentUserView, OperationRecord } from "../contracts/workspace";
import { MagicalParticleLayer } from "./MagicalParticleLayer";
import { OperationsTray } from "./OperationsTray";
import {
applyBackgroundImage,
applyThemeBackgroundPreset,
applyThemePalette,
loadThemeState,
themePaletteChangeEvent,
themePalettes,
themeTokens,
type ThemePaletteChangeDetail,
type WorkspaceThemeState
} from "../theme/tokens";
import { cx } from "../utils/classes";
interface AppShellProps {
routes: PageRoute[];
currentPage: PageId;
session: CurrentUserView;
operations: OperationRecord[];
onNavigate: (pageId: PageId, params?: PageParams) => void;
children: ReactNode;
}
interface MenuGroup {
id: string;
label: string;
routeIds: PageId[];
icon: ComponentType<{ size?: number; className?: string }>;
}
const menuGroups: MenuGroup[] = [
{ id: "overview", label: "平台概览", routeIds: ["home"], icon: LayoutDashboard },
{ id: "servers", label: "服务器管理", routeIds: ["servers"], icon: ServerCog },
{ id: "plugins", label: "插件市场", routeIds: ["plugins"], icon: PackageSearch },
{ id: "users", label: "用户管理", routeIds: ["users"], icon: ShieldCheck },
{ id: "ai", label: "AI 提供商管理", routeIds: ["aiProviders"], icon: Bot },
{ id: "tools", label: "系统工具", routeIds: ["maintenance"], icon: Wrench }
];
const roleLabels: Record<CurrentUserView["roles"][number], string> = {
platformAdmin: "平台管理员",
serverOwner: "服主",
serverAdmin: "服务器管理员"
};
export function AppShell({ routes, currentPage, session, operations, onNavigate, children }: AppShellProps) {
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const touchStart = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
function handleThemePaletteChange(event: Event) {
const paletteId = (event as CustomEvent<ThemePaletteChangeDetail>).detail?.paletteId;
if (!paletteId) {
return;
}
setThemeState((current) => (current.paletteId === paletteId ? current : { ...current, paletteId }));
}
window.addEventListener(themePaletteChangeEvent, handleThemePaletteChange);
const stored = loadThemeState();
applyThemePalette(stored.paletteId);
applyThemeBackgroundPreset(stored.backgroundPresetId);
applyBackgroundImage(stored.backgroundImage);
setThemeState(stored);
return () => window.removeEventListener(themePaletteChangeEvent, handleThemePaletteChange);
}, []);
useEffect(() => {
if (!isMobileSidebarOpen) {
return;
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsMobileSidebarOpen(false);
}
}
const isNarrow = window.matchMedia("(max-width: 760px)").matches;
const previousOverflow = document.body.style.overflow;
if (isNarrow) {
document.body.style.overflow = "hidden";
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
if (isNarrow) {
document.body.style.overflow = previousOverflow;
}
};
}, [isMobileSidebarOpen]);
const activePalette = themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0];
const routesById = new Map(routes.map((route) => [route.id, route]));
const visibleGroups = menuGroups
.map((group) => ({
...group,
routes: group.routeIds.map((routeId) => routesById.get(routeId)).filter((route): route is PageRoute => Boolean(route))
}))
.filter((group) => group.routes.length > 0);
function activateGroup(group: (typeof visibleGroups)[number]) {
setIsMobileSidebarOpen(false);
if (group.routes[0].id !== currentPage) {
onNavigate(group.routes[0].id);
}
}
function handleTouchStart(event: TouchEvent<HTMLDivElement>) {
const touch = event.touches[0];
touchStart.current = touch ? { x: touch.clientX, y: touch.clientY } : null;
}
function handleTouchEnd(event: TouchEvent<HTMLDivElement>) {
const start = touchStart.current;
const touch = event.changedTouches[0];
touchStart.current = null;
if (!start || !touch || !window.matchMedia("(max-width: 760px)").matches) {
return;
}
const deltaX = touch.clientX - start.x;
const deltaY = touch.clientY - start.y;
if (Math.abs(deltaY) >= Math.abs(deltaX)) {
return;
}
if (!isMobileSidebarOpen && start.x <= 28 && deltaX >= 56) {
setIsMobileSidebarOpen(true);
} else if (isMobileSidebarOpen && deltaX <= -56) {
setIsMobileSidebarOpen(false);
}
}
return (
<div
className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed", isMobileSidebarOpen && "app-shell-mobile-sidebar-open")}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<MagicalParticleLayer />
<button
type="button"
className="mobile-sidebar-handle"
aria-label="打开导航菜单"
aria-controls="primary-sidebar"
aria-expanded={isMobileSidebarOpen}
onClick={() => setIsMobileSidebarOpen(true)}
>
<WandSparkles size={17} aria-hidden="true" />
<span>菜单</span>
</button>
<button
type="button"
className="mobile-sidebar-backdrop"
aria-label="关闭导航菜单"
aria-hidden={!isMobileSidebarOpen}
tabIndex={isMobileSidebarOpen ? 0 : -1}
onClick={() => setIsMobileSidebarOpen(false)}
/>
<aside id="primary-sidebar" className={cx("app-sidebar", isMobileSidebarOpen && "app-sidebar-mobile-open")}>
<button type="button" className="mobile-sidebar-close" aria-label="关闭导航菜单" onClick={() => setIsMobileSidebarOpen(false)}>
<X size={18} aria-hidden="true" />
</button>
<div className="app-brand" aria-label={themeTokens.appName}>
<span className="app-brand-mark">
<WandSparkles size={17} />
</span>
<span>
<strong>{themeTokens.appName}</strong>
<span className="app-brand-subtitle">{activePalette.label} / OPS</span>
</span>
</div>
<div className="app-menu-toolbar" aria-label="sidebar mode">
<span>导航</span>
<button type="button" className="app-menu-toggle" aria-label={isSidebarCollapsed ? "展开完整菜单栏" : "收起为图标栏"} aria-pressed={isSidebarCollapsed} onClick={() => setIsSidebarCollapsed((current) => !current)}>
{isSidebarCollapsed ? <PanelRight size={18} /> : <PanelLeft size={18} />}
<span>{isSidebarCollapsed ? "展开" : "收起"}</span>
</button>
</div>
<nav className="app-nav" aria-label="primary">
{visibleGroups.map((group) => {
const Icon = group.icon;
const isActive = group.routes.some((route) => route.id === currentPage);
return (
<section key={group.id} className={cx("app-nav-group", isActive && "app-nav-group-active")}>
<button
type="button"
className={cx("app-nav-group-button", isActive && "app-nav-item-active")}
aria-current={isActive ? "page" : undefined}
title={group.label}
onClick={() => activateGroup(group)}
>
<span className="app-nav-icon" aria-hidden="true">
<Icon size={22} />
</span>
<span className="app-nav-copy">
<strong>{group.label}</strong>
</span>
</button>
</section>
);
})}
</nav>
<OperationsTray operations={operations} />
<div className="app-session">
<button
type="button"
className="app-account-button"
aria-current={currentPage === "profileSettings" ? "page" : undefined}
onClick={() => {
setIsMobileSidebarOpen(false);
onNavigate("profileSettings");
}}
>
<span className="account-avatar" aria-hidden="true">
{session.profile.avatarUrl ? <img src={session.profile.avatarUrl} alt="" /> : <Heart size={17} />}
</span>
<span>
<strong>{session.displayName}</strong>
<span className="provider-id">{session.roles.map((role) => roleLabels[role]).join(" / ")}</span>
</span>
<UserRoundPen className="account-chevron" size={16} />
</button>
<div className="palette-mini-strip" aria-label={`当前配色 ${activePalette.label}`}>
{activePalette.swatches.map((swatch) => (
<span key={swatch} style={{ background: swatch }} />
))}
</div>
</div>
</aside>
<main className="app-main">{children}</main>
</div>
);
}