From 62295a023020fd0afb5577fad0287d7df2632c04 Mon Sep 17 00:00:00 2001 From: chenpei Date: Mon, 3 Aug 2026 09:53:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=99=8D=E4=BD=8E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=80=89=E5=9B=BE=E5=86=85=E5=AD=98=E5=B3=B0?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/upload/DetailImageSorter.test.tsx | 34 +++ components/upload/DetailImageSorter.tsx | 64 ++++-- .../upload/ProductPackageUploader.test.tsx | 213 +++++++++++++++++- components/upload/ProductPackageUploader.tsx | 198 ++++++++++++++-- docs/未完成问题处理记录-2026-08-01.md | 55 ++++- .../task-detail-image-browser.test.ts | 26 ++- lib/performance-survey-image-browser.ts | 4 +- lib/performance-survey-image.worker.ts | 26 ++- lib/task-detail-image-browser.ts | 27 +++ 9 files changed, 596 insertions(+), 51 deletions(-) diff --git a/components/upload/DetailImageSorter.test.tsx b/components/upload/DetailImageSorter.test.tsx index 46ae2c9..6b232f8 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 2dcafdb..5001651 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 ( + {alt} + ); + } + + 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 89efb15..6e1bd5a 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 a23cf05..b8b0825 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 ( + {alt} + ); + } + + 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) => (
- +