first commit
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Bot,
|
||||
Heart,
|
||||
LayoutDashboard,
|
||||
PackageSearch,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
UserRoundPen,
|
||||
WandSparkles,
|
||||
Wrench
|
||||
} from "lucide-react";
|
||||
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import type { PageId, PageParams, PageRoute } from "../contracts/page";
|
||||
import type { CurrentUserView } from "../contracts/workspace";
|
||||
import { MagicalParticleLayer } from "./MagicalParticleLayer";
|
||||
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;
|
||||
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, onNavigate, children }: AppShellProps) {
|
||||
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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]) {
|
||||
if (group.routes[0].id !== currentPage) {
|
||||
onNavigate(group.routes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed")}>
|
||||
<MagicalParticleLayer />
|
||||
<aside className="app-sidebar">
|
||||
<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>
|
||||
<div className="app-session">
|
||||
<button type="button" className="app-account-button" aria-current={currentPage === "profileSettings" ? "page" : undefined} onClick={() => 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user