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
新增后台侧栏业务分组与当前页自动展开行为 补充折叠交互、路由分组和权限过滤测试 同步后台导航分组行为文档
231 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|