WIP: 作品管理与Phaser4客户端发布 #565
@@ -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,15 @@ import {
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAdminGameDistributionPreviewSession,
|
||||
getAdminGameDistributionVersion,
|
||||
listAdminGameDistributionReviews,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
||||
import type {
|
||||
AdminGameDistributionReviewEntry,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
@@ -28,11 +32,11 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
createAdminGameDistributionPreviewSession: vi.fn(),
|
||||
getAdminGameDistributionVersion: vi.fn(),
|
||||
listAdminGameDistributionReviews: vi.fn(),
|
||||
reviewAdminGameDistributionVersion: vi.fn(),
|
||||
suspendAdminGameDistributionGame: vi.fn(),
|
||||
}));
|
||||
|
||||
const entry: AdminGameDistributionReviewEntry = {
|
||||
versionId: 'version-1',
|
||||
gameId: 'game_1',
|
||||
@@ -42,14 +46,44 @@ 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',
|
||||
};
|
||||
|
||||
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(reviewAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockReset();
|
||||
vi.mocked(getAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockReset();
|
||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
||||
entries: [entry],
|
||||
nextCursor: null,
|
||||
@@ -135,17 +169,7 @@ 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"
|
||||
@@ -154,35 +178,35 @@ test('安全下架需要先填写原因,再二次确认并携带公开修订
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
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('game_1');
|
||||
|
||||
const row = screen.getByText('game_1').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 () => {
|
||||
@@ -202,7 +226,7 @@ test('取消理由输入时不做审核操作', async () => {
|
||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('取消安全下架确认时不下架', async () => {
|
||||
test('窄视口下 gameId 截断显示,完整值由 title 暴露', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
@@ -211,22 +235,46 @@ test('取消安全下架确认时不下架', async () => {
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
const reasonDialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(
|
||||
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
|
||||
{
|
||||
target: { value: '盗用素材' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(reasonDialog).getByRole('button', { name: '继续下架' }),
|
||||
);
|
||||
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
const gameIdCell = screen.getByTitle('game_1');
|
||||
expect(gameIdCell.textContent).toBe('game_1');
|
||||
});
|
||||
|
||||
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('game_1');
|
||||
|
||||
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,93 @@
|
||||
/*
|
||||
* 「游戏审核」待审版本表的页面专属样式。
|
||||
*
|
||||
* 目标: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;
|
||||
}
|
||||
|
||||
/* gameId 是 37 字符的定长标识:窄视口截断显示,靠 title 暴露完整值。 */
|
||||
.admin-game-review-game-id {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* 窄视口(≤1366)下把 gameId 降到 12px:13px 时它的文本宽 259px 只比列宽多 1px 的余量,
|
||||
* 字体回退或换行差异都会让它变成省略号;降到 12px 留出约 19px 余量,完整标识仍然可见。
|
||||
*/
|
||||
@media (max-width: 1366px) {
|
||||
table.admin-game-review-table td:first-child code {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端/窄屏:动作列保持不换行,仍靠省略号吸收 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;
|
||||
}
|
||||
}
|
||||
@@ -3507,3 +3507,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,
|
||||
|
||||
@@ -133,8 +133,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`
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
|
||||
@@ -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 目录")?;
|
||||
|
||||
@@ -84,7 +84,6 @@ import {
|
||||
type ResourceReferenceInsertEventDetail,
|
||||
} from './features/project-workspace/resourceReferences';
|
||||
import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog';
|
||||
import { readGamePublishAvailability } from './services/gameDistributionPublish';
|
||||
import {
|
||||
setAgcPluginProjectPath,
|
||||
startAvailableAgcEditorPlugins,
|
||||
@@ -330,8 +329,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('');
|
||||
@@ -862,20 +859,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]);
|
||||
|
||||
/**
|
||||
* 导出试玩包并打开发布面板。
|
||||
*
|
||||
@@ -910,6 +893,7 @@ export function App({
|
||||
setPublishProgress({
|
||||
phase: 'running',
|
||||
message: '正在构建并打包试玩包,请稍候…',
|
||||
step: 'build',
|
||||
});
|
||||
try {
|
||||
const result = await invoke<LocalProjectExportPackageResult>(
|
||||
@@ -1811,9 +1795,7 @@ export function App({
|
||||
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
|
||||
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
|
||||
initialTurn={initialDirectTurn}
|
||||
onRequestGamePublish={
|
||||
gamePublishAllowed ? requestGamePublish : undefined
|
||||
}
|
||||
onRequestGamePublish={requestGamePublish}
|
||||
onCancelConfirmation={cancelUiCommandConfirmation}
|
||||
onConfirmConfirmation={confirmUiCommand}
|
||||
pendingConfirmation={pendingUiConfirmation}
|
||||
@@ -1826,6 +1808,11 @@ export function App({
|
||||
manifest={manifest}
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
onPublished={(published) => {
|
||||
// 发布成功会把平台作品绑定写回本地清单;用原生返回的整份 manifest 覆盖
|
||||
// 工作台状态,两处面板挂载点(普通项目 / DirectProject)共享同一份状态。
|
||||
if (published.manifest) setManifest(published.manifest);
|
||||
}}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
@@ -1957,6 +1944,9 @@ export function App({
|
||||
manifest={manifest}
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
onPublished={(published) => {
|
||||
if (published.manifest) setManifest(published.manifest);
|
||||
}}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
|
||||
+350
-20
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
GAME_PUBLISH_STEPS,
|
||||
type GamePublishStepId,
|
||||
} from '../../services/gamePackageUploadProgress';
|
||||
|
||||
/**
|
||||
* 发布阶段步骤条:构建 → 准备 → 上传 → 校验 → 送审。
|
||||
*
|
||||
* 只做展示:当前阶段之前的一律标成已完成,当前阶段带百分比(只有上传阶段有可换算的总量),
|
||||
* 之后的仍为待执行。组件不订阅事件,进度由调用方(发布面板 / 全屏进度弹窗)注入。
|
||||
*/
|
||||
export function GamePublishPhaseSteps({
|
||||
activeStep,
|
||||
percent = null,
|
||||
message = '',
|
||||
}: {
|
||||
activeStep: GamePublishStepId;
|
||||
percent?: number | null;
|
||||
message?: string;
|
||||
}) {
|
||||
const activeIndex = Math.max(
|
||||
0,
|
||||
GAME_PUBLISH_STEPS.findIndex((step) => step.id === activeStep),
|
||||
);
|
||||
const activeLabel = GAME_PUBLISH_STEPS[activeIndex]?.label ?? '';
|
||||
return (
|
||||
<div className="game-publish-phase">
|
||||
<ol className="game-publish-phase__steps" aria-label="发布阶段">
|
||||
{GAME_PUBLISH_STEPS.map((step, index) => {
|
||||
const state =
|
||||
index === activeIndex
|
||||
? 'active'
|
||||
: index < activeIndex
|
||||
? 'done'
|
||||
: 'pending';
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`game-publish-phase__step is-${state}`}
|
||||
aria-current={state === 'active' ? 'step' : undefined}
|
||||
>
|
||||
<span className="game-publish-phase__marker" aria-hidden="true">
|
||||
{state === 'done' ? '✓' : index + 1}
|
||||
</span>
|
||||
<span className="game-publish-phase__label">{step.label}</span>
|
||||
{state === 'active' && percent !== null ? (
|
||||
<span className="game-publish-phase__percent">{percent}%</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{percent === null ? null : (
|
||||
<div
|
||||
className="game-publish-phase__bar"
|
||||
role="progressbar"
|
||||
aria-label={`${activeLabel}进度`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{message ? (
|
||||
<p className="game-publish-phase__message">{message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
@@ -1,11 +1,17 @@
|
||||
import { CircleAlert, LoaderCircle } from 'lucide-react';
|
||||
|
||||
import type { GamePublishStepId } from '../../services/gamePackageUploadProgress';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
import { GamePublishPhaseSteps } from './GamePublishPhaseSteps';
|
||||
|
||||
export type GamePublishProgressState =
|
||||
| {
|
||||
phase: 'running';
|
||||
message: string;
|
||||
/** 当前步骤;缺省按「构建」处理——导出打包是发布链路的第一步。 */
|
||||
step?: GamePublishStepId;
|
||||
/** 只有上传阶段有可换算的百分比;其它阶段为 `null`(不确定进度)。 */
|
||||
percent?: number | null;
|
||||
}
|
||||
| {
|
||||
phase: 'failed';
|
||||
@@ -21,6 +27,7 @@ export function GamePublishProgressDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const failed = progress?.phase === 'failed';
|
||||
const running = progress?.phase === 'running' ? progress : null;
|
||||
return (
|
||||
<ThemedModal
|
||||
open={progress !== null}
|
||||
@@ -46,6 +53,12 @@ export function GamePublishProgressDialog({
|
||||
<p role={failed ? 'alert' : 'status'}>
|
||||
{failed ? progress.detail : progress?.message}
|
||||
</p>
|
||||
{running ? (
|
||||
<GamePublishPhaseSteps
|
||||
activeStep={running.step ?? 'build'}
|
||||
percent={running.percent ?? null}
|
||||
/>
|
||||
) : null}
|
||||
{failed ? (
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { GameCreationAppManifest } from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
type GameCreationAppManifest,
|
||||
type GameCreationAppPublicationBinding,
|
||||
resolveGameCreationAppProjectVersion,
|
||||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
GameDistributionCategory,
|
||||
GameDistributionCreateGameRequest,
|
||||
GameDistributionDeviceSupport,
|
||||
GameDistributionInputMode,
|
||||
GameDistributionOrientation,
|
||||
} from '../../../../packages/shared/src/contracts/gameDistribution';
|
||||
@@ -38,19 +43,6 @@ function requireNativeInvoke(invoke?: TauriInvoke) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前账号的游戏发布灰度。
|
||||
*
|
||||
* 与网页端共用 `/api/runtime/frontend-config`:后端未配置 `game-distribution:publish`
|
||||
* 时对已登录作者默认开放,运营收紧后只有白名单/灰度命中的作者返回 `true`。读取失败按
|
||||
* “不开放入口”处理——写入口本身还会再拦一次,AGC 面板不作为唯一把关点。
|
||||
*/
|
||||
export async function readGamePublishAvailability(
|
||||
invoke?: TauriInvoke,
|
||||
): Promise<boolean> {
|
||||
return requireNativeInvoke(invoke)('read_game_publish_availability');
|
||||
}
|
||||
|
||||
export type GameDistributionPublishResult = {
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
@@ -59,14 +51,89 @@ export type GameDistributionPublishResult = {
|
||||
packageSha256: string;
|
||||
packageSizeBytes: number;
|
||||
fileCount: number;
|
||||
/** 发布成功并回写绑定后的整份 manifest;渲染层据此同步工作台状态。 */
|
||||
manifest?: GameCreationAppManifest | null;
|
||||
publication?: GameCreationAppPublicationBinding | null;
|
||||
};
|
||||
|
||||
export function createGameDistributionPublishKey() {
|
||||
const randomUuid =
|
||||
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `agc-publish-${randomUuid}`;
|
||||
/** 更新发布时默认回填的封面/截图;`assetId` 缺失表示历史版本没有冻结素材,需重新选择。 */
|
||||
export type GameDistributionPublicationImageDraft = {
|
||||
assetId: string | null;
|
||||
previewUrl: string | null;
|
||||
};
|
||||
|
||||
export type GameDistributionPublicationDraft = {
|
||||
title: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
category: GameDistributionCategory;
|
||||
tags: string[];
|
||||
cover: GameDistributionPublicationImageDraft | null;
|
||||
screenshots: GameDistributionPublicationImageDraft[];
|
||||
/** 随版本冻结的设备/输入/方向声明;更新发布必须原样沿用,避免被默认值改写。 */
|
||||
deviceSupport?: GameDistributionDeviceSupport | null;
|
||||
inputModes?: GameDistributionInputMode[];
|
||||
orientation?: GameDistributionOrientation | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 发布面板打开时的发布状态回读。
|
||||
*
|
||||
* `state` 三态必须分开处理:`unavailable` 表示恢复被网络/401/5xx 挡住,绝不能当成首次发布;
|
||||
* `first-publish` 只在服务端给出确定答案(无绑定且作者名下没有匹配作品)时出现。
|
||||
*/
|
||||
export type GameDistributionPublicationReadResult =
|
||||
| {
|
||||
state: 'first-publish';
|
||||
binding: null;
|
||||
refreshed: boolean;
|
||||
draft: null;
|
||||
visibility: null;
|
||||
message?: string | null;
|
||||
}
|
||||
| {
|
||||
state: 'update';
|
||||
binding: GameCreationAppPublicationBinding;
|
||||
refreshed: boolean;
|
||||
draft: GameDistributionPublicationDraft | null;
|
||||
visibility?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
| {
|
||||
state: 'unavailable';
|
||||
binding: null;
|
||||
refreshed: false;
|
||||
draft: null;
|
||||
visibility: null;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 发布面板打开时由原生恢复本地绑定与线上作品状态。
|
||||
*
|
||||
* 原生按当前会话(账号 + origin)隔离绑定:切换账号/环境后不会接管他人作品;无本地绑定时
|
||||
* 按作者作品的 `localProjectId` 精确匹配做一次性恢复。网络/401/5xx 返回 `unavailable`,
|
||||
* 调用方不得据此按首次发布继续。
|
||||
*/
|
||||
export async function readGameDistributionPublication(
|
||||
args: { projectPath: string },
|
||||
invoke?: TauriInvoke,
|
||||
): Promise<GameDistributionPublicationReadResult> {
|
||||
const projectPath = args.projectPath.trim();
|
||||
if (!projectPath) {
|
||||
throw new Error('发布需要绑定本地项目');
|
||||
}
|
||||
const payload = await requireNativeInvoke(invoke)<
|
||||
GameDistributionPublicationReadResult | null | undefined
|
||||
>('read_game_distribution_publication', { projectPath });
|
||||
const state = payload?.state;
|
||||
if (
|
||||
!payload ||
|
||||
(state !== 'first-publish' && state !== 'update' && state !== 'unavailable')
|
||||
) {
|
||||
throw new Error('发布状态回读结果无效');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export type GameDistributionPublishMetadataSuggestion = {
|
||||
@@ -226,6 +293,27 @@ export async function publishLocalProjectGame(args: {
|
||||
packageRelativePath: string;
|
||||
manifest: GameCreationAppManifest;
|
||||
metadata?: Partial<GameDistributionPublishMetadata>;
|
||||
/**
|
||||
* 更新已有作品时的目标游戏身份(来自发布绑定)。提供时必须同时给出
|
||||
* `expectedPublicationRevision`:原生会跳过 create game,直接在原 gameId 上创建新版本。
|
||||
*/
|
||||
gameId?: string | null;
|
||||
/**
|
||||
* 这次提交使用的项目版本标签。
|
||||
*
|
||||
* 缺省时按 AGC 工程内部版本 `versions[]` 派生(见 `resolveGameCreationAppProjectVersion`):
|
||||
* 标签等于当前存活的内部版本条数,发布面板展示的只读值就是它,不存在第二份版本来源,也不
|
||||
* 读取平台绑定里的版本号。同一个版本标签可以反复提交,每次提交生成新的 `versionId`。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
/** 更新送审所需的公开修订号 CAS;来自发布绑定。 */
|
||||
expectedPublicationRevision?: number | null;
|
||||
/**
|
||||
* 发布根幂等键。渲染层默认**不**生成也不持久化它:原生发布链路按
|
||||
* 「账号身份 + 本地项目 + 发行包摘要 + 目标游戏/版本 + 资料摘要」在应用数据里维护发布
|
||||
* 尝试账本,同一份包重发、响应丢失、进程重启与分片续传都落在同一次服务端尝试上。
|
||||
* 只有显式传入(测试或调用方已持有键)时才覆盖账本。
|
||||
*/
|
||||
idempotencyKey?: string;
|
||||
}): Promise<GameDistributionPublishResult> {
|
||||
const projectPath = args.projectPath.trim();
|
||||
@@ -238,18 +326,37 @@ export async function publishLocalProjectGame(args: {
|
||||
if (!localProjectId) {
|
||||
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
|
||||
}
|
||||
const gameId = args.gameId?.trim() ?? '';
|
||||
// 版本标签只有一个来源:调用方显式传入的值优先(发布面板展示的派生值),否则回落到按 AGC
|
||||
// 工程内部版本派生的同一个值。服务端的自动递增路径只服务不传版本号的老客户端。
|
||||
const versionNumber =
|
||||
args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest);
|
||||
if (!Number.isSafeInteger(versionNumber) || versionNumber <= 0) {
|
||||
throw new Error('项目版本必须是大于 0 的安全整数');
|
||||
}
|
||||
if (
|
||||
gameId &&
|
||||
(args.expectedPublicationRevision === null ||
|
||||
args.expectedPublicationRevision === undefined)
|
||||
) {
|
||||
throw new Error('缺少公开修订号,请重新打开发布面板后再试');
|
||||
}
|
||||
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
|
||||
const rootKey =
|
||||
args.idempotencyKey?.trim() || createGameDistributionPublishKey();
|
||||
const rootKey = args.idempotencyKey?.trim() ?? '';
|
||||
// 发布编排、认证、origin、分片续传与送审都在 Rust 完成;渲染进程只传递
|
||||
// 已归一化的资料和本地项目相对包路径,不接触 token、base URL 或整包字节。
|
||||
// 已归一化的资料、项目版本和本地项目相对包路径,不接触 token、base URL 或整包字节。
|
||||
return args.invoke<GameDistributionPublishResult>(
|
||||
'publish_local_project_game',
|
||||
{
|
||||
projectPath,
|
||||
packageRelativePath,
|
||||
metadata: gameMetadata,
|
||||
idempotencyKey: rootKey,
|
||||
...(gameId ? { gameId } : {}),
|
||||
versionNumber,
|
||||
...(gameId
|
||||
? { expectedPublicationRevision: args.expectedPublicationRevision }
|
||||
: {}),
|
||||
...(rootKey ? { idempotencyKey: rootKey } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user