gongxiangp2/components/upload/file-upload-handlers.ts
WuYongJie d8d843e6ad feat: add live clip material type with date host and platform
Register 直播切片 as a video-only material type. Upload and edit flows
collect required title, live date (defaults to today), optional host with
user link, and live platform presets. Persist the fields on Material and
skip SPU formatting for free-text titles.
2026-09-22 18:59:05 +08:00

358 lines
10 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.

/**
* 文件上传处理函数
* 包括单文件上传、表单提交等
*/
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
liveDate?: string
liveHost?: string
liveHostUserId?: string
livePlatform?: string
}): FormData {
const {
previewImages,
displayImage,
modelImages,
zipFile,
productModel,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer,
taskNo,
dingtalkApprovalNo,
completedChunkUpload,
liveDate,
liveHost,
liveHostUserId,
livePlatform,
} = 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)
}
if (liveDate) {
formData.append('liveDate', liveDate)
}
if (liveHost) {
formData.append('liveHost', liveHost)
}
if (liveHostUserId) {
formData.append('liveHostUserId', liveHostUserId)
}
if (livePlatform) {
formData.append('livePlatform', livePlatform)
}
// 统一处理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
liveDate?: string
liveHost?: string
liveHostUserId?: string
livePlatform?: string
onProgress: (progress: number, speed?: number) => void
onStageChange?: (stage: string) => void
isCancelled?: () => boolean // 取消检查回调
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void // 活跃XHR集合回调
}): Promise<CompletedChunkUploadReference | 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集合回调
})
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<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)
})
}