/** * 文件上传处理函数 * 包括单文件上传、表单提交等 */ import { chunkUpload } from '@/lib/chunk-upload' import { CHUNK_THRESHOLD } from './upload-constants' import { shouldPassRenderer, shouldAutoFillGeneral } from '@/lib/material-validation' export type CompletedChunkUploadReference = { existingFilePath: string originalFileName: string uploadId: string } /** * 构建表单数据 */ 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 completedChunkUpload?: CompletedChunkUploadReference }): FormData { const { previewImages, displayImage, modelImages, zipFile, productModel, materialType, categoryName, sceneName, platformName, productCategoryName, renderer, taskNo, dingtalkApprovalNo, completedChunkUpload, } = 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) } if (completedChunkUpload) { formData.append('existingFilePath', completedChunkUpload.existingFilePath) formData.append('originalFileName', completedChunkUpload.originalFileName) formData.append('uploadId', completedChunkUpload.uploadId) } 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) => void // 活跃XHR集合回调 }): Promise { 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集合回调 }) const existingFilePath = String(result?.existingFilePath || '').trim() const uploadId = String(result?.uploadId || '').trim() if (!existingFilePath || !uploadId) { throw new Error('分片上传结果不完整,请重新上传') } return { existingFilePath, originalFileName: zipFile.name, 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 { const progressBase = opts?.progressBase ?? 0 const progressSpan = opts?.progressSpan ?? 100 const materialType = opts?.materialType || '' return await new Promise((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) }) }