fix: 修复 objectKey-only 私有素材的多选与批量导出。
统一使用 src || objectKey 判断素材是否可读 已同步导出方案文档
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user