Merge branch 'master' into opt/compile-warning
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 4m1s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m20s
Project CI / Repository checks (pull_request) Successful in 1m52s

This commit is contained in:
2026-09-23 22:36:36 +08:00
59 changed files with 3054 additions and 315 deletions
@@ -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`
+81 -2
View File
@@ -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(
+43
View File
@@ -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,
},
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
+51 -1
View File
@@ -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;
+7
View File
@@ -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}
+2
View File
@@ -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',
+2
View File
@@ -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 },
@@ -9,10 +9,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 +50,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 +64,8 @@ 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.getByText('通过后由系统分配发行地址')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
@@ -99,7 +79,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(
@@ -47,26 +47,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,9 +58,6 @@ 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>
>({});
@@ -111,15 +88,8 @@ export function AdminGameDistributionReviewPage({
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
) {
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) {
if (decision === 'reject' && !reason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
@@ -135,7 +105,6 @@ export function AdminGameDistributionReviewPage({
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
@@ -269,23 +238,9 @@ export function AdminGameDistributionReviewPage({
<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}
/>
<span className="admin-muted-text">
</span>
</div>
<button
type="button"
@@ -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>
);
}
+21 -6
View File
@@ -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;
@@ -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 };
}
@@ -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' },
}),
},
'生成游戏封面失败',
+88 -22
View File
@@ -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>
)}
{/*
@@ -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') {
@@ -3883,6 +3883,11 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(runTab);
const runInfoPanel = screen.getByLabelText('资源信息面板');
expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows);
// 有内容就自动展开;手动收起后条目卡片整体让位,只剩那一行开合按钮。
fireEvent.click(screen.getByRole('button', { name: '收起信息栏' }));
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '展开信息栏' }));
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
// 画布浮层只属于画布:切到运行视图后不再渲染。
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
});
@@ -6453,8 +6458,10 @@ export function registerProjectWorkbenchFoundationTests() {
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
);
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
// 底部信息栏没有内容就整栏不渲染:此时没有选中资源,「信息展示」拿不到字段,「数值微调」的
// 登记表也还没接,于是条目卡片与开合行都不该出现——留着就是验收现场那半条「空信息栏白占一块高度」。
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
@@ -276,6 +276,7 @@ test('封面生成直接使用返回的平台素材 ID,不再次上传', async
aspectRatio: '16:9',
imageSize: '2K',
assetLabel: '游戏封面',
generationInputs: { source: 'ai-game-creator-client' },
});
});
@@ -6,77 +6,151 @@ import { describe, expect, it, vi } from 'vitest';
import {
LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
type LocalGamePreviewContentSize,
LocalGamePreviewFrame,
parseLocalGamePreviewContentSize,
resolveLocalGamePreviewContentSizeUpdate,
resolveLocalGamePreviewFitLayout,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
/**
* 运行画面用 `getBoundingClientRect` 量容器、用 `ResizeObserver` 跟踪;jsdom 两者都不给,这里补一份
* 最小可驱动的:能改容器矩形、能手放 ResizeObserver 回调、能送跨窗口尺寸上报。
*/
function renderFittedFrame(initialContainer: {
width: number;
height: number;
}) {
let containerRect = initialContainer;
let resizeCallback: ResizeObserverCallback | null = null;
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(
() =>
({
...containerRect,
x: 0,
y: 0,
top: 0,
right: containerRect.width,
bottom: containerRect.height,
left: 0,
toJSON: () => ({}),
}) as DOMRect,
);
const previousResizeObserver = window.ResizeObserver;
window.ResizeObserver = class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
const view = render(
createElement(LocalGamePreviewFrame, {
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
title: 'preview',
}),
);
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
return {
iframe,
resizeTo(next: { width: number; height: number }) {
containerRect = next;
act(() => {
if (!resizeCallback) {
throw new Error('ResizeObserver was not registered');
}
resizeCallback([], {} as ResizeObserver);
});
},
reportSize(size: LocalGamePreviewContentSize) {
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
origin: 'http://127.0.0.1:1234',
source: iframe.contentWindow,
data: { type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, ...size },
}),
);
});
},
cleanup() {
view.unmount();
window.ResizeObserver = previousResizeObserver;
rectSpy.mockRestore();
},
};
}
describe('local game preview viewport fitting', () => {
it('does not reset the fitted iframe to native size while its container resizes', () => {
let containerRect = { width: 800, height: 500 };
let resizeCallback: ResizeObserverCallback | null = null;
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(
() =>
({
...containerRect,
x: 0,
y: 0,
top: 0,
right: containerRect.width,
bottom: containerRect.height,
left: 0,
toJSON: () => ({}),
}) as DOMRect,
);
const previousResizeObserver = window.ResizeObserver;
window.ResizeObserver = class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
const view = render(
createElement(LocalGamePreviewFrame, {
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
title: 'preview',
}),
);
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
origin: 'http://127.0.0.1:1234',
source: iframe.contentWindow,
data: {
type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
contentWidth: 800,
contentHeight: 835,
viewportWidth: 800,
viewportHeight: 500,
},
}),
);
const frame = renderFittedFrame({ width: 800, height: 500 });
frame.reportSize({
contentWidth: 800,
contentHeight: 835,
viewportWidth: 800,
viewportHeight: 500,
});
expect(iframe.style.height).toBe('835px');
expect(frame.iframe.style.height).toBe('835px');
containerRect = { width: 1000, height: 600 };
act(() => {
if (!resizeCallback) throw new Error('ResizeObserver was not registered');
resizeCallback([], {} as ResizeObserver);
frame.resizeTo({ width: 1000, height: 600 });
expect(frame.iframe.style.width).toBe('1000px');
expect(frame.iframe.style.height).toBe('835px');
frame.cleanup();
});
it('returns the fitted iframe to the container after the host viewport shrinks', () => {
// 全屏预览把运行视口放大到 1416x808,自适应游戏把视口原样报回来;退出全屏后画布必须跟着
// 缩回容器尺寸,不能把全屏那一帧的尺寸钉在画布上(改前这里会一直是 1416x808 + 缩放到 0.72)。
const frame = renderFittedFrame({ width: 1416, height: 808 });
frame.reportSize({
contentWidth: 1416,
contentHeight: 808,
viewportWidth: 1416,
viewportHeight: 808,
});
expect(iframe.style.width).toBe('1000px');
expect(iframe.style.height).toBe('835px');
expect(frame.iframe.style.width).toBe('1416px');
expect(frame.iframe.style.height).toBe('808px');
view.unmount();
window.ResizeObserver = previousResizeObserver;
rectSpy.mockRestore();
frame.resizeTo({ width: 1015, height: 660 });
expect(frame.iframe.style.width).toBe('1015px');
expect(frame.iframe.style.height).toBe('660px');
frame.cleanup();
});
it('refits to the reported content size after the container shrinks', () => {
// 容器缩小后先按容器取画布(上一次的内容尺寸已不能代表当前容器),页面在新容器上重新量出
// 更大的内容(真的溢出)时,画布回到 `max(容器, 内容)` 并把整幅内容等比缩小。
const frame = renderFittedFrame({ width: 1015, height: 660 });
frame.reportSize({
contentWidth: 1015,
contentHeight: 660,
viewportWidth: 1015,
viewportHeight: 660,
});
expect(frame.iframe.style.width).toBe('1015px');
frame.resizeTo({ width: 800, height: 520 });
expect(frame.iframe.style.width).toBe('800px');
frame.reportSize({
contentWidth: 1200,
contentHeight: 900,
viewportWidth: 800,
viewportHeight: 520,
});
expect(frame.iframe.style.width).toBe('1200px');
expect(frame.iframe.style.height).toBe('900px');
expect(
Number(/scale\(([\d.]+)\)/u.exec(frame.iframe.style.transform)?.[1]),
).toBeCloseTo(520 / 900, 10);
frame.cleanup();
});
it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => {
@@ -0,0 +1,142 @@
/** @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
const PREVIEW_URL = 'http://127.0.0.1:4173/';
function installInvoke() {
window.__TAURI__ = {
core: {
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
categories: [],
diagnostics: [],
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
}),
},
} as unknown as typeof window.__TAURI__;
}
function renderRunView() {
installInvoke();
const manifest = createGameCreationAppManifest(
'run-preview-fullscreen',
'运行页全屏预览',
);
render(
<ProjectDevelopmentView
projectName={manifest.name}
projectPath="/tmp/run-preview-fullscreen"
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={{ status: 'running', url: PREVIEW_URL, port: 4173 }}
supervisor={<div></div>}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
/>,
);
}
/**
* jsdom 完全没有 Fullscreen API`document.fullscreenEnabled` / `requestFullscreen` /
* `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来:
* 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。
*/
function installFullscreenHost() {
let fullscreenElement: Element | null = null;
const requestFullscreen = vi.fn(function (this: Element) {
// 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。
// eslint-disable-next-line @typescript-eslint/no-this-alias
fullscreenElement = this;
document.dispatchEvent(new Event('fullscreenchange'));
return Promise.resolve();
});
const exitFullscreen = vi.fn(() => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
return Promise.resolve();
});
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
Object.defineProperty(Element.prototype, 'requestFullscreen', {
configurable: true,
writable: true,
value: requestFullscreen,
});
return { requestFullscreen, exitFullscreen };
}
afterEach(() => {
document.body.innerHTML = '';
Reflect.deleteProperty(document, 'fullscreenElement');
Reflect.deleteProperty(document, 'exitFullscreen');
Reflect.deleteProperty(Element.prototype, 'requestFullscreen');
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行页「全屏预览」', () => {
it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => {
const { requestFullscreen, exitFullscreen } = installFullscreenHost();
renderRunView();
const button = await screen.findByRole('button', { name: '全屏预览' });
// 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。
const stage = document.querySelector('.game-run-preview');
expect(stage).not.toBeNull();
expect(button.closest('.game-run-preview')).toBe(stage);
expect(button.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(button);
expect(requestFullscreen).toHaveBeenCalledTimes(1);
// 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。
expect(document.fullscreenElement).toBe(stage);
const exitButton = await screen.findByRole('button', {
name: '退出全屏预览',
});
expect(exitButton.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(exitButton);
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(
(await screen.findByRole('button', { name: '全屏预览' })).getAttribute(
'aria-pressed',
),
).toBe('false');
});
it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => {
renderRunView();
await screen.findByTitle('运行页全屏预览 游戏运行画面');
expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull();
});
});
+4
View File
@@ -71,3 +71,7 @@ GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
# 游戏发行入口模板:审核通过时按 {gameId} 占位符派生每游戏独立来源地址,例如
# https://{gameId}.games.example.com/。模板必须含 {gameId},生产未配置时审核通过直接失败。
GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=

Some files were not shown because too many files have changed in this diff Show More