diff --git a/TRACKING.md b/TRACKING.md index 155fc39e..3d010f0e 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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 前端拆分第十一阶段:新增 `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 前端拆分第十三阶段:新增 `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。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 459851b0..11192fda 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -117,6 +117,13 @@ - 主视图继续负责生成提交 API、生成对象 active / archived 状态、资源持久化、图层选择、侧栏切换、对话框收起和适合视图等副作用,避免把多个画布生成对象的生命周期拆成浅 wrapper。 - 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框只作为定位参考”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。 +## 第十三阶段模块 + +- `useImageCanvasAssetExportWorkflow.ts` + - 承载画布素材导出工作流:导出状态、空画布提示、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、`metadata.json`、`manifest.txt`、下载链接创建和浏览器不支持时的错误提示。 + - 主视图继续负责右键菜单目标解析、下载按钮渲染和状态提示展示;导出 hook 不接管图层选择、右键菜单生命周期或画布状态。 + - 该 hook 用独立单测覆盖 ZIP 内容、重复图层复用同一图片文件、失败图片 metadata、manifest 失败数量、空画布提示和单图文件名清理。 + ## 后续阶段 - 生成工作流 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 check:encoding` - `git diff --check` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 8b8752a4..c3153e9f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,5 +1,4 @@ import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react'; -import JSZip from 'jszip'; import { type CSSProperties, type DragEvent as ReactDragEvent, @@ -85,15 +84,6 @@ import { resolveContextMenuPosition, serializeLayer, } from './ImageCanvasEditorModel'; -import { - blobToUint8Array, - buildLayerExportMetadata, - formatExportDate, - getImageExtensionFromTypeOrSrc, - getLayerExportKey, - readLayerImageBlob, - sanitizeExportFilePart, -} from './ImageCanvasExportModel'; import { CHARACTER_ANIMATION_DURATION_OPTIONS, CHARACTER_ANIMATION_MODEL, @@ -132,8 +122,6 @@ import { } from './ImageCanvasGenerationModel'; import type { AssetPointerDragState, - CanvasAssetExportImage, - CanvasAssetExportMetadata, CanvasClipboard, CanvasContextMenuState, CanvasGenerationDialogState, @@ -156,6 +144,7 @@ import type { import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; +import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; @@ -255,11 +244,6 @@ export function ImageCanvasEditorView() { const [projectRenameError, setProjectRenameError] = useState( null, ); - const [assetExportStatus, setAssetExportStatus] = useState<{ - tone: 'info' | 'success' | 'error'; - message: string; - } | null>(null); - const [isExportingAssets, setIsExportingAssets] = useState(false); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [viewport, setViewport] = useState({ @@ -687,6 +671,16 @@ export function ImageCanvasEditorView() { viewport, openEditorLoginModal, }); + const { + assetExportStatus, + isExportingAssets, + exportCanvasAssets, + exportLayerImage, + } = useImageCanvasAssetExportWorkflow({ + layers, + projectId, + projectTitle, + }); const { uploadInputRef, setUploadTarget, @@ -1207,15 +1201,7 @@ export function ImageCanvasEditorView() { const exportContextLayer = () => { const targetIds = getContextTargetLayerIds(); const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); - if (!targetLayer) { - 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(); + exportLayerImage(targetLayer ?? null); setContextMenu(null); }; @@ -1241,156 +1227,6 @@ export function ImageCanvasEditorView() { }; 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(); - const usedFileNames = new Map(); - - 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 = () => { setProjectRenameValue(projectTitle); setProjectRenameError(null); diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx new file mode 100644 index 00000000..682a3091 --- /dev/null +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -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 { + 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 ( +
+ {String(workflow.isExportingAssets)} + + {workflow.assetExportStatus + ? `${workflow.assetExportStatus.tone}:${workflow.assetExportStatus.message}` + : '-'} + + + +
+ ); +} + +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( + , + ); + + 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( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '导出单图' })); + + expect(downloadName).toBe('单图 导出.png'); + + render(); + fireEvent.click( + screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!, + ); + + await waitFor(() => { + expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe( + 'info:当前画布没有可导出的素材', + ); + }); + }); +}); diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts new file mode 100644 index 00000000..1717e5ac --- /dev/null +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -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(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(); + const usedFileNames = new Map(); + + 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, + }; +}