Merge remote-tracking branch 'origin/master' into feat/game-works-management
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m5s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m33s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Successful in 6m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m39s
Project CI / Repository checks (pull_request) Successful in 6m43s
Project CI / Native shell tests (pull_request) Successful in 7m39s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m5s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m33s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Successful in 6m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m39s
Project CI / Repository checks (pull_request) Successful in 6m43s
Project CI / Native shell tests (pull_request) Successful in 7m39s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md # docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md # server-rs/crates/api-server/src/modules/game_distribution.rs # server-rs/crates/spacetime-client/src/active.rs # server-rs/crates/spacetime-module/src/game_distribution.rs # src/components/game-distribution/GameDistributionPages.test.tsx
This commit is contained in:
@@ -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');
|
||||
});
|
||||
@@ -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<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">
|
||||
@@ -86,20 +153,35 @@ export function AdminShell({
|
||||
</div>
|
||||
|
||||
<nav className="admin-nav" aria-label="后台导航">
|
||||
{routes.map((route) => {
|
||||
const Icon = routeIcons[route.id];
|
||||
{navGroups.map((group) => {
|
||||
const isCollapsed = collapsedGroupIds.has(group.id);
|
||||
const groupItemsId = `admin-nav-group-${group.id}`;
|
||||
return (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
<div
|
||||
className="admin-nav-group"
|
||||
data-collapsed={isCollapsed}
|
||||
key={group.id}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user