Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m27s
Project CI / Native shell tests (pull_request) Failing after 2m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 45s
Project CI / Frontend tests (pull_request) Successful in 2m24s
Project CI / Repository checks (pull_request) Failing after 1m1s
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs # apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx # apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx # docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md # package.json # packages/shared/src/contracts/gameDistribution.ts # scripts/check-game-distribution-dto-parity.mjs # server-rs/crates/api-server/src/modules/game_distribution.rs # server-rs/crates/module-game-distribution/src/errors.rs # server-rs/crates/module-game-distribution/src/lib.rs # server-rs/crates/shared-contracts/src/game_distribution.rs # server-rs/crates/spacetime-client/src/active.rs # server-rs/crates/spacetime-client/src/active/mapper.rs # server-rs/crates/spacetime-client/src/active/mapper/game_distribution.rs # server-rs/crates/spacetime-client/src/game_distribution.rs # server-rs/crates/spacetime-client/src/module_bindings.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_snapshot_type.rs # server-rs/crates/spacetime-client/src/module_bindings/game_distribution_game_type.rs # server-rs/crates/spacetime-module/src/game_distribution.rs # server-rs/crates/spacetime-module/src/migration.rs # src/components/game-distribution/GameDetailPage.tsx # src/components/game-distribution/GameDistributionPages.test.tsx # src/components/game-distribution/GamePlayPage.tsx # src/components/platform-entry/PlatformEntryActiveFlowShell.tsx # src/services/gameDistributionClient.test.ts # src/services/gameDistributionClient.ts
This commit is contained in:
@@ -1183,6 +1183,8 @@ export interface AdminGameDistributionReviewEntry {
|
||||
packageBytes: number;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
|
||||
priceMudPoints?: number;
|
||||
reviewReason: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -1211,6 +1213,8 @@ export interface AdminGameDistributionGameDetail {
|
||||
orientation: string;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
|
||||
priceMudPoints?: number;
|
||||
playCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
reviewAdminGameDistributionVersion,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameDistributionGameDetail,
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionReviewEntry,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
@@ -50,6 +51,7 @@ const entry: AdminGameDistributionReviewEntry = {
|
||||
packageBytes: 2048,
|
||||
status: 'pending_review',
|
||||
publicationRevision: 4,
|
||||
priceMudPoints: 30,
|
||||
reviewReason: null,
|
||||
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
|
||||
createdAt: '1791008678.817257Z',
|
||||
@@ -94,6 +96,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = {
|
||||
orientation: 'landscape',
|
||||
status: 'unpublished',
|
||||
publicationRevision: 7,
|
||||
priceMudPoints: 30,
|
||||
playCount: 0,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
},
|
||||
@@ -230,7 +233,8 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
|
||||
|
||||
const row = screen.getByText(gameTitle).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
|
||||
// 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。
|
||||
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[4];
|
||||
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
|
||||
expect(submittedCell?.textContent).not.toBe('-');
|
||||
expect(submittedCell?.textContent).toMatch(
|
||||
@@ -238,6 +242,56 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
|
||||
);
|
||||
});
|
||||
|
||||
test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN', async () => {
|
||||
const freeEntry: AdminGameDistributionReviewEntry = {
|
||||
...entry,
|
||||
versionId: 'version-2',
|
||||
gameId: 'game_2',
|
||||
versionNumber: 1,
|
||||
priceMudPoints: 0,
|
||||
};
|
||||
// 旧响应可能完全没有该字段;列表必须回落到“免费”而不是 NaN。
|
||||
const legacyEntry = {
|
||||
...entry,
|
||||
versionId: 'version-3',
|
||||
gameId: 'game_3',
|
||||
versionNumber: 3,
|
||||
priceMudPoints: undefined,
|
||||
} as unknown as AdminGameDistributionReviewEntry;
|
||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
||||
entries: [entry, freeEntry, legacyEntry],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
const table = screen.getByText(gameTitle).closest('table');
|
||||
expect(table).not.toBeNull();
|
||||
// 按列名定位:绑「第 4 个格子」的话,任何插列或换序都会读到别的字段,断言随之失效。
|
||||
const priceColumnIndex = within(table as HTMLElement)
|
||||
.getAllByRole('columnheader')
|
||||
.findIndex((header) => header.textContent?.trim() === '价格');
|
||||
expect(priceColumnIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const cellAt = (text: string) => {
|
||||
const row = screen.getByText(text).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
return within(row as HTMLElement).getAllByRole('cell')[priceColumnIndex]
|
||||
?.textContent;
|
||||
};
|
||||
|
||||
// 三行三态都按精确文案钉死:付费、免费与旧响应缺字段都不能渲染成 NaN。
|
||||
expect(cellAt(gameTitle)).toBe('30 泥点');
|
||||
expect(cellAt('game_2')).toBe('免费');
|
||||
expect(cellAt('game_3')).toBe('免费');
|
||||
});
|
||||
|
||||
test('取消理由输入时不做审核操作', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
@@ -300,6 +354,9 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('测试详细介绍')).toBeTruthy();
|
||||
// 详情资料区展示买断制价格(0 或缺字段按“免费”)。
|
||||
expect(screen.getByText('价格')).toBeTruthy();
|
||||
expect(screen.getByText('30 泥点')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
|
||||
expect(frame.getAttribute('src')).toBe(
|
||||
@@ -372,7 +429,6 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
|
||||
expiresAt: '2026-10-02T12:20:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
@@ -415,3 +471,60 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('详情价格只渲染服务端下发的 priceMudPoints,不按冻结资料二次取价', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
|
||||
// 服务端 `version_detail_payload` 已把「待审版本冻结价优先、缺字段按 0」归一写进
|
||||
// `game.priceMudPoints`,审核页只渲染这个字段。
|
||||
game: { ...detailResponse.game, priceMudPoints: 30 },
|
||||
version: {
|
||||
...detailResponse.version,
|
||||
// 对抗用例:冻结资料里的价格与服务端归一结果故意不同(999 ≠ 30)。客户端若自行
|
||||
// 复刻「冻结价优先」,就会显示 999 —— 这里必须仍然是服务端字段的 30。
|
||||
frozenMetadata: { title: '测试游戏', priceMudPoints: 999 },
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
expect(screen.getByText('30 泥点')).toBeTruthy();
|
||||
expect(screen.queryByText('999 泥点')).toBeNull();
|
||||
expect(screen.queryByText('免费')).toBeNull();
|
||||
});
|
||||
|
||||
test('旧版本冻结资料没有价格时按免费回落', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
|
||||
game: {
|
||||
...detailResponse.game,
|
||||
priceMudPoints: undefined,
|
||||
} as unknown as AdminGameDistributionGameDetail,
|
||||
version: {
|
||||
...detailResponse.version,
|
||||
frozenMetadata: { title: '测试游戏' },
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
expect(screen.queryByText(/NaN/u)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -100,6 +100,8 @@ function applyFrozenGameMetadata(
|
||||
typeof metadata.orientation === 'string'
|
||||
? metadata.orientation
|
||||
: game.orientation,
|
||||
// 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、
|
||||
// 缺字段按 0」把结果归一写进 `game.priceMudPoints`,客户端直接沿用该字段。
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +115,11 @@ function formatBytes(value: number) {
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */
|
||||
function formatPriceMudPoints(value: number | null | undefined) {
|
||||
return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费';
|
||||
}
|
||||
|
||||
function createReviewIdempotencyKey(versionId: string) {
|
||||
const random =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
@@ -439,6 +446,7 @@ export function AdminGameDistributionReviewPage({
|
||||
{ key: 'gameId', label: '游戏' },
|
||||
{ key: 'versionNumber', label: '版本' },
|
||||
{ key: 'packageBytes', label: '发行包' },
|
||||
{ key: 'priceMudPoints', label: '价格' },
|
||||
{ key: 'createdAt', label: '提交时间' },
|
||||
{ key: 'actions', label: '审核' },
|
||||
]}
|
||||
@@ -484,6 +492,7 @@ export function AdminGameDistributionReviewPage({
|
||||
<code>{entry.packageSha256.slice(0, 12)}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatPriceMudPoints(entry.priceMudPoints)}</td>
|
||||
<td>{formatAdminDateTime(entry.createdAt)}</td>
|
||||
<td className="admin-game-review-cell--actions">
|
||||
<AdminActionRow className="admin-review-actions">
|
||||
@@ -649,6 +658,10 @@ function AdminGameDistributionReviewDetailView({
|
||||
versionLabel={`v${detail.version.versionNumber}`}
|
||||
infoCards={[
|
||||
{ label: '状态', value: detail.version.status },
|
||||
{
|
||||
label: '价格',
|
||||
value: formatPriceMudPoints(detail.game.priceMudPoints),
|
||||
},
|
||||
{
|
||||
label: '提交时间',
|
||||
value: formatAdminDateTime(detail.version.createdAt),
|
||||
|
||||
@@ -27,14 +27,22 @@ table.admin-game-review-table td:nth-child(3) {
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
/* 价格列:只展示「免费」或「N 泥点」,上限 1000000 泥点最长;给足宽度并允许换行,
|
||||
固定列宽 + overflow:hidden 下保持 nowrap 会把最大价硬裁掉。 */
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4) {
|
||||
width: 172px;
|
||||
white-space: nowrap;
|
||||
width: 120px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: 172px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(6),
|
||||
table.admin-game-review-table td:nth-child(6) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
@@ -100,7 +108,9 @@ table.admin-game-review-table td .admin-muted-text {
|
||||
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) {
|
||||
table.admin-game-review-table td:nth-child(5),
|
||||
table.admin-game-review-table th:nth-child(6),
|
||||
table.admin-game-review-table td:nth-child(6) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user