抽出素材上传生命周期模型

扩展 ImageCanvasUploadModel 承载素材上传占位、进度、失败和持久化回写状态迁移

精简 useImageCanvasUploadWorkflow 中的资产与图层状态补丁逻辑

补充上传模型单测覆盖生命周期状态和图层绑定

更新 TRACKING.md 记录第四十六执行批次与验证结果
This commit is contained in:
2026-06-17 20:36:11 +08:00
parent 6afc1cf920
commit d8bd371c69
4 changed files with 409 additions and 86 deletions

View File

@@ -7,9 +7,17 @@ import type {
} from './ImageCanvasEditorTypes';
import {
applyGenerationReferenceUpload,
applyMeasuredUploadAssetSize,
applyPersistedUploadAsset,
applyUploadAssetCreateFailure,
applyUploadAssetCreatePending,
applyUploadAssetReadFailure,
applyUploadAssetReadSuccess,
bindUploadLayerToPersistedAsset,
createUploadCanvasLayer,
createUploadedGenerationReference,
createUploadingAssetPlaceholder,
expandUploadFolder,
resizeUploadCanvasLayerToImage,
resolveUploadFolderId,
setFailedGenerationIdle,
@@ -47,6 +55,19 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
};
}
function createUploadingAsset(
overrides: Partial<ReturnType<typeof createUploadingAssetPlaceholder>> = {},
) {
return {
...createUploadingAssetPlaceholder({
uploadIndex: 1,
fileName: '上传图片.png',
folderId: 'project',
}),
...overrides,
};
}
function createDialog(
overrides: Partial<GenerateDialogState> = {},
): GenerateDialogState {
@@ -109,6 +130,167 @@ describe('ImageCanvasUploadModel', () => {
});
});
it('opens the target upload folder without changing other folders', () => {
expect(
expandUploadFolder({
folders: [
createFolder({ id: 'project', collapsed: true }),
createFolder({ id: 'characters', label: '角色素材', collapsed: true }),
],
folderId: 'characters',
}),
).toEqual([
createFolder({ id: 'project', collapsed: true }),
createFolder({ id: 'characters', label: '角色素材', collapsed: false }),
]);
});
it('applies upload asset lifecycle patches', () => {
const asset = createUploadingAsset();
const otherAsset = createUploadingAsset({
id: 'upload-other',
label: '其他图片.png',
});
expect(
applyUploadAssetReadSuccess({
assets: [asset, otherAsset],
uploadAssetId: asset.id,
imageSrc: 'data:image/png;base64,read',
}),
).toMatchObject([
{
id: 'upload-1',
src: 'data:image/png;base64,read',
uploadProgress: 42,
uploadMessage: '读取图片',
},
{ id: 'upload-other', label: '其他图片.png' },
]);
expect(
applyUploadAssetCreatePending({
assets: [asset],
uploadAssetId: asset.id,
}),
).toMatchObject([
{
id: 'upload-1',
uploadStatus: 'uploading',
uploadProgress: 68,
uploadMessage: '上传中',
},
]);
expect(
applyUploadAssetReadFailure({
assets: [asset],
uploadAssetId: asset.id,
}),
).toMatchObject([
{
id: 'upload-1',
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: '读取失败',
},
]);
expect(
applyUploadAssetCreateFailure({
assets: [asset],
uploadAssetId: asset.id,
message: '请先登录',
}),
).toMatchObject([
{
id: 'upload-1',
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: '请先登录',
},
]);
});
it('replaces upload placeholders with persisted assets and clears upload state', () => {
expect(
applyPersistedUploadAsset({
assets: [createUploadingAsset()],
uploadAssetId: 'upload-1',
persistedAsset: {
assetId: 'asset-1',
folderId: 'characters',
label: '角色.png',
imageSrc: 'data:image/png;base64,persisted',
width: 1024,
height: 768,
objectKey: 'object-key',
assetObjectId: 'asset-object',
},
}),
).toEqual([
{
id: 'asset-1',
label: '角色.png',
src: 'data:image/png;base64,persisted',
width: 1024,
height: 768,
folderId: 'characters',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
objectKey: 'object-key',
assetObjectId: 'asset-object',
uploadStatus: undefined,
uploadProgress: undefined,
uploadMessage: undefined,
},
]);
});
it('updates measured asset size after the browser loads the uploaded image', () => {
expect(
applyMeasuredUploadAssetSize({
assets: [createUploadingAsset()],
uploadAssetId: 'upload-1',
originalWidth: 1600,
originalHeight: 900,
}),
).toMatchObject([
{
id: 'upload-1',
width: 1600,
height: 900,
},
]);
});
it('binds uploaded canvas layers to persisted asset metadata', () => {
expect(
bindUploadLayerToPersistedAsset({
layers: [createLayer({ objectKey: 'local-object' })],
layerId: 'layer-upload-1',
persistedAsset: {
assetId: 'asset-1',
folderId: 'project',
label: '上传图片.png',
imageSrc: 'data:image/png;base64,persisted',
width: 420,
height: 315,
objectKey: 'object-key',
assetObjectId: 'asset-object',
},
}),
).toMatchObject([
{
id: 'layer-upload-1',
sourceAssetId: 'asset-1',
objectKey: 'object-key',
assetObjectId: 'asset-object',
},
]);
});
it('creates uploaded generation references with fallback labels', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));

