diff --git a/src/components/game-distribution/GamePublishMediaFieldset.tsx b/src/components/game-distribution/GamePublishMediaFieldset.tsx
index 1539f6b8b..77dae53d2 100644
--- a/src/components/game-distribution/GamePublishMediaFieldset.tsx
+++ b/src/components/game-distribution/GamePublishMediaFieldset.tsx
@@ -181,10 +181,7 @@ export function GamePublishMediaFieldset({
{screenshots.length > 0 ? (
{screenshots.map((image, index) => (
- -
+
-
{image.src ? (
) : (
diff --git a/src/components/game-distribution/gamePublishAssets.test.ts b/src/components/game-distribution/gamePublishAssets.test.ts
new file mode 100644
index 000000000..77b636ce4
--- /dev/null
+++ b/src/components/game-distribution/gamePublishAssets.test.ts
@@ -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);
+ });
+});
diff --git a/src/components/game-distribution/gamePublishAssets.ts b/src/components/game-distribution/gamePublishAssets.ts
index a9d15be57..6d08da5ab 100644
--- a/src/components/game-distribution/gamePublishAssets.ts
+++ b/src/components/game-distribution/gamePublishAssets.ts
@@ -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),
diff --git a/src/components/game-distribution/gamePublishMediaDefaults.ts b/src/components/game-distribution/gamePublishMediaDefaults.ts
index 010988832..29219ab5b 100644
--- a/src/components/game-distribution/gamePublishMediaDefaults.ts
+++ b/src/components/game-distribution/gamePublishMediaDefaults.ts
@@ -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}`,