后台侧栏按业务分组支持折叠
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

新增后台侧栏业务分组与当前页自动展开行为

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

同步后台导航分组行为文档
This commit is contained in:
kdletters
2026-10-04 13:25:58 +08:00
parent a6fb2f01c1
commit 81cfbae207
6 changed files with 357 additions and 17 deletions
@@ -0,0 +1,78 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { AdminSessionPayload } from '../api/adminApiTypes';
import { adminRoutes } from './adminRoutes';
import { AdminShell } from './AdminShell';
const admin: AdminSessionPayload = {
subject: 'admin-1',
username: 'owner',
displayName: '后台管理员',
roles: ['owner'],
accountRole: 'owner',
tabPermissions: [],
actionPermissions: [],
issuedAt: '2026-10-04T00:00:00.000Z',
expiresAt: '2026-10-05T00:00:00.000Z',
};
test('侧边栏默认展开当前页分组并支持折叠其它分组', async () => {
const user = userEvent.setup();
const onRouteChange = vi.fn();
const view = render(
<AdminShell
admin={admin}
routeId="dashboard"
routes={adminRoutes}
onLogout={vi.fn()}
onRouteChange={onRouteChange}
>
<div>页面内容</div>
</AdminShell>,
);
const sidebar = screen.getAllByRole('navigation', { name: '后台导航' })[0]!;
const workspaceToggle = within(sidebar).getByRole('button', {
name: /工作台/,
});
const observabilityToggle = within(sidebar).getByRole('button', {
name: /数据与监控/,
});
expect(workspaceToggle.getAttribute('aria-expanded')).toBe('true');
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
expect(
within(sidebar).getByRole('button', { name: '服务总览' }),
).toBeTruthy();
expect(
within(sidebar).queryByRole('button', { name: '埋点数据' }),
).toBeNull();
await user.click(observabilityToggle);
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('true');
await user.click(within(sidebar).getByRole('button', { name: '埋点数据' }));
expect(onRouteChange).toHaveBeenCalledWith('tracking');
await user.click(observabilityToggle);
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
view.rerender(
<AdminShell
admin={admin}
routeId="tracking"
routes={adminRoutes}
onLogout={vi.fn()}
onRouteChange={onRouteChange}
>
<div>页面内容</div>
</AdminShell>,
);
expect(
within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!)
.getByRole('button', { name: /数据与监控/ })
.getAttribute('aria-expanded'),
).toBe('true');
});
+96 -14
View File
@@ -3,6 +3,7 @@ import {
Activity, Activity,
BadgeDollarSign, BadgeDollarSign,
Bug, Bug,
ChevronDown,
Coins, Coins,
Database, Database,
FolderArchive, FolderArchive,
@@ -22,10 +23,15 @@ import {
Users, Users,
WalletCards, WalletCards,
} from 'lucide-react'; } from 'lucide-react';
import type { ReactNode } from 'react'; import { type ReactNode, useEffect, useState } from 'react';
import type { AdminSessionPayload } from '../api/adminApiTypes'; import type { AdminSessionPayload } from '../api/adminApiTypes';
import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes'; import {
type AdminNavGroupId,
type AdminRouteDefinition,
type AdminRouteId,
getAdminNavGroups,
} from './adminRoutes';
interface AdminShellProps { interface AdminShellProps {
admin: AdminSessionPayload; admin: AdminSessionPayload;
@@ -72,6 +78,67 @@ export function AdminShell({
onRouteChange, onRouteChange,
onLogout, onLogout,
}: AdminShellProps) { }: 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 ( return (
<div className="admin-shell"> <div className="admin-shell">
<aside className="admin-sidebar"> <aside className="admin-sidebar">
@@ -86,20 +153,35 @@ export function AdminShell({
</div> </div>
<nav className="admin-nav" aria-label="后台导航"> <nav className="admin-nav" aria-label="后台导航">
{routes.map((route) => { {navGroups.map((group) => {
const Icon = routeIcons[route.id]; const isCollapsed = collapsedGroupIds.has(group.id);
const groupItemsId = `admin-nav-group-${group.id}`;
return ( return (
<button <div
className="admin-nav-button" className="admin-nav-group"
data-active={route.id === routeId} data-collapsed={isCollapsed}
key={route.id} key={group.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
> >
<Icon size={18} aria-hidden="true" /> <button
<span>{route.label}</span> aria-controls={groupItemsId}
</button> 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> </nav>
@@ -3,11 +3,46 @@ import { expect, test } from 'vitest';
import { import {
adminRoutes, adminRoutes,
getAccessibleAdminRoutes, getAccessibleAdminRoutes,
getAdminNavGroups,
resolveAccessibleAdminRoute, resolveAccessibleAdminRoute,
resolveAdminRoute, resolveAdminRoute,
routeHash, routeHash,
} from './adminRoutes'; } from './adminRoutes';
test('后台侧边栏路由按业务分组且不丢失任何页签', () => {
const groups = getAdminNavGroups(adminRoutes);
const groupedRouteIds = groups.flatMap((group) =>
group.routes.map((route) => route.id),
);
expect(groups.map((group) => group.label)).toEqual([
'工作台',
'数据与监控',
'运营配置',
'充值与支付',
'AGC 管理',
'内容运营',
'账号权限',
]);
expect(groupedRouteIds).toEqual(adminRoutes.map((route) => route.id));
});
test('后台侧边栏分组只保留当前管理员可见的页签', () => {
const groups = getAdminNavGroups(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking', 'game-reviews', 'accounts'],
}),
);
expect(
groups.map((group) => [group.label, group.routes.map((route) => route.id)]),
).toEqual([
['数据与监控', ['tracking']],
['内容运营', ['game-reviews']],
]);
});
test('游戏评价路由独立授权并支持游戏参数', () => { test('游戏评价路由独立授权并支持游戏参数', () => {
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews'); expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
expect( expect(
+85
View File
@@ -71,6 +71,91 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
]; ];
/**
* 桌面端侧边栏的导航分组。分组只影响展示顺序和折叠状态,不改变路由
* 权限、hash 或移动端底栏的平铺顺序。
*/
export type AdminNavGroupId =
| 'workspace'
| 'observability'
| 'operations'
| 'billing'
| 'content'
| 'agc'
| 'access';
export interface AdminNavGroupDefinition {
id: AdminNavGroupId;
label: string;
routeIds: AdminRouteId[];
}
export const adminNavGroups: AdminNavGroupDefinition[] = [
{
id: 'workspace',
label: '工作台',
routeIds: ['dashboard', 'overview', 'tables', 'debug'],
},
{
id: 'observability',
label: '数据与监控',
routeIds: ['tracking', 'agc-tracking', 'error-reports'],
},
{
id: 'operations',
label: '运营配置',
routeIds: ['gray-release', 'redeem', 'invite', 'profile-wallet', 'tasks'],
},
{
id: 'billing',
label: '充值与支付',
routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'],
},
{
id: 'agc',
label: 'AGC 管理',
routeIds: ['editor-generation-pricing', 'agc-models', 'agc-templates'],
},
{
id: 'content',
label: '内容运营',
routeIds: [
'editor-showcase',
'game-distribution',
'game-management',
'game-reviews',
'editor-assets',
'project-snapshots',
],
},
{
id: 'access',
label: '账号权限',
routeIds: ['accounts'],
},
];
export interface AdminNavGroup {
id: AdminNavGroupId;
label: string;
routes: AdminRouteDefinition[];
}
/** 按固定业务分组筛选当前管理员可见的路由。 */
export function getAdminNavGroups(
routes: AdminRouteDefinition[],
): AdminNavGroup[] {
return adminNavGroups
.map((group) => ({
id: group.id,
label: group.label,
routes: group.routeIds
.map((routeId) => routes.find((route) => route.id === routeId))
.filter((route): route is AdminRouteDefinition => route !== undefined),
}))
.filter((group) => group.routes.length > 0);
}
export interface AdminRouteAccess { export interface AdminRouteAccess {
accountRole: 'owner' | 'member'; accountRole: 'owner' | 'member';
tabPermissions: string[]; tabPermissions: string[];
+62 -3
View File
@@ -289,7 +289,66 @@ button:disabled {
.admin-nav { .admin-nav {
display: grid; display: grid;
gap: 8px; gap: 12px;
}
.admin-nav-group {
display: grid;
gap: 4px;
}
.admin-nav-group-toggle {
display: flex;
align-items: center;
gap: 6px;
min-height: 28px;
border: 0;
border-radius: 6px;
padding: 0 8px;
color: #a08472;
background: transparent;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-align: left;
cursor: pointer;
}
.admin-nav-group-toggle svg {
flex: 0 0 auto;
transition: transform 160ms ease;
}
.admin-nav-group[data-collapsed='true'] .admin-nav-group-toggle svg {
transform: rotate(-90deg);
}
.admin-nav-group-toggle small {
min-width: 18px;
margin-left: auto;
border-radius: 999px;
padding: 2px 5px;
color: #b18d76;
background: #faf0e8;
font-size: 10px;
line-height: 1.2;
text-align: center;
}
.admin-nav-group-toggle:hover,
.admin-nav-group-toggle:focus-visible {
color: #8f3f27;
background: #fff8f2;
outline: none;
}
.admin-nav-group-items {
display: grid;
gap: 4px;
}
.admin-nav-group-items[data-collapsed='true'] {
display: none;
} }
.admin-nav-button, .admin-nav-button,
@@ -305,8 +364,8 @@ button:disabled {
.admin-nav-button { .admin-nav-button {
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 10px;
min-height: 42px; min-height: 40px;
padding: 0 12px; padding: 0 12px 0 28px;
color: #755a49; color: #755a49;
background: transparent; background: transparent;
} }
@@ -3,6 +3,7 @@
## 范围 ## 范围
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。 - 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
- 桌面端后台侧边栏按“工作台 / 数据与监控 / 运营配置 / 充值与支付 / AGC 管理 / 内容运营 / 账号权限”分组,默认只展开当前路由所在组。分组标题可独立折叠;每次切换路由(包括同组内跳转和浏览器 hash 导航)自动展开目标组,折叠动作不切换页面。只展示当前账号有权访问的页签,空组不渲染,组内数量只统计可访问页签。折叠状态仅保留在当前页面会话;分组不改变路由 hash、页签权限或移动端底栏的平铺顺序。
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。 - Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。
- 不新增持久化统计表或字段;profile / tracking 私有事实由仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure 在同一事务快照内聚合,再经 `spacetime-client` facade 返回 api-server;素材与钱包继续复用原有查询。api-server 不再通过固定 `LIMIT` 拉取原始访问明细后自行拼装精确指标;该权威聚合失败时整个 Dashboard 请求失败,不用 0 伪装未知值。 - 不新增持久化统计表或字段;profile / tracking 私有事实由仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure 在同一事务快照内聚合,再经 `spacetime-client` facade 返回 api-server;素材与钱包继续复用原有查询。api-server 不再通过固定 `LIMIT` 拉取原始访问明细后自行拼装精确指标;该权威聚合失败时整个 Dashboard 请求失败,不用 0 伪装未知值。
- 当前 profile / tracking 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。 - 当前 profile / tracking 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。