gongxiangp2/hooks/useBatchFilesUpload.ts
2026-08-07 23:28:55 +08:00

504 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 批量文件上传 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<React.SetStateAction<boolean>>
setUploadProgress: React.Dispatch<React.SetStateAction<number>>
setUploadSpeed: React.Dispatch<React.SetStateAction<number>>
setBatchUploadStatus: React.Dispatch<React.SetStateAction<BatchUploadStatus | null>>
setError: React.Dispatch<React.SetStateAction<string>>
}
/**
* 批量文件上传 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<Map<string, XMLHttpRequest>>(new Map())
const batchUploadIdsRef = useRef<Map<string, string>>(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<void>((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,
}
}