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