315 lines
9.0 KiB
TypeScript
315 lines
9.0 KiB
TypeScript
/**
|
||
* 文件上传处理函数
|
||
* 包括单文件上传、表单提交等
|
||
*/
|
||
|
||
import { chunkUpload } from '@/lib/chunk-upload'
|
||
import { CHUNK_THRESHOLD } from './upload-constants'
|
||
import { shouldPassRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
|
||
|
||
/**
|
||
* 构建表单数据
|
||
*/
|
||
export function buildFormData(params: {
|
||
previewImages: File[]
|
||
displayImage: File | null
|
||
modelImages: File[]
|
||
zipFile: File | null
|
||
productModel: string
|
||
materialType: string
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName: string
|
||
productCategoryName: string
|
||
renderer: string
|
||
taskNo?: string
|
||
dingtalkApprovalNo?: string
|
||
}): FormData {
|
||
const {
|
||
previewImages,
|
||
displayImage,
|
||
modelImages,
|
||
zipFile,
|
||
productModel,
|
||
materialType,
|
||
categoryName,
|
||
sceneName,
|
||
platformName,
|
||
productCategoryName,
|
||
renderer,
|
||
taskNo,
|
||
dingtalkApprovalNo,
|
||
} = params
|
||
|
||
const formData = new FormData()
|
||
|
||
// 预览图:第一张作为主预览图,其他作为展示图
|
||
if (previewImages.length > 0) {
|
||
previewImages.forEach((image, index) => {
|
||
if (index === 0) {
|
||
formData.append('previewImage', image)
|
||
} else {
|
||
formData.append('previewImages', image)
|
||
}
|
||
})
|
||
}
|
||
// 展示图:详情页/详情模板上传原图,后端自动切片
|
||
if (displayImage) {
|
||
formData.append('displayImage', displayImage)
|
||
}
|
||
if (materialType === '模特' && modelImages.length > 0) {
|
||
modelImages.forEach((image) => formData.append('modelImages', image))
|
||
}
|
||
if (zipFile) {
|
||
formData.append('zipFile', zipFile)
|
||
}
|
||
formData.append('productModel', productModel)
|
||
formData.append('categoryName', shouldAutoFillGeneral(materialType) ? '通用' : categoryName)
|
||
formData.append('sceneName', shouldAutoFillGeneral(materialType) ? '通用' : sceneName)
|
||
formData.append('platformName', platformName || '')
|
||
formData.append('productCategoryName', productCategoryName || '')
|
||
formData.append('materialType', materialType)
|
||
|
||
// 任务号(可选)
|
||
if (taskNo) {
|
||
formData.append('taskNo', taskNo)
|
||
}
|
||
if (dingtalkApprovalNo) {
|
||
formData.append('dingtalkApprovalNo', dingtalkApprovalNo)
|
||
}
|
||
|
||
// 统一处理renderer字段:根据素材类型判断是否需要传递
|
||
const rendererInfo = shouldPassRenderer(materialType, renderer)
|
||
if (rendererInfo.shouldPass) {
|
||
formData.append('renderer', rendererInfo.value)
|
||
}
|
||
|
||
return formData
|
||
}
|
||
|
||
/**
|
||
* 单文件上传处理
|
||
*/
|
||
export async function uploadSingleFile(params: {
|
||
zipFile: File | null
|
||
previewImages: File[]
|
||
displayImage: File | null
|
||
modelImages: File[]
|
||
productModel: string
|
||
materialType: string
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName: string
|
||
productCategoryName: string
|
||
renderer: string
|
||
onProgress: (progress: number, speed?: number) => void
|
||
onStageChange?: (stage: string) => void
|
||
isCancelled?: () => boolean // 取消检查回调
|
||
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void // 活跃XHR集合回调
|
||
}): Promise<{ existingFilePath?: string; originalFileName?: string; uploadId?: string } | null> {
|
||
const {
|
||
zipFile,
|
||
previewImages,
|
||
displayImage,
|
||
modelImages,
|
||
productModel,
|
||
materialType,
|
||
categoryName,
|
||
sceneName,
|
||
platformName,
|
||
productCategoryName,
|
||
renderer,
|
||
onProgress,
|
||
onStageChange,
|
||
isCancelled,
|
||
onActiveXhrChange,
|
||
} = params
|
||
|
||
if (!zipFile) return null
|
||
|
||
// 大文件走分片,小文件继续直传
|
||
const useChunk = zipFile.size >= CHUNK_THRESHOLD
|
||
|
||
if (useChunk) {
|
||
const metadata = {
|
||
materialType,
|
||
modelImagesCount: modelImages.length,
|
||
}
|
||
|
||
const result = await chunkUpload(zipFile, metadata, {
|
||
// chunkSize使用默认配置:内网20MB,公网10MB
|
||
onProgress: (progress, speed) => {
|
||
onProgress(progress, speed)
|
||
},
|
||
onStageChange: (stage) => {
|
||
if (onStageChange) {
|
||
onStageChange(stage)
|
||
}
|
||
if (stage === 'processing') {
|
||
onProgress(100)
|
||
}
|
||
},
|
||
isCancelled, // 传递取消检查回调
|
||
onActiveXhrChange, // 传递活跃XHR集合回调
|
||
})
|
||
|
||
return {
|
||
existingFilePath: result?.existingFilePath,
|
||
originalFileName: zipFile.name,
|
||
uploadId: result?.uploadId,
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 提交表单数据
|
||
*/
|
||
export async function submitFormData(
|
||
formData: FormData,
|
||
opts?: {
|
||
progressBase?: number
|
||
progressSpan?: number
|
||
silent?: boolean
|
||
onProgress?: (progress: number, speed?: number) => void
|
||
onProcessing?: (isProcessing: boolean) => void
|
||
onError?: (error: string) => void
|
||
onSuccess?: () => void
|
||
materialType?: string
|
||
onRequest?: (xhr: XMLHttpRequest | null) => void // 暴露XHR以便取消
|
||
}
|
||
): Promise<void> {
|
||
const progressBase = opts?.progressBase ?? 0
|
||
const progressSpan = opts?.progressSpan ?? 100
|
||
const materialType = opts?.materialType || ''
|
||
|
||
return await new Promise<void>((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest()
|
||
|
||
// 暴露 XHR 以便外部可以取消
|
||
opts?.onRequest?.(xhr)
|
||
|
||
xhr.timeout = 0
|
||
let startTime = Date.now()
|
||
let startLoaded = 0
|
||
|
||
xhr.upload.addEventListener('progress', (e) => {
|
||
if (e.lengthComputable) {
|
||
const percentComplete = Math.round((e.loaded / e.total) * progressSpan + progressBase)
|
||
const progress = Math.min(95, percentComplete) // 最多显示95%,留5%给服务器处理
|
||
if (opts?.onProgress) {
|
||
opts.onProgress(progress)
|
||
}
|
||
if (opts?.onProcessing) {
|
||
opts.onProcessing(false) // 上传阶段
|
||
}
|
||
|
||
const currentTime = Date.now()
|
||
const timeElapsed = (currentTime - startTime) / 1000 // 秒
|
||
if (timeElapsed > 0) {
|
||
const speed = (e.loaded - startLoaded) / timeElapsed
|
||
if (opts?.onProgress) {
|
||
opts.onProgress(progress, speed)
|
||
}
|
||
startTime = currentTime
|
||
startLoaded = e.loaded
|
||
}
|
||
}
|
||
})
|
||
|
||
// 监听上传完成,进入处理阶段
|
||
xhr.upload.addEventListener('load', () => {
|
||
if (opts?.onProgress) {
|
||
opts.onProgress(95)
|
||
}
|
||
if (opts?.onProcessing) {
|
||
opts.onProcessing(true)
|
||
}
|
||
})
|
||
|
||
xhr.addEventListener('load', () => {
|
||
opts?.onRequest?.(null) // XHR 完成,通知外部
|
||
if (xhr.status === 201) {
|
||
// 上传接口返回 201 即视为“上传完成”,不再在前端等待后台的后续处理(如视频转码等)
|
||
if (opts?.onProgress) {
|
||
opts.onProgress(100)
|
||
}
|
||
if (opts?.onProcessing) {
|
||
opts.onProcessing(false)
|
||
}
|
||
|
||
// 这里不区分素材类型,统一在上传成功后直接回调 onSuccess 并结束 Promise
|
||
if (opts?.onSuccess && !opts?.silent) {
|
||
opts.onSuccess()
|
||
}
|
||
resolve()
|
||
} else {
|
||
try {
|
||
const data = JSON.parse(xhr.responseText)
|
||
let errorMsg = data.error || '上传失败'
|
||
|
||
// 提供更详细的错误信息
|
||
if (xhr.status === 413) {
|
||
errorMsg = '文件过大,请联系管理员增加上传限制'
|
||
} else if (xhr.status === 408 || xhr.status === 504) {
|
||
errorMsg = '上传超时,建议:\n1. 压缩文件后再上传\n2. 使用内网地址(更快)\n3. 分批上传大文件'
|
||
} else if (xhr.status >= 500) {
|
||
errorMsg = `服务器错误 (${xhr.status}):${errorMsg}`
|
||
}
|
||
|
||
if (opts?.onError) {
|
||
opts.onError(errorMsg)
|
||
}
|
||
reject(new Error(errorMsg))
|
||
} catch {
|
||
const msg = `上传失败 (状态码: ${xhr.status})`
|
||
if (opts?.onError) {
|
||
opts.onError(msg)
|
||
}
|
||
reject(new Error(msg))
|
||
}
|
||
}
|
||
})
|
||
|
||
xhr.addEventListener('error', (e) => {
|
||
opts?.onRequest?.(null) // XHR 完成,通知外部
|
||
console.error('Upload XHR error:', e)
|
||
console.error('XHR status:', xhr.status)
|
||
console.error('XHR readyState:', xhr.readyState)
|
||
|
||
// 检测是否是连接重置错误
|
||
const isConnectionReset = xhr.status === 0
|
||
|
||
let errorMsg = '上传失败,请稍后重试'
|
||
|
||
if (isConnectionReset) {
|
||
errorMsg = '网络连接中断,可能原因:\n• 文件过大导致超时\n• 网络不稳定\n• 服务器响应慢\n\n建议:\n1. 检查文件大小(压缩包建议<1GB)\n2. 使用内网地址上传(更快更稳定)\n3. 如果是大文件,尝试压缩后再上传\n4. 刷新页面检查文件是否已上传成功'
|
||
}
|
||
|
||
if (opts?.onError) {
|
||
opts.onError(errorMsg)
|
||
}
|
||
reject(new Error(errorMsg))
|
||
})
|
||
|
||
xhr.addEventListener('abort', () => {
|
||
opts?.onRequest?.(null) // XHR 取消,通知外部
|
||
const msg = '上传已取消'
|
||
if (opts?.onError) {
|
||
opts.onError(msg)
|
||
}
|
||
reject(new Error(msg))
|
||
})
|
||
|
||
// 始终使用相对路径上传,避免跨域Cookie问题
|
||
const uploadUrl = '/api/materials/upload'
|
||
|
||
console.log(`🚀 上传地址: ${uploadUrl}`)
|
||
|
||
xhr.open('POST', uploadUrl)
|
||
xhr.send(formData)
|
||
})
|
||
}
|
||
|