发布图片加稳定 id,截图列表不再按 name-index 做 key

- GamePublishImage 新增 id,本地新选图与线上沿用图各自铸一次
- 截图列表 key 改用 image.id,删除/重排时不再因 index 位移重建节点,同名文件也能区分
- 补同名本地图 id 互异的单测
This commit is contained in:
2026-10-07 11:13:45 +08:00
parent 9184e92774
commit 12d4e8477a
4 changed files with 44 additions and 4 deletions
@@ -181,10 +181,7 @@ export function GamePublishMediaFieldset({
{screenshots.length > 0 ? (
<ul className="game-publish-media__strip">
{screenshots.map((image, index) => (
<li
key={`${image.objectKey ?? image.name}-${index}`}
className="game-publish-media__shot"
>
<li key={image.id} className="game-publish-media__shot">
{image.src ? (
<img src={image.src} alt={`截图 ${index + 1} 预览`} />
) : (
@@ -0,0 +1,23 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from 'vitest';
import { createLocalGamePublishImage } from './gamePublishAssets';
describe('createLocalGamePublishImage', () => {
it('本地新选图各自带稳定且不同的 id', () => {
const first = createLocalGamePublishImage(
new File(['a'], '同名.png', { type: 'image/png' }),
'screenshot',
);
const second = createLocalGamePublishImage(
new File(['b'], '同名.png', { type: 'image/png' }),
'screenshot',
);
// 同名的本地文件不能只靠 `name-index` 区分:id 必须是每次选择各自铸出来的一份。
expect(first.id).toBeTruthy();
expect(second.id).toBeTruthy();
expect(first.id).not.toBe(second.id);
});
});
@@ -22,6 +22,11 @@ export type GamePublishImageKind = 'cover' | 'screenshot';
* `URL.createObjectURL`,线上图用 `/api/game-distribution/media/read-url` 换签),不参与提交。
*/
export type GamePublishImage = {
/**
* 稳定标识:本地新选图与线上沿用图各自生成一次。截图删除/重排时 React 不会因 `index`
* 位移而重建节点,同名文件也能区分开。
*/
id: string;
file?: File;
objectKey?: string;
/** 预览地址;换签失败或本地环境不支持时为空字符串,只显示占位。 */
@@ -29,6 +34,17 @@ export type GamePublishImage = {
name: string;
};
/** 生成发布图片的稳定标识;测试环境缺 `crypto.randomUUID` 时退回时间戳 + 随机串。 */
export function createGamePublishImageId(): string {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `gpimg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function formatBytes(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
@@ -76,6 +92,7 @@ export function createLocalGamePublishImage(
): GamePublishImage {
validateGamePublishImageFile(file, kind);
return {
id: createGamePublishImageId(),
file,
src: createLocalPreviewUrl(file),
name: file.name.trim() || resolveGamePublishImageKindLabel(kind),
@@ -1,5 +1,6 @@
import type { GameDistributionVersionDetail } from '../../../packages/shared/src/contracts/gameDistribution';
import {
createGamePublishImageId,
type GamePublishImage,
resolveGamePublishImagePreview,
} from './gamePublishAssets';
@@ -31,6 +32,7 @@ export async function applyVersionMediaDefaults(
detail.game.coverObjectKey?.trim() || frozen?.coverObjectKey?.trim() || '';
const coverName = detail.game.title.trim() || '当前封面';
const buildCover = (src: string): GamePublishImage => ({
id: createGamePublishImageId(),
objectKey: coverObjectKey,
src,
name: coverName,
@@ -47,6 +49,7 @@ export async function applyVersionMediaDefaults(
apply.setCover(() => (coverObjectKey ? buildCover('') : null));
apply.setScreenshots(() =>
screenshotObjectKeys.map((objectKey, index) => ({
id: createGamePublishImageId(),
objectKey,
src: '',
name: `截图 ${index + 1}`,