Files
Genarrative/apps/admin-web/src/app/AdminShell.tsx
T
kdletters 81cfbae207
Project CI / AI game creator shell Rust crates (push) Successful in 2m45s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m17s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m27s
Project CI / Frontend tests (push) Successful in 3m4s
Project CI / Backend tests (push) Successful in 6m32s
Project CI / Native shell tests (push) Successful in 7m36s
Project CI / AI game creator shell web tests (push) Successful in 3m24s
Project CI / Repository checks (push) Successful in 6m44s
后台侧栏按业务分组支持折叠
新增后台侧栏业务分组与当前页自动展开行为

补充折叠交互、路由分组和权限过滤测试

同步后台导航分组行为文档
2026-10-04 13:25:58 +08:00

231 lines
6.0 KiB
TypeScript

import { AdminButton } from '@genarrative/shared/components';
import {
Activity,
BadgeDollarSign,
Bug,
ChevronDown,
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
ListChecks,
LogOut,
ReceiptText,
ShieldCheck,
Star,
Swords,
Table2,
TicketCheck,
TicketPercent,
Users,
WalletCards,
} from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import type { AdminSessionPayload } from '../api/adminApiTypes';
import {
type AdminNavGroupId,
type AdminRouteDefinition,
type AdminRouteId,
getAdminNavGroups,
} from './adminRoutes';
interface AdminShellProps {
admin: AdminSessionPayload;
routeId: AdminRouteId | null;
routes: AdminRouteDefinition[];
children: ReactNode;
onRouteChange: (routeId: AdminRouteId) => void;
onLogout: () => void;
}
const routeIcons = {
dashboard: LayoutDashboard,
overview: Activity,
tables: Database,
debug: Bug,
tracking: Table2,
'agc-tracking': Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
invite: TicketCheck,
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'recharge-orders': ReceiptText,
'payment-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'game-management': Swords,
'game-reviews': Star,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
'agc-templates': Images,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
admin,
routeId,
routes,
children,
onRouteChange,
onLogout,
}: AdminShellProps) {
const navGroups = getAdminNavGroups(routes);
const activeGroupId =
navGroups.find((group) =>
group.routes.some((route) => route.id === routeId),
)?.id ?? navGroups[0]?.id;
const [collapsedGroupIds, setCollapsedGroupIds] = useState<
Set<AdminNavGroupId>
>(() => {
return new Set(
navGroups
.map((group) => group.id)
.filter((groupId) => groupId !== activeGroupId),
);
});
useEffect(() => {
if (!activeGroupId) {
return;
}
setCollapsedGroupIds((current) => {
if (!current.has(activeGroupId)) {
return current;
}
const next = new Set(current);
next.delete(activeGroupId);
return next;
});
}, [activeGroupId, routeId]);
const toggleGroup = (groupId: AdminNavGroupId) => {
setCollapsedGroupIds((current) => {
const next = new Set(current);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const renderRouteButton = (route: AdminRouteDefinition) => {
const Icon = routeIcons[route.id];
return (
<button
className="admin-nav-button"
aria-current={route.id === routeId ? 'page' : undefined}
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
>
<Icon size={18} aria-hidden="true" />
<span>{route.label}</span>
</button>
);
};
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<div className="admin-brand">
<div className="admin-brand-icon">
<ShieldCheck size={20} aria-hidden="true" />
</div>
<div>
<strong>陶泥儿后台</strong>
<span>Admin</span>
</div>
</div>
<nav className="admin-nav" aria-label="后台导航">
{navGroups.map((group) => {
const isCollapsed = collapsedGroupIds.has(group.id);
const groupItemsId = `admin-nav-group-${group.id}`;
return (
<div
className="admin-nav-group"
data-collapsed={isCollapsed}
key={group.id}
>
<button
aria-controls={groupItemsId}
aria-expanded={!isCollapsed}
className="admin-nav-group-toggle"
type="button"
onClick={() => toggleGroup(group.id)}
>
<ChevronDown size={15} aria-hidden="true" />
<span>{group.label}</span>
<small aria-hidden="true">{group.routes.length}</small>
</button>
<div
className="admin-nav-group-items"
data-collapsed={isCollapsed}
hidden={isCollapsed}
id={groupItemsId}
>
{group.routes.map(renderRouteButton)}
</div>
</div>
);
})}
</nav>
</aside>
<div className="admin-main">
<header className="admin-topbar">
<div className="admin-user">
<span>{admin.displayName || admin.username}</span>
<small>{admin.accountRole === 'owner' ? 'owner' : 'member'}</small>
</div>
<AdminButton
variant="icon"
title="退出登录"
type="button"
onClick={onLogout}
>
<LogOut size={18} aria-hidden="true" />
<span>退出</span>
</AdminButton>
</header>
<main className="admin-content">{children}</main>
</div>
<nav className="admin-bottom-nav" aria-label="后台导航">
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
className="admin-bottom-nav-button"
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
>
<Icon size={19} aria-hidden="true" />
<span>{route.label}</span>
</button>
);
})}
</nav>
</div>
);
}