450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
/**
|
||
* 批量文件上传 Hook
|
||
* 封装通用批量上传逻辑(支持队列管理和详细进度)
|
||
*/
|
||
|
||
import { useRef, useCallback } from 'react'
|
||
import { chunkUpload } from '@/lib/chunk-upload'
|
||
import { PREVIEW_OPTIONAL_TYPES, CHUNK_THRESHOLD } from '@/components/upload/upload-constants'
|
||
import { fileToBase64String, completeBatchUpload } from '@/components/upload/special-batch-uploaders'
|
||
import { requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
|
||
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 || '',
|
||
}
|
||
|
||
// 添加渲染器(3D场景)
|
||
if (requiresRenderer(materialType) && item.renderer) {
|
||
metadata.renderer = item.renderer
|
||
}
|
||
|
||
// 处理预览图(如果需要)
|
||
const needsPreview = !PREVIEW_OPTIONAL_TYPES.includes(materialType) && materialType !== '详情页'
|
||
if (needsPreview && item.previewImage) {
|
||
metadata.previewImage = await fileToBase64String(item.previewImage)
|
||
}
|
||
|
||
// 判断是否需要分片上传
|
||
const useChunk = item.file.size >= CHUNK_THRESHOLD
|
||
|
||
if (useChunk) {
|
||
// 分片上传
|
||
const result = await chunkUpload(item.file, metadata, {
|
||
chunkSize: 5 * 1024 * 1024,
|
||
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)
|
||
}
|
||
},
|
||
skipComplete: true,
|
||
maxRetries: Infinity,
|
||
})
|
||
|
||
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) {
|
||
// 后台异步处理
|
||
completeBatchUpload(result.uploadId, metadata).catch((err) => {
|
||
console.error(`[Upload] 文件 ${item.file.name} 后台处理失败:`, err)
|
||
setBatchUploadStatus(prev => {
|
||
if (!prev) return prev
|
||
const newStatuses = [...prev.fileStatuses]
|
||
newStatuses[fileIndex] = {
|
||
...newStatuses[fileIndex],
|
||
status: 'failed',
|
||
error: '后台处理失败'
|
||
}
|
||
return { ...prev, fileStatuses: newStatuses }
|
||
})
|
||
})
|
||
}
|
||
|
||
results.push({
|
||
tempId: `pending-${Date.now()}-${fileIndex}`,
|
||
uploadId: result?.uploadId || null,
|
||
productModel: item.productModel,
|
||
fileName: item.file.name,
|
||
fileSize: item.file.size,
|
||
status: 'processing',
|
||
})
|
||
} 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 (item.previewImage) {
|
||
formData.append('previewImage', item.previewImage)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 检查是否有失败的文件
|
||
const failedFiles = batchUploadStatus?.fileStatuses.filter(s => s.status === 'failed') || []
|
||
if (failedFiles.length > 0) {
|
||
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
|
||
} else {
|
||
setUploadProgress(100)
|
||
onComplete()
|
||
onSuccess()
|
||
onClose()
|
||
}
|
||
} catch (error) {
|
||
console.error('[Upload] 批量上传失败:', error)
|
||
setError('批量上传失败,请重试')
|
||
} finally {
|
||
setUploading(false)
|
||
batchUploadXhrRefs.current.clear()
|
||
}
|
||
}, [materialType, batchUploadStatus, setUploading, setUploadProgress, setUploadSpeed, setBatchUploadStatus, setError, onSuccess, onClose])
|
||
|
||
return {
|
||
handleBatchFilesUpload,
|
||
handleCancelBatchUpload,
|
||
batchUploadCancelRef,
|
||
batchUploadXhrRefs,
|
||
batchUploadIdsRef,
|
||
}
|
||
}
|