From 81cfbae207add8f15629a907bc2cda73e18f59cd Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:25:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E4=BE=A7=E6=A0=8F=E6=8C=89?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E5=88=86=E7=BB=84=E6=94=AF=E6=8C=81=E6=8A=98?= =?UTF-8?q?=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增后台侧栏业务分组与当前页自动展开行为 补充折叠交互、路由分组和权限过滤测试 同步后台导航分组行为文档 --- apps/admin-web/src/app/AdminShell.test.tsx | 78 +++++++++++++ apps/admin-web/src/app/AdminShell.tsx | 110 +++++++++++++++--- apps/admin-web/src/app/adminRoutes.test.ts | 35 ++++++ apps/admin-web/src/app/adminRoutes.ts | 85 ++++++++++++++ apps/admin-web/src/styles/admin.css | 65 ++++++++++- ...Ž台管理】Dashboard运营看板方案-2026-06-23.md | 1 + 6 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 apps/admin-web/src/app/AdminShell.test.tsx diff --git a/apps/admin-web/src/app/AdminShell.test.tsx b/apps/admin-web/src/app/AdminShell.test.tsx new file mode 100644 index 000000000..e407bb059 --- /dev/null +++ b/apps/admin-web/src/app/AdminShell.test.tsx @@ -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( + +
页面内容
+
, + ); + + 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( + +
页面内容
+
, + ); + expect( + within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!) + .getByRole('button', { name: /数据与监控/ }) + .getAttribute('aria-expanded'), + ).toBe('true'); +}); diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index c9fe37b00..c49b36141 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -3,6 +3,7 @@ import { Activity, BadgeDollarSign, Bug, + ChevronDown, Coins, Database, FolderArchive, @@ -22,10 +23,15 @@ import { Users, WalletCards, } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import type { AdminSessionPayload } from '../api/adminApiTypes'; -import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes'; +import { + type AdminNavGroupId, + type AdminRouteDefinition, + type AdminRouteId, + getAdminNavGroups, +} from './adminRoutes'; interface AdminShellProps { admin: AdminSessionPayload; @@ -72,6 +78,67 @@ export function AdminShell({ 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 + >(() => { + 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 ( + + ); + }; + return (
diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index 1e5898280..c6c3f7df3 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -3,11 +3,46 @@ import { expect, test } from 'vitest'; import { adminRoutes, getAccessibleAdminRoutes, + getAdminNavGroups, resolveAccessibleAdminRoute, resolveAdminRoute, routeHash, } 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('游戏评价路由独立授权并支持游戏参数', () => { expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews'); expect( diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index f85f9404a..3eba4cd69 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -71,6 +71,91 @@ export const adminRoutes: AdminRouteDefinition[] = [ { 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 { accountRole: 'owner' | 'member'; tabPermissions: string[]; diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 8a861808c..a3dc2b53e 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -289,7 +289,66 @@ button:disabled { .admin-nav { 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, @@ -305,8 +364,8 @@ button:disabled { .admin-nav-button { justify-content: flex-start; gap: 10px; - min-height: 42px; - padding: 0 12px; + min-height: 40px; + padding: 0 12px 0 28px; color: #755a49; background: transparent; } diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index b26f58358..ef292bf77 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -3,6 +3,7 @@ ## 范围 - 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。 +- 桌面端后台侧边栏按“工作台 / 数据与监控 / 运营配置 / 充值与支付 / AGC 管理 / 内容运营 / 账号权限”分组,默认只展开当前路由所在组。分组标题可独立折叠;每次切换路由(包括同组内跳转和浏览器 hash 导航)自动展开目标组,折叠动作不切换页面。只展示当前账号有权访问的页签,空组不渲染,组内数量只统计可访问页签。折叠状态仅保留在当前页面会话;分组不改变路由 hash、页签权限或移动端底栏的平铺顺序。 - 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 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。