fix: 修复 objectKey-only 私有素材的多选与批量导出。

统一使用 src || objectKey 判断素材是否可读
已同步导出方案文档
This commit is contained in:
2026-08-04 10:52:15 +08:00
parent 3aefd70b5a
commit 9afd1ebfdb
7 changed files with 164 additions and 8 deletions
@@ -30,6 +30,8 @@
左侧素材库选择模式同时支持导出当前可见范围内的选中素材:只选中一个普通素材时直接下载原文件;选中多个素材时沿用画布素材 ZIP 的读取、去重、媒体分目录、序列帧、元数据、部分失败和浏览器下载能力,生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,包内根目录为 `项目名-选中素材/`。单素材、单序列帧、选中素材 ZIP 和画布素材 ZIP 的下载文件名都必须包含到秒的本地时间戳,避免同一天重复导出时重名。该入口不改变中央画布和图层列表的选择 / 下载语义。
持久素材只要 `src``objectKey` 任一有效即可进入点击、Shift、框选和全选范围。`src` 为空但保留私有 `objectKey` 的素材,导出时复用统一资源读取链路换签;浏览器无法直读签名 URL 时继续回退同源 `/api/assets/read-bytes` 字节代理,不得因缺少临时展示地址而过滤。
暂不实现:
- 画布整体截图 PNG。
@@ -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',
}),
@@ -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(
<ExportWorkflowHarness
layers={[
createLayer('object-key-a', {
title: '私有素材A',
src: '',
objectKey: 'generated/editor/private-a.png',
zIndex: 0,
}),
createLayer('object-key-b', {
title: '私有素材B',
src: '',
objectKey: 'generated/editor/private-b.png',
zIndex: 1,
}),
]}
/>,
);
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 = () => {};
@@ -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({
@@ -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<HTMLDivElement>(),
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', () => {
@@ -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),
);
}
+11 -2
View File
@@ -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);