diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index 1c6cbb25b..1e2ae5e4f 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -30,6 +30,8 @@ 左侧素材库选择模式同时支持导出当前可见范围内的选中素材:只选中一个普通素材时直接下载原文件;选中多个素材时沿用画布素材 ZIP 的读取、去重、媒体分目录、序列帧、元数据、部分失败和浏览器下载能力,生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,包内根目录为 `项目名-选中素材/`。单素材、单序列帧、选中素材 ZIP 和画布素材 ZIP 的下载文件名都必须包含到秒的本地时间戳,避免同一天重复导出时重名。该入口不改变中央画布和图层列表的选择 / 下载语义。 +持久素材只要 `src` 或 `objectKey` 任一有效即可进入点击、Shift、框选和全选范围。`src` 为空但保留私有 `objectKey` 的素材,导出时复用统一资源读取链路换签;浏览器无法直读签名 URL 时继续回退同源 `/api/assets/read-bytes` 字节代理,不得因缺少临时展示地址而过滤。 + 暂不实现: - 画布整体截图 PNG。 diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 18b49d772..323f05972 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -282,7 +282,7 @@ describe('ImageCanvasExportModel', () => { try { const blob = await readLayerAssetBlob( buildLayer({ - src: '/generated-editor-videos/video.mp4', + src: '', mediaType: 'video', objectKey: 'generated/video.mp4', }), diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index fb2e52973..d411b4901 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -175,6 +175,89 @@ describe('useImageCanvasAssetExportWorkflow', () => { } }); + it('exports selected object-key-only assets through the private byte reader', async () => { + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/assets/read-url?')) { + return new Response(null, { status: 404 }); + } + if (url.startsWith('/api/assets/read-bytes?')) { + const objectKey = new URL(url, 'http://localhost').searchParams.get( + 'objectKey', + ); + return new Response(new Blob([objectKey ?? ''], { type: 'image/png' })); + } + return new Response(null, { status: 404 }); + }); + globalThis.fetch = fetchMock as typeof fetch; + let exportedBlob: Blob | null = null; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + exportedBlob = blob; + return 'blob:object-key-only-export'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + try { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '下载多个选中素材' })); + await waitFor(() => expect(exportedBlob).toBeTruthy()); + + const byteReadUrls = fetchMock.mock.calls + .map(([input]) => String(input)) + .filter((url) => url.startsWith('/api/assets/read-bytes?')); + expect(byteReadUrls).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'objectKey=generated%2Feditor%2Fprivate-a.png', + ), + expect.stringContaining( + 'objectKey=generated%2Feditor%2Fprivate-b.png', + ), + ]), + ); + const zip = await JSZip.loadAsync(exportedBlob!); + expect( + zip.file('导出项目-选中素材/images/001-私有素材A.png'), + ).toBeTruthy(); + expect( + zip.file('导出项目-选中素材/images/002-私有素材B.png'), + ).toBeTruthy(); + expect(screen.getByTestId('status').textContent).toBe( + 'success:选中素材已导出', + ); + } finally { + globalThis.fetch = originalFetch; + delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL; + delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL; + } + }); + it('prevents concurrent collection exports before React re-renders', async () => { const originalFetch = globalThis.fetch; let resolveFetch: (response: Response) => void = () => {}; diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 88d843f12..092cc1e4a 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -1,6 +1,7 @@ import JSZip from 'jszip'; import { useCallback, useRef, useState } from 'react'; +import { hasReadableAssetSource } from '../../services/assetReadUrlService'; import type { CanvasAssetExportImage, CanvasAssetExportMetadata, @@ -418,7 +419,9 @@ export function useImageCanvasAssetExportWorkflow({ return; } const exportableLayers = targetLayers - .filter((layer) => layer.src.trim().length > 0) + .filter((layer) => + hasReadableAssetSource(layer.src, layer.objectKey), + ) .sort((left, right) => left.zIndex - right.zIndex); if (!exportableLayers.length) { setAssetExportStatus({ @@ -759,7 +762,7 @@ export function useImageCanvasAssetExportWorkflow({ return; } const exportableLayers = selectedLayers.filter( - (layer) => layer.src.trim().length > 0, + (layer) => hasReadableAssetSource(layer.src, layer.objectKey), ); if (!exportableLayers.length) { setAssetExportStatus({ diff --git a/src/components/image-editor/useImageCanvasAssetSelection.test.tsx b/src/components/image-editor/useImageCanvasAssetSelection.test.tsx index 3922e92cf..8e4c9eba5 100644 --- a/src/components/image-editor/useImageCanvasAssetSelection.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetSelection.test.tsx @@ -46,9 +46,14 @@ describe('useImageCanvasAssetSelection', () => { createAsset({ id: 'uploading', uploadStatus: 'uploading' }), createAsset({ id: 'failed', uploadStatus: 'failed' }), createAsset({ id: 'empty', src: ' ' }), + createAsset({ + id: 'object-key-only', + src: '', + objectKey: 'generated/editor/object-key-only.png', + }), createAsset({ id: 'built-in', sourceKind: 'built-in' }), ]).map((asset) => asset.id), - ).toEqual(['ready']); + ).toEqual(['ready', 'object-key-only']); }); it('owns marquee geometry and selectable hit testing', () => { @@ -80,6 +85,11 @@ describe('useImageCanvasAssetSelection', () => { assets: [ createAsset({ id: 'asset-a' }), createAsset({ id: 'asset-b' }), + createAsset({ + id: 'object-key-only', + src: '', + objectKey: 'generated/editor/object-key-only.png', + }), createAsset({ id: 'built-in', sourceKind: 'built-in' }), ], assetTargets: [ @@ -95,10 +105,58 @@ describe('useImageCanvasAssetSelection', () => { assetId: 'built-in', rect: { left: 15, right: 25, top: 15, bottom: 25 }, }, + { + assetId: 'object-key-only', + rect: { left: 25, right: 30, top: 25, bottom: 30 }, + }, ], selectionRect: { left: 20, right: 35, top: 20, bottom: 35 }, }), - ]).toEqual(['asset-a', 'asset-b']); + ]).toEqual(['asset-a', 'asset-b', 'object-key-only']); + }); + + it('supports click, shift and select-all for object-key-only assets', () => { + const assets = [ + createAsset({ + id: 'object-key-a', + src: '', + objectKey: 'generated/editor/object-key-a.png', + }), + createAsset({ + id: 'object-key-b', + src: '', + objectKey: 'generated/editor/object-key-b.png', + }), + ]; + const visibleAssetIds = getSelectableAssets(assets).map( + (asset) => asset.id, + ); + const { result } = renderHook(() => + useImageCanvasAssetSelection({ + assetListRef: createRef(), + assets, + }), + ); + + act(() => result.current.openAssetSelectionMode()); + act(() => + result.current.toggleAssetSelected('object-key-a', { visibleAssetIds }), + ); + act(() => + result.current.toggleAssetSelected('object-key-b', { + visibleAssetIds, + shiftKey: true, + }), + ); + expect([...result.current.selectedAssetIds]).toEqual([ + 'object-key-a', + 'object-key-b', + ]); + + act(() => result.current.toggleAllAssetsSelected(visibleAssetIds)); + expect(result.current.selectedAssetIds.size).toBe(0); + act(() => result.current.toggleAllAssetsSelected(visibleAssetIds)); + expect([...result.current.selectedAssetIds]).toEqual(visibleAssetIds); }); it('applies tick, shift and visible select-all as deltas', () => { diff --git a/src/components/image-editor/useImageCanvasAssetSelection.ts b/src/components/image-editor/useImageCanvasAssetSelection.ts index 219b7f5e8..c94a0a45b 100644 --- a/src/components/image-editor/useImageCanvasAssetSelection.ts +++ b/src/components/image-editor/useImageCanvasAssetSelection.ts @@ -1,6 +1,7 @@ import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { hasReadableAssetSource } from '../../services/assetReadUrlService'; import type { AssetMarqueeState, EditorAsset } from './ImageCanvasEditorTypes'; const ASSET_ITEM_SELECTOR = '[data-asset-id]'; @@ -40,7 +41,7 @@ export function getSelectableAssets(assets: EditorAsset[]) { asset.sourceKind === 'uploaded' && asset.uploadStatus !== 'uploading' && asset.uploadStatus !== 'failed' && - asset.src.trim().length > 0, + hasReadableAssetSource(asset.src, asset.objectKey), ); } diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 85feb9380..671384fd5 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -102,6 +102,15 @@ export function shouldResolveAssetReadUrl(source: string | null | undefined) { ); } +export function hasReadableAssetSource( + source: string | null | undefined, + objectKey?: string | null, +) { + return Boolean( + source?.trim() || objectKey?.trim().replace(/^\/+/u, ''), + ); +} + function normalizeLegacyPublicPath(value: string) { return `/${value.trim().replace(/^\/+/u, '')}`; } @@ -531,11 +540,11 @@ export async function readAssetBytes( options: AssetReadBytesOptions = {}, ) { const value = source?.trim() ?? ''; - if (!value) { + const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? ''; + if (!hasReadableAssetSource(value, objectKey)) { throw new Error('资源路径不能为空'); } - const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? ''; const legacyPath = isGeneratedLegacyPath(value) ? value : resolveGeneratedLegacyPathFromUrl(value);