拆分图片画布素材导出工作流

新增画布素材导出 hook 和单测

主视图改为通过导出 hook 处理单图和整包下载

更新图片画布前端拆分文档和 TRACKING 回归记录
This commit is contained in:
2026-06-17 07:17:15 +08:00
parent 3c37108ef6
commit 3c933b2202
5 changed files with 434 additions and 177 deletions

View File

@@ -127,3 +127,4 @@
- 2026-06-17 前端拆分第十阶段:新增 `useImageCanvasAssetLibrary`,把账号级素材库加载、文件夹新建 / 折叠 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和素材库 401 登录弹窗从主视图抽出;主视图继续保留上传读取、上传进度、拖到画布坐标、画布图层创建和工程资源持久化。新增 hook 单测覆盖素材库归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后生成占位和 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见;登录临时开发账号后上传图片成功进入 `项目素材`,点击素材加入画布,切换 `图层` 可看到对应图层,控制台无前端 error。 - 2026-06-17 前端拆分第十阶段:新增 `useImageCanvasAssetLibrary`,把账号级素材库加载、文件夹新建 / 折叠 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和素材库 401 登录弹窗从主视图抽出;主视图继续保留上传读取、上传进度、拖到画布坐标、画布图层创建和工程资源持久化。新增 hook 单测覆盖素材库归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后生成占位和 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见;登录临时开发账号后上传图片成功进入 `项目素材`,点击素材加入画布,切换 `图层` 可看到对应图层,控制台无前端 error。
- 2026-06-17 前端拆分第十一阶段:新增 `ImageCanvasFileModel``useImageCanvasUploadWorkflow`,把隐藏上传 input、上传目标分发、未登录续传、上传占位卡片、素材落库、拖到画布建层、生成参考图上传从主视图抽出主视图保留画布 drop 外层判断和项目资源持久化注入。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;上传图片后素材数增加,点击素材加入画布,切换 `图层` 面板可看到 2 个图层,登录后控制台无前端 error。 - 2026-06-17 前端拆分第十一阶段:新增 `ImageCanvasFileModel``useImageCanvasUploadWorkflow`,把隐藏上传 input、上传目标分发、未登录续传、上传占位卡片、素材落库、拖到画布建层、生成参考图上传从主视图抽出主视图保留画布 drop 外层判断和项目资源持久化注入。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;上传图片后素材数增加,点击素材加入画布,切换 `图层` 面板可看到 2 个图层,登录后控制台无前端 error。
- 2026-06-17 前端拆分第十二阶段:新增 `ImageCanvasGenerationLayerModel`,把普通生图、修改图片、快速编辑和图标素材批量生成结果落画布的图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、源图关联和 `generationInputs` 纯规则从主视图抽出;主视图继续负责 API 提交、生成对象状态、资源持久化、选中态、侧栏和适合视图副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板保留色相 / 自定义颜色 / 预设 / HEX / 恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;真实上传图片后素材数从 2 增至 3登录后控制台无前端 error。 - 2026-06-17 前端拆分第十二阶段:新增 `ImageCanvasGenerationLayerModel`,把普通生图、修改图片、快速编辑和图标素材批量生成结果落画布的图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、源图关联和 `generationInputs` 纯规则从主视图抽出;主视图继续负责 API 提交、生成对象状态、资源持久化、选中态、侧栏和适合视图副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板保留色相 / 自定义颜色 / 预设 / HEX / 恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;真实上传图片后素材数从 2 增至 3登录后控制台无前端 error。
- 2026-06-17 前端拆分第十三阶段:新增 `useImageCanvasAssetExportWorkflow`,把画布素材导出状态、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、metadata / manifest 和下载链接副作用从主视图抽出;主视图保留右键目标解析和状态提示渲染。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx``npm run typecheck``npm run check:encoding``git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后下载按钮启用,点击后触发真实下载 `未命名画布-画布素材-20260617.zip` 并显示导出状态;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)``background-image: none`,点击 `生成工具``生成图片` 对话框出现且 `AI画布工具栏` 保持可见,登录后控制台无前端 error。

View File

@@ -117,6 +117,13 @@
- 主视图继续负责生成提交 API、生成对象 active / archived 状态、资源持久化、图层选择、侧栏切换、对话框收起和适合视图等副作用,避免把多个画布生成对象的生命周期拆成浅 wrapper。 - 主视图继续负责生成提交 API、生成对象 active / archived 状态、资源持久化、图层选择、侧栏切换、对话框收起和适合视图等副作用,避免把多个画布生成对象的生命周期拆成浅 wrapper。
- 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框只作为定位参考”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。 - 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框只作为定位参考”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。
## 第十三阶段模块
- `useImageCanvasAssetExportWorkflow.ts`
- 承载画布素材导出工作流:导出状态、空画布提示、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、`metadata.json``manifest.txt`、下载链接创建和浏览器不支持时的错误提示。
- 主视图继续负责右键菜单目标解析、下载按钮渲染和状态提示展示;导出 hook 不接管图层选择、右键菜单生命周期或画布状态。
- 该 hook 用独立单测覆盖 ZIP 内容、重复图层复用同一图片文件、失败图片 metadata、manifest 失败数量、空画布提示和单图文件名清理。
## 后续阶段 ## 后续阶段
- 生成工作流 hook等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传。 - 生成工作流 hook等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传。
@@ -124,7 +131,7 @@
## 验证计划 ## 验证计划
- `npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx` - `npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
- `npm run typecheck` - `npm run typecheck`
- `npm run check:encoding` - `npm run check:encoding`
- `git diff --check` - `git diff --check`

