504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
/**
|
||
* 批量文件上传 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,
|
||
}
|
||
}
|