diff --git a/components/upload/DetailImageSorter.test.tsx b/components/upload/DetailImageSorter.test.tsx
index 46ae2c90..6b232f8c 100644
--- a/components/upload/DetailImageSorter.test.tsx
+++ b/components/upload/DetailImageSorter.test.tsx
@@ -7,17 +7,51 @@ const images = [
id: 'detail-1',
file: new File(['first'], '1.jpg', { type: 'image/jpeg' }),
preview: 'blob:first',
+ previewStatus: 'ready' as const,
sortOrder: 0,
},
{
id: 'detail-2',
file: new File(['second'], '2.jpg', { type: 'image/jpeg' }),
preview: 'blob:second',
+ previewStatus: 'ready' as const,
sortOrder: 1,
},
]
describe('DetailImageSorter', () => {
+ test('加载中和不可用详情图只显示占位,不渲染空 src', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(screen.getAllByRole('img', { name: 'loading.jpg:正在生成缩略图' })).toHaveLength(2)
+ expect(screen.getAllByRole('img', { name: 'unavailable.jpg:预览不可用' })).toHaveLength(2)
+ expect(container.querySelector('img')).toBeNull()
+ expect(container.querySelector('img[src=""]')).toBeNull()
+ })
+
test('外部文件拖到已有详情图上时应继续交给外层上传区域处理', () => {
const onOuterDrop = jest.fn()
diff --git a/components/upload/DetailImageSorter.tsx b/components/upload/DetailImageSorter.tsx
index 2dcafdb7..50016513 100644
--- a/components/upload/DetailImageSorter.tsx
+++ b/components/upload/DetailImageSorter.tsx
@@ -12,6 +12,8 @@ interface DetailImage {
id: string;
file: File;
preview: string;
+ previewStatus: 'loading' | 'ready' | 'unavailable';
+ previewMessage?: string;
sortOrder: number;
}
@@ -22,6 +24,47 @@ interface DetailImageSorterProps {
disabled?: boolean;
}
+function DetailImagePreview({
+ image,
+ alt,
+ compact = false,
+}: {
+ image: DetailImage;
+ alt: string;
+ compact?: boolean;
+}) {
+ const status = image.previewStatus;
+ if (status === 'ready' && image.preview) {
+ return (
+
+ );
+ }
+
+ const statusLabel = status === 'loading' ? '正在生成缩略图' : '预览不可用';
+ return (
+
+
+
+ {image.file.name}
+
+
+ {image.previewMessage || statusLabel}
+
+
+ );
+}
+
export default function DetailImageSorter({
images,
onChange,
@@ -120,9 +163,9 @@ export default function DetailImageSorter({
const scrollToImage = (index: number) => {
if (!previewRef.current) return;
- const previewImages = previewRef.current.querySelectorAll('img');
- if (previewImages[index]) {
- previewImages[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
+ const previewItem = previewRef.current.querySelector(`[data-detail-preview-index="${index}"]`);
+ if (previewItem) {
+ previewItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
@@ -156,13 +199,8 @@ export default function DetailImageSorter({
className="max-h-[600px] overflow-y-auto"
>
{sortedImages.map((img, index) => (
-
-

+
+
{index + 1}
@@ -194,11 +232,7 @@ export default function DetailImageSorter({
hover:border-blue-300 dark:hover:border-blue-500
`}
>
-

+
{/* 序号 */}
diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx
index 89efb158..6e1bd5a3 100644
--- a/components/upload/ProductPackageUploader.test.tsx
+++ b/components/upload/ProductPackageUploader.test.tsx
@@ -9,6 +9,7 @@ import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
const mockChunkUpload = jest.fn()
const mockPrepareTaskDetailImagePreview = jest.fn()
+const mockPrepareTaskDetailSelectionThumbnail = jest.fn()
const mockPollTaskDetailUploadJob = jest.fn()
jest.mock('@/lib/chunk-upload', () => ({
@@ -17,6 +18,7 @@ jest.mock('@/lib/chunk-upload', () => ({
jest.mock('@/lib/task-detail-image-browser', () => ({
prepareTaskDetailImagePreview: (...args: unknown[]) => mockPrepareTaskDetailImagePreview(...args),
+ prepareTaskDetailSelectionThumbnail: (...args: unknown[]) => mockPrepareTaskDetailSelectionThumbnail(...args),
}))
jest.mock('@/lib/task-detail-upload-status', () => ({
@@ -99,6 +101,20 @@ const capabilityError = () => new PerformanceSurveyBrowserImageError(
'当前 Edge 环境无法解码这张合法图片',
)
+function thumbnailResult(sourceName: string) {
+ return {
+ file: new File(['thumbnail'], `${sourceName}-thumbnail.webp`, { type: 'image/webp' }),
+ mimeType: 'image/webp' as const,
+ width: 320,
+ height: 240,
+ kind: 'preview' as const,
+ sourceName,
+ sourceIndex: 0,
+ sliceIndex: 0,
+ sliceCount: 1,
+ }
+}
+
function installUploadFlowFetch(completeResponse?: Promise
) {
const fetchMock = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
@@ -124,6 +140,9 @@ describe('ProductPackageUploader', () => {
beforeEach(() => {
jest.clearAllMocks()
mockChunkUpload.mockResolvedValue({ uploadId: 'upload-original-1' })
+ mockPrepareTaskDetailSelectionThumbnail.mockImplementation((file: File) => (
+ Promise.resolve(thumbnailResult(file.name))
+ ))
mockPollTaskDetailUploadJob.mockResolvedValue({
status: 'completed',
progress: 100,
@@ -140,10 +159,11 @@ describe('ProductPackageUploader', () => {
],
}) as jest.Mock
+ let objectUrlIndex = 0
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
- value: jest.fn(() => 'blob:mock'),
+ value: jest.fn(() => `blob:mock-${++objectUrlIndex}`),
})
Object.defineProperty(URL, 'revokeObjectURL', {
@@ -377,7 +397,15 @@ describe('ProductPackageUploader', () => {
fireEvent.change(input, { target: { files: [second, third] } })
})
- expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
+ await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
+ expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2)
+ expect(mockPrepareTaskDetailSelectionThumbnail.mock.calls.map(([file]) => (file as File).name))
+ .toEqual(['first.jpg', 'second.jpg'])
+ expect((URL.createObjectURL as jest.Mock).mock.calls.every(([file]) => (
+ file instanceof File && file.type === 'image/webp'
+ ))).toBe(true)
+ expect((URL.createObjectURL as jest.Mock).mock.calls.some(([file]) => file === first || file === second || file === third))
+ .toBe(false)
expect(screen.getByRole('button', { name: '删除主图 first.jpg' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '删除主图 second.jpg' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '删除主图 third.jpg' })).not.toBeInTheDocument()
@@ -389,6 +417,187 @@ describe('ProductPackageUploader', () => {
}
})
+ test('多批选图共用单一缩略图队列,不会并行解码原图', async () => {
+ const firstThumbnail = createDeferred>()
+ const secondThumbnail = createDeferred>()
+ mockPrepareTaskDetailSelectionThumbnail
+ .mockImplementationOnce(() => firstThumbnail.promise)
+ .mockImplementationOnce(() => secondThumbnail.promise)
+
+ render()
+ await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
+ const input = screen.getByLabelText('主图') as HTMLInputElement
+ const first = new File(['first'], 'queue-first.jpg', { type: 'image/jpeg' })
+ const second = new File(['second'], 'queue-second.jpg', { type: 'image/jpeg' })
+
+ fireEvent.change(input, { target: { files: [first] } })
+ fireEvent.change(input, { target: { files: [second] } })
+
+ await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(1))
+ expect(screen.getByRole('img', { name: 'queue-first.jpg:正在生成缩略图' })).toBeInTheDocument()
+ expect(screen.getByRole('img', { name: 'queue-second.jpg:正在生成缩略图' })).toBeInTheDocument()
+
+ await act(async () => {
+ firstThumbnail.resolve(thumbnailResult(first.name))
+ await Promise.resolve()
+ })
+ await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2))
+
+ await act(async () => {
+ secondThumbnail.resolve(thumbnailResult(second.name))
+ await Promise.resolve()
+ })
+ await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
+ })
+
+ test('浏览器能力不足时显示文件占位,不把原始 File 退回 img', async () => {
+ const user = userEvent.setup()
+ mockPrepareTaskDetailSelectionThumbnail.mockRejectedValueOnce(capabilityError())
+ const { container } = render()
+ await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
+ const source = new File(['source'], 'no-thumbnail.jpg', { type: 'image/jpeg' })
+
+ await user.upload(screen.getByLabelText('主图'), source)
+
+ expect(await screen.findByRole('img', { name: 'no-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
+ expect(screen.getByText('浏览器无法生成缩略图')).toBeInTheDocument()
+ expect(URL.createObjectURL).not.toHaveBeenCalled()
+ expect(container.querySelector('img')).toBeNull()
+ })
+
+ test('移除正在生成缩略图的文件会立即 abort,且不创建 Blob URL', async () => {
+ const user = userEvent.setup()
+ let thumbnailSignal: AbortSignal | undefined
+ mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
+ _file: File,
+ options?: { signal?: AbortSignal },
+ ) => new Promise((_, reject) => {
+ thumbnailSignal = options?.signal
+ thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
+ }))
+
+ render()
+ await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
+ await user.upload(
+ screen.getByLabelText('主图'),
+ new File(['source'], 'remove-loading.jpg', { type: 'image/jpeg' }),
+ )
+ await waitFor(() => expect(thumbnailSignal).toBeDefined())
+
+ await user.click(screen.getByRole('button', { name: '删除主图 remove-loading.jpg' }))
+
+ expect(thumbnailSignal?.aborted).toBe(true)
+ expect(screen.queryByRole('button', { name: '删除主图 remove-loading.jpg' })).not.toBeInTheDocument()
+ expect(URL.createObjectURL).not.toHaveBeenCalled()
+ })
+
+ test('组件卸载会 abort 正在生成的缩略图且不遗留 Blob URL', async () => {
+ const user = userEvent.setup()
+ let thumbnailSignal: AbortSignal | undefined
+ mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
+ _file: File,
+ options?: { signal?: AbortSignal },
+ ) => new Promise((_, reject) => {
+ thumbnailSignal = options?.signal
+ thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
+ }))
+
+ const { unmount } = render()
+ await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
+ await user.upload(
+ screen.getByLabelText('主图'),
+ new File(['source'], 'unmount-loading.jpg', { type: 'image/jpeg' }),
+ )
+ await waitFor(() => expect(thumbnailSignal).toBeDefined())
+
+ unmount()
+
+ expect(thumbnailSignal?.aborted).toBe(true)
+ expect(URL.createObjectURL).not.toHaveBeenCalled()
+ expect(URL.revokeObjectURL).not.toHaveBeenCalled()
+ })
+
+ test('开始提交会终止选图缩略图队列,再进入正式预览处理', async () => {
+ const user = userEvent.setup()
+ jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ let thumbnailSignal: AbortSignal | undefined
+ mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
+ _file: File,
+ options?: { signal?: AbortSignal },
+ ) => new Promise((_, reject) => {
+ thumbnailSignal = options?.signal
+ thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
+ }))
+ mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
+ installUploadFlowFetch(Promise.resolve({
+ ok: true,
+ json: async () => ({ data: { jobId: 'job-after-thumbnail-stop' } }),
+ }))
+
+ render()
+ await fillRequiredTaskFields(
+ user,
+ new File(['source'], 'submit-stops-thumbnail.jpg', { type: 'image/jpeg' }),
+ )
+ await waitFor(() => expect(thumbnailSignal).toBeDefined())
+
+ await user.click(screen.getByRole('button', { name: '提交' }))
+
+ expect(thumbnailSignal?.aborted).toBe(true)
+ await waitFor(() => expect(mockPrepareTaskDetailImagePreview).toHaveBeenCalledTimes(1))
+ expect(screen.getByRole('img', { name: 'submit-stops-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
+ })
+
+ test('已生成的缩略图在删除和卸载时只回收对应 Blob URL', async () => {
+ const user = userEvent.setup()
+ const firstRender = render()
+ await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
+ await user.upload(
+ screen.getByLabelText('主图'),
+ new File(['first'], 'remove-ready.jpg', { type: 'image/jpeg' }),
+ )
+ await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
+ await user.click(screen.getByRole('button', { name: '删除主图 remove-ready.jpg' }))
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
+ expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-1')
+ firstRender.unmount()
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
+
+ const secondRender = render()
+ await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
+ await user.upload(
+ screen.getByLabelText('主图'),
+ new File(['second'], 'unmount-ready.jpg', { type: 'image/jpeg' }),
+ )
+ await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
+ secondRender.unmount()
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
+ expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-2')
+ })
+
+ test('上传成功关闭前回收所有选图缩略图 Blob URL', async () => {
+ const user = userEvent.setup()
+ const onSuccess = jest.fn()
+ mockPrepareTaskDetailImagePreview.mockResolvedValueOnce([thumbnailResult('success.jpg')])
+ installUploadFlowFetch(Promise.resolve({
+ ok: true,
+ json: async () => ({ data: { jobId: 'job-success' } }),
+ }))
+
+ render()
+ await fillRequiredTaskFields(
+ user,
+ new File(['success'], 'success.jpg', { type: 'image/jpeg' }),
+ )
+ await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
+
+ await user.click(screen.getByRole('button', { name: '提交' }))
+
+ await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1), { timeout: 3_000 })
+ expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-1')
+ })
+
test('取消上传会 abort 正在运行的浏览器图片预处理', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx
index a23cf053..b8b0825c 100644
--- a/components/upload/ProductPackageUploader.tsx
+++ b/components/upload/ProductPackageUploader.tsx
@@ -27,6 +27,7 @@ import {
} from '@/lib/task-detail-upload-policy';
import {
prepareTaskDetailImagePreview,
+ prepareTaskDetailSelectionThumbnail,
type TaskDetailAssetKind,
type TaskDetailBrowserPreview,
} from '@/lib/task-detail-image-browser';
@@ -46,6 +47,7 @@ const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar
// TIFF/HEIC/AVIF/SVG 等当前处理链不支持的类型,避免最后提交时才报错。
const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const;
const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
+const TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS = 60_000;
function splitEmptyFiles(files: FileList | File[] | null): {
validFiles: File[];
@@ -94,6 +96,8 @@ interface ProductPackageUploaderProps {
interface AssetFile {
file: File;
preview: string;
+ previewStatus: 'loading' | 'ready' | 'unavailable';
+ previewMessage?: string;
id: string;
sortOrder: number;
skuCode?: string;
@@ -248,14 +252,49 @@ function parseBarcodeInput(input: string): string[] {
);
}
-function revokeAssetPreviews(groups: Array) {
- const urls = new Set(
- groups
- .flat()
- .map((item) => item.preview)
- .filter((preview): preview is string => Boolean(preview) && preview.startsWith('blob:')),
+function AssetThumbnail({
+ asset,
+ alt,
+}: {
+ asset: AssetFile;
+ alt: string;
+}) {
+ if (asset.previewStatus === 'ready' && asset.preview) {
+ return (
+
+ );
+ }
+
+ const isLoading = asset.previewStatus === 'loading';
+ const statusLabel = isLoading ? '正在生成缩略图' : '预览不可用';
+ return (
+
+
+
+ {asset.file.name}
+
+
+ {asset.previewMessage || statusLabel}
+
+
);
- urls.forEach((preview) => URL.revokeObjectURL(preview));
}
export default function ProductPackageUploader({
@@ -316,7 +355,12 @@ export default function ProductPackageUploader({
const completionTimerRef = useRef | null>(null);
const progressRef = useRef(0);
const jobAcceptedRef = useRef(false);
- const assetGroupsRef = useRef([]);
+ const thumbnailQueueRef = useRef>(Promise.resolve());
+ const thumbnailGenerationEpochRef = useRef(0);
+ const thumbnailAbortRef = useRef(null);
+ const thumbnailActiveAssetIdRef = useRef(null);
+ const thumbnailCancelledAssetIdsRef = useRef>(new Set());
+ const thumbnailObjectUrlsRef = useRef>(new Set());
const selectionSnapshotRef = useRef({
main: [],
sku: [],
@@ -330,9 +374,116 @@ export default function ProductPackageUploader({
browserPreviewAbortRef.current = null;
}, []);
- useEffect(() => {
- assetGroupsRef.current = [mainImages, skuImages, detailImages, psdFiles];
- }, [mainImages, skuImages, detailImages, psdFiles]);
+ const updateAssetPreview = useCallback((
+ group: ImageUploadGroup,
+ id: string,
+ updates: Pick,
+ ) => {
+ const applyUpdates = (items: AssetFile[]) => items.map((item) => (
+ item.id === id ? { ...item, ...updates } : item
+ ));
+ if (group === 'main') setMainImages(applyUpdates);
+ else if (group === 'sku') setSkuImages(applyUpdates);
+ else setDetailImages(applyUpdates);
+ }, []);
+
+ const revokeThumbnailObjectUrl = useCallback((url: string) => {
+ if (!url || !thumbnailObjectUrlsRef.current.delete(url)) return;
+ URL.revokeObjectURL(url);
+ }, []);
+
+ const revokeAllThumbnailObjectUrls = useCallback(() => {
+ thumbnailObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
+ thumbnailObjectUrlsRef.current.clear();
+ }, []);
+
+ const stopThumbnailGeneration = useCallback((markUnavailable: boolean) => {
+ thumbnailGenerationEpochRef.current += 1;
+ thumbnailAbortRef.current?.abort();
+ thumbnailAbortRef.current = null;
+ thumbnailActiveAssetIdRef.current = null;
+ // 旧队列中的任务会因 epoch 不一致而跳过;新一轮选择无需等待旧链收尾。
+ thumbnailQueueRef.current = Promise.resolve();
+
+ if (!markUnavailable || !mountedRef.current) return;
+ const mark = (items: AssetFile[]) => items.map((item) => (
+ item.previewStatus === 'loading'
+ ? {
+ ...item,
+ previewStatus: 'unavailable' as const,
+ previewMessage: '上传时重新处理',
+ }
+ : item
+ ));
+ setMainImages(mark);
+ setSkuImages(mark);
+ setDetailImages(mark);
+ }, []);
+
+ const enqueueSelectionThumbnails = useCallback((
+ group: ImageUploadGroup,
+ assets: AssetFile[],
+ ) => {
+ const epoch = thumbnailGenerationEpochRef.current;
+ assets.forEach((asset) => {
+ thumbnailCancelledAssetIdsRef.current.delete(asset.id);
+ thumbnailQueueRef.current = thumbnailQueueRef.current
+ .catch(() => undefined)
+ .then(async () => {
+ if (
+ !mountedRef.current
+ || epoch !== thumbnailGenerationEpochRef.current
+ || thumbnailCancelledAssetIdsRef.current.has(asset.id)
+ ) {
+ return;
+ }
+
+ const controller = new AbortController();
+ thumbnailAbortRef.current = controller;
+ thumbnailActiveAssetIdRef.current = asset.id;
+ try {
+ const thumbnail = await prepareTaskDetailSelectionThumbnail(asset.file, {
+ signal: controller.signal,
+ timeoutMs: TASK_DETAIL_SELECTION_THUMBNAIL_TIMEOUT_MS,
+ });
+ if (
+ !mountedRef.current
+ || epoch !== thumbnailGenerationEpochRef.current
+ || thumbnailCancelledAssetIdsRef.current.has(asset.id)
+ ) {
+ return;
+ }
+
+ const previewUrl = URL.createObjectURL(thumbnail.file);
+ thumbnailObjectUrlsRef.current.add(previewUrl);
+ updateAssetPreview(group, asset.id, {
+ preview: previewUrl,
+ previewStatus: 'ready',
+ previewMessage: undefined,
+ });
+ } catch (thumbnailError) {
+ if (
+ controller.signal.aborted
+ || !mountedRef.current
+ || epoch !== thumbnailGenerationEpochRef.current
+ || thumbnailCancelledAssetIdsRef.current.has(asset.id)
+ ) {
+ return;
+ }
+ updateAssetPreview(group, asset.id, {
+ preview: '',
+ previewStatus: 'unavailable',
+ previewMessage: isPerformanceSurveyBrowserCapabilityError(thumbnailError)
+ ? '浏览器无法生成缩略图'
+ : '缩略图不可用,提交时校验',
+ });
+ } finally {
+ if (thumbnailAbortRef.current === controller) thumbnailAbortRef.current = null;
+ if (thumbnailActiveAssetIdRef.current === asset.id) thumbnailActiveAssetIdRef.current = null;
+ }
+ });
+ });
+ }, [updateAssetPreview]);
useEffect(() => {
// React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。
@@ -347,12 +498,13 @@ export default function ProductPackageUploader({
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSets.clear();
+ stopThumbnailGeneration(false);
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
- revokeAssetPreviews(assetGroupsRef.current);
+ revokeAllThumbnailObjectUrls();
};
- }, [abortActiveBrowserPreview]);
+ }, [abortActiveBrowserPreview, revokeAllThumbnailObjectUrls, stopThumbnailGeneration]);
useEffect(() => {
let active = true;
@@ -449,7 +601,10 @@ export default function ProductPackageUploader({
if (files.length === 0) return;
const preparedFiles: AssetFile[] = files.map((file) => ({
file,
- preview: group === 'psd' ? '' : URL.createObjectURL(file),
+ // 原始 File 不进入 img;图片缩略图由单通道 Worker 异步生成。
+ preview: '',
+ previewStatus: group === 'psd' ? 'unavailable' : 'loading',
+ previewMessage: group === 'psd' ? '源文件无需预览' : '正在生成缩略图',
id: Math.random().toString(36).substring(2, 11),
sortOrder: 0,
}));
@@ -466,6 +621,8 @@ export default function ProductPackageUploader({
else if (group === 'sku') setSkuImages(append);
else if (group === 'detail') setDetailImages(append);
else setPsdFiles(append);
+
+ if (group !== 'psd') enqueueSelectionThumbnails(group, preparedFiles);
};
const setSelectionFeedback = (
@@ -556,7 +713,9 @@ export default function ProductPackageUploader({
const removed = assets.find((item) => item.id === id);
if (!removed) return;
- if (removed.preview) URL.revokeObjectURL(removed.preview);
+ thumbnailCancelledAssetIdsRef.current.add(id);
+ if (thumbnailActiveAssetIdRef.current === id) thumbnailAbortRef.current?.abort();
+ if (removed.preview) revokeThumbnailObjectUrl(removed.preview);
selectionSnapshotRef.current = {
...selectionSnapshotRef.current,
[group]: selectionSnapshotRef.current[group].filter((file) => file !== removed.file),
@@ -774,6 +933,9 @@ export default function ProductPackageUploader({
}
}
+ // 提交链会重新生成正式预览。先终止非必要的选图缩略图,避免两条 Worker
+ // 链同时解码大图;尚未完成的缩略图改为低干扰占位。
+ stopThumbnailGeneration(true);
setUploading(true);
setUploadProgress(0);
progressRef.current = 0;
@@ -1056,10 +1218,10 @@ export default function ProductPackageUploader({
setProgress(100);
setProcessingStep('完成!');
completed = true;
- revokeAssetPreviews([mainImages, skuImages, detailImages, psdFiles]);
completionTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return;
setProcessingStep('');
+ revokeAllThumbnailObjectUrls();
onSuccess();
}, 500);
} catch (error: unknown) {
@@ -1242,7 +1404,7 @@ export default function ProductPackageUploader({
{mainImages.map((img) => (
-

+