Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87e52860a7 | |||
| efbdf7031e | |||
| 093f832ef9 | |||
| cc958f3678 | |||
| cf19241544 | |||
| e0c324c0ac | |||
| 985df53a4e | |||
| 1e0aeb95b0 | |||
| 5b1fc59d63 | |||
| c723e0b1bf | |||
| 5e5407e4cf | |||
| 16361bfbe1 | |||
| d7440473af | |||
| 8d487685f3 | |||
| 09fb8bc076 |
+11
@@ -41,3 +41,14 @@
|
||||
## 注意
|
||||
|
||||
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。
|
||||
|
||||
## 通用表查询页的枚举展示(2026-09-23 起)
|
||||
|
||||
后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析:
|
||||
|
||||
- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。
|
||||
- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。
|
||||
- 映射同时应用到 `cells` 与 `raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。
|
||||
- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。
|
||||
|
||||
因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`。
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
restoreAdminGameDistributionGame,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
updateAdminAccount,
|
||||
@@ -501,7 +503,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
|
||||
{
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 3,
|
||||
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -521,12 +522,90 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
|
||||
body: JSON.stringify({
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 3,
|
||||
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ok: true, data: { games: [] } }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
game: {
|
||||
id: 'game/1',
|
||||
title: '测试游戏',
|
||||
status: 'published',
|
||||
publicationRevision: 10,
|
||||
},
|
||||
replayed: false,
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const controller = new AbortController();
|
||||
|
||||
await listAdminGameDistributionGames(
|
||||
'admin-token',
|
||||
{ limit: 80 },
|
||||
controller.signal,
|
||||
);
|
||||
await restoreAdminGameDistributionGame(
|
||||
'admin-token',
|
||||
' game/1 ',
|
||||
' game-restore-key-1 ',
|
||||
{ expectedPublicationRevision: 9 },
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=50',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games/game%2F1/restore',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
'Idempotency-Key': 'game-restore-key-1',
|
||||
}),
|
||||
body: JSON.stringify({ expectedPublicationRevision: 9 }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
restoreAdminGameDistributionGame('admin-token', ' ', 'key', {
|
||||
expectedPublicationRevision: 9,
|
||||
}),
|
||||
).toThrow('缺少游戏 ID');
|
||||
expect(() =>
|
||||
restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', {
|
||||
expectedPublicationRevision: 9,
|
||||
}),
|
||||
).toThrow('恢复幂等键必须是 1 到 128 个字符');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -32,6 +32,9 @@ import type {
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionRestoreRequest,
|
||||
AdminGameDistributionRestoreResponse,
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
@@ -1240,6 +1243,46 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? 50;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
|
||||
: 50;
|
||||
return request<AdminGameDistributionGameListResponse>(
|
||||
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreAdminGameDistributionGame(
|
||||
token: string,
|
||||
gameId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminGameDistributionRestoreRequest,
|
||||
) {
|
||||
const normalizedGameId = gameId.trim();
|
||||
const normalizedKey = idempotencyKey.trim();
|
||||
if (!normalizedGameId) {
|
||||
throw new Error('缺少游戏 ID');
|
||||
}
|
||||
if (!normalizedKey || normalizedKey.length > 128) {
|
||||
throw new Error('恢复幂等键必须是 1 到 128 个字符');
|
||||
}
|
||||
return request<AdminGameDistributionRestoreResponse>(
|
||||
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`,
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
headers: { 'Idempotency-Key': normalizedKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
|
||||
* 两条审核结论。
|
||||
|
||||
@@ -1110,7 +1110,6 @@ export interface AdminGameDistributionReviewRequest {
|
||||
decision: 'approve' | 'reject';
|
||||
expectedPublicationRevision: number;
|
||||
reviewReason?: string;
|
||||
entryUrl?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionReviewResponse {
|
||||
@@ -1133,6 +1132,57 @@ export interface AdminGameDistributionSuspendResponse {
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionGameVersionEntry {
|
||||
versionId: string;
|
||||
gameId: string;
|
||||
versionNumber: number;
|
||||
status: string;
|
||||
reviewReason: string | null;
|
||||
packageBytes: number;
|
||||
packageSha256: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
reviewedAt: string | null;
|
||||
publishedAt: string | null;
|
||||
entryUrl: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionGameEntry {
|
||||
gameId: string;
|
||||
title: string;
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
status: string;
|
||||
versionCount: number;
|
||||
playCount: number;
|
||||
activeVersionId: string | null;
|
||||
publicationRevision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
versions: AdminGameDistributionGameVersionEntry[];
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionGameListResponse {
|
||||
games: AdminGameDistributionGameEntry[];
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionRestoreRequest {
|
||||
expectedPublicationRevision: number;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionRestoreResponse {
|
||||
game: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
};
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration
|
||||
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -321,6 +322,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-management' ? (
|
||||
<AdminGameManagementPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-assets' ? (
|
||||
<AdminEditorAssetQueryPage
|
||||
token={token}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ReceiptText,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Swords,
|
||||
Table2,
|
||||
TicketCheck,
|
||||
TicketPercent,
|
||||
@@ -52,6 +53,7 @@ const routeIcons = {
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
|
||||
@@ -168,6 +168,16 @@ test('后台游戏审核路由可通过导航和 hash 访问', () => {
|
||||
expect(routeHash('game-distribution')).toBe('#game-distribution');
|
||||
});
|
||||
|
||||
test('后台游戏管理路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'game-management',
|
||||
label: '游戏管理',
|
||||
hash: '#game-management',
|
||||
});
|
||||
expect(resolveAdminRoute('#game-management')).toBe('game-management');
|
||||
expect(routeHash('game-management')).toBe('#game-management');
|
||||
});
|
||||
|
||||
test('member 可单独获得游戏审核 Tab 权限', () => {
|
||||
const routes = getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
|
||||
@@ -17,6 +17,7 @@ export type AdminRouteId =
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -60,6 +61,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -9,10 +15,7 @@ import {
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
||||
import {
|
||||
AdminGameDistributionReviewPage,
|
||||
resolveGameReleaseEntryUrlError,
|
||||
} from './AdminGameDistributionReviewPage';
|
||||
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
isAdminApiError: vi.fn(
|
||||
@@ -53,23 +56,7 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
|
||||
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
|
||||
).toBe('发行入口必须以 https:// 开头');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
|
||||
).toBe('发行入口不能包含 query 或 fragment');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
|
||||
).toBe('发行入口不能包含凭据');
|
||||
expect(
|
||||
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
|
||||
test('通过审核只提交当前 publicationRevision 并刷新列表', async () => {
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
|
||||
version: { ...entry, status: 'published' },
|
||||
replayed: false,
|
||||
@@ -83,9 +70,10 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发行入口'), {
|
||||
target: { value: 'https://games.test/releases/game_1/index.html' },
|
||||
});
|
||||
expect(screen.queryByLabelText('发行入口')).toBeNull();
|
||||
expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull();
|
||||
expect(screen.queryByLabelText('拒绝理由')).toBeNull();
|
||||
expect(screen.queryByLabelText('下架原因')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '通过' }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -99,7 +87,6 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
|
||||
expect(payload).toEqual({
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 4,
|
||||
entryUrl: 'https://games.test/releases/game_1/index.html',
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
|
||||
@@ -108,7 +95,12 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少拒绝理由时不调用审核接口', async () => {
|
||||
test('点击拒绝后填写理由再提交审核接口', async () => {
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
|
||||
version: { ...entry, status: 'rejected', reviewReason: '运行时报错' },
|
||||
replayed: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
@@ -118,12 +110,32 @@ test('缺少拒绝理由时不调用审核接口', async () => {
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const reasonInput = within(dialog).getByRole('textbox', {
|
||||
name: '拒绝理由',
|
||||
});
|
||||
|
||||
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
|
||||
expect(await within(dialog).findByText('拒绝审核必须填写理由')).toBeTruthy();
|
||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(reasonInput, { target: { value: '运行时报错' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [, versionId, , payload] =
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
|
||||
expect(versionId).toBe('version-1');
|
||||
expect(payload).toEqual({
|
||||
decision: 'reject',
|
||||
expectedPublicationRevision: 4,
|
||||
reviewReason: '运行时报错',
|
||||
});
|
||||
});
|
||||
|
||||
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
|
||||
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game_1',
|
||||
@@ -142,16 +154,21 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('下架原因'), {
|
||||
target: { value: '盗用素材' },
|
||||
});
|
||||
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(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole('dialog')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
@@ -168,7 +185,24 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async
|
||||
expect(await screen.findByText(/已安全下架/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('取消确认时不下架', async () => {
|
||||
test('取消理由输入时不做审核操作', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('取消安全下架确认时不下架', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
@@ -178,8 +212,20 @@ test('取消确认时不下架', async () => {
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
await screen.findByRole('dialog');
|
||||
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: '继续下架' }),
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Modal, TextField } from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
@@ -15,6 +16,11 @@ interface AdminGameDistributionReviewPageProps {
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
interface ReviewReasonPrompt {
|
||||
decision: 'reject' | 'suspend';
|
||||
entry: AdminGameDistributionReviewEntry;
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
@@ -47,26 +53,6 @@ function createReviewIdempotencyKey(versionId: string) {
|
||||
return `game-review-${versionId}-${random}`.slice(0, 128);
|
||||
}
|
||||
|
||||
export function resolveGameReleaseEntryUrlError(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return '请填写发行入口';
|
||||
if (!normalized.startsWith('https://')) {
|
||||
return '发行入口必须以 https:// 开头';
|
||||
}
|
||||
if (normalized.includes('?') || normalized.includes('#')) {
|
||||
return '发行入口不能包含 query 或 fragment';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
if (parsed.username || parsed.password) {
|
||||
return '发行入口不能包含凭据';
|
||||
}
|
||||
} catch {
|
||||
return '发行入口不是合法 URL';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function AdminGameDistributionReviewPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
@@ -78,15 +64,11 @@ export function AdminGameDistributionReviewPage({
|
||||
const [busyVersionId, setBusyVersionId] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [reasonByVersion, setReasonByVersion] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [reasonPrompt, setReasonPrompt] = useState<ReviewReasonPrompt | null>(
|
||||
null,
|
||||
);
|
||||
const [reasonDraft, setReasonDraft] = useState('');
|
||||
const [reasonError, setReasonError] = useState('');
|
||||
const [busyGameId, setBusyGameId] = useState('');
|
||||
const writeConfirm = useAdminWriteConfirm();
|
||||
|
||||
@@ -110,16 +92,10 @@ export function AdminGameDistributionReviewPage({
|
||||
async function submitReview(
|
||||
entry: AdminGameDistributionReviewEntry,
|
||||
decision: 'approve' | 'reject',
|
||||
reason = '',
|
||||
) {
|
||||
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
|
||||
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
|
||||
if (decision === 'approve') {
|
||||
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
|
||||
if (invalid) {
|
||||
setErrorMessage(invalid);
|
||||
return;
|
||||
}
|
||||
} else if (!reason) {
|
||||
const trimmedReason = reason.trim();
|
||||
if (decision === 'reject' && !trimmedReason) {
|
||||
setErrorMessage('拒绝审核必须填写理由');
|
||||
return;
|
||||
}
|
||||
@@ -135,12 +111,11 @@ export function AdminGameDistributionReviewPage({
|
||||
? {
|
||||
decision,
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
entryUrl,
|
||||
}
|
||||
: {
|
||||
decision,
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
reviewReason: reason,
|
||||
reviewReason: trimmedReason,
|
||||
},
|
||||
);
|
||||
setStatusMessage(
|
||||
@@ -160,8 +135,11 @@ export function AdminGameDistributionReviewPage({
|
||||
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
|
||||
* 由服务端返回冲突,前端只提示刷新,不静默重试。
|
||||
*/
|
||||
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
|
||||
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
|
||||
async function suspendGame(
|
||||
entry: AdminGameDistributionReviewEntry,
|
||||
reason: string,
|
||||
) {
|
||||
const trimmedReason = reason.trim();
|
||||
const confirmed = await writeConfirm.confirmWrite({
|
||||
action: '安全下架游戏',
|
||||
target: `${entry.gameId}(版本 v${entry.versionNumber})`,
|
||||
@@ -177,11 +155,10 @@ export function AdminGameDistributionReviewPage({
|
||||
createSuspendIdempotencyKey(entry.gameId),
|
||||
{
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
...(reason ? { reason } : {}),
|
||||
...(trimmedReason ? { reason: trimmedReason } : {}),
|
||||
},
|
||||
);
|
||||
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
|
||||
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
|
||||
await loadReviews();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
@@ -190,6 +167,39 @@ export function AdminGameDistributionReviewPage({
|
||||
}
|
||||
}
|
||||
|
||||
function openReasonPrompt(
|
||||
entry: AdminGameDistributionReviewEntry,
|
||||
decision: ReviewReasonPrompt['decision'],
|
||||
) {
|
||||
setReasonDraft('');
|
||||
setReasonError('');
|
||||
setReasonPrompt({ decision, entry });
|
||||
}
|
||||
|
||||
function closeReasonPrompt() {
|
||||
setReasonPrompt(null);
|
||||
setReasonDraft('');
|
||||
setReasonError('');
|
||||
}
|
||||
|
||||
function confirmReasonPrompt() {
|
||||
if (!reasonPrompt) return;
|
||||
|
||||
const reason = reasonDraft.trim();
|
||||
if (reasonPrompt.decision === 'reject' && !reason) {
|
||||
setReasonError('拒绝审核必须填写理由');
|
||||
return;
|
||||
}
|
||||
|
||||
const { decision, entry } = reasonPrompt;
|
||||
closeReasonPrompt();
|
||||
if (decision === 'reject') {
|
||||
void submitReview(entry, 'reject', reason);
|
||||
return;
|
||||
}
|
||||
void suspendGame(entry, reason);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
@@ -268,25 +278,6 @@ export function AdminGameDistributionReviewPage({
|
||||
<td>{formatTime(entry.createdAt)}</td>
|
||||
<td>
|
||||
<div className="admin-action-row">
|
||||
<div className="admin-field">
|
||||
<label
|
||||
htmlFor={`game-release-url-${entry.versionId}`}
|
||||
>
|
||||
发行入口
|
||||
</label>
|
||||
<input
|
||||
id={`game-release-url-${entry.versionId}`}
|
||||
value={entryUrlByVersion[entry.versionId] ?? ''}
|
||||
placeholder="https://"
|
||||
onChange={(event) =>
|
||||
setEntryUrlByVersion((current) => ({
|
||||
...current,
|
||||
[entry.versionId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-primary-button"
|
||||
@@ -295,55 +286,19 @@ export function AdminGameDistributionReviewPage({
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<div className="admin-field">
|
||||
<label
|
||||
htmlFor={`game-reject-reason-${entry.versionId}`}
|
||||
>
|
||||
拒绝理由
|
||||
</label>
|
||||
<input
|
||||
id={`game-reject-reason-${entry.versionId}`}
|
||||
value={reasonByVersion[entry.versionId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setReasonByVersion((current) => ({
|
||||
...current,
|
||||
[entry.versionId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-ghost-button"
|
||||
disabled={busy}
|
||||
onClick={() => void submitReview(entry, 'reject')}
|
||||
onClick={() => openReasonPrompt(entry, 'reject')}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<div className="admin-field">
|
||||
<label
|
||||
htmlFor={`game-suspend-reason-${entry.versionId}`}
|
||||
>
|
||||
下架原因
|
||||
</label>
|
||||
<input
|
||||
id={`game-suspend-reason-${entry.versionId}`}
|
||||
value={suspendReasonByGame[entry.gameId] ?? ''}
|
||||
onChange={(event) =>
|
||||
setSuspendReasonByGame((current) => ({
|
||||
...current,
|
||||
[entry.gameId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-danger-button"
|
||||
disabled={busy || busyGameId === entry.gameId}
|
||||
onClick={() => void suspendGame(entry)}
|
||||
onClick={() => openReasonPrompt(entry, 'suspend')}
|
||||
>
|
||||
{busyGameId === entry.gameId
|
||||
? '正在下架…'
|
||||
@@ -359,6 +314,52 @@ export function AdminGameDistributionReviewPage({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{reasonPrompt ? (
|
||||
<Modal
|
||||
open
|
||||
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
|
||||
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
|
||||
closeLabel="关闭理由输入"
|
||||
onClose={closeReasonPrompt}
|
||||
size="sm"
|
||||
className="genarrative-ui"
|
||||
footer={
|
||||
<div className="admin-confirm-actions" style={{ width: '100%' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
onClick={closeReasonPrompt}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
reasonPrompt.decision === 'reject'
|
||||
? 'admin-ghost-button'
|
||||
: 'admin-danger-button'
|
||||
}
|
||||
onClick={confirmReasonPrompt}
|
||||
>
|
||||
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
multiline
|
||||
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
|
||||
value={reasonDraft}
|
||||
error={reasonError}
|
||||
rows={4}
|
||||
onChange={(event) => {
|
||||
setReasonDraft(event.target.value);
|
||||
if (reasonError) setReasonError('');
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
) : null}
|
||||
{writeConfirm.confirmDialog}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
listAdminGameDistributionGames,
|
||||
restoreAdminGameDistributionGame,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionGameEntry,
|
||||
AdminGameDistributionGameVersionEntry,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameManagementPage } from './AdminGameManagementPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
isAdminApiError: vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'status' in error &&
|
||||
typeof error.status === 'number',
|
||||
),
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
listAdminGameDistributionGames: vi.fn(),
|
||||
restoreAdminGameDistributionGame: vi.fn(),
|
||||
suspendAdminGameDistributionGame: vi.fn(),
|
||||
}));
|
||||
|
||||
const version: AdminGameDistributionGameVersionEntry = {
|
||||
versionId: 'version-3',
|
||||
gameId: 'game_1',
|
||||
versionNumber: 3,
|
||||
status: 'published',
|
||||
reviewReason: '测试原因',
|
||||
packageBytes: 2048,
|
||||
packageSha256:
|
||||
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T08:00:00Z',
|
||||
reviewedAt: '2026-09-20T09:00:00Z',
|
||||
publishedAt: '2026-09-20T10:00:00Z',
|
||||
entryUrl: 'https://game.example.com/game_1',
|
||||
};
|
||||
|
||||
const publishedGame: AdminGameDistributionGameEntry = {
|
||||
gameId: 'game_1',
|
||||
title: '测试游戏',
|
||||
author: {
|
||||
id: 'user_1',
|
||||
name: '作者甲',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
},
|
||||
status: 'published',
|
||||
versionCount: 21,
|
||||
playCount: 345,
|
||||
activeVersionId: 'version-3',
|
||||
publicationRevision: 4,
|
||||
createdAt: '2026-09-18T08:00:00Z',
|
||||
updatedAt: '2026-09-20T10:00:00Z',
|
||||
versions: [version],
|
||||
};
|
||||
|
||||
const suspendedGame: AdminGameDistributionGameEntry = {
|
||||
gameId: 'game_2',
|
||||
title: '下架游戏',
|
||||
author: {
|
||||
id: 'user_2',
|
||||
name: '作者乙',
|
||||
avatarUrl: null,
|
||||
},
|
||||
status: 'suspended',
|
||||
versionCount: 2,
|
||||
playCount: 8,
|
||||
activeVersionId: null,
|
||||
publicationRevision: 7,
|
||||
createdAt: '2026-09-19T08:00:00Z',
|
||||
updatedAt: '2026-09-21T08:00:00Z',
|
||||
versions: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminGameDistributionGames)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ games: [publishedGame] });
|
||||
vi.mocked(restoreAdminGameDistributionGame)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
game: {
|
||||
id: suspendedGame.gameId,
|
||||
title: suspendedGame.title,
|
||||
status: 'published',
|
||||
publicationRevision: 8,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
vi.mocked(suspendAdminGameDistributionGame)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
game: {
|
||||
id: publishedGame.gameId,
|
||||
title: publishedGame.title,
|
||||
status: 'suspended',
|
||||
publicationRevision: 5,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('列表展示作者头像、状态、版本数和游玩数', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
|
||||
games: [publishedGame, suspendedGame],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const publishedRow = (await screen.findByText('测试游戏')).closest('tr')!;
|
||||
expect(within(publishedRow).getByText('作者甲')).toBeTruthy();
|
||||
const avatar = within(publishedRow).getByRole('img', {
|
||||
name: '作者甲 头像',
|
||||
});
|
||||
expect(avatar.getAttribute('src')).toBe('https://example.com/avatar.png');
|
||||
expect(within(publishedRow).getByText('已公开')).toBeTruthy();
|
||||
expect(within(publishedRow).getByText('21')).toBeTruthy();
|
||||
expect(within(publishedRow).getByText('345')).toBeTruthy();
|
||||
|
||||
const suspendedRow = (await screen.findByText('下架游戏')).closest('tr')!;
|
||||
expect(within(suspendedRow).getByText('作者乙')).toBeTruthy();
|
||||
expect(suspendedRow.querySelector('.admin-user-avatar')?.textContent).toBe(
|
||||
'作',
|
||||
);
|
||||
expect(within(suspendedRow).getByText('已下架')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列表', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames)
|
||||
.mockResolvedValueOnce({ games: [suspendedGame] })
|
||||
.mockResolvedValueOnce({
|
||||
games: [{ ...suspendedGame, status: 'published' }],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('下架游戏');
|
||||
expect(screen.queryByRole('button', { name: '下架' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
|
||||
|
||||
expect(restoreAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(restoreAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const [token, gameId, idempotencyKey, payload] =
|
||||
vi.mocked(restoreAdminGameDistributionGame).mock.calls[0] ?? [];
|
||||
expect(token).toBe('admin-token');
|
||||
expect(gameId).toBe('game_2');
|
||||
expect(String(idempotencyKey)).toContain('game_2');
|
||||
expect(payload).toEqual({ expectedPublicationRevision: 7 });
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
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('游戏《测试游戏》已下架')).toBeTruthy();
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect(within(dialog).getByText('共 21 个版本,展示最近 20 个')).toBeTruthy();
|
||||
expect(within(dialog).getByText('v3')).toBeTruthy();
|
||||
expect(within(dialog).getByText('published')).toBeTruthy();
|
||||
expect(within(dialog).getByText('2.0 KiB')).toBeTruthy();
|
||||
expect(within(dialog).getByText('abcdef012345')).toBeTruthy();
|
||||
expect(within(dialog).getByText('测试原因')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText('https://game.example.com/game_1'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('接口失败显示错误文案', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
|
||||
new Error('游戏列表读取失败'),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('游戏列表读取失败')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('401 交给 onUnauthorized 处理', async () => {
|
||||
const onUnauthorized = vi.fn();
|
||||
vi.mocked(listAdminGameDistributionGames).mockRejectedValue(
|
||||
Object.assign(new Error('未授权'), { status: 401 }),
|
||||
);
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage
|
||||
token="admin-token"
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import { RefreshCcw, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAdminGameDistributionGames,
|
||||
restoreAdminGameDistributionGame,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionGameEntry,
|
||||
AdminGameDistributionGameVersionEntry,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminGameManagementPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
const GAME_STATUS_META: Record<string, { label: string; className: string }> = {
|
||||
published: { label: '已公开', className: 'admin-status-ok' },
|
||||
suspended: { label: '已下架', className: 'admin-status-error' },
|
||||
unpublished: { label: '未公开', className: 'admin-status-pending' },
|
||||
};
|
||||
|
||||
function gameStatusMeta(status: string) {
|
||||
return (
|
||||
GAME_STATUS_META[status] ?? {
|
||||
label: status || '—',
|
||||
className: 'admin-status-pending',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function formatOptionalTime(value: string | null) {
|
||||
return value ? formatTime(value) : '—';
|
||||
}
|
||||
|
||||
function authorName(entry: AdminGameDistributionGameEntry) {
|
||||
return entry.author?.name?.trim() || '—';
|
||||
}
|
||||
|
||||
function authorInitial(entry: AdminGameDistributionGameEntry) {
|
||||
const name = authorName(entry);
|
||||
return name === '—' ? '—' : (Array.from(name)[0] ?? '—');
|
||||
}
|
||||
|
||||
function createGameActionIdempotencyKey(
|
||||
action: 'suspend' | 'restore',
|
||||
gameId: string,
|
||||
) {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const prefix = action === 'suspend' ? 'game-suspend' : 'game-restore';
|
||||
return `${prefix}-${gameId}-${random}`.slice(0, 128);
|
||||
}
|
||||
|
||||
export function AdminGameManagementPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminGameManagementPageProps) {
|
||||
const [games, setGames] = useState<AdminGameDistributionGameEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [busyGameId, setBusyGameId] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [versionGame, setVersionGame] =
|
||||
useState<AdminGameDistributionGameEntry | null>(null);
|
||||
const writeConfirm = useAdminWriteConfirm();
|
||||
|
||||
const loadGames = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminGameDistributionGames(token, {
|
||||
limit: 50,
|
||||
});
|
||||
setGames(response.games);
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [token, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadGames();
|
||||
}, [loadGames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionGame) return undefined;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setVersionGame(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [versionGame]);
|
||||
|
||||
async function suspendGame(entry: AdminGameDistributionGameEntry) {
|
||||
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
|
||||
if (!reason) {
|
||||
setErrorMessage('下架原因不能为空');
|
||||
setStatusMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await writeConfirm.confirmWrite({
|
||||
action: '下架游戏',
|
||||
target: `${entry.title || entry.gameId}(${entry.gameId})`,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyGameId(entry.gameId);
|
||||
setErrorMessage('');
|
||||
setStatusMessage('');
|
||||
try {
|
||||
await suspendAdminGameDistributionGame(
|
||||
token,
|
||||
entry.gameId,
|
||||
createGameActionIdempotencyKey('suspend', entry.gameId),
|
||||
{
|
||||
expectedPublicationRevision: entry.publicationRevision,
|
||||
reason,
|
||||
},
|
||||
);
|
||||
setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`);
|
||||
setSuspendReasonByGame((current) => ({
|
||||
...current,
|
||||
[entry.gameId]: '',
|
||||
}));
|
||||
await loadGames();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setBusyGameId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreGame(entry: AdminGameDistributionGameEntry) {
|
||||
const confirmed = await writeConfirm.confirmWrite({
|
||||
action: '恢复游戏',
|
||||
target: `${entry.title || entry.gameId}(${entry.gameId})`,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
setBusyGameId(entry.gameId);
|
||||
setErrorMessage('');
|
||||
setStatusMessage('');
|
||||
try {
|
||||
await restoreAdminGameDistributionGame(
|
||||
token,
|
||||
entry.gameId,
|
||||
createGameActionIdempotencyKey('restore', entry.gameId),
|
||||
{ expectedPublicationRevision: entry.publicationRevision },
|
||||
);
|
||||
setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`);
|
||||
await loadGames();
|
||||
} catch (error) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setBusyGameId('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<h1>游戏管理</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
onClick={() => void loadGames()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert admin-alert-warning" role="alert">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{statusMessage ? (
|
||||
<div className="admin-alert admin-alert-success" role="status">
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h2>游戏列表</h2>
|
||||
<span className="admin-muted-text">共 {games.length} 条</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="admin-muted-text">正在加载游戏列表…</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && games.length === 0 ? (
|
||||
<p className="admin-muted-text">暂无游戏。</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && games.length > 0 ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>gameId</th>
|
||||
<th>状态</th>
|
||||
<th>版本数</th>
|
||||
<th>游玩数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{games.map((entry) => {
|
||||
const status = gameStatusMeta(entry.status);
|
||||
const isSuspended = entry.status === 'suspended';
|
||||
const busy = busyGameId === entry.gameId;
|
||||
return (
|
||||
<tr key={entry.gameId}>
|
||||
<td>
|
||||
<strong>{entry.title?.trim() || '—'}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div
|
||||
className="admin-database-user-cell"
|
||||
style={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
<div
|
||||
className="admin-user-avatar"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
flex: '0 0 32px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{entry.author?.avatarUrl ? (
|
||||
<img
|
||||
alt={`${authorName(entry)} 头像`}
|
||||
src={entry.author.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
authorInitial(entry)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span>{authorName(entry)}</span>
|
||||
<small>{entry.author?.id?.trim() || '—'}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{entry.gameId}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-status ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>{entry.versionCount}</td>
|
||||
<td>{entry.playCount}</td>
|
||||
<td>
|
||||
<div className="admin-action-row">
|
||||
{!isSuspended ? (
|
||||
<>
|
||||
<div className="admin-field">
|
||||
<label
|
||||
htmlFor={`game-suspend-reason-${entry.gameId}`}
|
||||
>
|
||||
下架原因
|
||||
</label>
|
||||
<input
|
||||
id={`game-suspend-reason-${entry.gameId}`}
|
||||
value={
|
||||
suspendReasonByGame[entry.gameId] ?? ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
setSuspendReasonByGame((current) => ({
|
||||
...current,
|
||||
[entry.gameId]: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-danger-button"
|
||||
disabled={busy}
|
||||
onClick={() => void suspendGame(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '下架'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-primary-button"
|
||||
disabled={busy}
|
||||
onClick={() => void restoreGame(entry)}
|
||||
>
|
||||
{busy ? '处理中…' : '恢复'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() => setVersionGame(entry)}
|
||||
>
|
||||
版本历史
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{versionGame ? (
|
||||
<div
|
||||
className="admin-confirm-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setVersionGame(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
aria-labelledby="admin-game-version-history-title"
|
||||
aria-modal="true"
|
||||
className="admin-detail-panel"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h2 id="admin-game-version-history-title">版本历史</h2>
|
||||
<span>
|
||||
{versionGame.title?.trim() || '—'}({versionGame.gameId})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="关闭版本历史"
|
||||
className="admin-ghost-button"
|
||||
type="button"
|
||||
onClick={() => setVersionGame(null)}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="admin-muted-text">
|
||||
共 {versionGame.versionCount} 个版本,展示最近 20 个
|
||||
</p>
|
||||
{versionGame.versions.length === 0 ? (
|
||||
<p className="admin-muted-text">暂无版本记录。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>版本</th>
|
||||
<th>状态</th>
|
||||
<th>包大小</th>
|
||||
<th>SHA</th>
|
||||
<th>创建时间</th>
|
||||
<th>审核时间</th>
|
||||
<th>公开时间</th>
|
||||
<th>审核原因</th>
|
||||
<th>发行入口</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versionGame.versions.map((version) => (
|
||||
<GameVersionRow
|
||||
key={version.versionId}
|
||||
version={version}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{writeConfirm.confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GameVersionRow({
|
||||
version,
|
||||
}: {
|
||||
version: AdminGameDistributionGameVersionEntry;
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td>v{version.versionNumber}</td>
|
||||
<td>{version.status || '—'}</td>
|
||||
<td>{formatBytes(version.packageBytes)}</td>
|
||||
<td>
|
||||
<code>{version.packageSha256?.slice(0, 12) || '—'}</code>
|
||||
</td>
|
||||
<td>{formatOptionalTime(version.createdAt)}</td>
|
||||
<td>{formatOptionalTime(version.reviewedAt)}</td>
|
||||
<td>{formatOptionalTime(version.publishedAt)}</td>
|
||||
<td>{version.reviewReason?.trim() || '—'}</td>
|
||||
<td>
|
||||
{version.entryUrl ? (
|
||||
<a href={version.entryUrl} rel="noreferrer" target="_blank">
|
||||
{version.entryUrl}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -459,7 +459,11 @@ export interface AgentRuntimeResult {
|
||||
}
|
||||
|
||||
export type AgentRuntimeResponseStreamStatus =
|
||||
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
|
||||
| 'streaming'
|
||||
| 'ready'
|
||||
| 'committed'
|
||||
| 'discarded'
|
||||
| 'failed';
|
||||
|
||||
export interface AgentRuntimeResponseStream {
|
||||
schemaVersion: string;
|
||||
@@ -565,13 +569,22 @@ export interface GameCreatorAgentLlmConfigStatus {
|
||||
}
|
||||
|
||||
export type GameCreatorLlmApiKind =
|
||||
'openai_responses' | 'openai_chat' | 'anthropic';
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
| 'anthropic';
|
||||
export type GameCreatorAgentMode =
|
||||
'codex_app_server' | 'codex_cli' | 'provider';
|
||||
| 'codex_app_server'
|
||||
| 'codex_cli'
|
||||
| 'provider';
|
||||
export type RuntimeLlmProviderPresetId =
|
||||
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
|
||||
| 'custom'
|
||||
| 'openai'
|
||||
| 'deepseek'
|
||||
| 'anthropic'
|
||||
| 'ark';
|
||||
export type RuntimeAgentLlmProviderPresetId =
|
||||
'inherit' | RuntimeLlmProviderPresetId;
|
||||
| 'inherit'
|
||||
| RuntimeLlmProviderPresetId;
|
||||
|
||||
export interface GameCreatorLlmConfig {
|
||||
customEnabled?: boolean;
|
||||
@@ -931,7 +944,9 @@ export type GameCreatorDirectToolCallKind =
|
||||
| 'other';
|
||||
|
||||
export type GameCreatorDirectToolCallStatus =
|
||||
'running' | 'completed' | 'failed';
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface GameCreatorDirectToolCallChange {
|
||||
path: string;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { CLIENT_MAINTENANCE_EVENT } from '../../services/clientApi';
|
||||
import { ThemedModal } from './ThemedModal';
|
||||
|
||||
/** 维护响应的唯一客户端出口,避免各业务面板把同一个 503 展示成不同兜底错误。 */
|
||||
export function MaintenanceNotice() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMaintenance = () => setOpen(true);
|
||||
window.addEventListener(CLIENT_MAINTENANCE_EVENT, handleMaintenance);
|
||||
return () =>
|
||||
window.removeEventListener(CLIENT_MAINTENANCE_EVENT, handleMaintenance);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open={open}
|
||||
ariaLabel="系统维护中"
|
||||
theme="light"
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
panelClassName="w-full max-w-2xl rounded-3xl border border-white/70 px-7 py-10 shadow-2xl sm:px-14 sm:py-14"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex min-h-[min(38vh,24rem)] flex-col items-center justify-center text-center">
|
||||
<div className="mb-5 text-5xl" aria-hidden="true">
|
||||
🛠️
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)]">
|
||||
系统维护中
|
||||
</h1>
|
||||
<p className="mt-5 max-w-lg text-base leading-8 text-[var(--platform-text-base)] sm:text-lg">
|
||||
服务正在维护,当前操作暂时无法完成。请稍后再试。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-9 min-w-36 rounded-full bg-[var(--platform-accent)] px-6 py-3 text-base font-semibold text-white shadow-sm transition hover:brightness-105"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
+17
-1
@@ -186,7 +186,23 @@ export function resolveLocalGamePreviewFitLayout(
|
||||
): LocalGamePreviewFitLayout {
|
||||
const containerWidth = Math.max(1, container.width);
|
||||
const containerHeight = Math.max(1, container.height);
|
||||
if (!content) {
|
||||
// 上报的「内容尺寸」等于它被接受时的容器尺寸,说明这个页面没有超出视口的固有内容——自适应的
|
||||
// 全屏游戏(Phaser `Scale.RESIZE` 那类)就是这样,桥把视口原样报回来。这份数字不携带固有尺寸,
|
||||
// 不能当高水位:否则运行视口一放大(例如全屏预览)就把画布钉在那个尺寸上,退出全屏后画布仍按
|
||||
// 全屏比例被缩进小容器、两边留出黑边,且再也回不去(iframe 视口不变 ⇒ 桥不会再报新尺寸)。
|
||||
// 这种页面继续让画布跟着容器走;真的比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。
|
||||
//
|
||||
// 已知残余(不修,因为它与上面这条在数据上不可区分):某个**固定尺寸**页面恰好等于它被接受时
|
||||
// 的容器(1px 内),且缩小容器后上报的内容尺寸再不变,就会一直按容器取画布——页面自身溢出被
|
||||
// `overflow: hidden` 裁掉。改成「内容尺寸没变也把这条记录改认新容器」会反过来让上面那种自适应
|
||||
// 页面的过渡期上报(内容仍是放大前的旧值、视口已是新容器)被当成固有尺寸,全屏那类问题原样
|
||||
// 复现(实测过)。AGC 的桥对溢出文档才报出更大的内容尺寸,实测自适应与固定画布两种页面都报
|
||||
// 「内容 = 视口」,所以按自适应优先。
|
||||
if (
|
||||
!content ||
|
||||
(Math.abs(content.contentWidth - content.viewportWidth) < 1 &&
|
||||
Math.abs(content.contentHeight - content.viewportHeight) < 1)
|
||||
) {
|
||||
return { width: containerWidth, height: containerHeight, scale: 1 };
|
||||
}
|
||||
const width = Math.max(containerWidth, content.contentWidth);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export type ElementFullscreenController<T extends HTMLElement> = {
|
||||
/** 要全屏的那一格;挂在它的 `ref` 上。 */
|
||||
ref: RefObject<T | null>;
|
||||
isFullscreen: boolean;
|
||||
isSupported: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行画面「全屏预览」用的**元素级**全屏。
|
||||
*
|
||||
* 只走标准 Fullscreen API:宿主是浏览器还是客户端 WebView 都是同一份实现。**不接 Tauri 的
|
||||
* 窗口级全屏**——那是把整块工作台(连对话栏)放大,语义是「全屏应用」,不是「全屏预览画面」。
|
||||
*
|
||||
* 支持判据是两层:`requestFullscreen` 真的在,且没有被宿主显式关掉
|
||||
* (`fullscreenEnabled === false`,例如被权限策略挡住)。任一不成立就不渲染这枚按钮——
|
||||
* 一枚点了没反应的全屏按钮比没有按钮更糟。
|
||||
*
|
||||
* 全屏元素被移除时浏览器按规范自己退出全屏(切回资源页签、切项目都走这条),所以这里不为
|
||||
* 卸载补退出逻辑;按钮态只认 `fullscreenchange`,Esc 与宿主自己退出都会回落。
|
||||
*/
|
||||
export function useElementFullscreen<
|
||||
T extends HTMLElement,
|
||||
>(): ElementFullscreenController<T> {
|
||||
const elementRef = useRef<T | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const isSupported =
|
||||
typeof document !== 'undefined' &&
|
||||
document.fullscreenEnabled !== false &&
|
||||
typeof document.documentElement?.requestFullscreen === 'function';
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
// 事件挂在全局 `document`、在回调里读 ref:运行画面是条件挂载的,按 ref 订监听会在
|
||||
// 「进运行页签之前」就订不上,退出全屏(Esc、F11、宿主)再也收不回来。
|
||||
const sync = () => {
|
||||
const element = elementRef.current;
|
||||
setIsFullscreen(
|
||||
element !== null && document.fullscreenElement === element,
|
||||
);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', sync);
|
||||
sync();
|
||||
return () => document.removeEventListener('fullscreenchange', sync);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const ownerDocument = element.ownerDocument;
|
||||
// 已经有人在全屏(本元素,或页面里别的东西)时这一步只负责退出:退出本身幂等,
|
||||
// 不需要先判断当前全屏的是不是自己。
|
||||
if (ownerDocument.fullscreenElement) {
|
||||
void ownerDocument.exitFullscreen?.()?.catch(() => {});
|
||||
return;
|
||||
}
|
||||
// 失败(用户手势丢失、权限策略拒绝)不改按钮状态,界面回到「还是没全屏」的原样;
|
||||
// 拒绝的 Promise 必须接住,否则会冒成未处理拒绝。
|
||||
void element.requestFullscreen?.()?.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return { ref: elementRef, isFullscreen, isSupported, toggleFullscreen };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { AuthenticatedClient, WorkspaceLauncher } from './App';
|
||||
import { MaintenanceNotice } from './components/modal/MaintenanceNotice';
|
||||
import { WindowChrome } from './components/WindowChrome';
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
@@ -16,6 +17,7 @@ createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<WorkspaceLauncher currentUser={user} onLogout={logout} />
|
||||
)}
|
||||
</AuthenticatedClient>
|
||||
<MaintenanceNotice />
|
||||
</WindowChrome>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../../../packages/shared/src';
|
||||
import { getStoredAuthAccessToken } from './clientAuth';
|
||||
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
|
||||
import { emitClientMaintenanceEvent } from './clientMaintenance';
|
||||
import { captureClientError } from './errorReporting';
|
||||
import {
|
||||
currentPlatformSessionGeneration,
|
||||
@@ -22,36 +23,97 @@ export {
|
||||
getStoredAuthAccessToken,
|
||||
setStoredAuthAccessToken,
|
||||
} from './clientAuth';
|
||||
export { CLIENT_MAINTENANCE_EVENT } from './clientMaintenance';
|
||||
|
||||
type ClientApiErrorOptions = {
|
||||
status?: number | null;
|
||||
networkError?: boolean;
|
||||
code?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export class ClientAuthRequestError extends Error {
|
||||
readonly status: number | null;
|
||||
readonly networkError: boolean;
|
||||
readonly code: string;
|
||||
readonly requestId: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: { status?: number | null; networkError?: boolean } = {},
|
||||
) {
|
||||
constructor(message: string, options: ClientApiErrorOptions = {}) {
|
||||
super(message);
|
||||
this.name = 'ClientAuthRequestError';
|
||||
this.status = options.status ?? null;
|
||||
this.networkError = options.networkError ?? false;
|
||||
this.code =
|
||||
options.code?.trim() ||
|
||||
(this.status ? `HTTP_${this.status}` : 'CLIENT_ERROR');
|
||||
this.requestId = options.requestId?.trim() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
async function readApiErrorMessage(
|
||||
export function isClientMaintenanceError(
|
||||
error: unknown,
|
||||
): error is ClientAuthRequestError {
|
||||
return (
|
||||
error instanceof ClientAuthRequestError &&
|
||||
(error.code.toUpperCase() === 'MAINTENANCE' ||
|
||||
(error.status === 503 && error.message.includes('维护')))
|
||||
);
|
||||
}
|
||||
|
||||
export function emitClientMaintenanceNotice(error: unknown) {
|
||||
if (!isClientMaintenanceError(error) || typeof window === 'undefined') return;
|
||||
const maintenanceError = error as ClientAuthRequestError;
|
||||
emitClientMaintenanceEvent({
|
||||
code: maintenanceError.code,
|
||||
status: maintenanceError.status,
|
||||
requestId: maintenanceError.requestId || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
type ParsedClientApiError = {
|
||||
message: string;
|
||||
code: string;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
async function readApiErrorInfo(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
url: string,
|
||||
) {
|
||||
): Promise<ParsedClientApiError> {
|
||||
const text = await readClientHttpResponseText(response, { url });
|
||||
if (!text.trim()) {
|
||||
return fallback;
|
||||
return {
|
||||
message: fallback,
|
||||
code: `HTTP_${response.status}`,
|
||||
requestId: '',
|
||||
};
|
||||
}
|
||||
try {
|
||||
unwrapApiResponse(JSON.parse(text) as unknown);
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
const parsed = JSON.parse(text) as {
|
||||
error?: { code?: unknown; message?: unknown };
|
||||
meta?: { requestId?: unknown };
|
||||
};
|
||||
const message =
|
||||
typeof parsed.error?.message === 'string' && parsed.error.message.trim()
|
||||
? parsed.error.message.trim()
|
||||
: fallback;
|
||||
const code =
|
||||
typeof parsed.error?.code === 'string' && parsed.error.code.trim()
|
||||
? parsed.error.code.trim()
|
||||
: `HTTP_${response.status}`;
|
||||
const requestId =
|
||||
typeof parsed.meta?.requestId === 'string'
|
||||
? parsed.meta.requestId.trim()
|
||||
: '';
|
||||
return { message, code, requestId };
|
||||
} catch {
|
||||
return {
|
||||
message: fallback,
|
||||
code: `HTTP_${response.status}`,
|
||||
requestId: '',
|
||||
};
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function captureApiErrorStatus(url: string, response: Response) {
|
||||
@@ -118,10 +180,14 @@ export async function requestClientApi<T>(
|
||||
}
|
||||
if (!response.ok) {
|
||||
captureApiErrorStatus(url, response);
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage, url),
|
||||
{ status: response.status },
|
||||
);
|
||||
const errorInfo = await readApiErrorInfo(response, fallbackMessage, url);
|
||||
const error = new ClientAuthRequestError(errorInfo.message, {
|
||||
status: response.status,
|
||||
code: errorInfo.code,
|
||||
requestId: errorInfo.requestId,
|
||||
});
|
||||
emitClientMaintenanceNotice(error);
|
||||
throw error;
|
||||
}
|
||||
const text = await readClientHttpResponseText(response, { url });
|
||||
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
|
||||
@@ -155,10 +221,14 @@ export async function requestClientApiBytes(
|
||||
}
|
||||
if (!response.ok) {
|
||||
captureApiErrorStatus(url, response);
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage, url),
|
||||
{ status: response.status },
|
||||
);
|
||||
const errorInfo = await readApiErrorInfo(response, fallbackMessage, url);
|
||||
const error = new ClientAuthRequestError(errorInfo.message, {
|
||||
status: response.status,
|
||||
code: errorInfo.code,
|
||||
requestId: errorInfo.requestId,
|
||||
});
|
||||
emitClientMaintenanceNotice(error);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getClientServerBaseUrl,
|
||||
readClientHttpResponseText,
|
||||
} from './clientHttp';
|
||||
import { emitClientMaintenanceEvent } from './clientMaintenance';
|
||||
import {
|
||||
type ClientOperation,
|
||||
createClientOperation,
|
||||
@@ -249,10 +250,11 @@ async function requestAuthJson<T>(
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ClientAuthRequestError(
|
||||
await readAuthErrorMessage(response, fallbackMessage),
|
||||
{ status: response.status },
|
||||
);
|
||||
const message = await readAuthErrorMessage(response, fallbackMessage);
|
||||
if (response.status === 503 && message.includes('维护')) {
|
||||
emitClientMaintenanceEvent({ status: response.status });
|
||||
}
|
||||
throw new ClientAuthRequestError(message, { status: response.status });
|
||||
}
|
||||
const text = await readClientHttpResponseText(response, {
|
||||
url,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const CLIENT_MAINTENANCE_EVENT =
|
||||
'genarrative-client-maintenance-detected';
|
||||
|
||||
export type ClientMaintenanceDetail = {
|
||||
code?: string;
|
||||
status?: number | null;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export function emitClientMaintenanceEvent(
|
||||
detail: ClientMaintenanceDetail = {},
|
||||
) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent(CLIENT_MAINTENANCE_EVENT, { detail }));
|
||||
}
|
||||
@@ -232,6 +232,9 @@ export async function generateGameDistributionCover(args: {
|
||||
aspectRatio: args.aspectRatio ?? '16:9',
|
||||
imageSize: args.imageSize ?? '2K',
|
||||
assetLabel: args.assetLabel?.trim() || '游戏封面',
|
||||
// 中文注释:队列结果按客户端来源选择回填契约;否则后端会按 Standard
|
||||
// consumer 紧凑化并不返回 result,生成完成后客户端自然拿不到平台素材 ID。
|
||||
generationInputs: { source: 'ai-game-creator-client' },
|
||||
}),
|
||||
},
|
||||
'生成游戏封面失败',
|
||||
|
||||
@@ -8731,6 +8731,43 @@ iframe.preview-frame {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都
|
||||
* 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮
|
||||
* 在全屏里照旧可用,用户点它就能退出来。
|
||||
*/
|
||||
.game-run-preview-fullscreen {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
border-radius: 10px;
|
||||
background: rgb(12 14 20 / 62%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.game-run-preview-fullscreen:hover,
|
||||
.game-run-preview-fullscreen:focus-visible {
|
||||
background: rgb(12 14 20 / 84%);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* 全屏里这一格就是整块屏幕:舞台自己的虚线边框与圆角让位给画面。 */
|
||||
.game-run-preview:fullscreen {
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #05070b;
|
||||
}
|
||||
|
||||
.game-run-preview-empty {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
@@ -8753,13 +8790,61 @@ iframe.preview-frame {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/*
|
||||
* 运行页签的底部信息栏。没有内容时整栏不渲染(判据在 `project-development/index.tsx`),
|
||||
* 收起态只剩上面那一行开合按钮——条目卡片的 156px 最小高度不会再变成一片空白色块。
|
||||
*
|
||||
* 卡片只在**有内容**时渲染:一栏也铺满整行,不留半张空位。原先「数值微调」那一栏没有登记表
|
||||
* (前端没有数据源),按用户口径没有功能就先不渲染,它的字段样式(label / input)随这一栏一起
|
||||
* 删掉;登记表接进来时样式与卡片一起回来。
|
||||
*/
|
||||
.game-run-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-run-panels-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #eadbd4;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #62483d;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle:hover,
|
||||
.game-run-panels-toggle:focus-visible {
|
||||
border-color: #dfb59f;
|
||||
background: #fdf1ea;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle-chevron {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.game-run-panels-toggle-chevron.is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.game-run-panels-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.game-run-panels > section {
|
||||
.game-run-panels-body > section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
@@ -8781,7 +8866,6 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-run-panels p,
|
||||
.game-run-panels label,
|
||||
.game-run-panels dt,
|
||||
.game-run-panels dd {
|
||||
margin: 0;
|
||||
@@ -8809,24 +8893,6 @@ iframe.preview-frame {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.game-run-panels label {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 88px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-run-panels input {
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #eadbd4;
|
||||
border-radius: 8px;
|
||||
background: #faf7f5;
|
||||
color: #8f7d75;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-workbench-chat {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -10173,7 +10239,7 @@ iframe.preview-frame {
|
||||
min-width: 460px;
|
||||
}
|
||||
|
||||
.game-run-panels {
|
||||
.game-run-panels-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import {
|
||||
AtSign,
|
||||
Box,
|
||||
ChevronDown,
|
||||
Crosshair,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
Music2,
|
||||
PackageOpen,
|
||||
@@ -141,6 +143,7 @@ import {
|
||||
resourceLabelResolver,
|
||||
resourceReferenceCategoryLabel,
|
||||
} from '../../features/project-workspace/resourceReferences';
|
||||
import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen';
|
||||
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
|
||||
import {
|
||||
type ResourceCanvasAssetGenerationPanelDraft,
|
||||
@@ -1818,6 +1821,9 @@ export default function ProjectDevelopmentView({
|
||||
}: ProjectDevelopmentViewProps) {
|
||||
const [mode, setMode] = useState<WorkbenchMode>('resources');
|
||||
const [runtimeInspectMode, setRuntimeInspectMode] = useState(false);
|
||||
// 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台,
|
||||
// 所以按钮与 ref 都归运行表现层自己持有。
|
||||
const runPreviewFullscreen = useElementFullscreen<HTMLDivElement>();
|
||||
const [resourceBookState, dispatchResourceBook] = useReducer(
|
||||
resourceBookReducer,
|
||||
initialResourceBookState,
|
||||
@@ -3000,6 +3006,31 @@ export default function ProjectDevelopmentView({
|
||||
const selectedResource =
|
||||
canvasResources.find((resource) => resource.id === selectedResourceId) ??
|
||||
null;
|
||||
/**
|
||||
* 运行页签底部信息栏(`.game-run-panels`)的开合。
|
||||
*
|
||||
* 只有一条判据:**有没有内容**——当前是「运行页里选中了一张资源」,「信息展示」渲染的是它
|
||||
* 的只读字段。内容从无到有 / 从有到无都自动跟上(没有内容就自动收起),用户在同一段内容里
|
||||
* 手动收 / 展则一直有效,不会被别的渲染重开。
|
||||
*
|
||||
* 手动态不挂 effect、也不按下标存活,而是在渲染期按**资源 id** 判定:手动的收 / 展只对
|
||||
* 做出这个动作时的那张资源有效,换资源或清空后回到默认(有内容就展开)。这样「刚有内容」
|
||||
* 的那一帧就已经是展开态——挂 effect 回写会先画一帧收起态再展开,画面高度会抖一下。
|
||||
*
|
||||
* 「数值微调」暂时没有登记表(前端没有数据源),按用户口径没有功能就先不渲染这一栏;等
|
||||
* 后端编辑态登记表接进来后,它与它的内容一起进这个判据。
|
||||
*/
|
||||
const runPanelsHaveContent = selectedResource !== null;
|
||||
const [runPanelsManualState, setRunPanelsManualState] = useState<{
|
||||
resourceId: string | null;
|
||||
expanded: boolean;
|
||||
} | null>(null);
|
||||
const runPanelsExpanded =
|
||||
runPanelsManualState !== null &&
|
||||
runPanelsManualState.resourceId === selectedResourceId
|
||||
? runPanelsManualState.expanded
|
||||
: runPanelsHaveContent;
|
||||
const runPanelsBodyId = useId();
|
||||
/**
|
||||
* 画布选中工具栏「编辑标签」的入口判定:
|
||||
* - 选中 1 项:既有的单素材标签编辑(增删标签行为不变);
|
||||
@@ -11002,7 +11033,7 @@ export default function ProjectDevelopmentView({
|
||||
</>
|
||||
) : (
|
||||
<section className="game-run-surface" aria-label="运行表现层">
|
||||
<div className="game-run-preview">
|
||||
<div className="game-run-preview" ref={runPreviewFullscreen.ref}>
|
||||
{embeddedPreviewUrl ? (
|
||||
<LocalGamePreviewFrame
|
||||
title={`${projectName} 游戏运行画面`}
|
||||
@@ -11018,24 +11049,81 @@ export default function ProjectDevelopmentView({
|
||||
<span>点击顶部播放按钮后将在这里直接运行游戏</span>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与
|
||||
底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染,
|
||||
宿主没有 Fullscreen API 时也不渲染(见 `useElementFullscreen`)。
|
||||
*/}
|
||||
{embeddedPreviewUrl && runPreviewFullscreen.isSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-run-preview-fullscreen"
|
||||
aria-label={
|
||||
runPreviewFullscreen.isFullscreen
|
||||
? '退出全屏预览'
|
||||
: '全屏预览'
|
||||
}
|
||||
aria-pressed={runPreviewFullscreen.isFullscreen}
|
||||
title={
|
||||
runPreviewFullscreen.isFullscreen
|
||||
? '退出全屏预览'
|
||||
: '全屏预览游戏画面'
|
||||
}
|
||||
onClick={runPreviewFullscreen.toggleFullscreen}
|
||||
>
|
||||
{runPreviewFullscreen.isFullscreen ? (
|
||||
<Minimize2 size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Maximize2 size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="game-run-panels">
|
||||
<section aria-label="资源信息面板">
|
||||
<header>
|
||||
<Info size={16} aria-hidden="true" />
|
||||
信息展示
|
||||
</header>
|
||||
{selectedResource ? (
|
||||
<ResourceInfoFieldsView resource={selectedResource} />
|
||||
{/*
|
||||
底部信息栏:有内容才存在,没有内容就自动收起(整栏不渲染)。
|
||||
收起态只留下这一行开合按钮,展开态才渲染条目卡片——卡片的 156px 最小高度
|
||||
因此不会再变成一片空白色块。
|
||||
*/}
|
||||
{runPanelsHaveContent ? (
|
||||
<div className="game-run-panels">
|
||||
<div className="game-run-panels-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="game-run-panels-toggle"
|
||||
aria-expanded={runPanelsExpanded}
|
||||
aria-controls={runPanelsBodyId}
|
||||
onClick={() =>
|
||||
setRunPanelsManualState({
|
||||
resourceId: selectedResourceId,
|
||||
expanded: !runPanelsExpanded,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`game-run-panels-toggle-chevron${
|
||||
runPanelsExpanded ? '' : ' is-collapsed'
|
||||
}`}
|
||||
/>
|
||||
{runPanelsExpanded ? '收起信息栏' : '展开信息栏'}
|
||||
</button>
|
||||
</div>
|
||||
{runPanelsExpanded ? (
|
||||
<div id={runPanelsBodyId} className="game-run-panels-body">
|
||||
<section aria-label="资源信息面板">
|
||||
<header>
|
||||
<Info size={16} aria-hidden="true" />
|
||||
信息展示
|
||||
</header>
|
||||
{selectedResource ? (
|
||||
<ResourceInfoFieldsView resource={selectedResource} />
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<section aria-label="数值微调面板">
|
||||
<header>
|
||||
<SlidersHorizontal size={16} aria-hidden="true" />
|
||||
数值微调
|
||||
</header>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
{/*
|
||||
|
||||
+1
-1
@@ -198,7 +198,7 @@ export function PlanningChatView({
|
||||
: undefined;
|
||||
const streaming = Boolean(
|
||||
animation &&
|
||||
(!animation.persisted || animation.visible !== animation.target),
|
||||
(!animation.persisted || animation.visible !== animation.target),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1716,7 +1716,8 @@ export function registerHomeProjectCreationTests() {
|
||||
'卡住的自动建项',
|
||||
);
|
||||
let resolveAutomaticProject:
|
||||
((result: Record<string, unknown>) => void) | null = null;
|
||||
| ((result: Record<string, unknown>) => void)
|
||||
| null = null;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'preflight_web_game_creation') return { status: 'ready' };
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user