/** * 任务-详情上传组件 */ 'use client'; import Select from '@/components/ui/Select' import { useCallback, useEffect, useRef, useState } from 'react'; import DetailImageSorter from './DetailImageSorter'; import { dropZoneActiveClass, dropZoneIdleClass, inkButtonClass, outlineButtonClass } from './upload-field-styles'; import UploadForm from './UploadForm'; import styles from './ProductPackageUploader.module.css'; import { addCategory, addScene } from './category-handlers'; import SearchableUserSelect from '@/components/SearchableUserSelect'; import { TASK_DETAIL_SOURCE_TYPE_OPTIONS, type TaskDetailSourceType, } from '@/lib/task-detail-source-type'; import { normalizeTaskDetailPrivacyFields, TASK_DETAIL_DEMAND_TYPE_OPTIONS, type TaskDetailDemandType, } from '@/lib/task-detail-privacy-fields'; import { hasDragLeftDropZone, handleDropZoneKeyDown, prepareFileDrag, validateImageFiles, } from './drag-drop-utils'; import TaskReferenceFields from './TaskReferenceFields'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; import { detectSkuOrBarcodeName } from '@/lib/sku-barcode-detector'; import { TASK_DETAIL_UPLOAD_LIMITS, validateTaskDetailUploadFiles, } from '@/lib/task-detail-upload-policy'; import { prepareTaskDetailImagePreview, prepareTaskDetailSelectionThumbnail, type TaskDetailAssetKind, type TaskDetailBrowserPreview, } from '@/lib/task-detail-image-browser'; import { isPerformanceSurveyBrowserCapabilityError, PerformanceSurveyBrowserImageError, } from '@/lib/performance-survey-image-error'; import { pollTaskDetailUploadJob, TaskDetailUploadJobError, } from '@/lib/task-detail-upload-status'; const TASK_DETAIL_SOURCE_FILE_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const; const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z'; const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar、.7z'; // 浏览器预处理 worker 的输入格式白名单。不要使用 image/*,它还会放行 // 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; const TASK_DETAIL_METADATA_FIELD_CLASS_NAME = 'w-full rounded-[8px] border border-[#e7e8ea] bg-white px-3 py-2 text-[#1a1c1d] outline-none focus:border-[#1a1c1d] focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 disabled:cursor-not-allowed disabled:opacity-60'; function splitEmptyFiles(files: FileList | File[] | null): { validFiles: File[]; emptyFileNames: string[]; } { if (!files) { return { validFiles: [], emptyFileNames: [], }; } const validFiles: File[] = []; const emptyFileNames: string[] = []; Array.from(files).forEach((file) => { if (!file) { return; } if (!file.name?.trim() || !Number.isFinite(file.size) || file.size <= 0) { emptyFileNames.push(file.name || '未命名文件'); return; } validFiles.push(file); }); return { validFiles, emptyFileNames, }; } interface ProductPackageUploaderProps { onSuccess: () => void; onCancel: () => void; categories: Array<{ id: string; name: string }>; scenes: Array<{ id: string; name: string }>; platforms: Array<{ id: string; name: string }>; productCategories: Array<{ id: string; name: string }>; onRefreshCategories: () => Promise; onRefreshScenes: () => Promise; } interface AssetFile { file: File; preview: string; previewStatus: 'loading' | 'ready' | 'unavailable'; previewMessage?: string; id: string; sortOrder: number; skuCode?: string; barcodeInput?: string; barcodes?: string[]; } type UploadGroup = 'main' | 'sku' | 'detail' | 'psd'; type ImageUploadGroup = Exclude; type TaskDetailFileSelectionSnapshot = Record; type TaskDetailFileSelectionLimitKey = | 'maxImagesPerGroup' | 'maxTotalImageCount' | 'maxPsdFileCount' | 'maxTotalSourceFileCount' | 'maxTotalSourceBytes' | 'maxSinglePsdBytes' | 'maxTotalPsdBytes'; type TaskDetailFileSelectionLimits = Readonly>; type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes' | 'psdSingle' | 'psdBytes'; 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: [], psdSingle: [], psdBytes: [], }; 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 totalImageBytes = [...existing.main, ...existing.sku, ...existing.detail] .reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); let totalPsdBytes = existing.psd .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 (isImageGroup && totalImageBytes + file.size > limits.maxTotalSourceBytes) { reason = 'totalBytes'; } else if (!isImageGroup && file.size > limits.maxSinglePsdBytes) { reason = 'psdSingle'; } else if (!isImageGroup && totalPsdBytes + file.size > limits.maxTotalPsdBytes) { reason = 'psdBytes'; } if (reason) { rejected[reason].push(file); return; } acceptedFiles.push(file); groupCount += 1; if (isImageGroup) { totalImageCount += 1; totalImageBytes += file.size; } else { totalPsdBytes += file.size; } totalSourceCount += 1; }); 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)};请压缩、删除部分图片,或分批提交`, ); } if (rejected.psdSingle.length > 0) { errors.push( `单个源文件最多 ${formatSelectionLimitBytes(limits.maxSinglePsdBytes)},已忽略 ${summarizeRejectedFiles(rejected.psdSingle)}`, ); } if (rejected.psdBytes.length > 0) { errors.push( `本次源文件总大小最多 ${formatSelectionLimitBytes(limits.maxTotalPsdBytes)},已忽略 ${summarizeRejectedFiles(rejected.psdBytes)};请分批提交`, ); } return { acceptedFiles, errors }; } type UploadedAsset = { fileName: string; fileSize: number; uploadId: string; sortOrder: number; previewAssets?: Array<{ uploadId: string; fileName: string; fileSize: number; width: number; height: number; kind: 'preview' | 'slice'; sourceName: string; sourceIndex: number; sliceIndex: number; sliceCount: number; }>; previewUploadId?: string; previewFileName?: string; previewFileSize?: number; }; type UploadedTaskResult = { group: UploadGroup; asset: UploadedAsset; }; function parseBarcodeInput(input: string): string[] { return Array.from( new Set( String(input || '') .split(/[\r\n,,、;;]+/) .map((item) => item.trim()) .filter(Boolean), ), ); } const TRAILING_IMAGE_EXTENSION_PATTERN = /\.(jpe?g|png|webp|gif|bmp|tiff?)$/i; /** * 剥掉文件名末尾的图片扩展名,含 .jpg.jpg / .png.png / .jpg.png 等双重、多重后缀。 * 只认图片后缀白名单,条码内部的点号(如 690.123)不会被误伤。 */ export function stripTrailingImageExtensions(fileName: string): string { let result = String(fileName || ''); while (TRAILING_IMAGE_EXTENSION_PATTERN.test(result)) { result = result.replace(TRAILING_IMAGE_EXTENSION_PATTERN, ''); } return result; } // 后台处理阶段的用户话术;未知阶段一律兜底中文,内部 stage 常量绝不直接上屏。 const JOB_STAGE_LABELS: Record = { queued: '后台排队中...', validating: '正在校验素材信息...', assembling: '正在合并上传文件...', previews: '正在生成预览图...', zips: '正在打包下载文件...', database: '正在保存素材信息...', cleanup: '正在清理临时文件...', completed: '完成!', failed: '后台处理失败', } 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}
); } export default function ProductPackageUploader({ onSuccess, onCancel, categories, scenes, platforms, productCategories, onRefreshCategories, onRefreshScenes, }: ProductPackageUploaderProps) { // 基本信息 const [taskNo, setTaskNo] = useState(''); const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState(''); const [productModel, setProductModel] = useState(''); const [categoryName, setCategoryName] = useState(''); const [sceneName, setSceneName] = useState(''); const [platformName, setPlatformName] = useState(''); const [productCategoryName, setProductCategoryName] = useState(''); const [taskDetailSourceType, setTaskDetailSourceType] = useState(''); const [taskDetailDemandType, setTaskDetailDemandType] = useState('新品'); const [taskDetailPrivate, setTaskDetailPrivate] = useState(false); const [taskDetailRequesterUserId, setTaskDetailRequesterUserId] = useState(''); const [users, setUsers] = useState>([]); const [usersLoading, setUsersLoading] = useState(true); const [usersLoadError, setUsersLoadError] = useState(''); // 资产文件 const [mainImages, setMainImages] = useState([]); const [skuImages, setSkuImages] = useState([]); const [detailImages, setDetailImages] = useState([]); const [psdFiles, setPsdFiles] = useState([]); // 拖拽状态 const [isMainDragging, setIsMainDragging] = useState(false); const [isSkuDragging, setIsSkuDragging] = useState(false); const [isDetailDragging, setIsDetailDragging] = useState(false); const [isPsdDragging, setIsPsdDragging] = useState(false); // 上传状态 const [noBarcodes, setNoBarcodes] = useState(false); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [processingStep, setProcessingStep] = useState(''); const [error, setError] = useState(''); // 文件输入引用 const mainInputRef = useRef(null); const skuInputRef = useRef(null); const detailInputRef = useRef(null); const psdInputRef = useRef(null); const errorRef = useRef(null); const mountedRef = useRef(true); const cancelledRef = useRef(false); 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 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: [], detail: [], psd: [], }); useEffect(() => { if (error) errorRef.current?.scrollIntoView?.({ block: 'nearest' }); }, [error]); const abortActiveBrowserPreview = useCallback(() => { const controller = browserPreviewAbortRef.current; if (controller) controller.abort(); browserPreviewAbortRef.current = null; }, []); 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]); const isOptimizationDemand = taskDetailDemandType === '优化'; const taskDetailVisibilityValue = taskDetailPrivate ? 'private' : 'public'; const taskDetailPrivacyHint = isOptimizationDemand ? '私密仅所选需求运营及已授权权限组可访问,上传人不自动放行。' : '新品固定公开,仅优化需求支持私密;若设为私密,仅所选需求运营及已授权权限组可访问,上传人不自动放行。'; const handleTaskDetailDemandTypeChange = useCallback((value: TaskDetailDemandType) => { setTaskDetailDemandType(value); if (value !== '优化') { setTaskDetailPrivate(false); } setError(''); }, []); const handleTaskDetailVisibilityChange = useCallback((value: 'public' | 'private') => { setTaskDetailPrivate(value === 'private' && taskDetailDemandType === '优化'); setError(''); }, [taskDetailDemandType]); useEffect(() => { // React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。 // 每次 setup 都要恢复挂载标记,避免后续进度和错误状态被误判为已卸载。 mountedRef.current = true; cancelledRef.current = false; const activeXhrSets = activeXhrSetsRef.current; return () => { mountedRef.current = false; cancelledRef.current = true; uploadStopRef.current = true; activeXhrsRef.current.forEach((xhr) => xhr.abort()); activeXhrsRef.current.clear(); activeXhrSets.clear(); stopThumbnailGeneration(false); abortActiveBrowserPreview(); jobPollAbortRef.current?.abort(); if (completionTimerRef.current) clearTimeout(completionTimerRef.current); revokeAllThumbnailObjectUrls(); }; }, [abortActiveBrowserPreview, revokeAllThumbnailObjectUrls, stopThumbnailGeneration]); useEffect(() => { let active = true; const loadUsers = async () => { try { const response = await fetch('/api/users/list'); if (!response.ok) { throw new Error('人员列表加载失败'); } const data = await response.json(); if (active) { setUsers(Array.isArray(data) ? data : []); setUsersLoadError(''); } } catch (loadError) { console.error('[ProductPackageUploader] 加载需求运营人员失败:', loadError); if (active) { setUsersLoadError('人员列表加载失败,请刷新后重试'); } } finally { if (active) { setUsersLoading(false); } } }; void loadUsers(); return () => { active = false; }; }, []); // 验证任务号格式 // 支持格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复) // 示例: // TASK202601030184 - 基础任务号 // TASK202601071437194808-01 - 一级子任务 // TASK202601141713525624-01-01 - 二级子任务(任务号-子任务-子任务) const validateTaskNo = (value: string): boolean => { const regex = /^TASK\d{12,18}(-\d{2})*$/; return regex.test(value); }; // 处理添加新店铺 const handleAddNewCategory = async (name: string) => { await addCategory( name, async (newCategory) => { await onRefreshCategories(); setCategoryName(newCategory.name); }, (error) => setError(error) ); }; // 处理添加新场景 const handleAddNewScene = async (name: string) => { await addScene( name, async (newScene) => { await onRefreshScenes(); setSceneName(newScene.name); }, (error) => setError(error) ); }; // 拖拽处理函数 const handleDragEnter = (e: React.DragEvent, setter: (value: boolean) => void) => { prepareFileDrag(e, uploading); if (uploading) return; setter(true); }; const handleDragLeave = (e: React.DragEvent, setter: (value: boolean) => void) => { e.preventDefault(); e.stopPropagation(); if (hasDragLeftDropZone(e)) { setter(false); } }; const handleDragOver = (e: React.DragEvent) => { 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) => { // SKU 图按部门命名习惯自动预填:品类码+纯数字 → SKU 编号框,带 N..V.. 段 → 条码框; // 判别不出(序号、复合串等)不自动填,由上传人员人工填写。 let autoSkuCode: string | undefined; let autoBarcodeInput: string | undefined; if (group === 'sku') { const detection = detectSkuOrBarcodeName(stripTrailingImageExtensions(file.name)); if (detection?.type === 'skuCode') autoSkuCode = detection.value; else if (detection?.type === 'barcode') autoBarcodeInput = detection.value; } return { file, // 原始 File 不进入 img;图片缩略图由单通道 Worker 异步生成。 preview: '', previewStatus: group === 'psd' ? 'unavailable' : 'loading', previewMessage: group === 'psd' ? '源文件无需预览' : '正在生成缩略图', id: Math.random().toString(36).substring(2, 11), sortOrder: 0, ...(autoSkuCode ? { skuCode: autoSkuCode } : {}), ...(autoBarcodeInput ? { barcodeInput: autoBarcodeInput } : {}), }; }); 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); if (group !== 'psd') enqueueSelectionThumbnails(group, preparedFiles); }; 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, group: UploadGroup, dragSetter: (value: boolean) => void, accept: 'image' | 'psd' ) => { prepareFileDrag(e, uploading); dragSetter(false); if (uploading) { setError('文件正在上传,请等待完成后再添加'); return; } const files = Array.from(e.dataTransfer.files); if (accept === 'image') { handleImageSelect(files, group as ImageUploadGroup); } else if (accept === 'psd') { handlePsdSelect(files); } }; // 处理图片选择 const handleImageSelect = ( files: FileList | File[] | null, group: ImageUploadGroup, ) => { if (!files) return; if (uploading) { setError('文件正在上传,请等待完成后再添加'); return; } const { validFiles, emptyFileNames } = splitEmptyFiles(files); const { acceptedFiles: formatAcceptedFiles, errors } = validateImageFiles(validFiles, { allowedExtensions: TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS, }); const oversizedFiles = formatAcceptedFiles.filter( (file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes, ); const singleSizeAcceptedFiles = formatAcceptedFiles.filter( (file) => file.size <= TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes, ); const validationErrors = [...errors]; if (emptyFileNames.length > 0) { validationErrors.unshift(`以下文件为空,无法上传:${emptyFileNames.join('、')}`); } if (oversizedFiles.length > 0) { validationErrors.push( `以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')};请先压缩或拆分后重新选择`, ); } const selection = selectTaskDetailFilesWithinLimits( group, singleSizeAcceptedFiles, getSelectionSnapshot(), ); appendSelectedFiles(group, selection.acceptedFiles); setSelectionFeedback( [...validationErrors, ...selection.errors], selection.acceptedFiles.length, '张', ); }; // 删除图片 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; 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), }; 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) => { setSkuImages((prev) => prev.map((item) => (item.id === id ? { ...item, ...updates } : item)), ); }; // 处理 PSD 文件选择 const handlePsdSelect = (files: FileList | File[] | null) => { if (!files || files.length === 0) return; if (uploading) { setError('文件正在上传,请等待完成后再添加'); return; } const validFiles: File[] = []; const invalidFiles: string[] = []; const { validFiles: nonEmptyFiles, emptyFileNames } = splitEmptyFiles(files); nonEmptyFiles.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])) { validFiles.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} 文件`); } const selection = selectTaskDetailFilesWithinLimits( 'psd', validFiles, getSelectionSnapshot(), ); appendSelectedFiles('psd', selection.acceptedFiles); setSelectionFeedback( [...errors, ...selection.errors], selection.acceptedFiles.length, '个', ); }; // 使用分片上传到本地服务器(和任务-3D原图一样)。 // 图片预处理和网络上传是两条不同的资源链路:预处理保持单张,网络分片 // 允许两个并发,避免 300M 公网链路被单个请求的往返等待限制吞吐。 const uploadFileWithChunks = async ( file: File, onProgress?: (progress: number) => void, isCancelled?: () => boolean, requestKey?: string, ): Promise => { const { chunkUpload } = await import('@/lib/chunk-upload'); const result = await chunkUpload(file, { materialType: '任务-详情', fileName: file.name, }, { // chunkSize使用默认配置:内网20MB,公网10MB skipComplete: true, chunkConcurrency: 2, isCancelled, onProgress: (progress) => onProgress?.(progress), onActiveXhrChange: (xhrs) => { if (requestKey) { activeXhrSetsRef.current.set(requestKey, xhrs); const merged = new Set(); activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr))); activeXhrsRef.current = merged; } }, }); if (requestKey) { activeXhrSetsRef.current.delete(requestKey); const merged = new Set(); activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr))); activeXhrsRef.current = merged; } return result.uploadId; }; // 网络传输允许两个文件同时进行;浏览器预处理在提交函数内另有单槽位, // 不会同时解码两张大图。服务器端真正的 Sharp/ZIP 处理仍由后台 worker 全局串行。 const MAX_FILE_CONCURRENCY = 2; const runWithConcurrency = async ( items: TInput[], limit: number, worker: (item: TInput, index: number) => Promise ): Promise => { if (items.length === 0) return []; const safeLimit = Math.max(1, Math.min(limit, items.length)); const results: TOutput[] = new Array(items.length); let nextIndex = 0; let stopped = false; let hasError = false; let firstError: unknown; const stopConsumers = (error: unknown) => { if (hasError) return; hasError = true; firstError = error; stopped = true; uploadStopRef.current = true; // 第一处失败出现后立即停止同批网络传输;consumer 仍需全部 settle, // 这样稍后完成的上传 ID 能进入最终一次素材回滚请求。 abortActiveBrowserPreview(); activeXhrsRef.current.forEach((xhr) => xhr.abort()); }; const consume = async () => { while (!stopped && nextIndex < items.length) { const current = nextIndex; nextIndex += 1; try { results[current] = await worker(items[current], current); } catch (error) { stopConsumers(error); return; } } }; await Promise.allSettled(Array.from({ length: safeLimit }, () => consume())); if (hasError) throw firstError; return results; }; // 提交上传 const handleSubmit = async () => { let taskDetailPrivacyFields: ReturnType; try { taskDetailPrivacyFields = normalizeTaskDetailPrivacyFields({ taskDetailDemandType, taskDetailPrivate, }); } catch (validationError) { setError(validationError instanceof Error ? validationError.message : '任务详情上传选项不合法'); return; } if (!taskDetailRequesterUserId) { setError('请选择需求运营人员'); return; } if (!taskDetailSourceType) { setError('请选择出图方式'); return; } if (!productModel || !categoryName) { setError('请填写SPU型号和店铺'); return; } if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) { setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)'); return; } const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); if (dingtalkApprovalNoError) { setError(dingtalkApprovalNoError); return; } if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) { setError('请至少上传一张图片(主图、SKU图或详情图)'); return; } const uploadPolicyError = validateTaskDetailUploadFiles({ main: mainImages.map((item) => item.file), sku: skuImages.map((item) => item.file), detail: detailImages.map((item) => item.file), psd: psdFiles.map((item) => item.file), }); if (uploadPolicyError) { setError(uploadPolicyError); return; } let normalizedSkuImages: AssetFile[]; try { normalizedSkuImages = skuImages.map((image, index) => { const skuCode = String(image.skuCode || '').trim(); if (!skuCode) throw new Error(`请为第 ${index + 1} 张 SKU 图填写 SKU 编号`); const barcodes = parseBarcodeInput(image.barcodeInput || ''); if (!noBarcodes && barcodes.length === 0) { throw new Error(`请为 SKU 图 ${image.file.name} 至少填写 1 个产品条码`); } return { ...image, skuCode, barcodeInput: barcodes.join('\n'), barcodes }; }); } catch (validationError) { setError(validationError instanceof Error ? validationError.message : 'SKU 条码信息校验失败'); return; } const barcodeOwners = new Map(); for (const image of normalizedSkuImages) { for (const barcode of image.barcodes || []) { const existingOwner = barcodeOwners.get(barcode); if (existingOwner && existingOwner !== image.id) { setError(`产品条码 ${barcode} 不能同时绑定两张 SKU 图`); return; } barcodeOwners.set(barcode, image.id); } } // 提交链会重新生成正式预览。先终止非必要的选图缩略图,避免两条 Worker // 链同时解码大图;尚未完成的缩略图改为低干扰占位。 stopThumbnailGeneration(true); setUploading(true); setUploadProgress(0); progressRef.current = 0; setProcessingStep(''); setError(''); cancelledRef.current = false; uploadStopRef.current = false; jobAcceptedRef.current = false; abortActiveBrowserPreview(); jobPollAbortRef.current?.abort(); let createdMaterialId: string | null = null; const uploadedFileIds: string[] = []; let completed = false; let jobAccepted = false; let serverPreviewFallbackUsed = false; const setProgress = (nextProgress: number) => { if (!mountedRef.current) return; const next = Math.max(progressRef.current, Math.min(100, Math.round(nextProgress))); progressRef.current = next; setUploadProgress(next); }; try { setProcessingStep('初始化上传...'); const initResponse = await fetch('/api/materials/upload/product-package/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskNo, dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, productModel, categoryName, sceneName, platformName: platformName || undefined, productCategoryName: productCategoryName || undefined, taskDetailSourceType: taskDetailSourceType || undefined, taskDetailDemandType: taskDetailPrivacyFields.taskDetailDemandType, taskDetailPrivate: taskDetailPrivacyFields.taskDetailPrivate, taskDetailRequesterUserId, }), }); if (!initResponse.ok) { const errorData = await initResponse.json().catch(() => ({})); throw new Error(errorData.error || '初始化失败'); } const { data: initData } = await initResponse.json(); const materialId = initData.materialId; createdMaterialId = materialId; const uploadTasks: Array<{ group: UploadGroup; asset: AssetFile; sortOrder: number; skuCode?: string; barcodes?: string[]; }> = [ ...mainImages.map((asset, sortOrder) => ({ group: 'main' as const, asset, sortOrder })), ...normalizedSkuImages.map((asset, sortOrder) => ({ group: 'sku' as const, asset, sortOrder, skuCode: asset.skuCode, barcodes: asset.barcodes || [], })), ...detailImages.map((asset, sortOrder) => ({ group: 'detail' as const, asset, sortOrder })), ...psdFiles.map((asset, sortOrder) => ({ group: 'psd' as const, asset, sortOrder })), ]; let totalWorkBytes = uploadTasks.reduce((sum, task) => sum + task.asset.file.size, 0); let completedWorkBytes = 0; let reservedPreviewCount = 0; let reservedPreviewBytes = 0; const progressByRequest = new Map(); const reportFileProgress = (requestKey: string, fileSize: number, progress: number) => { const uploadedBytes = Math.min(fileSize, Math.max(0, fileSize * progress / 100)); const previousBytes = progressByRequest.get(requestKey) || 0; if (uploadedBytes <= previousBytes) return; progressByRequest.set(requestKey, uploadedBytes); completedWorkBytes += uploadedBytes - previousBytes; if (totalWorkBytes > 0) setProgress(completedWorkBytes / totalWorkBytes * 90); }; const fileConcurrency = MAX_FILE_CONCURRENCY; let browserPreviewBusy = false; const browserPreviewWaiters: Array<() => void> = []; 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(previewController.signal); } finally { if (browserPreviewAbortRef.current === previewController) { browserPreviewAbortRef.current = null; } browserPreviewBusy = false; browserPreviewWaiters.shift()?.(); } }; const uploadTask = async (task: typeof uploadTasks[number], taskIndex: number): Promise => { if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消'); const { group, asset } = task; const requestPrefix = `${taskIndex}-${asset.id}`; let previewAssets: NonNullable = []; if (group !== 'psd') { let browserPreviews: TaskDetailBrowserPreview[] = []; let browserPreviewFailed = false; try { browserPreviews = await withBrowserPreviewSlot((signal) => prepareTaskDetailImagePreview( asset.file, group as TaskDetailAssetKind, (label) => { if (mountedRef.current) setProcessingStep(`${asset.file.name}:${label}`); }, { signal }, ), ); } catch (previewError) { if (cancelledRef.current || uploadStopRef.current) throw previewError; if (!isPerformanceSurveyBrowserCapabilityError(previewError)) throw previewError; const reason = previewError instanceof Error ? previewError.message : String(previewError); console.warn(`[Upload] 浏览器预览处理失败,继续上传原图并交由服务器生成预览:${asset.file.name}`, reason); browserPreviewFailed = true; serverPreviewFallbackUsed = true; if (mountedRef.current) { setProcessingStep(`${asset.file.name}:浏览器预览不可用,继续上传原图,服务器将自动生成预览`); } } if (browserPreviews.length === 0 && !browserPreviewFailed) { throw new PerformanceSurveyBrowserImageError( 'PROCESSING_FAILED', `图片「${asset.file.name}」未生成浏览器预览,请重试或重新导出图片`, ); } if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消'); const previewBatchBytes = browserPreviews.reduce((sum, preview) => sum + preview.file.size, 0); if ( reservedPreviewCount + browserPreviews.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount || reservedPreviewBytes + previewBatchBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes ) { throw new Error('本次图片生成的预览切片过多或过大,请减少详情长图数量后分批提交'); } reservedPreviewCount += browserPreviews.length; reservedPreviewBytes += previewBatchBytes; // 预览生成失败只影响优化链路,不应阻断原图上传。完成接口会在缺少 // 客户端预览时进入受限的服务器兜底处理;网络上传错误仍然直接抛出, // 由外层回滚本次上传,避免把网络错误伪装成浏览器兼容问题。 for (let previewIndex = 0; previewIndex < browserPreviews.length; previewIndex += 1) { const preview: TaskDetailBrowserPreview = browserPreviews[previewIndex]; totalWorkBytes += preview.file.size; const previewKey = `${requestPrefix}-preview-${previewIndex}`; const previewUploadId = await uploadFileWithChunks( preview.file, (progress) => reportFileProgress(previewKey, preview.file.size, progress), () => cancelledRef.current || uploadStopRef.current, previewKey, ); uploadedFileIds.push(previewUploadId); previewAssets.push({ uploadId: previewUploadId, fileName: preview.file.name, fileSize: preview.file.size, width: preview.width, height: preview.height, kind: preview.kind, sourceName: preview.sourceName, sourceIndex: preview.sourceIndex, sliceIndex: preview.sliceIndex, sliceCount: preview.sliceCount, }); } } const originalUploadId = await uploadFileWithChunks( asset.file, (progress) => reportFileProgress(`${requestPrefix}-original`, asset.file.size, progress), () => cancelledRef.current || uploadStopRef.current, `${requestPrefix}-original`, ); uploadedFileIds.push(originalUploadId); return { group, asset: { fileName: asset.file.name, fileSize: asset.file.size, uploadId: originalUploadId, sortOrder: task.sortOrder, ...(previewAssets.length > 0 ? { previewAssets, previewUploadId: previewAssets[0].uploadId, previewFileName: previewAssets[0].fileName, previewFileSize: previewAssets[0].fileSize, } : {}), ...(group === 'sku' ? { skuCode: task.skuCode, barcodes: task.barcodes || [] } : {}), }, }; }; setProcessingStep('浏览器预处理并上传素材中...'); const uploadedResults = await runWithConcurrency(uploadTasks, fileConcurrency, uploadTask); if (cancelledRef.current) throw new Error('上传已取消'); const sortAssets = (group: UploadGroup) => uploadedResults .filter((result) => result.group === group) .sort((left, right) => left.asset.sortOrder - right.asset.sortOrder) .map((result) => result.asset); const mainAssets = sortAssets('main'); const skuAssets = sortAssets('sku'); const detailAssets = sortAssets('detail'); const psdAssets = sortAssets('psd'); setProcessingStep('已上传,正在提交后台处理任务...'); setProgress(90); const completeResponse = await fetch('/api/materials/upload/product-package/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, taskNo, dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, noBarcodes, allowServerPreviewFallback: serverPreviewFallbackUsed, assets: { main: mainAssets, sku: skuAssets, detail: detailAssets, psd: psdAssets.length > 0 ? psdAssets : undefined, }, }), }); const completePayload = await completeResponse.json().catch(() => ({})); if (!completeResponse.ok) { const errorMessage = completePayload.error || '完成上传失败'; console.error('[Upload] Complete failed:', errorMessage, completePayload); throw new Error(errorMessage); } const jobId = completePayload?.data?.jobId; if (!jobId) { throw new Error('后台处理任务创建失败,请重试'); } jobAccepted = true; jobAcceptedRef.current = true; // 用户在 complete 请求返回前取消或关闭弹窗时,服务端可能已经接单。 // 此时保留 accepted 状态避免误删任务,但绝不能再创建最长 30 分钟的隐形轮询。 if (cancelledRef.current || !mountedRef.current) { throw new Error('上传已取消'); } if (jobId) { const pollController = new AbortController(); jobPollAbortRef.current = pollController; await pollTaskDetailUploadJob(jobId, { signal: pollController.signal, onStatus: (status) => { if (!mountedRef.current) return; const backendProgress = Number.isFinite(status.progress) ? Math.max(0, Math.min(100, status.progress || 0)) : 0; setProgress(90 + backendProgress * 0.1); const stageLabel = status.stage ? JOB_STAGE_LABELS[status.stage] : undefined; setProcessingStep(stageLabel || (status.status === 'queued' ? JOB_STAGE_LABELS.queued : '后台处理中...')); }, }); } setProgress(100); setProcessingStep('完成!'); completed = true; completionTimerRef.current = setTimeout(() => { if (!mountedRef.current) return; setProcessingStep(''); revokeAllThumbnailObjectUrls(); onSuccess(); }, 500); } catch (error: unknown) { console.error('上传失败:', error); if (createdMaterialId && !completed && !jobAccepted) { try { await fetch('/api/materials/upload/product-package/abort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId: createdMaterialId, uploadIds: uploadedFileIds }), }); } catch (abortError) { console.error('[Upload] 失败回滚未完成素材失败:', abortError); } } if (cancelledRef.current || (error instanceof Error && error.message === '上传已取消')) { if (mountedRef.current) { setError(jobAccepted || jobAcceptedRef.current ? '已停止等待,后台任务仍在服务器处理中,可稍后查看素材状态' : '已取消上传'); setProcessingStep(''); } } else { const { parseApiError } = await import('@/lib/error-handler'); const { message } = parseApiError(error); if (mountedRef.current) { const isFailedJob = error instanceof TaskDetailUploadJobError && error.jobStatus === 'failed'; setError(jobAccepted && !isFailedJob ? `文件已上传,后台任务仍在处理:${message}` : message); setProcessingStep(''); } } } finally { activeXhrsRef.current.forEach((xhr) => xhr.abort()); activeXhrsRef.current.clear(); activeXhrSetsRef.current.clear(); abortActiveBrowserPreview(); jobPollAbortRef.current = null; uploadStopRef.current = true; if (mountedRef.current) setUploading(false); } }; const handleCancelUpload = () => { if (!uploading) { onCancel(); return; } cancelledRef.current = true; uploadStopRef.current = true; activeXhrsRef.current.forEach((xhr) => xhr.abort()); activeXhrsRef.current.clear(); activeXhrSetsRef.current.clear(); abortActiveBrowserPreview(); jobPollAbortRef.current?.abort(); setProcessingStep(jobAcceptedRef.current ? '已停止等待,后台任务继续处理...' : '正在取消上传...'); }; return (
{ setTaskDetailRequesterUserId(userId); setError(''); }} placeholder={usersLoading ? '正在加载人员...' : '搜索发起本次出图需求的运营人员'} required disabled={uploading || usersLoading || Boolean(usersLoadError)} />