View File

@@ -10,6 +10,16 @@ import type {
type CanvasSize = { width: number; height: number };
type CanvasPoint = { x: number; y: number };
type PersistedUploadAsset = {
assetId: string;
folderId: string;
label: string;
imageSrc: string;
width: number;
height: number;
objectKey?: string | null;
assetObjectId?: string | null;
};
type GenerationReferenceUploadTarget =
| 'character-reference'
| 'character-spec'
@@ -140,6 +150,175 @@ export function createUploadingAssetPlaceholder({
};
}
export function expandUploadFolder({
folders,
folderId,
}: {
folders: EditorAssetFolder[];
folderId: string;
}) {
return folders.map((folder) =>
folder.id === folderId
? {
...folder,
collapsed: false,
}
: folder,
);
}
export function applyUploadAssetReadSuccess({
assets,
uploadAssetId,
imageSrc,
}: {
assets: EditorAsset[];
uploadAssetId: string;
imageSrc: string;
}) {
return assets.map((asset) =>
asset.id === uploadAssetId
? {
...asset,
src: imageSrc,
uploadProgress: 42,
uploadMessage: '读取图片',
}
: asset,
);
}
export function applyUploadAssetReadFailure({
assets,
uploadAssetId,
}: {
assets: EditorAsset[];
uploadAssetId: string;
}) {
return assets.map((asset) =>
asset.id === uploadAssetId
? {
...asset,
uploadStatus: 'failed' as const,
uploadProgress: 100,
uploadMessage: '读取失败',
}
: asset,
);
}
export function applyUploadAssetCreatePending({
assets,
uploadAssetId,
}: {
assets: EditorAsset[];
uploadAssetId: string;
}) {
return assets.map((asset) =>
asset.id === uploadAssetId
? {
...asset,
uploadProgress: 68,
uploadMessage: '上传中',
}
: asset,
);
}
export function applyPersistedUploadAsset({
assets,
uploadAssetId,
persistedAsset,
}: {
assets: EditorAsset[];
uploadAssetId: string;
persistedAsset: PersistedUploadAsset;
}) {
return assets.map((asset) =>
asset.id === uploadAssetId
? {
...asset,
id: persistedAsset.assetId,
folderId: persistedAsset.folderId,
label: persistedAsset.label,
src: persistedAsset.imageSrc,
width: persistedAsset.width,
height: persistedAsset.height,
objectKey: persistedAsset.objectKey ?? undefined,
assetObjectId: persistedAsset.assetObjectId ?? undefined,
persisted: true,
uploadStatus: undefined,
uploadProgress: undefined,
uploadMessage: undefined,
}
: asset,
);
}
export function applyUploadAssetCreateFailure({
assets,
uploadAssetId,
message,
}: {
assets: EditorAsset[];
uploadAssetId: string;
message: string;
}) {
return assets.map((asset) =>
asset.id === uploadAssetId
? {
...asset,
uploadStatus: 'failed' as const,
uploadProgress: 100,
uploadMessage: message,
}
: asset,
);
}
export function applyMeasuredUploadAssetSize({
assets,
uploadAssetId,
originalWidth,
originalHeight,
}: {
assets: EditorAsset[];
uploadAssetId: string;
originalWidth: number;
originalHeight: number;
}) {
return assets.map((asset) =>
asset.id === uploadAssetId
? {
...asset,
width: originalWidth,
height: originalHeight,
}
: asset,
);
}
export function bindUploadLayerToPersistedAsset({
layers,
layerId,
persistedAsset,
}: {
layers: CanvasLayer[];
layerId: string;
persistedAsset: PersistedUploadAsset;
}) {
return layers.map((layer) =>
layer.id === layerId
? {
...layer,
sourceAssetId: persistedAsset.assetId,
objectKey: persistedAsset.objectKey ?? layer.objectKey,
assetObjectId: persistedAsset.assetObjectId ?? layer.assetObjectId,
}
: layer,
);
}
export function normalizeUploadScreenPoint({
canvasPoint,
canvasSize,

View File

@@ -21,9 +21,17 @@ import type {
import { isImageFile, readImageFileAsDataUrl } from './ImageCanvasFileModel';
import {
applyGenerationReferenceUpload,
applyMeasuredUploadAssetSize,
applyPersistedUploadAsset,
applyUploadAssetCreateFailure,
applyUploadAssetCreatePending,
applyUploadAssetReadFailure,
applyUploadAssetReadSuccess,
bindUploadLayerToPersistedAsset,
createUploadCanvasLayer,
createUploadedGenerationReference,
createUploadingAssetPlaceholder,
expandUploadFolder,
resizeUploadCanvasLayerToImage,
resolveUploadFolderId,
} from './ImageCanvasUploadModel';
@@ -232,43 +240,28 @@ export function useImageCanvasUploadWorkflow({
});
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === uploadFolderId
? {
...folder,
collapsed: false,
}
: folder,
),
expandUploadFolder({
folders: currentFolders,
folderId: uploadFolderId,
}),
);
let imageSrc = '';
try {
imageSrc = await readImageFileAsDataUrl(file);
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
src: imageSrc,
uploadProgress: 42,
uploadMessage: '读取图片',
}
: asset,
),
applyUploadAssetReadSuccess({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
imageSrc,
}),
);
} catch {
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: '读取失败',
}
: asset,
),
applyUploadAssetReadFailure({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
}),
);
return;
}
@@ -288,15 +281,10 @@ export function useImageCanvasUploadWorkflow({
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadProgress: 68,
uploadMessage: '上传中',
}
: asset,
),
applyUploadAssetCreatePending({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
}),
);
createEditorAsset({
folderId: uploadFolderId,
@@ -308,39 +296,19 @@ export function useImageCanvasUploadWorkflow({
})
.then((asset) => {
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === uploadedAsset.id
? {
...currentAsset,
id: asset.assetId,
folderId: asset.folderId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
persisted: true,
uploadStatus: undefined,
uploadProgress: undefined,
uploadMessage: undefined,
}
: currentAsset,
),
applyPersistedUploadAsset({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
persistedAsset: asset,
}),
);
if (options.addToCanvas) {
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === nextLayer.id
? {
...currentLayer,
sourceAssetId: asset.assetId,
objectKey: asset.objectKey ?? currentLayer.objectKey,
assetObjectId:
asset.assetObjectId ?? currentLayer.assetObjectId,
}
: currentLayer,
),
bindUploadLayerToPersistedAsset({
layers: currentLayers,
layerId: nextLayer.id,
persistedAsset: asset,
}),
);
}
})
@@ -350,16 +318,11 @@ export function useImageCanvasUploadWorkflow({
openEditorLoginModal();
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
uploadStatus: 'failed',
uploadProgress: 100,
uploadMessage: isAuthError ? '请先登录' : '上传失败',
}
: asset,
),
applyUploadAssetCreateFailure({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
message: isAuthError ? '请先登录' : '上传失败',
}),
);
});
@@ -387,15 +350,12 @@ export function useImageCanvasUploadWorkflow({
);
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
? {
...asset,
width: originalWidth,
height: originalHeight,
}
: asset,
),
applyMeasuredUploadAssetSize({
assets: currentAssets,
uploadAssetId: uploadedAsset.id,
originalWidth,
originalHeight,
}),
);
};
uploadedImage.src = imageSrc;