From 3495530f3d56e88ae347f8876313632fe6dd3a12 Mon Sep 17 00:00:00 2001 From: chenpei Date: Sun, 2 Aug 2026 01:04:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8A=A0=E5=9B=BA=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E4=B8=8A=E4=BC=A0=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/page.tsx | 43 ++- .../upload/ProductPackageUploader.test.tsx | 133 ++++++- components/upload/ProductPackageUploader.tsx | 346 +++++++++++++----- docs/未完成问题处理记录-2026-08-01.md | 28 +- .../image-processing-limiter.test.ts | 63 ++++ lib/__tests__/material-preview-url.test.ts | 33 ++ .../performance-survey-image-browser.test.ts | 177 +++++++++ .../performance-survey-image-error.test.ts | 1 + .../product-package-completion.test.ts | 58 +++ .../task-detail-image-browser.test.ts | 46 +++ .../task-detail-upload-status.test.ts | 49 +++ .../upload-sessions-security.test.ts | 13 + lib/image-processing-limiter.ts | 7 +- lib/material-preview-url.ts | 13 + lib/performance-survey-image-browser.ts | 62 +++- lib/performance-survey-image-error.ts | 2 + lib/product-package-completion.ts | 25 +- lib/task-detail-image-browser.ts | 10 +- lib/task-detail-upload-status.ts | 17 +- lib/upload-sessions.ts | 106 +++++- 20 files changed, 1084 insertions(+), 148 deletions(-) create mode 100644 lib/__tests__/image-processing-limiter.test.ts create mode 100644 lib/__tests__/material-preview-url.test.ts create mode 100644 lib/__tests__/performance-survey-image-browser.test.ts create mode 100644 lib/__tests__/task-detail-image-browser.test.ts create mode 100644 lib/__tests__/task-detail-upload-status.test.ts create mode 100644 lib/material-preview-url.ts diff --git a/app/page.tsx b/app/page.tsx index 608b062..a1ad490 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -15,6 +15,7 @@ import { validateAndTriggerDownload } from '@/lib/client-download' import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler' import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue } from '@/lib/video-material-types' import { buildPublicMaterialShareUrl } from '@/lib/public-app-url' +import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/material-preview-url' // 首页拆分组件 import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home' @@ -590,19 +591,36 @@ export default function Home() { const data = await response.json() // 清理素材 ID,并为预览图补齐统一 fallback const materialsList = ((data.materials || data) as Material[]) - .map((e) => { - if (!e.id || typeof e.id !== 'string') return null + .flatMap((e): Material[] => { + if (!e.id || typeof e.id !== 'string') return [] const trimmedId = e.id.trim() - if (!trimmedId) return null + if (!trimmedId) return [] - // previewUrl fallback逻辑 - const previewUrl = e.previewUrl || - e.videoCoverUrl || - (e.displayImages && e.displayImages.length > 0 ? e.displayImages[0] : '') + const displayImages = (Array.isArray(e.displayImages) ? e.displayImages : []) + .map(normalizeMaterialPreviewUrl) + .filter((url): url is string => Boolean(url)) + const videoSourceUrl = normalizeMaterialPreviewUrl(e.videoSourceUrl) + const videoPreview540Url = normalizeMaterialPreviewUrl(e.videoPreview540Url) + const videoPreviewClipUrl = normalizeMaterialPreviewUrl(e.videoPreviewClipUrl) + const videoCoverUrl = normalizeMaterialPreviewUrl(e.videoCoverUrl) + // 只允许非空字符串进入预览 URL,避免历史异常布尔值挡住后续合法地址。 + const previewUrl = pickFirstMaterialPreviewUrl( + e.previewUrl, + videoCoverUrl, + ...displayImages, + ) || '' - return { ...e, id: trimmedId, previewUrl } + return [{ + ...e, + id: trimmedId, + previewUrl, + displayImages, + videoSourceUrl, + videoPreview540Url, + videoPreviewClipUrl, + videoCoverUrl, + }] }) - .filter((e): e is Material => e !== null) let appendedItems: Material[] = materialsList if (append) { @@ -729,9 +747,10 @@ export default function Home() { ? { ...m, videoProcessingStatus: data.status, - videoPreview540Url: m.videoPreview540Url || data.hasPreview540 ? data.hasPreview540 : null, - videoPreviewClipUrl: m.videoPreviewClipUrl || data.hasClip ? data.hasClip : null, - videoCoverUrl: m.videoCoverUrl || data.hasCover ? data.hasCover : null, + // 状态接口只有 has* 布尔标志,真实 URL 由完成后的列表刷新取得。 + videoPreview540Url: normalizeMaterialPreviewUrl(m.videoPreview540Url), + videoPreviewClipUrl: normalizeMaterialPreviewUrl(m.videoPreviewClipUrl), + videoCoverUrl: normalizeMaterialPreviewUrl(m.videoCoverUrl), videoDuration: data.duration ?? m.videoDuration, transcodeAttempts: data.attempts ?? m.transcodeAttempts, transcodeProgress: data.progress ?? m.transcodeProgress, diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index d223d1b..89efb15 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -1,7 +1,9 @@ import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import ProductPackageUploader from '@/components/upload/ProductPackageUploader' +import ProductPackageUploader, { + selectTaskDetailFilesWithinLimits, +} from '@/components/upload/ProductPackageUploader' import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error' import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' @@ -296,6 +298,135 @@ describe('ProductPackageUploader', () => { .toBe('image/jpeg,image/png,image/gif,image/webp') }) + test('选图门禁逐个保留合格文件,并覆盖单组、总图片、总条目和总大小限制', () => { + const file = (name: string, size: number) => { + const value = new File(['x'], name, { type: 'image/jpeg' }) + Object.defineProperty(value, 'size', { configurable: true, value: size }) + return value + } + const emptySnapshot = () => ({ main: [], sku: [], detail: [], psd: [] }) + const baseLimits = { + maxImagesPerGroup: 10, + maxTotalImageCount: 10, + maxPsdFileCount: 10, + maxTotalSourceFileCount: 10, + maxTotalSourceBytes: 100, + } + + const groupResult = selectTaskDetailFilesWithinLimits( + 'main', + [file('a.jpg', 1), file('b.jpg', 1)], + emptySnapshot(), + { ...baseLimits, maxImagesPerGroup: 1 }, + ) + expect(groupResult.acceptedFiles.map((item) => item.name)).toEqual(['a.jpg']) + expect(groupResult.errors.join('')).toContain('主图最多选择 1 张') + + const psdGroupResult = selectTaskDetailFilesWithinLimits( + 'psd', + [file('source-a.psd', 1), file('source-b.psd', 1)], + emptySnapshot(), + { ...baseLimits, maxPsdFileCount: 1 }, + ) + expect(psdGroupResult.acceptedFiles.map((item) => item.name)).toEqual(['source-a.psd']) + expect(psdGroupResult.errors.join('')).toContain('PSD 源文件最多选择 1 个') + + const totalImageResult = selectTaskDetailFilesWithinLimits( + 'detail', + [file('detail.jpg', 1)], + { ...emptySnapshot(), main: [file('existing.jpg', 1)] }, + { ...baseLimits, maxTotalImageCount: 1 }, + ) + expect(totalImageResult.acceptedFiles).toHaveLength(0) + expect(totalImageResult.errors.join('')).toContain('本次图片合计最多 1 张') + + const totalSourceResult = selectTaskDetailFilesWithinLimits( + 'main', + [file('new.jpg', 1)], + { ...emptySnapshot(), psd: [file('existing.psd', 1)] }, + { ...baseLimits, maxTotalSourceFileCount: 1 }, + ) + expect(totalSourceResult.acceptedFiles).toHaveLength(0) + expect(totalSourceResult.errors.join('')).toContain('原图和源文件合计最多 1 个') + + const totalBytesResult = selectTaskDetailFilesWithinLimits( + 'sku', + [file('fits.jpg', 1), file('too-much.jpg', 2)], + { ...emptySnapshot(), main: [file('existing.jpg', 2)] }, + { ...baseLimits, maxTotalSourceBytes: 4 }, + ) + expect(totalBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['fits.jpg']) + expect(totalBytesResult.errors.join('')).toContain('原图和源文件总大小最多 4B') + }) + + test('快速连续选图使用同步快照计数,超限文件不创建 Blob URL', async () => { + const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxImagesPerGroup: number } + const originalLimit = mutableLimits.maxImagesPerGroup + mutableLimits.maxImagesPerGroup = 2 + + try { + render() + await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员') + const input = screen.getByLabelText('主图') as HTMLInputElement + const first = new File(['first'], 'first.jpg', { type: 'image/jpeg' }) + const second = new File(['second'], 'second.jpg', { type: 'image/jpeg' }) + const third = new File(['third'], 'third.jpg', { type: 'image/jpeg' }) + + act(() => { + fireEvent.change(input, { target: { files: [first] } }) + fireEvent.change(input, { target: { files: [second, third] } }) + }) + + expect(URL.createObjectURL).toHaveBeenCalledTimes(2) + 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() + expect(screen.getByRole('alert')).toHaveTextContent('主图最多选择 2 张') + expect(screen.getByRole('alert')).toHaveTextContent('请删除不需要的文件,或分批提交') + expect(screen.getByRole('alert')).toHaveTextContent('其余 1 张符合限制的文件已保留') + } finally { + mutableLimits.maxImagesPerGroup = originalLimit + } + }) + + test('取消上传会 abort 正在运行的浏览器图片预处理', async () => { + const user = userEvent.setup() + jest.spyOn(console, 'error').mockImplementation(() => undefined) + const previewDeferred = createDeferred() + let previewSignal: AbortSignal | undefined + mockPrepareTaskDetailImagePreview.mockImplementationOnce(( + _file: File, + _group: string, + _onProgress: unknown, + options?: { signal?: AbortSignal }, + ) => { + previewSignal = options?.signal + previewSignal?.addEventListener('abort', () => { + previewDeferred.reject(new DOMException('图片处理已取消', 'AbortError')) + }, { once: true }) + return previewDeferred.promise + }) + const fetchMock = installUploadFlowFetch() + + render() + await fillRequiredTaskFields( + user, + new File(['image'], 'cancel-processing.jpg', { type: 'image/jpeg' }), + ) + await user.click(screen.getByRole('button', { name: '提交' })) + await waitFor(() => expect(previewSignal).toBeDefined()) + + await user.click(screen.getByRole('button', { name: '取消上传' })) + + expect(previewSignal?.aborted).toBe(true) + expect(await screen.findByText('已取消上传')).toBeInTheDocument() + expect(mockChunkUpload).not.toHaveBeenCalled() + expect(fetchMock).toHaveBeenCalledWith( + '/api/materials/upload/product-package/abort', + expect.objectContaining({ method: 'POST' }), + ) + }) + test('Edge 浏览器预览处理失败时继续上传原图并显式请求服务器兜底', async () => { const user = userEvent.setup() const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index ff6a088..a23cf05 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -4,7 +4,7 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import DetailImageSorter from './DetailImageSorter'; import UploadForm from './UploadForm'; import { addCategory, addScene } from './category-handlers'; @@ -102,6 +102,113 @@ interface AssetFile { } type UploadGroup = 'main' | 'sku' | 'detail' | 'psd'; +type ImageUploadGroup = Exclude; + +type TaskDetailFileSelectionSnapshot = Record; + +type TaskDetailFileSelectionLimitKey = + | 'maxImagesPerGroup' + | 'maxTotalImageCount' + | 'maxPsdFileCount' + | 'maxTotalSourceFileCount' + | 'maxTotalSourceBytes'; + +type TaskDetailFileSelectionLimits = Readonly>; + +type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes'; + +const UPLOAD_GROUP_LABELS: Record = { + main: '主图', + sku: 'SKU 图', + detail: '详情图', + psd: 'PSD 源文件', +}; + +function formatSelectionLimitBytes(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024 / 1024)}GB`; + if (bytes >= 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}MB`; + if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`; + return `${bytes}B`; +} + +function summarizeRejectedFiles(files: File[]): string { + const names = files.slice(0, 3).map((file) => file.name || '未命名文件'); + return files.length > names.length + ? `${names.join('、')}等 ${files.length} 个文件` + : names.join('、'); +} + +export function selectTaskDetailFilesWithinLimits( + group: UploadGroup, + candidates: File[], + existing: TaskDetailFileSelectionSnapshot, + limits: TaskDetailFileSelectionLimits = TASK_DETAIL_UPLOAD_LIMITS, +): { acceptedFiles: File[]; errors: string[] } { + const rejected: Record = { + group: [], + totalImages: [], + totalSources: [], + totalBytes: [], + }; + const acceptedFiles: File[] = []; + const isImageGroup = group !== 'psd'; + let groupCount = existing[group].length; + let totalImageCount = existing.main.length + existing.sku.length + existing.detail.length; + let totalSourceCount = totalImageCount + existing.psd.length; + let totalSourceBytes = Object.values(existing) + .flat() + .reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); + + candidates.forEach((file) => { + const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount; + let reason: SelectionLimitReason | null = null; + if (groupCount >= groupLimit) { + reason = 'group'; + } else if (isImageGroup && totalImageCount >= limits.maxTotalImageCount) { + reason = 'totalImages'; + } else if (totalSourceCount >= limits.maxTotalSourceFileCount) { + reason = 'totalSources'; + } else if (totalSourceBytes + file.size > limits.maxTotalSourceBytes) { + reason = 'totalBytes'; + } + + if (reason) { + rejected[reason].push(file); + return; + } + + acceptedFiles.push(file); + groupCount += 1; + if (isImageGroup) totalImageCount += 1; + totalSourceCount += 1; + totalSourceBytes += file.size; + }); + + const errors: string[] = []; + const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount; + if (rejected.group.length > 0) { + errors.push( + `${UPLOAD_GROUP_LABELS[group]}最多选择 ${groupLimit} ${isImageGroup ? '张' : '个'},已忽略 ${summarizeRejectedFiles(rejected.group)};请删除不需要的文件,或分批提交`, + ); + } + if (rejected.totalImages.length > 0) { + errors.push( + `本次图片合计最多 ${limits.maxTotalImageCount} 张,已忽略 ${summarizeRejectedFiles(rejected.totalImages)};请减少其他分组的图片,或分批提交`, + ); + } + if (rejected.totalSources.length > 0) { + errors.push( + `本次原图和源文件合计最多 ${limits.maxTotalSourceFileCount} 个,已忽略 ${summarizeRejectedFiles(rejected.totalSources)};请删除部分已选文件,或分批提交`, + ); + } + if (rejected.totalBytes.length > 0) { + errors.push( + `本次原图和源文件总大小最多 ${formatSelectionLimitBytes(limits.maxTotalSourceBytes)},已忽略 ${summarizeRejectedFiles(rejected.totalBytes)};请压缩、删除部分文件,或分批提交`, + ); + } + + return { acceptedFiles, errors }; +} type UploadedAsset = { fileName: string; @@ -204,11 +311,24 @@ export default function ProductPackageUploader({ const uploadStopRef = useRef(false); const activeXhrsRef = useRef>(new Set()); const activeXhrSetsRef = useRef>>(new Map()); + const browserPreviewAbortRef = useRef(null); const jobPollAbortRef = useRef(null); const completionTimerRef = useRef | null>(null); const progressRef = useRef(0); const jobAcceptedRef = useRef(false); const assetGroupsRef = useRef([]); + const selectionSnapshotRef = useRef({ + main: [], + sku: [], + detail: [], + psd: [], + }); + + const abortActiveBrowserPreview = useCallback(() => { + const controller = browserPreviewAbortRef.current; + if (controller) controller.abort(); + browserPreviewAbortRef.current = null; + }, []); useEffect(() => { assetGroupsRef.current = [mainImages, skuImages, detailImages, psdFiles]; @@ -227,11 +347,12 @@ export default function ProductPackageUploader({ activeXhrsRef.current.forEach((xhr) => xhr.abort()); activeXhrsRef.current.clear(); activeXhrSets.clear(); + abortActiveBrowserPreview(); jobPollAbortRef.current?.abort(); if (completionTimerRef.current) clearTimeout(completionTimerRef.current); revokeAssetPreviews(assetGroupsRef.current); }; - }, []); + }, [abortActiveBrowserPreview]); useEffect(() => { let active = true; @@ -322,11 +443,48 @@ export default function ProductPackageUploader({ prepareFileDrag(e, uploading); }; + const getSelectionSnapshot = (): TaskDetailFileSelectionSnapshot => selectionSnapshotRef.current; + + const appendSelectedFiles = (group: UploadGroup, files: File[]) => { + if (files.length === 0) return; + const preparedFiles: AssetFile[] = files.map((file) => ({ + file, + preview: group === 'psd' ? '' : URL.createObjectURL(file), + id: Math.random().toString(36).substring(2, 11), + sortOrder: 0, + })); + selectionSnapshotRef.current = { + ...selectionSnapshotRef.current, + [group]: [...selectionSnapshotRef.current[group], ...files], + }; + const append = (previous: AssetFile[]) => [ + ...previous, + ...preparedFiles.map((item, index) => ({ ...item, sortOrder: previous.length + index })), + ]; + + if (group === 'main') setMainImages(append); + else if (group === 'sku') setSkuImages(append); + else if (group === 'detail') setDetailImages(append); + else setPsdFiles(append); + }; + + const setSelectionFeedback = ( + validationErrors: string[], + acceptedCount: number, + unit: '张' | '个', + ) => { + const messages = [...validationErrors]; + if (messages.length > 0 && acceptedCount > 0) { + messages.push(`其余 ${acceptedCount} ${unit}符合限制的文件已保留,可继续补充或直接提交`); + } + setError(messages.join(';')); + }; + const handleDrop = ( e: React.DragEvent, - setter: React.Dispatch>, + group: UploadGroup, dragSetter: (value: boolean) => void, - accept: 'image' | 'file' | 'psd' + accept: 'image' | 'psd' ) => { prepareFileDrag(e, uploading); dragSetter(false); @@ -337,51 +495,16 @@ export default function ProductPackageUploader({ const files = Array.from(e.dataTransfer.files); if (accept === 'image') { - handleImageSelect(files, setter); + handleImageSelect(files, group as ImageUploadGroup); } else if (accept === 'psd') { - // PSD文件拖拽(支持多个文件) - const psdFiles: File[] = []; - const invalidFiles: string[] = []; - const { validFiles, emptyFileNames } = splitEmptyFiles(files); - - validFiles.forEach((file) => { - const ext = file.name.split('.').pop()?.toLowerCase(); - if (TASK_DETAIL_SOURCE_FILE_EXTENSIONS.includes((ext || '') as typeof TASK_DETAIL_SOURCE_FILE_EXTENSIONS[number])) { - psdFiles.push(file); - } else { - invalidFiles.push(file.name); - } - }); - - const errors: string[] = []; - if (emptyFileNames.length > 0) { - errors.push(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`); - } - if (invalidFiles.length > 0) { - errors.push(`以下文件格式不支持:${invalidFiles.join(', ')},请上传 ${TASK_DETAIL_SOURCE_FILE_LABEL} 文件`); - } - - if (psdFiles.length > 0) { - setPsdFiles((prev) => { - const newFiles: AssetFile[] = psdFiles.map((file, index) => ({ - file, - preview: '', // PSD文件不需要预览 - id: Math.random().toString(36).substring(2, 11), - sortOrder: prev.length + index, - })); - return [...prev, ...newFiles]; - }); - setError(errors.join(';')); - } else if (errors.length > 0) { - setError(errors.join(';')); - } + handlePsdSelect(files); } }; // 处理图片选择 const handleImageSelect = ( files: FileList | File[] | null, - setter: React.Dispatch>, + group: ImageUploadGroup, ) => { if (!files) return; if (uploading) { @@ -396,7 +519,7 @@ export default function ProductPackageUploader({ const oversizedFiles = formatAcceptedFiles.filter( (file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes, ); - const acceptedFiles = formatAcceptedFiles.filter( + const singleSizeAcceptedFiles = formatAcceptedFiles.filter( (file) => file.size <= TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes, ); const validationErrors = [...errors]; @@ -405,36 +528,44 @@ export default function ProductPackageUploader({ } if (oversizedFiles.length > 0) { validationErrors.push( - `以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')}`, + `以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')};请先压缩或拆分后重新选择`, ); } - setError(validationErrors.join(';')); - - setter((prev) => { - const startOrder = prev.length; - const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({ - file, - preview: URL.createObjectURL(file), - id: Math.random().toString(36).substring(2, 11), - sortOrder: startOrder + index, - })); - - return [...prev, ...newFiles]; - }); + const selection = selectTaskDetailFilesWithinLimits( + group, + singleSizeAcceptedFiles, + getSelectionSnapshot(), + ); + appendSelectedFiles(group, selection.acceptedFiles); + setSelectionFeedback( + [...validationErrors, ...selection.errors], + selection.acceptedFiles.length, + '张', + ); }; // 删除图片 - const handleRemoveImage = ( - id: string, - setter: React.Dispatch> - ) => { - setter((prev) => { - const removed = prev.find((item) => item.id === id); - if (removed) { - URL.revokeObjectURL(removed.preview); - } - return prev.filter((item) => item.id !== id); - }); + const handleRemoveFile = (id: string, group: UploadGroup) => { + const assets = group === 'main' + ? mainImages + : group === 'sku' + ? skuImages + : group === 'detail' + ? detailImages + : psdFiles; + const removed = assets.find((item) => item.id === id); + if (!removed) return; + + if (removed.preview) URL.revokeObjectURL(removed.preview); + selectionSnapshotRef.current = { + ...selectionSnapshotRef.current, + [group]: selectionSnapshotRef.current[group].filter((file) => file !== removed.file), + }; + + if (group === 'main') setMainImages((previous) => previous.filter((item) => item.id !== id)); + else if (group === 'sku') setSkuImages((previous) => previous.filter((item) => item.id !== id)); + else if (group === 'detail') setDetailImages((previous) => previous.filter((item) => item.id !== id)); + else setPsdFiles((previous) => previous.filter((item) => item.id !== id)); }; const updateSkuImageMeta = (id: string, updates: Partial) => { @@ -444,7 +575,7 @@ export default function ProductPackageUploader({ }; // 处理 PSD 文件选择 - const handlePsdSelect = (files: FileList | null) => { + const handlePsdSelect = (files: FileList | File[] | null) => { if (!files || files.length === 0) return; if (uploading) { setError('文件正在上传,请等待完成后再添加'); @@ -472,20 +603,17 @@ export default function ProductPackageUploader({ errors.push(`以下文件格式不支持:${invalidFiles.join(', ')},请上传 ${TASK_DETAIL_SOURCE_FILE_LABEL} 文件`); } - if (validFiles.length > 0) { - setPsdFiles((prev) => { - const newFiles: AssetFile[] = validFiles.map((file, index) => ({ - file, - preview: '', // PSD文件不需要预览 - id: Math.random().toString(36).substring(2, 11), - sortOrder: prev.length + index, - })); - return [...prev, ...newFiles]; - }); - setError(errors.join(';')); - } else if (errors.length > 0) { - setError(errors.join(';')); - } + const selection = selectTaskDetailFilesWithinLimits( + 'psd', + validFiles, + getSelectionSnapshot(), + ); + appendSelectedFiles('psd', selection.acceptedFiles); + setSelectionFeedback( + [...errors, ...selection.errors], + selection.acceptedFiles.length, + '个', + ); }; // 使用分片上传到本地服务器(和任务-3D原图一样)。 @@ -553,6 +681,7 @@ export default function ProductPackageUploader({ uploadStopRef.current = true; // 第一处失败出现后立即停止同批网络传输;consumer 仍需全部 settle, // 这样稍后完成的上传 ID 能进入最终一次素材回滚请求。 + abortActiveBrowserPreview(); activeXhrsRef.current.forEach((xhr) => xhr.abort()); }; @@ -653,6 +782,7 @@ export default function ProductPackageUploader({ cancelledRef.current = false; uploadStopRef.current = false; jobAcceptedRef.current = false; + abortActiveBrowserPreview(); jobPollAbortRef.current?.abort(); let createdMaterialId: string | null = null; @@ -732,15 +862,20 @@ export default function ProductPackageUploader({ let browserPreviewBusy = false; const browserPreviewWaiters: Array<() => void> = []; - const withBrowserPreviewSlot = async (worker: () => Promise): Promise => { + const withBrowserPreviewSlot = async (worker: (signal: AbortSignal) => Promise): Promise => { if (browserPreviewBusy) { await new Promise((resolve) => browserPreviewWaiters.push(resolve)); } browserPreviewBusy = true; + const previewController = new AbortController(); + browserPreviewAbortRef.current = previewController; try { if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消'); - return await worker(); + return await worker(previewController.signal); } finally { + if (browserPreviewAbortRef.current === previewController) { + browserPreviewAbortRef.current = null; + } browserPreviewBusy = false; browserPreviewWaiters.shift()?.(); } @@ -756,13 +891,14 @@ export default function ProductPackageUploader({ let browserPreviews: TaskDetailBrowserPreview[] = []; let browserPreviewFailed = false; try { - browserPreviews = await withBrowserPreviewSlot(() => + browserPreviews = await withBrowserPreviewSlot((signal) => prepareTaskDetailImagePreview( asset.file, group as TaskDetailAssetKind, (label) => { if (mountedRef.current) setProcessingStep(`${asset.file.name}:${label}`); }, + { signal }, ), ); } catch (previewError) { @@ -961,6 +1097,7 @@ export default function ProductPackageUploader({ activeXhrsRef.current.forEach((xhr) => xhr.abort()); activeXhrsRef.current.clear(); activeXhrSetsRef.current.clear(); + abortActiveBrowserPreview(); jobPollAbortRef.current = null; uploadStopRef.current = true; if (mountedRef.current) setUploading(false); @@ -977,6 +1114,7 @@ export default function ProductPackageUploader({ activeXhrsRef.current.forEach((xhr) => xhr.abort()); activeXhrsRef.current.clear(); activeXhrSetsRef.current.clear(); + abortActiveBrowserPreview(); jobPollAbortRef.current?.abort(); setProcessingStep(jobAcceptedRef.current ? '已停止等待,后台任务继续处理...' : '正在取消上传...'); }; @@ -1084,7 +1222,7 @@ export default function ProductPackageUploader({ onDragEnter={(e) => handleDragEnter(e, setIsMainDragging)} onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)} onDragOver={handleDragOver} - onDrop={(e) => handleDrop(e, setMainImages, setIsMainDragging, 'image')} + onDrop={(e) => handleDrop(e, 'main', setIsMainDragging, 'image')} onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())} > handleImageSelect(e.target.files, setMainImages)} + onChange={(e) => { + handleImageSelect(e.target.files, 'main'); + e.currentTarget.value = ''; + }} className="hidden" disabled={uploading} /> @@ -1104,7 +1245,7 @@ export default function ProductPackageUploader({