{usersLoadError || '记录发起本次出图需求的运营人员,与素材上传人可以不同。'}

{/* 使用 UploadForm 组件显示 SPU、店铺、场景字段 */}
{}} onAddCategory={handleAddNewCategory} onAddScene={handleAddNewScene} />

{taskDetailPrivacyHint}

同时上传主图 + SKU 图 + 详情图时自动标记为整套

{/* 主图上传 */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
handleDragEnter(e, setIsMainDragging)} onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)} onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, 'main', setIsMainDragging, 'image')} onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())} > { handleImageSelect(e.target.files, 'main'); e.currentTarget.value = ''; }} className="hidden" disabled={uploading} />
{mainImages.map((img) => (
))}
{/* SKU图上传 */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
handleDragEnter(e, setIsSkuDragging)} onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)} onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, 'sku', setIsSkuDragging, 'image')} onKeyDown={(event) => handleDropZoneKeyDown(event, () => skuInputRef.current?.click())} > { handleImageSelect(e.target.files, 'sku'); e.currentTarget.value = ''; }} className="hidden" disabled={uploading} />
{skuImages.map((img) => (
updateSkuImageMeta(img.id, { skuCode: e.target.value })} placeholder="SKU 编号" disabled={uploading} className="w-full rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" /> {!noBarcodes && (