View File

@@ -1,5 +1,4 @@
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react'; import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import JSZip from 'jszip';
import { import {
type CSSProperties, type CSSProperties,
type DragEvent as ReactDragEvent, type DragEvent as ReactDragEvent,
@@ -85,15 +84,6 @@ import {
resolveContextMenuPosition, resolveContextMenuPosition,
serializeLayer, serializeLayer,
} from './ImageCanvasEditorModel'; } from './ImageCanvasEditorModel';
import {
blobToUint8Array,
buildLayerExportMetadata,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerImageBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import { import {
CHARACTER_ANIMATION_DURATION_OPTIONS, CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL, CHARACTER_ANIMATION_MODEL,
@@ -132,8 +122,6 @@ import {
} from './ImageCanvasGenerationModel'; } from './ImageCanvasGenerationModel';
import type { import type {
AssetPointerDragState, AssetPointerDragState,
CanvasAssetExportImage,
CanvasAssetExportMetadata,
CanvasClipboard, CanvasClipboard,
CanvasContextMenuState, CanvasContextMenuState,
CanvasGenerationDialogState, CanvasGenerationDialogState,
@@ -156,6 +144,7 @@ import type {
import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasHistory } from './useCanvasHistory';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
@@ -255,11 +244,6 @@ export function ImageCanvasEditorView() {
const [projectRenameError, setProjectRenameError] = useState<string | null>( const [projectRenameError, setProjectRenameError] = useState<string | null>(
null, null,
); );
const [assetExportStatus, setAssetExportStatus] = useState<{
tone: 'info' | 'success' | 'error';
message: string;
} | null>(null);
const [isExportingAssets, setIsExportingAssets] = useState(false);
const [activeSidebarPanel, setActiveSidebarPanel] = const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets'); useState<SidebarPanel | null>('assets');
const [viewport, setViewport] = useState<CanvasViewport>({ const [viewport, setViewport] = useState<CanvasViewport>({
@@ -687,6 +671,16 @@ export function ImageCanvasEditorView() {
viewport, viewport,
openEditorLoginModal, openEditorLoginModal,
}); });
const {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportLayerImage,
} = useImageCanvasAssetExportWorkflow({
layers,
projectId,
projectTitle,
});
const { const {
uploadInputRef, uploadInputRef,
setUploadTarget, setUploadTarget,
@@ -1207,15 +1201,7 @@ export function ImageCanvasEditorView() {
const exportContextLayer = () => { const exportContextLayer = () => {
const targetIds = getContextTargetLayerIds(); const targetIds = getContextTargetLayerIds();
const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
if (!targetLayer) { exportLayerImage(targetLayer ?? null);
return;
}
const link = document.createElement('a');
link.href = targetLayer.src;
link.download = `${sanitizeExportFilePart(targetLayer.title, 'canvas-layer')}.png`;
document.body.appendChild(link);
link.click();
link.remove();
setContextMenu(null); setContextMenu(null);
}; };
@@ -1241,156 +1227,6 @@ export function ImageCanvasEditorView() {
}; };
addAssetLayerRef.current = addAssetLayer; addAssetLayerRef.current = addAssetLayer;
const exportCanvasAssets = async () => {
if (isExportingAssets) {
return;
}
const exportableLayers = layers
.filter((layer) => layer.src.trim().length > 0)
.sort((left, right) => left.zIndex - right.zIndex);
if (!exportableLayers.length) {
setAssetExportStatus({
tone: 'info',
message: '当前画布没有可导出的素材',
});
return;
}
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
const exportedAt = new Date();
const projectName = sanitizeExportFilePart(projectTitle, '未命名画布');
const rootFolderName = `${projectName}-画布素材`;
const zip = new JSZip();
const rootFolder = zip.folder(rootFolderName) ?? zip;
const imagesFolder = rootFolder.folder('images') ?? rootFolder;
const imageByKey = new Map<string, CanvasAssetExportImage>();
const usedFileNames = new Map<string, number>();
for (const layer of exportableLayers) {
const key = getLayerExportKey(layer);
if (imageByKey.has(key)) {
continue;
}
const index = imageByKey.size + 1;
const safeTitle = sanitizeExportFilePart(layer.title, '画布素材');
const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`;
const duplicateCount = usedFileNames.get(baseFileName) ?? 0;
usedFileNames.set(baseFileName, duplicateCount + 1);
const indexedFileName =
duplicateCount > 0
? `${baseFileName}-${duplicateCount + 1}`
: baseFileName;
try {
const blob = await readLayerImageBlob(layer);
const extension = getImageExtensionFromTypeOrSrc(
blob.type,
layer.src,
);
const file = `images/${indexedFileName}.${extension}`;
imageByKey.set(key, {
key,
file,
layer,
blob,
});
imagesFolder.file(
`${indexedFileName}.${extension}`,
await blobToUint8Array(blob),
);
} catch (error) {
imageByKey.set(key, {
key,
file: `images/${indexedFileName}.png`,
layer,
error: error instanceof Error ? error.message : '图片读取失败',
});
}
}
const failedImages = [...imageByKey.values()].filter(
(image) => image.error,
);
const successfulImages = [...imageByKey.values()].filter(
(image) => image.blob,
);
if (!successfulImages.length) {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
return;
}
const metadata: CanvasAssetExportMetadata = {
projectId,
projectTitle,
exportedAt: exportedAt.toISOString(),
layers: exportableLayers.map((layer) => {
const image = imageByKey.get(getLayerExportKey(layer));
return buildLayerExportMetadata(
layer,
image?.blob ? image.file : null,
image?.error,
);
}),
failedImages: failedImages.map((image) => ({
key: image.key,
title: image.layer.title,
src: image.layer.src,
error: image.error ?? '图片读取失败',
})),
};
const manifest = [
`项目:${projectTitle}`,
`导出时间:${metadata.exportedAt}`,
`素材数量:${successfulImages.length}`,
`图层数量:${exportableLayers.length}`,
failedImages.length ? `失败素材数量:${failedImages.length}` : null,
]
.filter(Boolean)
.join('\n');
rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2));
rootFolder.file('manifest.txt', manifest);
const zipBlob = await zip.generateAsync({ type: 'blob' });
if (
typeof URL.createObjectURL !== 'function' ||
typeof URL.revokeObjectURL !== 'function'
) {
setAssetExportStatus({
tone: 'error',
message: '当前浏览器不支持素材下载',
});
return;
}
const downloadUrl = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
setAssetExportStatus({
tone: failedImages.length ? 'error' : 'success',
message: failedImages.length ? '部分素材未能导出' : '画布素材已导出',
});
} catch {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
} finally {
setIsExportingAssets(false);
}
};
const startProjectRename = () => { const startProjectRename = () => {
setProjectRenameValue(projectTitle); setProjectRenameValue(projectTitle);
setProjectRenameError(null); setProjectRenameError(null);

View File

@@ -0,0 +1,206 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import JSZip from 'jszip';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
function createLayer(
id: string,
overrides: Partial<CanvasLayer> = {},
): CanvasLayer {
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `data:image/png;base64,${btoa(`layer-${id.replace(/[^\w-]/gu, '-')}`)}`,
x: 10,
y: 20,
width: 100,
height: 80,
originalWidth: 100,
originalHeight: 80,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
async function readZipText(zip: JSZip, path: string) {
const file = zip.file(path);
if (!file) {
throw new Error(`Missing zip file: ${path}`);
}
return file.async('text');
}
function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
const workflow = useImageCanvasAssetExportWorkflow({
layers,
projectId: 'project-export',
projectTitle: '导出项目',
});
return (
<div>
<span data-testid="exporting">{String(workflow.isExportingAssets)}</span>
<span data-testid="status">
{workflow.assetExportStatus
? `${workflow.assetExportStatus.tone}:${workflow.assetExportStatus.message}`
: '-'}
</span>
<button
type="button"
onClick={() => {
void workflow.exportCanvasAssets();
}}
>
</button>
<button
type="button"
onClick={() => workflow.exportLayerImage(layers[0] ?? null)}
>
</button>
</div>
);
}
describe('useImageCanvasAssetExportWorkflow', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('exports canvas assets into a zip with metadata and failed image records', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url === '/generated-ok.png') {
return new Response(new Blob(['generated'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
let exportedBlob: Blob | null = null;
let downloadName = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
exportedBlob = blob;
return 'blob:editor-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('asset-a', {
title: '素材 A',
sourceAssetId: 'asset-a',
zIndex: 1,
hidden: true,
locked: true,
flipX: true,
}),
createLayer('asset-a-duplicate', {
title: '素材 A 副本',
sourceAssetId: 'asset-a',
zIndex: 2,
}),
createLayer('generated', {
title: '生成图',
src: '/generated-ok.png',
sourceType: 'generated',
prompt: '明亮主视觉',
zIndex: 3,
}),
createLayer('failed', {
title: '失败图',
src: '/missing.png',
zIndex: 4,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toMatch(/^--\d{8}\.zip$/u);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/002-生成图.png')).toBeTruthy();
expect(zip.file('导出项目-画布素材/images/003-失败图.png')).toBeNull();
const metadata = JSON.parse(
await readZipText(zip, '导出项目-画布素材/metadata.json'),
);
expect(metadata.projectId).toBe('project-export');
expect(metadata.layers).toHaveLength(4);
expect(metadata.layers[0].file).toBe('images/001-素材 A.png');
expect(metadata.layers[1].file).toBe('images/001-素材 A.png');
expect(metadata.layers[0].canvas.hidden).toBe(true);
expect(metadata.layers[0].canvas.locked).toBe(true);
expect(metadata.layers[0].canvas.flipX).toBe(true);
expect(metadata.layers[2].sourceType).toBe('generated');
expect(metadata.layers[2].prompt).toBe('明亮主视觉');
expect(metadata.layers[3].file).toBeNull();
expect(metadata.layers[3].exportError).toContain('404');
expect(metadata.failedImages).toHaveLength(1);
expect(
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
).toContain('失败素材数量1');
expect(screen.getByTestId('status').textContent).toBe(
'error:部分素材未能导出',
);
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('reports empty exports and supports direct layer image downloads', async () => {
let downloadName = '';
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
},
);
render(
<ExportWorkflowHarness
layers={[createLayer('single-export', { title: '单图/导出:*?' })]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
expect(downloadName).toBe('单图 导出.png');
render(<ExportWorkflowHarness layers={[]} />);
fireEvent.click(
screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!,
);
await waitFor(() => {
expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe(
'info:当前画布没有可导出的素材',
);
});
});
});

View File

@@ -0,0 +1,207 @@
import JSZip from 'jszip';
import { useCallback, useState } from 'react';
import type {
CanvasAssetExportImage,
CanvasAssetExportMetadata,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
blobToUint8Array,
buildLayerExportMetadata,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerImageBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
type AssetExportStatus = {
tone: 'info' | 'success' | 'error';
message: string;
};
type UseImageCanvasAssetExportWorkflowOptions = {
layers: CanvasLayer[];
projectId: string | null;
projectTitle: string;
};
export function useImageCanvasAssetExportWorkflow({
layers,
projectId,
projectTitle,
}: UseImageCanvasAssetExportWorkflowOptions) {
const [assetExportStatus, setAssetExportStatus] =
useState<AssetExportStatus | null>(null);
const [isExportingAssets, setIsExportingAssets] = useState(false);
const exportLayerImage = useCallback((layer: CanvasLayer | null) => {
if (!layer) {
return;
}
const link = document.createElement('a');
link.href = layer.src;
link.download = `${sanitizeExportFilePart(layer.title, 'canvas-layer')}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}, []);
const exportCanvasAssets = useCallback(async () => {
if (isExportingAssets) {
return;
}
const exportableLayers = layers
.filter((layer) => layer.src.trim().length > 0)
.sort((left, right) => left.zIndex - right.zIndex);
if (!exportableLayers.length) {
setAssetExportStatus({
tone: 'info',
message: '当前画布没有可导出的素材',
});
return;
}
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
const exportedAt = new Date();
const projectName = sanitizeExportFilePart(projectTitle, '未命名画布');
const rootFolderName = `${projectName}-画布素材`;
const zip = new JSZip();
const rootFolder = zip.folder(rootFolderName) ?? zip;
const imagesFolder = rootFolder.folder('images') ?? rootFolder;
const imageByKey = new Map<string, CanvasAssetExportImage>();
const usedFileNames = new Map<string, number>();
for (const layer of exportableLayers) {
const key = getLayerExportKey(layer);
if (imageByKey.has(key)) {
continue;
}
const index = imageByKey.size + 1;
const safeTitle = sanitizeExportFilePart(layer.title, '画布素材');
const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`;
const duplicateCount = usedFileNames.get(baseFileName) ?? 0;
usedFileNames.set(baseFileName, duplicateCount + 1);
const indexedFileName =
duplicateCount > 0
? `${baseFileName}-${duplicateCount + 1}`
: baseFileName;
try {
const blob = await readLayerImageBlob(layer);
const extension = getImageExtensionFromTypeOrSrc(
blob.type,
layer.src,
);
const file = `images/${indexedFileName}.${extension}`;
imageByKey.set(key, {
key,
file,
layer,
blob,
});
imagesFolder.file(
`${indexedFileName}.${extension}`,
await blobToUint8Array(blob),
);
} catch (error) {
imageByKey.set(key, {
key,
file: `images/${indexedFileName}.png`,
layer,
error: error instanceof Error ? error.message : '图片读取失败',
});
}
}
const failedImages = [...imageByKey.values()].filter(
(image) => image.error,
);
const successfulImages = [...imageByKey.values()].filter(
(image) => image.blob,
);
if (!successfulImages.length) {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
return;
}
const metadata: CanvasAssetExportMetadata = {
projectId,
projectTitle,
exportedAt: exportedAt.toISOString(),
layers: exportableLayers.map((layer) => {
const image = imageByKey.get(getLayerExportKey(layer));
return buildLayerExportMetadata(
layer,
image?.blob ? image.file : null,
image?.error,
);
}),
failedImages: failedImages.map((image) => ({
key: image.key,
title: image.layer.title,
src: image.layer.src,
error: image.error ?? '图片读取失败',
})),
};
const manifest = [
`项目:${projectTitle}`,
`导出时间:${metadata.exportedAt}`,
`素材数量:${successfulImages.length}`,
`图层数量:${exportableLayers.length}`,
failedImages.length ? `失败素材数量:${failedImages.length}` : null,
]
.filter(Boolean)
.join('\n');
rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2));
rootFolder.file('manifest.txt', manifest);
const zipBlob = await zip.generateAsync({ type: 'blob' });
if (
typeof URL.createObjectURL !== 'function' ||
typeof URL.revokeObjectURL !== 'function'
) {
setAssetExportStatus({
tone: 'error',
message: '当前浏览器不支持素材下载',
});
return;
}
const downloadUrl = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
setAssetExportStatus({
tone: failedImages.length ? 'error' : 'success',
message: failedImages.length ? '部分素材未能导出' : '画布素材已导出',
});
} catch {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
} finally {
setIsExportingAssets(false);
}
}, [isExportingAssets, layers, projectId, projectTitle]);
return {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportLayerImage,
};
}