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.
358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
/**
|
||
* 文件上传处理函数
|
||
* 包括单文件上传、表单提交等
|
||
*/
|
||
|
||
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)
|
||
})
|
||
}
|
||
|