Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -20,6 +20,9 @@ env:
|
||||
CARGO_HTTP_MULTIPLEXING: 'false'
|
||||
CARGO_NET_RETRY: '10'
|
||||
CARGO_TERM_COLOR: always
|
||||
# 等价于给每条 `cargo test` 加 `-q`:通过的用例只打点号,失败的才按名字列出。
|
||||
# 对 npm 脚本、shard runner 里嵌套的 cargo 同样生效,避免逐条 `test ... ok` 刷屏。
|
||||
CARGO_TERM_QUIET: 'true'
|
||||
NPM_CONFIG_AUDIT: 'false'
|
||||
NPM_CONFIG_FETCH_RETRIES: '10'
|
||||
NPM_CONFIG_FETCH_RETRY_FACTOR: '2'
|
||||
|
||||
@@ -618,7 +618,7 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=50',
|
||||
'/admin/api/game-distribution/games?limit=80',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -656,6 +656,32 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('游戏管理列表把筛选与游标编码进查询串', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ ok: true, data: { games: [], nextCursor: null } }),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listAdminGameDistributionGames('admin-token', {
|
||||
limit: 20,
|
||||
keyword: ' 拼图 ',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
cursor: '1710000000000:game_1',
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=20&keyword=%E6%8B%BC%E5%9B%BE&owner=user_9&status=suspended&cursor=1710000000000%3Agame_1',
|
||||
);
|
||||
});
|
||||
|
||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -33,11 +33,14 @@ import type {
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionGameStatus,
|
||||
AdminGameDistributionPreviewSessionResponse,
|
||||
AdminGameDistributionRestoreRequest,
|
||||
AdminGameDistributionRestoreResponse,
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewGamesQuery,
|
||||
AdminGameReviewGamesResponse,
|
||||
@@ -1281,6 +1284,39 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminGameDistributionVersion(
|
||||
token: string,
|
||||
versionId: string,
|
||||
) {
|
||||
return request<AdminGameDistributionVersionDetailResponse>(
|
||||
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function createAdminGameDistributionPreviewSession(
|
||||
token: string,
|
||||
versionId: string,
|
||||
) {
|
||||
return request<AdminGameDistributionPreviewSessionResponse>(
|
||||
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}/preview-session`,
|
||||
{ token, method: 'POST' },
|
||||
).then((response) => ({
|
||||
...response,
|
||||
previewUrl: new URL(
|
||||
response.previewUrl,
|
||||
ADMIN_API_BASE_URL || window.location.origin,
|
||||
).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台作品管理列表。
|
||||
*
|
||||
* 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数,
|
||||
* 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。
|
||||
*/
|
||||
|
||||
function gameReviewQuery(
|
||||
query: AdminGameReviewGamesQuery | AdminGameReviewsQuery,
|
||||
) {
|
||||
@@ -1335,18 +1371,30 @@ export function moderateAdminGameReview(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
options: {
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
owner?: string;
|
||||
status?: AdminGameDistributionGameStatus | '';
|
||||
cursor?: string | null;
|
||||
} = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? 50;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 200)
|
||||
: 50;
|
||||
const params = new URLSearchParams({ limit: String(normalizedLimit) });
|
||||
const keyword = options.keyword?.trim();
|
||||
if (keyword) params.set('keyword', keyword);
|
||||
const owner = options.owner?.trim();
|
||||
if (owner) params.set('owner', owner);
|
||||
if (options.status) params.set('status', options.status);
|
||||
if (options.cursor) params.set('cursor', options.cursor);
|
||||
return request<AdminGameDistributionGameListResponse>(
|
||||
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
|
||||
`/admin/api/game-distribution/games?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1210,6 +1210,52 @@ export interface AdminGameDistributionReviewEntry {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionGameDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
coverObjectKey: string | null;
|
||||
screenshots: string[];
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
deviceSupport: {
|
||||
desktop: boolean;
|
||||
mobile: boolean;
|
||||
touch: boolean;
|
||||
};
|
||||
inputModes: string[];
|
||||
orientation: string;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
playCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionVersionDetail
|
||||
extends AdminGameDistributionReviewEntry {
|
||||
packageFileCount: number;
|
||||
entryUrl: string | null;
|
||||
recoveryAction: string;
|
||||
frozenMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionVersionDetailResponse {
|
||||
game: AdminGameDistributionGameDetail;
|
||||
version: AdminGameDistributionVersionDetail;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionPreviewSessionResponse {
|
||||
previewUrl: string;
|
||||
expiresAt: string;
|
||||
versionId: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionReviewListResponse {
|
||||
entries: AdminGameDistributionReviewEntry[];
|
||||
nextCursor: string | null;
|
||||
@@ -1264,18 +1310,29 @@ export interface AdminGameDistributionGameEntry {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
status: string;
|
||||
status: AdminGameDistributionGameStatus;
|
||||
versionCount: number;
|
||||
playCount: number;
|
||||
activeVersionId: string | null;
|
||||
publicationRevision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** 作者软删除时间;为空表示未删除。 */
|
||||
deletedAt: string | null;
|
||||
versions: AdminGameDistributionGameVersionEntry[];
|
||||
}
|
||||
|
||||
/** 后台作品列表的状态:游戏可见性 + 作者软删除。 */
|
||||
export type AdminGameDistributionGameStatus =
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'suspended'
|
||||
| 'deleted';
|
||||
|
||||
export interface AdminGameDistributionGameListResponse {
|
||||
games: AdminGameDistributionGameEntry[];
|
||||
/** 还有下一页时为下一页游标;旧响应没有该字段,按空值处理。 */
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionRestoreRequest {
|
||||
|
||||
@@ -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;
|
||||
@@ -73,6 +79,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">
|
||||
@@ -87,20 +154,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(
|
||||
|
||||
@@ -73,6 +73,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[];
|
||||
|
||||
@@ -10,11 +10,17 @@ import {
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAdminGameDistributionPreviewSession,
|
||||
getAdminGameDistributionVersion,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
||||
import type {
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionReviewEntry,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
@@ -28,11 +34,12 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
createAdminGameDistributionPreviewSession: vi.fn(),
|
||||
getAdminGameDistributionVersion: vi.fn(),
|
||||
listAdminGameDistributionGames: vi.fn(),
|
||||
listAdminGameDistributionReviews: vi.fn(),
|
||||
reviewAdminGameDistributionVersion: vi.fn(),
|
||||
suspendAdminGameDistributionGame: vi.fn(),
|
||||
}));
|
||||
|
||||
const entry: AdminGameDistributionReviewEntry = {
|
||||
versionId: 'version-1',
|
||||
gameId: 'game_1',
|
||||
@@ -42,18 +49,72 @@ const entry: AdminGameDistributionReviewEntry = {
|
||||
status: 'pending_review',
|
||||
publicationRevision: 4,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T08:00:00Z',
|
||||
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
|
||||
createdAt: '1791008678.817257Z',
|
||||
updatedAt: '1791008678.817257Z',
|
||||
};
|
||||
|
||||
/** 游戏名与作者由既有后台作品列表接口按 gameId 反查(待审列表本身不含这两个字段)。 */
|
||||
const gameTitle = '测试游戏名';
|
||||
const gameListResponse: AdminGameDistributionGameListResponse = {
|
||||
games: [
|
||||
{
|
||||
gameId: 'game_1',
|
||||
title: gameTitle,
|
||||
author: { id: 'user-9f1', name: '136****8867', avatarUrl: null },
|
||||
status: 'unpublished',
|
||||
versionCount: 1,
|
||||
playCount: 0,
|
||||
activeVersionId: null,
|
||||
publicationRevision: 4,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T08:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
};
|
||||
|
||||
const detailResponse: AdminGameDistributionVersionDetailResponse = {
|
||||
game: {
|
||||
id: 'game_1',
|
||||
title: '测试游戏',
|
||||
summary: '测试简介',
|
||||
description: '测试详细介绍',
|
||||
category: '益智',
|
||||
tags: ['测试'],
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
author: { id: 'user-1', name: '测试作者', avatarUrl: null },
|
||||
deviceSupport: { desktop: true, mobile: false, touch: true },
|
||||
inputModes: ['keyboard'],
|
||||
orientation: 'landscape',
|
||||
status: 'unpublished',
|
||||
publicationRevision: 7,
|
||||
playCount: 0,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
},
|
||||
version: {
|
||||
...entry,
|
||||
packageFileCount: 3,
|
||||
entryUrl: null,
|
||||
recoveryAction: 'wait',
|
||||
frozenMetadata: { title: '测试游戏' },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminGameDistributionReviews).mockReset();
|
||||
vi.mocked(listAdminGameDistributionGames).mockReset();
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockReset();
|
||||
vi.mocked(getAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockReset();
|
||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
||||
entries: [entry],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(listAdminGameDistributionGames).mockResolvedValue(gameListResponse);
|
||||
});
|
||||
|
||||
test('通过审核只提交当前 publicationRevision 并刷新列表', async () => {
|
||||
@@ -68,7 +129,7 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async ()
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
expect(screen.queryByLabelText('发行入口')).toBeNull();
|
||||
expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull();
|
||||
@@ -107,7 +168,7 @@ test('点击拒绝后填写理由再提交审核接口', async () => {
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
@@ -135,54 +196,44 @@ test('点击拒绝后填写理由再提交审核接口', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game_1',
|
||||
title: '测试游戏',
|
||||
status: 'suspended',
|
||||
publicationRevision: 5,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
|
||||
test('待审版本行内只保留版本级审核动作,不提供游戏级安全下架', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
const reasonDialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(
|
||||
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
|
||||
{
|
||||
target: { value: '盗用素材' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(reasonDialog).getByRole('button', { name: '继续下架' }),
|
||||
);
|
||||
// 「安全下架」是游戏级动作(强制下线线上版本),入口在「游戏管理」页;
|
||||
// 审核一个新版本时不该出现该动作。
|
||||
expect(screen.queryByRole('button', { name: '安全下架' })).toBeNull();
|
||||
expect(screen.queryByText('继续下架')).toBeNull();
|
||||
expect(screen.queryByLabelText('下架原因')).toBeNull();
|
||||
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).queryByRole('button', { name: '继续下架' })).toBeNull();
|
||||
expect(within(dialog).getByLabelText('拒绝理由')).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
const row = screen.getByText(gameTitle).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
|
||||
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
|
||||
expect(submittedCell?.textContent).not.toBe('-');
|
||||
expect(submittedCell?.textContent).toMatch(
|
||||
/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/u,
|
||||
);
|
||||
const [token, gameId, idempotencyKey, payload] =
|
||||
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(gameId).toBe('game_1');
|
||||
expect(String(idempotencyKey)).toContain('game_1');
|
||||
expect(payload).toEqual({
|
||||
expectedPublicationRevision: 4,
|
||||
reason: '盗用素材',
|
||||
});
|
||||
expect(await screen.findByText(/已安全下架/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('取消理由输入时不做审核操作', async () => {
|
||||
@@ -192,7 +243,7 @@ test('取消理由输入时不做审核操作', async () => {
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
@@ -202,31 +253,67 @@ test('取消理由输入时不做审核操作', async () => {
|
||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('取消安全下架确认时不下架', async () => {
|
||||
test('游戏列显示游戏名与作者,不再把 gameId 当主展示', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
const reasonDialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(
|
||||
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
|
||||
{
|
||||
target: { value: '盗用素材' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(reasonDialog).getByRole('button', { name: '继续下架' }),
|
||||
// 按 gameId 反查一次,第二次刷新走缓存(本次只加载一次列表)。
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({ keyword: 'game_1' }),
|
||||
);
|
||||
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
const row = screen.getByText(gameTitle).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
const gameCell = within(row as HTMLElement).getAllByRole('cell')[0];
|
||||
// 主展示是游戏名 + 打码作者名;gameId 只留在悬浮标题里。
|
||||
expect(gameCell?.textContent).toContain('136****8867');
|
||||
expect(gameCell?.textContent).not.toContain('game_1');
|
||||
expect(screen.getByTitle('user-9f1').textContent).toBe('user-9f1');
|
||||
expect(gameCell?.querySelector('[title="game_1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('详情展示发布者和冻结资料,并试玩当前待审版本', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
expiresAt: '2026-10-02T12:10:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('测试详细介绍')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
|
||||
expect(frame.getAttribute('src')).toBe(
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
);
|
||||
expect(frame.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
fireEvent.click(screen.getByRole('button', { name: '通过' }));
|
||||
await waitFor(() =>
|
||||
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
'version-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 7,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@ const publishedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 4,
|
||||
createdAt: '2026-09-18T08:00:00Z',
|
||||
updatedAt: '2026-09-20T10:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [version],
|
||||
};
|
||||
|
||||
@@ -86,6 +87,26 @@ const suspendedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 7,
|
||||
createdAt: '2026-09-19T08:00:00Z',
|
||||
updatedAt: '2026-09-21T08:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [],
|
||||
};
|
||||
|
||||
const deletedGame: AdminGameDistributionGameEntry = {
|
||||
gameId: 'game_3',
|
||||
title: '已删除作品',
|
||||
author: {
|
||||
id: 'user_3',
|
||||
name: '作者丙',
|
||||
avatarUrl: null,
|
||||
},
|
||||
status: 'deleted',
|
||||
versionCount: 1,
|
||||
playCount: 3,
|
||||
activeVersionId: null,
|
||||
publicationRevision: 2,
|
||||
createdAt: '2026-09-17T08:00:00Z',
|
||||
updatedAt: '2026-09-22T08:00:00Z',
|
||||
deletedAt: '2026-09-22T08:00:00Z',
|
||||
versions: [],
|
||||
};
|
||||
|
||||
@@ -197,20 +218,62 @@ test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列
|
||||
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
|
||||
test('下架原因在独立面板内填写:空原因不提交并给出提示', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
// 操作列不再内嵌输入框,点按钮才打开面板。
|
||||
expect(screen.queryByLabelText('下架原因')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
const reasonInput = within(dialog).getByLabelText('下架原因');
|
||||
expect((reasonInput as HTMLTextAreaElement).value).toBe('');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
|
||||
|
||||
expect(await within(dialog).findByText('请填写下架原因')).toBeTruthy();
|
||||
expect(reasonInput.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(screen.queryByRole('dialog', { name: '确认操作' })).toBeNull();
|
||||
});
|
||||
|
||||
test('下架面板取消不触发请求', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('下架面板确认后仍需二次确认,提交原因与当前公开修订号', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
|
||||
|
||||
// 面板确认只进入原有的写入二次确认,此时还不能发请求。
|
||||
const confirmDialog = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
@@ -277,3 +340,92 @@ test('401 交给 onUnauthorized 处理', async () => {
|
||||
);
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), {
|
||||
target: { value: ' 拼图 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('作者 user ID'), {
|
||||
target: { value: 'user_9' },
|
||||
});
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '状态' }), {
|
||||
target: { value: 'suspended' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!;
|
||||
expect(applied).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '拼图',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!;
|
||||
expect(reset).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '',
|
||||
owner: '',
|
||||
status: '',
|
||||
});
|
||||
expect(
|
||||
(screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('游标分页把上一页返回的 nextCursor 传给下一页', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames)
|
||||
.mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' })
|
||||
.mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null });
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
const nextButton = screen.getByRole('button', { name: '下一页' });
|
||||
expect((nextButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await screen.findByText('下架游戏');
|
||||
expect(screen.queryByText('测试游戏')).toBeNull();
|
||||
const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock
|
||||
.calls[1]!;
|
||||
expect(secondPage?.cursor).toBe('cursor-1');
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('已删除作品显示删除时间且不再提供下架与恢复', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
|
||||
games: [deletedGame],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('已删除作品')).closest('tr')!;
|
||||
expect(within(row).getByText('已删除')).toBeTruthy();
|
||||
expect(within(row).getByText(/删除于/)).toBeTruthy();
|
||||
expect(within(row).queryByRole('button', { name: '下架' })).toBeNull();
|
||||
expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull();
|
||||
expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -290,6 +290,7 @@ export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) {
|
||||
</AdminPanel>
|
||||
<AdminListPanel
|
||||
title="评价列表"
|
||||
tableClassName="admin-game-reviews-table"
|
||||
busy={loading}
|
||||
busyText="正在加载评价…"
|
||||
busyVariant="line"
|
||||
|
||||
@@ -64,7 +64,7 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
key: 'game-distribution:publish',
|
||||
label: '游戏发布',
|
||||
description:
|
||||
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
|
||||
'游戏发布入口灰度:未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 「游戏审核」待审版本表的页面专属样式。
|
||||
*
|
||||
* 目标:1280 / 1440 / 1920 三档视口下,「详情 / 通过 / 拒绝」三个版本级动作
|
||||
* 都在面板可视带内、单行不换行,不依赖面板内横向滚动。
|
||||
*
|
||||
* 做法:只在本页表格上加自己的类,覆盖 `.admin-table-wide` 的 min-width 并固定列宽,
|
||||
* 把动作列按内容取宽;gameId 最长(37 字符)用省略号截断并保留 title 作为完整值出口。
|
||||
* 不改共享 `.admin-table-wide` 的全局语义,也不影响其它后台页面。
|
||||
*/
|
||||
|
||||
/* `.admin-table-wide` 是共享类,用元素+类提升优先级覆盖它,只作用于本页表格。 */
|
||||
table.admin-game-review-table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(2),
|
||||
table.admin-game-review-table td:nth-child(2) {
|
||||
/* `pending_review` 是最长状态值(约 120px + 单元格内边距),必须给足,否则会压到「发行包」列。 */
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(3),
|
||||
table.admin-game-review-table td:nth-child(3) {
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4) {
|
||||
width: 172px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* 固定列宽下,任何溢出都应该在本列内被裁掉,而不是压到相邻列上。 */
|
||||
table.admin-game-review-table td {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table.admin-game-review-table td .admin-muted-text {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-game-review-cell--actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-review-cell--actions .admin-review-actions {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 游戏列:主行游戏名、副行作者(打码名 + user_id 截断,完整值放 title)。 */
|
||||
.admin-game-review-game {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-game-review-game-author {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-game-review-game-author-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-review-game-author-id {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* 移动端/窄屏:动作列保持不换行,仍靠省略号吸收 gameId 的宽度。 */
|
||||
@media (max-width: 980px) {
|
||||
table.admin-game-review-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(2),
|
||||
table.admin-game-review-table td:nth-child(2),
|
||||
table.admin-game-review-table th:nth-child(3),
|
||||
table.admin-game-review-table td:nth-child(3),
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4),
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -787,6 +846,20 @@ button:disabled {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table .admin-status {
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table td:last-child .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table td:last-child .admin-action-row > button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-review-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -3507,3 +3580,55 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-game-review-detail {
|
||||
max-width: 70rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1rem, 2.4vw, 2.75rem);
|
||||
border-radius: 1.75rem;
|
||||
background: var(--platform-body-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.admin-game-review-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 42rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/*
|
||||
* 游戏管理列表(AdminGameManagementPage)专属排布,选择器全部框在本页表格内,
|
||||
* 不影响其它后台页面:
|
||||
* - 操作列按内容取宽且按钮组不换行,避免「查看评价」被挤到第二行;
|
||||
* - 单元格垂直居中,行高不再随最长单元格跳动;
|
||||
* - 标题列给最小宽度,避免被长 gameId 挤压成一行一个字。
|
||||
* 这里用 `.admin-table.admin-game-management-table` 复合选择器:共享样式表在 dev 下
|
||||
* 由组件模块后注入,单类选择器会被 `.admin-table th, .admin-table td` 反超。
|
||||
*/
|
||||
.admin-table.admin-game-management-table th,
|
||||
.admin-table.admin-game-management-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-game-management-table td:first-child {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.admin-game-management-table th:last-child,
|
||||
.admin-game-management-table td.admin-game-actions-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-actions-cell .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-game-author-cell {
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export default defineConfig(({ command, mode }) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api/game-distribution/admin-previews': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/healthz': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"bundled-resources:check": "node scripts/check-package-layout.mjs",
|
||||
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
|
||||
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
|
||||
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
|
||||
"bundled-resources:test": "node --test --test-reporter=dot scripts/prepare-bundled-resources.test.mjs",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
"check:tests:types": "tsc -p tsconfig.tests.json --noEmit",
|
||||
|
||||
@@ -135,8 +135,8 @@ const allowedUncalledTauriCommands = [
|
||||
'generate_game_distribution_cover',
|
||||
'prepare_local_project_game_package',
|
||||
'read_game_cover_generation_price',
|
||||
'read_game_publish_availability',
|
||||
'suggest_game_distribution_publish_metadata',
|
||||
'read_game_distribution_publication',
|
||||
'upload_local_project_game_package',
|
||||
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
|
||||
@@ -4179,10 +4179,18 @@ fn sync_direct_codex_project_file_projection_at(
|
||||
return Err("Codex 返回后没有可登记的游戏文件".to_string());
|
||||
}
|
||||
update_manifest_task_status_at(root, "code-prototype", GameCreationAppTaskStatus::Completed)?;
|
||||
let mut version_registered = false;
|
||||
if outputs_changed || manifest_requires_sync {
|
||||
let _lock = acquire_project_write_lock_with_wait(root, "direct-codex.version")?;
|
||||
let version_revision = advance_agent_runtime_project_revision_locked(root)?;
|
||||
append_agent_game_iteration_version_at(root, version_revision)?;
|
||||
version_registered = append_agent_game_iteration_version_at(root, version_revision)?;
|
||||
}
|
||||
if version_registered {
|
||||
// 新增的内部版本只落在清单里:这条写入不经过任何工具桥,也就没有任何事件。
|
||||
// 必须显式宣告清单失效,否则渲染层的资源投影(「项目版本」卡、发布面板的版本标签)
|
||||
// 在本回合结束后不会重读清单 —— 版本已经在盘上,用户却看不到新的版本卡。
|
||||
// 与工具桥的素材写入同一口径(见 direct_tool_bridge.rs 的 asset-import)。
|
||||
emit_game_creator_manifest_invalidated(root, "direct-codex.version");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1200,6 +1200,10 @@ pub(crate) async fn upload_local_project_game_package(
|
||||
}
|
||||
let emit_handle = app.clone();
|
||||
let progress_version_id = version_id.clone();
|
||||
let progress_publish_id = idempotency_key.trim().to_string();
|
||||
let verify_handle = app.clone();
|
||||
let verify_version_id = version_id.clone();
|
||||
let verify_publish_id = progress_publish_id.clone();
|
||||
crate::game_package_upload::upload_staged_game_package(
|
||||
&client,
|
||||
crate::game_package_upload::GamePackageUploadRequest {
|
||||
@@ -1215,11 +1219,27 @@ pub(crate) async fn upload_local_project_game_package(
|
||||
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
&progress_version_id,
|
||||
&progress_publish_id,
|
||||
"upload",
|
||||
"正在上传发行包…",
|
||||
received_bytes,
|
||||
total_bytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
move || {
|
||||
let _ = verify_handle.emit(
|
||||
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
crate::game_package_upload::progress_event_payload(
|
||||
&verify_version_id,
|
||||
&verify_publish_id,
|
||||
"verify",
|
||||
"正在校验发行包…",
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -608,10 +608,10 @@ pub(super) fn run() {
|
||||
login_client_with_password,
|
||||
login_client_with_phone_code,
|
||||
logout_client_session,
|
||||
read_game_publish_availability,
|
||||
suggest_game_distribution_publish_metadata,
|
||||
read_game_cover_generation_price,
|
||||
generate_game_distribution_cover,
|
||||
read_game_distribution_publication,
|
||||
publish_local_project_game,
|
||||
read_game_creator_app_config,
|
||||
clear_game_creator_acl_elevation_denials,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -329,6 +329,7 @@ pub(crate) async fn upload_staged_game_package(
|
||||
client: &reqwest::Client,
|
||||
request: GamePackageUploadRequest<'_>,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
mut on_verify: impl FnMut(),
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
let total_bytes = fs::metadata(request.staging_path)
|
||||
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
|
||||
@@ -406,6 +407,7 @@ pub(crate) async fn upload_staged_game_package(
|
||||
on_progress(received, total_bytes);
|
||||
}
|
||||
let access_token = request.current_access_token()?;
|
||||
on_verify();
|
||||
let mut outcome = complete_upload(
|
||||
client,
|
||||
request.api_base_url,
|
||||
@@ -418,14 +420,20 @@ pub(crate) async fn upload_staged_game_package(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 进度事件的载荷形状(渲染进程按它显示进度)。
|
||||
/// 发布进度载荷;上传阶段带字节进度,其他阶段按阶段文案展示。
|
||||
pub(crate) fn progress_event_payload(
|
||||
version_id: &str,
|
||||
publish_id: &str,
|
||||
phase: &str,
|
||||
message: &str,
|
||||
received_bytes: u64,
|
||||
total_bytes: u64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"versionId": version_id,
|
||||
"publishId": publish_id,
|
||||
"phase": phase,
|
||||
"message": message,
|
||||
"receivedBytes": received_bytes,
|
||||
"totalBytes": total_bytes,
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user