/** * 批量文件上传 Hook * 封装通用批量上传逻辑(支持队列管理和详细进度) */ import { useRef, useCallback } from 'react' import { chunkUpload } from '@/lib/chunk-upload' import { CHUNK_THRESHOLD } from '@/components/upload/upload-constants' import { fileToBase64String, completeBatchUpload } from '@/components/upload/special-batch-uploaders' import { compressPreviewImage, compressVideoPreviewImage } from '@/components/upload/image-processor' import { requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation' import { isVideoMaterialType } from '@/lib/video-material-types' import type { BatchFileItem, BatchUploadStatus } from '@/components/upload/upload-types' export interface UseBatchFilesUploadOptions { materialType: string onSuccess: () => void onClose: () => void } export interface BatchFilesUploadState { uploading: boolean uploadProgress: number uploadSpeed: number batchUploadStatus: BatchUploadStatus | null } export interface BatchFilesUploadActions { setUploading: React.Dispatch> setUploadProgress: React.Dispatch> setUploadSpeed: React.Dispatch> setBatchUploadStatus: React.Dispatch> setError: React.Dispatch> } /** * 批量文件上传 Hook */ export function useBatchFilesUpload( options: UseBatchFilesUploadOptions, state: BatchFilesUploadState, actions: BatchFilesUploadActions ) { const { materialType, onSuccess, onClose } = options const { uploading, batchUploadStatus } = state const { setUploading, setUploadProgress, setUploadSpeed, setBatchUploadStatus, setError } = actions // Refs const batchUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false }) const batchUploadXhrRefs = useRef>(new Map()) const batchUploadIdsRef = useRef>(new Map()) /** * 取消批量上传 */ const handleCancelBatchUpload = useCallback(async () => { batchUploadCancelRef.current.cancelled = true // 取消所有进行中的请求 batchUploadXhrRefs.current.forEach(xhr => { if (xhr) { xhr.abort() } }) batchUploadXhrRefs.current.clear() // 清理所有上传会话和临时文件 const uploadIdsToClean = Array.from(batchUploadIdsRef.current.values()) if (uploadIdsToClean.length > 0) { try { await Promise.all( uploadIdsToClean.map(async (uploadId) => { try { const response = await fetch('/api/materials/upload/cleanup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uploadId }), }) if (response.ok) { console.log(`[Cancel] 已清理上传会话: ${uploadId}`) } } catch (err) { console.warn(`[Cancel] 清理上传会话失败: ${uploadId}`, err) } }) ) } catch (err) { console.warn('[Cancel] 批量清理上传会话时出错:', err) } } batchUploadIdsRef.current.clear() setUploading(false) setBatchUploadStatus(null) setError('上传已取消') }, [setUploading, setBatchUploadStatus, setError]) /** * 执行批量文件上传 */ const handleBatchFilesUpload = useCallback(async ( batchFiles: BatchFileItem[], onComplete: () => void ) => { if (batchFiles.length === 0) return setUploading(true) setUploadProgress(0) setUploadSpeed(0) setError('') batchUploadCancelRef.current.cancelled = false batchUploadXhrRefs.current.clear() // 初始化上传状态 const fileStatuses = batchFiles.map(item => ({ fileName: item.file.name, status: 'pending' as const, progress: 0, uploadId: undefined, })) batchUploadIdsRef.current.clear() setBatchUploadStatus({ currentFileIndex: 0, totalFiles: batchFiles.length, currentFileName: batchFiles[0]?.file.name || '', currentFileProgress: 0, currentFileSpeed: 0, fileStatuses, isCancelled: false, startTime: Date.now(), }) const results: any[] = [] let totalUploadedBytes = 0 const totalFileSize = batchFiles.reduce((sum, item) => sum + item.file.size, 0) const startTime = Date.now() try { // 队列管理:逐个处理文件 for (let i = 0; i < batchFiles.length; i++) { if (batchUploadCancelRef.current.cancelled) { break } const item = batchFiles[i] const fileIndex = i // 更新当前文件状态 setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'uploading' } return { ...prev, currentFileIndex: fileIndex, currentFileName: item.file.name, currentFileProgress: 0, fileStatuses: newStatuses, } }) try { // 构建每个文件的元数据 const metadata: any = { materialType, productModel: item.productModel, categoryName: shouldAutoFillGeneral(materialType) ? '通用' : item.categoryName, sceneName: shouldAutoFillGeneral(materialType) ? '通用' : item.sceneName, platformName: item.platformName || '', productCategoryName: item.productCategoryName || '', // 重要:传递原始文件名,用于保留正确的扩展名(.mp4, .mp3 等) fileName: item.file.name, } // 添加渲染器(3D场景) if (requiresRenderer(materialType) && item.renderer) { metadata.renderer = item.renderer } // 是否必填由表单层校验;只要用户选择了预览图,这里就必须保留。 let compressedPreview: File | undefined if (item.previewImage) { compressedPreview = isVideoMaterialType(materialType) ? await compressVideoPreviewImage(item.previewImage) : await compressPreviewImage(item.previewImage) metadata.previewImage = await fileToBase64String(compressedPreview) } // 判断是否需要分片上传 const useChunk = item.file.size >= CHUNK_THRESHOLD if (useChunk) { // 分片上传(chunkSize使用默认配置:内网20MB,公网10MB) const result = await chunkUpload(item.file, metadata, { onProgress: (progress, speed) => { if (batchUploadCancelRef.current.cancelled) return setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'uploading', progress } return { ...prev, currentFileProgress: progress, currentFileSpeed: speed || 0, fileStatuses: newStatuses, } }) const fileProgress = (progress / 100) * item.file.size const currentTotalBytes = totalUploadedBytes + fileProgress const overallProgress = Math.round((currentTotalBytes / totalFileSize) * 100) setUploadProgress(overallProgress) if (speed) { setUploadSpeed(speed) } }, onRequest: (xhr) => { if (xhr) { batchUploadXhrRefs.current.set(item.id, xhr) } else { batchUploadXhrRefs.current.delete(item.id) } }, // 取消检查回调:让 chunkUpload 内部能够感知取消状态 isCancelled: () => batchUploadCancelRef.current.cancelled, // 活跃 XHR 集合回调:跟踪所有并发的 XHR 以便批量取消 onActiveXhrChange: (xhrs) => { // 将所有活跃的 XHR 添加到 batchUploadXhrRefs(用于取消) xhrs.forEach(xhr => { if (!Array.from(batchUploadXhrRefs.current.values()).includes(xhr)) { batchUploadXhrRefs.current.set(`${item.id}-chunk-${Date.now()}`, xhr) } }) }, skipComplete: true, maxRetries: 5, }) totalUploadedBytes += item.file.size // 更新状态为处理中 if (result?.uploadId) { batchUploadIdsRef.current.set(item.id, result.uploadId) setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'processing', progress: 100, uploadId: result.uploadId } return { ...prev, fileStatuses: newStatuses } }) } else { setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'processing', progress: 100 } return { ...prev, fileStatuses: newStatuses } }) } if (result?.uploadId) { // ⚠️ 修复:必须等待complete完成,并添加重试机制(修复批量上传失败问题) let completeSuccess = false let lastError: Error | null = null // 重试机制:最多重试3次 for (let retry = 0; retry < 3; retry++) { if (batchUploadCancelRef.current.cancelled) break try { await completeBatchUpload(result.uploadId, metadata) completeSuccess = true console.log(`[Upload] 文件 ${item.file.name} complete成功 (重试${retry}次)`) break } catch (err) { lastError = err as Error console.warn(`[Upload] 文件 ${item.file.name} complete失败 (第${retry + 1}次):`, err) // 如果不是最后一次重试,等待1秒后重试 if (retry < 2) { await new Promise(resolve => setTimeout(resolve, 1000)) } } } // 更新状态 setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: completeSuccess ? 'done' : 'failed', error: completeSuccess ? undefined : (lastError?.message || '处理失败'), uploadId: result.uploadId } return { ...prev, fileStatuses: newStatuses } }) if (!completeSuccess) { throw new Error(`文件 ${item.file.name} 处理失败: ${lastError?.message}`) } } results.push({ tempId: `pending-${Date.now()}-${fileIndex}`, uploadId: result?.uploadId || null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'done', }) } else { // 小文件直接上传 const formData = new FormData() formData.append('zipFile', item.file) formData.append('productModel', item.productModel) formData.append('categoryName', metadata.categoryName) formData.append('sceneName', metadata.sceneName) formData.append('platformName', metadata.platformName) formData.append('productCategoryName', metadata.productCategoryName) formData.append('materialType', materialType) if (metadata.renderer) { formData.append('renderer', metadata.renderer) } if (compressedPreview) { formData.append('previewImage', compressedPreview) } const xhr = new XMLHttpRequest() batchUploadXhrRefs.current.set(item.id, xhr) await new Promise((resolve, reject) => { xhr.upload.onprogress = (e) => { if (e.lengthComputable && !batchUploadCancelRef.current.cancelled) { const progress = Math.round((e.loaded / e.total) * 100) const elapsed = (Date.now() - startTime) / 1000 const speed = e.loaded / elapsed setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'uploading', progress } return { ...prev, currentFileProgress: progress, currentFileSpeed: speed, fileStatuses: newStatuses, } }) const fileProgress = (progress / 100) * item.file.size const currentTotalBytes = totalUploadedBytes + fileProgress const overallProgress = Math.round((currentTotalBytes / totalFileSize) * 100) setUploadProgress(overallProgress) setUploadSpeed(speed) } } xhr.onload = () => { batchUploadXhrRefs.current.delete(item.id) if (xhr.status >= 200 && xhr.status < 300) { totalUploadedBytes += item.file.size resolve() } else { reject(new Error(`上传失败: ${xhr.statusText}`)) } } xhr.onerror = () => { batchUploadXhrRefs.current.delete(item.id) reject(new Error('网络错误')) } xhr.onabort = () => { batchUploadXhrRefs.current.delete(item.id) reject(new Error('上传已取消')) } xhr.open('POST', '/api/materials/upload') xhr.send(formData) }) setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'processing', progress: 100 } return { ...prev, fileStatuses: newStatuses } }) results.push({ tempId: `pending-${Date.now()}-${fileIndex}`, uploadId: null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'processing', }) } // 更新完成状态 setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'done', progress: 100 } return { ...prev, fileStatuses: newStatuses } }) } catch (error: any) { console.error(`[Upload] 文件 ${item.file.name} 上传失败:`, error) setBatchUploadStatus(prev => { if (!prev) return prev const newStatuses = [...prev.fileStatuses] newStatuses[fileIndex] = { ...newStatuses[fileIndex], status: 'failed', error: error.message || '上传失败' } return { ...prev, fileStatuses: newStatuses } }) results.push({ tempId: `pending-${Date.now()}-${fileIndex}`, uploadId: null, productModel: item.productModel, fileName: item.file.name, fileSize: item.file.size, status: 'failed', }) } } if (batchUploadCancelRef.current.cancelled) { return } // 检查是否有失败的文件 // 注意:使用 results 数组而不是 batchUploadStatus,避免 React 闭包陷阱 // batchUploadStatus 是闭包捕获的旧值,而 results 是循环中实时更新的 const failedResults = results.filter(r => r.status === 'failed') if (failedResults.length > 0) { const failedNames = failedResults.map(r => r.fileName).join(', ') console.error(`[Upload] ${failedResults.length} 个文件上传失败:`, failedNames) setError(`${failedResults.length} 个文件上传失败: ${failedNames}`) } else if (results.length === 0) { // 没有任何结果,可能是全部被跳过 console.warn('[Upload] 批量上传完成但没有任何结果') setError('上传完成但没有成功的文件,请检查文件格式') } else { console.log(`[Upload] 批量上传成功: ${results.length} 个文件`) setUploadProgress(100) onComplete() onSuccess() onClose() } } catch (error) { console.error('[Upload] 批量上传失败:', error) setError('批量上传失败,请重试') } finally { setUploading(false) batchUploadXhrRefs.current.clear() } // 注意:移除了 batchUploadStatus 依赖,因为我们使用 setBatchUploadStatus 的函数式更新 // 和 results 数组来跟踪状态,避免闭包陷阱 }, [materialType, setUploading, setUploadProgress, setUploadSpeed, setBatchUploadStatus, setError, onSuccess, onClose]) return { handleBatchFilesUpload, handleCancelBatchUpload, batchUploadCancelRef, batchUploadXhrRefs, batchUploadIdsRef, } }