抽出素材上传生命周期模型
扩展 ImageCanvasUploadModel 承载素材上传占位、进度、失败和持久化回写状态迁移 精简 useImageCanvasUploadWorkflow 中的资产与图层状态补丁逻辑 补充上传模型单测覆盖生命周期状态和图层绑定 更新 TRACKING.md 记录第四十六执行批次与验证结果
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user