diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index af46eced9..139ea958d 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -29,6 +29,7 @@ import { resolveGameInputModes, } from './gameMetadataOptions'; import { + buildGamePublishMediaSubmission, type GamePublishImage, revokeGamePublishImagePreview, } from './gamePublishAssets'; @@ -312,19 +313,19 @@ export function GamePublishPage({ * 组装提交给服务端的资料 DTO。 * * 媒体槽位只描述来源:本地新图写 `null`(由对应的二进制 part 补位),沿用线上图写 objectKey; - * 原始字节另由 `buildPublishMediaParts` 作为 multipart part 提交。 + * 原始字节另由 `buildPublishMediaParts` 作为 multipart part 提交。两者共用 + * `buildGamePublishMediaSubmission`,槽位映射与 part 组装不会各改各的。 */ function buildPublishGameMetadata() { + const media = buildGamePublishMediaSubmission(coverImage, screenshotImages); return { title: title.trim(), summary: summary.trim(), description: description.trim() || summary.trim(), category, tags: parseGameTagsInput(tagsInput), - coverObjectKey: coverImage?.file ? null : (coverImage?.objectKey ?? null), - screenshots: screenshotImages.map((image) => - image.file ? null : (image.objectKey ?? null), - ), + coverObjectKey: media.coverObjectKey, + screenshots: media.screenshots, deviceSupport, inputModes: resolveGameInputModes(deviceSupport), orientation, @@ -332,10 +333,7 @@ export function GamePublishPage({ } function buildPublishMediaParts() { - return { - cover: coverImage?.file ?? null, - screenshots: screenshotImages.map((image) => image.file ?? null), - }; + return buildGamePublishMediaSubmission(coverImage, screenshotImages).parts; } /** 是否已有封面:本地新图或沿用线上图都算。 */ diff --git a/src/components/game-distribution/GameWorkMetadataEditor.tsx b/src/components/game-distribution/GameWorkMetadataEditor.tsx index d0b426266..9bfdad4e6 100644 --- a/src/components/game-distribution/GameWorkMetadataEditor.tsx +++ b/src/components/game-distribution/GameWorkMetadataEditor.tsx @@ -24,6 +24,7 @@ import { resolveGameInputModes, } from './gameMetadataOptions'; import { + buildGamePublishMediaSubmission, type GamePublishImage, revokeGamePublishImagePreview, } from './gamePublishAssets'; @@ -176,10 +177,12 @@ export function GameWorkMetadataEditor({ } const saveKey = saveKeyRef.current ?? createMetadataKey(game.id); saveKeyRef.current = saveKey; - const media = { - cover: coverImage?.file ?? null, - screenshots: screenshotImages.map((image) => image.file ?? null), - }; + // 槽位映射与二进制 part 同源,和发布页共用同一份合成规则。 + const submission = buildGamePublishMediaSubmission( + coverImage, + screenshotImages, + ); + const media = submission.parts; const payload: GameDistributionUpdateGameMetadataRequest = { expectedPublicationRevision: game.publicationRevision, title: title.trim(), @@ -187,12 +190,8 @@ export function GameWorkMetadataEditor({ description: description.trim() || summary.trim(), category, tags: parseGameTagsInput(tagsInput), - coverObjectKey: coverImage?.file - ? null - : (coverImage?.objectKey?.trim() ?? null), - screenshots: screenshotImages.map((image) => - image.file ? null : (image.objectKey?.trim() ?? null), - ), + coverObjectKey: submission.coverObjectKey, + screenshots: submission.screenshots, deviceSupport, inputModes: resolveGameInputModes(deviceSupport), orientation, diff --git a/src/components/game-distribution/gamePublishAssets.test.ts b/src/components/game-distribution/gamePublishAssets.test.ts index 77b636ce4..c4c867ff7 100644 --- a/src/components/game-distribution/gamePublishAssets.test.ts +++ b/src/components/game-distribution/gamePublishAssets.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; -import { createLocalGamePublishImage } from './gamePublishAssets'; +import { + buildGamePublishMediaSubmission, + createLocalGamePublishImage, +} from './gamePublishAssets'; describe('createLocalGamePublishImage', () => { it('本地新选图各自带稳定且不同的 id', () => { @@ -21,3 +24,43 @@ describe('createLocalGamePublishImage', () => { expect(first.id).not.toBe(second.id); }); }); + +describe('buildGamePublishMediaSubmission', () => { + it('本地新图只进 parts、槽位留 null,沿用线上图反之', () => { + const localCover = createLocalGamePublishImage( + new File(['a'], 'cover.png', { type: 'image/png' }), + 'cover', + ); + const onlineCover = { + id: 'online-cover', + objectKey: ' agc/cover.png ', + src: 'https://cdn/cover.png', + name: '线上封面', + }; + const localShot = createLocalGamePublishImage( + new File(['b'], 'shot.png', { type: 'image/png' }), + 'screenshot', + ); + const onlineShot = { + id: 'online-shot', + objectKey: ' ', + src: '', + name: '截图 1', + }; + + const submission = buildGamePublishMediaSubmission(onlineCover, [ + localShot, + onlineShot, + ]); + + // 槽位与 part 必须同源:封面沿用时 objectKey 去空白,本地截图占 null 槽位。 + expect(submission.coverObjectKey).toBe('agc/cover.png'); + expect(submission.parts.cover).toBeNull(); + expect(submission.screenshots).toEqual([null, null]); + expect(submission.parts.screenshots).toEqual([localShot.file, null]); + + const localSubmission = buildGamePublishMediaSubmission(localCover, []); + expect(localSubmission.coverObjectKey).toBeNull(); + expect(localSubmission.parts.cover).toBe(localCover.file); + }); +}); diff --git a/src/components/game-distribution/gamePublishAssets.ts b/src/components/game-distribution/gamePublishAssets.ts index 6d08da5ab..831252f81 100644 --- a/src/components/game-distribution/gamePublishAssets.ts +++ b/src/components/game-distribution/gamePublishAssets.ts @@ -99,6 +99,40 @@ export function createLocalGamePublishImage( }; } +/** 一次发布/编辑提交里的媒体:metadata 槽位与随请求提交的二进制 part。 */ +export type GamePublishMediaSubmission = { + /** 沿用线上封面时的 objectKey;本次带本地新封面时为 `null`。 */ + coverObjectKey: string | null; + /** 与最终截图顺序一一对应:`string` 沿用线上,`null` 表示消费对应的本地 `parts`。 */ + screenshots: (string | null)[]; + parts: { + cover: File | null; + screenshots: (File | null)[]; + }; +}; + +/** + * 把表单里的封面/截图列表合成一份提交:metadata 槽位与 multipart part 同源。 + * + * 「本地新图 / 沿用线上」是二选一:有 `file` 就归到 `parts` 并把槽位置 `null`,否则槽位取 + * 去空白后的 `objectKey`。发布与编辑两条链路共用这一份,避免槽位映射或 part 组装各改各的。 + */ +export function buildGamePublishMediaSubmission( + cover: GamePublishImage | null, + screenshots: GamePublishImage[], +): GamePublishMediaSubmission { + const objectKeyOf = (image: GamePublishImage | null | undefined) => + image?.file ? null : image?.objectKey?.trim() || null; + return { + coverObjectKey: objectKeyOf(cover), + screenshots: screenshots.map(objectKeyOf), + parts: { + cover: cover?.file ?? null, + screenshots: screenshots.map((image) => image.file ?? null), + }, + }; +} + /** 释放本地预览的 blob URL;远程签名地址与空地址直接忽略。 */ export function revokeGamePublishImagePreview( image: GamePublishImage | null | undefined,