gongxiangp2/components/UploadModal.tsx

2771 lines
102 KiB
TypeScript
Raw Permalink 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.

'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import { FileUp } from 'lucide-react'
import Image from 'next/image'
import { chunkUpload } from '@/lib/chunk-upload'
import {
PREVIEW_OPTIONAL_TYPES,
BATCH_LIMITS,
CHUNK_THRESHOLD,
MAX_RETRY
} from '@/components/upload/upload-constants'
import {
formatBytes,
getFileExtension,
isValidMainFileType,
getMainFileAccept,
extractSpuFromFileName,
detectMaterialType,
} from '@/components/upload/upload-utils'
import { fileToBase64String } from '@/components/upload/special-batch-uploaders'
import type { BatchFileItem, BatchUploadStatus } from '@/components/upload/upload-types'
import { getBatchLimit, createBatchFileItems, createBatchItemFromSingleFile } from '@/components/upload/file-handlers'
import BatchFileList from '@/components/upload/BatchFileList'
import UploadProgress from '@/components/upload/UploadProgress'
import PreviewImageUpload from '@/components/upload/PreviewImageUpload'
import DisplayImageUpload from '@/components/upload/DisplayImageUpload'
import ModelImagesUpload from '@/components/upload/ModelImagesUpload'
import { handlePreviewImageInput, handleDisplayImageInput, handleModelImagesInput } from '@/components/upload/image-input-handlers'
import { compressVideoPreviewImage } from '@/components/upload/image-processor'
import { addCategory, addScene } from '@/components/upload/category-handlers'
import {
uploadBatchMusic,
uploadBatchVideo,
uploadBatch3D,
uploadBatchBuyerShow,
uploadBatchXiaohongshu,
uploadBatchBrandVI,
completeBatchUpload,
} from '@/components/upload/special-batch-uploaders'
import { buildFormData, uploadSingleFile, submitFormData } from '@/components/upload/file-upload-handlers'
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral } from '@/lib/material-validation'
import { MATERIAL_TYPE_OPTIONS, type MaterialTypeOption } from '@/lib/material-type-options'
import {
isBatchVideoMaterialType,
isVideoMaterialType,
normalizeVideoMaterialType,
} from '@/lib/video-material-types'
import { useCategoryData } from '@/hooks/useCategoryData'
import { parseApiError } from '@/lib/error-handler'
import { parseSPU } from '@/lib/spu-parser'
import UploadForm from '@/components/upload/UploadForm'
import MaterialTypeSelect from '@/components/upload/MaterialTypeSelect'
import {
BatchMusicList,
BatchVideoList,
Batch3DList,
BatchBrandVIList,
BatchBuyerShowUI,
BatchXiaohongshuUI,
} from '@/components/upload/SpecialBatchUploadUI'
import type { BatchVideoFile } from '@/components/upload/SpecialBatchUploadUI'
import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader'
import TaskPageUploader from '@/components/upload/TaskPageUploader'
import CertificateReportUploader from '@/components/upload/CertificateReportUploader'
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
import {
hasDragLeftDropZone,
prepareFileDrag,
validateImageFiles,
} from '@/components/upload/drag-drop-utils'
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'
import {
dropZoneActiveClass,
dropZoneIdleClass,
fieldControlClass,
fieldHintClass,
fieldLabelClass,
inkButtonClass,
outlineButtonClass,
uploadAreaButtonClass,
} from '@/components/upload/upload-field-styles'
import {
getSpuProductModelValidationError,
isPlausibleSpuProductModel,
normalizeSpuProductModel,
} from '@/lib/product-model'
gsap.registerPlugin(useGSAP)
interface UploadModalProps {
onClose: () => void
onSuccess: () => void
materialTypeCounts?: Record<string, number>
}
function getSupportedMainFileDescription(materialType: string): string {
if (materialType === '音乐') {
return '音频文件(.mp3/.wav/.flac/.m4a/.aac/.ogg)'
}
if (isVideoMaterialType(materialType)) {
return '视频文件(.mp4/.mov/.avi/.mkv/.mts/.m4v)'
}
if (materialType === '品牌VI灵感') {
return 'PDF 文件(.pdf)'
}
if (materialType === '详情模板') {
return 'PSD/TIF/PSB 文件或压缩包(.psd / .tif / .tiff / .psb / .zip / .rar / .7z)'
}
return '压缩包格式(.zip / .rar / .7z)'
}
function getIgnoredMainFileError(files: File[], materialType: string): string {
const invalidNames = files
.filter((file) => !isValidMainFileType(file, materialType))
.map((file) => file.name)
return invalidNames.length > 0
? `以下文件不支持当前素材类型,已忽略:${invalidNames.join('、')}`
: ''
}
function isPreviewOptionalMaterialType(materialType: string): boolean {
return isVideoMaterialType(materialType) || PREVIEW_OPTIONAL_TYPES.includes(materialType)
}
export default function UploadModal({ onClose, onSuccess, materialTypeCounts = {} }: UploadModalProps) {
const SPECIAL_UPLOAD_TYPES = ['任务-详情', '任务-视频截图', '任务-页面', '证书-检测报告'] as const
const [previewImages, setPreviewImages] = useState<File[]>([]) // 预览图片数组(支持多图)
const [previewUrls, setPreviewUrls] = useState<string[]>([]) // 预览图片预览URLs
const [displayImage, setDisplayImage] = useState<File | null>(null) // 详情模板展示图片(原图,后端自动切片)
const [displayImageUrl, setDisplayImageUrl] = useState<string>('') // 详情模板展示图片预览URL
const [modelImages, setModelImages] = useState<File[]>([]) // 模特类型的多张展示图片
const [modelImageUrls, setModelImageUrls] = useState<string[]>([]) // 模特图片预览URLs
const [zipFile, setZipFile] = useState<File | null>(null)
const [productModel, setProductModel] = useState('')
const [batchFiles, setBatchFiles] = useState<BatchFileItem[]>([])
const [isBatchMode, setIsBatchMode] = useState(false) // 是否处于批量上传模式
// 保留旧的批量上传数据结构(用于兼容现有代码和特殊类型)
// 音乐:最多100个
const [batchMusicFiles, setBatchMusicFiles] = useState<Array<{ file: File; productModel: string }>>([])
// 淘系-逛逛视频:最多8个
const [batchVideoFiles, setBatchVideoFiles] = useState<BatchVideoFile[]>([])
// 3D场景:最多5个,每个需要预览图
const [batch3DFiles, setBatch3DFiles] = useState<Array<{ file: File; productModel: string; previewImage?: File }>>([])
// 品牌VI:最多50个PDF
const [batchBrandVIFiles, setBatchBrandVIFiles] = useState<Array<{ file: File; productModel: string }>>([])
// 买家秀:每批至少8张图
const [batchBuyerShowFiles, setBatchBuyerShowFiles] = useState<Array<{ productModel: string; images: File[] }>>([])
// 小红书:支持批量
const [batchXiaohongshuFiles, setBatchXiaohongshuFiles] = useState<Array<{ productModel: string; images: File[] }>>([])
// 使用 useCategoryData hook 获取分类数据
const {
data: categoryData,
refreshCategories: fetchCategories,
refreshScenes: fetchScenes
} = useCategoryData()
const { categories, scenes, platforms, productCategories } = categoryData
// 一级分类(店铺)选中值
const [categoryName, setCategoryName] = useState('')
// 二级分类(场景)选中值
const [sceneName, setSceneName] = useState('')
// 平台分类(可选)选中值
const [platformName, setPlatformName] = useState('')
// 品类分类(标准模型必填)选中值
const [productCategoryName, setProductCategoryName] = useState('')
// 素材类型
const [materialType, setMaterialType] = useState('3D场景')
// 可见的素材类型列表(从API动态加载)
const [availableMaterialTypes, setAvailableMaterialTypes] = useState<string[]>([])
// 渲染器(仅3D场景使用)
const [renderer, setRenderer] = useState('CR')
// 任务号(所有素材类型支持)
const [taskNo, setTaskNo] = useState('')
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('')
// SKU号(视频类素材支持)
const [sku, setSku] = useState('')
const [liveDate, setLiveDate] = useState('')
const [liveHost, setLiveHost] = useState('')
const [liveHostUserId, setLiveHostUserId] = useState('')
const [livePlatform, setLivePlatform] = useState('')
// 切换素材类型时清空直播切片字段,避免串类型残留
useEffect(() => {
if (materialType !== '直播切片') {
setLiveDate('')
setLiveHost('')
setLiveHostUserId('')
setLivePlatform('')
} else if (!liveDate) {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
setLiveDate(`${y}-${m}-${d}`)
}
}, [materialType])
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadSpeed, setUploadSpeed] = useState(0)
const [isProcessing, setIsProcessing] = useState(false) // 服务器处理阶段
const [previewProcessingCount, setPreviewProcessingCount] = useState(0)
const [error, setError] = useState('')
// 批量上传详细状态
const [batchUploadStatus, setBatchUploadStatus] = useState<BatchUploadStatus | null>(null)
const batchUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false })
const batchUploadXhrRefs = useRef<Map<string, XMLHttpRequest>>(new Map())
const batchUploadIdsRef = useRef<Map<string, string>>(new Map()) // 跟踪文件ID到uploadId的映射
const previewProcessingCountRef = useRef(0)
const isMountedRef = useRef(true)
const objectUrlsRef = useRef<Set<string>>(new Set())
const materialTypeRef = useRef(materialType)
const [isDragging, setIsDragging] = useState(false)
const [isDisplayImageDragging, setIsDisplayImageDragging] = useState(false) // 详情模板展示图片拖拽状态
const [isPreviewDragging, setIsPreviewDragging] = useState(false) // 预览图片拖拽状态
const [batchPreviewDragging, setBatchPreviewDragging] = useState<Record<string, boolean>>({}) // 批量模式下每个文件的预览图拖拽状态
const [retryCount, setRetryCount] = useState(0)
const isTaskOtherMaterial = materialType === '任务-其他'
const usesDedicatedUploader = SPECIAL_UPLOAD_TYPES.includes(materialType as typeof SPECIAL_UPLOAD_TYPES[number])
const interactionLocked = uploading || previewProcessingCount > 0
const previewInputRef = useRef<HTMLInputElement>(null)
const zipInputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const displayImageDropZoneRef = useRef<HTMLDivElement>(null) // 详情模板展示图片拖拽区域
const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区域
const xhrRef = useRef<XMLHttpRequest | null>(null)
const singleUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false }) // 单文件上传取消标志
const singleUploadXhrsRef = useRef<Set<XMLHttpRequest>>(new Set()) // 单文件上传的所有活跃XHR
const singleUploadServerStateRef = useRef<{ uploadId?: string; existingFilePath?: string } | null>(null)
const batchFileInputRef = useRef<HTMLInputElement>(null) // 批量文件选择input
const overlayRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(onClose)
const closeAnimatedRef = useRef(onClose)
onCloseRef.current = onClose
useGSAP((context, contextSafe) => {
if (typeof window.matchMedia !== 'function') return
const motion = gsap.matchMedia()
motion.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(
overlayRef.current,
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: 0.28, ease: 'power2.out' },
)
gsap.fromTo(
panelRef.current,
{ y: 16, autoAlpha: 0 },
{
y: 0,
autoAlpha: 1,
duration: 0.4,
ease: 'power3.out',
clearProps: 'opacity,visibility,transform',
},
)
gsap.fromTo(
'[data-upload-reveal]',
{ y: 8, autoAlpha: 0 },
{
y: 0,
autoAlpha: 1,
duration: 0.32,
stagger: 0.04,
delay: 0.06,
ease: 'power2.out',
clearProps: 'opacity,visibility,transform',
},
)
})
if (!contextSafe) return
closeAnimatedRef.current = contextSafe(() => {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduced || !overlayRef.current || !panelRef.current) {
onCloseRef.current()
return
}
gsap.timeline({ onComplete: () => onCloseRef.current() })
.to(panelRef.current, { y: 10, autoAlpha: 0, duration: 0.2, ease: 'power2.in' })
.to(overlayRef.current, { autoAlpha: 0, duration: 0.16, ease: 'power2.in' }, '<')
})
return () => motion.revert()
}, { scope: overlayRef })
const singleUploadTotalBytes =
(zipFile?.size || 0) +
previewImages.reduce((sum, file) => sum + file.size, 0) +
(displayImage?.size || 0) +
modelImages.reduce((sum, file) => sum + file.size, 0)
const registerObjectUrl = useCallback((url: string): boolean => {
if (!isMountedRef.current) {
URL.revokeObjectURL(url)
return false
}
objectUrlsRef.current.add(url)
return true
}, [])
const createTrackedObjectUrl = useCallback((file: File): string | null => {
const url = URL.createObjectURL(file)
return registerObjectUrl(url) ? url : null
}, [registerObjectUrl])
const revokeObjectUrl = useCallback((url?: string | null) => {
if (!url) return
URL.revokeObjectURL(url)
objectUrlsRef.current.delete(url)
}, [])
const handlePreviewProcessingChange = useCallback((processing: boolean) => {
if (!isMountedRef.current) return
const nextCount = processing
? previewProcessingCountRef.current + 1
: Math.max(0, previewProcessingCountRef.current - 1)
previewProcessingCountRef.current = nextCount
setPreviewProcessingCount(nextCount)
}, [])
useEffect(() => {
const objectUrls = objectUrlsRef.current
isMountedRef.current = true
return () => {
isMountedRef.current = false
objectUrls.forEach((url) => URL.revokeObjectURL(url))
objectUrls.clear()
}
}, [])
useEffect(() => {
materialTypeRef.current = materialType
}, [materialType])
// 加载当前用户可见的素材类型
useEffect(() => {
const loadViewableTypes = async () => {
try {
const res = await fetch('/api/materials/viewable-types')
if (res.ok) {
const data = await res.json()
const types = ((data.materialTypes || []) as string[])
.map((type) => normalizeVideoMaterialType(type))
.filter((type) => MATERIAL_TYPE_OPTIONS.includes(type as MaterialTypeOption))
setAvailableMaterialTypes(types)
// 如果当前选中的类型不在可见列表中,重置为第一个可见类型
setMaterialType((currentType) => (
types.length > 0 && !types.includes(currentType) ? types[0] : currentType
))
} else {
console.error('获取可见素材类型失败')
}
} catch (error) {
console.error('获取可见素材类型失败:', error)
}
}
loadViewableTypes()
}, []) // 只在组件挂载时执行一次
// 自动填充"通用"店铺/场景
useEffect(() => {
if (shouldAutoFillGeneral(materialType)) {
setCategoryName('通用')
setSceneName('通用')
}
}, [materialType])
// 切换素材类型时清空批量上传数据
useEffect(() => {
setBatchFiles((currentFiles) => {
currentFiles.forEach((item) => {
revokeObjectUrl(item.previewImageUrl)
})
return []
})
setBatchVideoFiles((currentFiles) => {
currentFiles.forEach((item) => {
revokeObjectUrl(item.previewImageUrl)
})
return []
})
setPreviewUrls((currentUrls) => {
currentUrls.forEach(revokeObjectUrl)
return []
})
setDisplayImageUrl((currentUrl) => {
revokeObjectUrl(currentUrl)
return ''
})
setModelImageUrls((currentUrls) => {
currentUrls.forEach(revokeObjectUrl)
return []
})
setPreviewImages([])
setDisplayImage(null)
setModelImages([])
setBatchBuyerShowFiles([])
setBatchXiaohongshuFiles([])
setZipFile(null)
setProductModel('')
setIsBatchMode(false)
setTaskNo('') // 切换素材类型时清空任务号
setDingtalkApprovalNo('')
setSku('') // 切换素材类型时清空SKU号
}, [materialType, revokeObjectUrl])
// 验证任务号格式(与后端保持一致)
// 支持格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
// 示例:TASK202601030184 或 TASK202601071437194808-01 或 TASK202601141713525624-01-01
const validateTaskNo = (value: string): boolean => {
if (!value.trim()) return true // 空值允许(可选字段,但提交时会验证)
const regex = /^TASK\d{12,18}(-\d{2})*$/
return regex.test(value)
}
const handlePreviewChange = async (
e: React.ChangeEvent<HTMLInputElement>,
preservedErrors: string[] = [],
) => {
if (uploading) {
setError('文件正在上传,请等待完成后再添加预览图')
e.currentTarget.value = ''
return
}
const { acceptedFiles: files, errors } = validateImageFiles(e.target.files || [])
const combinedErrors = [...preservedErrors, ...errors]
if (files.length === 0) {
setError(combinedErrors.join(';') || '请选择有效的图片文件')
e.currentTarget.value = ''
return
}
if (isTaskOtherMaterial && files.length > 1) {
setError('任务-其他一次只能上传一张预览图')
if (e.target) {
e.target.value = ''
}
return
}
await handlePreviewImageInput(
files,
(compressedFiles, urls) => {
if (!isMountedRef.current) {
urls.forEach((url) => URL.revokeObjectURL(url))
return
}
urls.forEach(registerObjectUrl)
if (isTaskOtherMaterial) {
setPreviewImages(compressedFiles.slice(0, 1))
setPreviewUrls((currentUrls) => {
currentUrls.forEach(revokeObjectUrl)
return urls.slice(0, 1)
})
} else {
setPreviewImages((prev) => [...prev, ...compressedFiles])
setPreviewUrls((prev) => [...prev, ...urls])
}
setError(combinedErrors.join(';'))
// 清空 input,允许重复选择同一文件
if (e.target) {
e.target.value = ''
}
},
(error) => setError(error)
)
}
const handleVideoPreviewSelect = async (file: File) => {
const selectedMaterialType = materialType
const compressedFile = await compressVideoPreviewImage(file)
if (!isMountedRef.current || materialTypeRef.current !== selectedMaterialType) return
const nextUrl = createTrackedObjectUrl(compressedFile)
if (!nextUrl) return
setPreviewUrls((currentUrls) => {
currentUrls.forEach(revokeObjectUrl)
return [nextUrl]
})
setPreviewImages([compressedFile])
setError('')
}
const handleDisplayImageChange = async (
e: React.ChangeEvent<HTMLInputElement>,
preservedErrors: string[] = [],
) => {
if (uploading) {
setError('文件正在上传,请等待完成后再更换展示图')
e.currentTarget.value = ''
return
}
const file = e.target.files?.[0]
if (!file) return
const { acceptedFiles, errors } = validateImageFiles([file], { maxCount: 1 })
const combinedErrors = [...preservedErrors, ...errors]
if (acceptedFiles.length === 0) {
setError(combinedErrors.join(';') || '请选择有效的图片文件')
e.currentTarget.value = ''
return
}
await handleDisplayImageInput(
acceptedFiles[0],
(file, url) => {
if (!registerObjectUrl(url)) return
setDisplayImage(file)
setDisplayImageUrl((currentUrl) => {
revokeObjectUrl(currentUrl)
return url
})
setError(combinedErrors.join(';'))
},
(error) => setError(error)
)
}
// 处理模特类型的多图上传(压缩 + 切片)
const handleModelImagesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
await handleModelImagesInput(
files,
(processedFiles, urls) => {
if (!isMountedRef.current) {
urls.forEach((url) => URL.revokeObjectURL(url))
return
}
urls.forEach(registerObjectUrl)
setModelImages((prev) => [...prev, ...processedFiles])
setModelImageUrls((prev) => [...prev, ...urls])
setError('')
},
(error) => setError(error)
)
}
// 删除模特图片
const removeModelImage = (index: number) => {
const newImages = modelImages.filter((_, i) => i !== index)
const newUrls = modelImageUrls.filter((_, i) => i !== index)
// 释放被删除图片的URL
revokeObjectUrl(modelImageUrls[index])
setModelImages(newImages)
setModelImageUrls(newUrls)
}
// 根据文件扩展名自动识别素材类型
const handleZipChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (uploading || previewProcessingCountRef.current > 0) {
setError(uploading ? '文件正在上传,请等待完成后再添加素材' : '视频预览图正在处理,请稍候再更换素材文件')
e.currentTarget.value = ''
return
}
const files = Array.from(e.target.files || [])
if (files.length === 0) return
// 过滤有效文件
const validFiles = files.filter(f => isValidMainFileType(f, materialType))
const ignoredFileError = getIgnoredMainFileError(files, materialType)
if (validFiles.length === 0) {
setError(`请选择有效的素材文件,支持:${getSupportedMainFileDescription(materialType)}`)
// 清空input,允许重复选择
if (e.target) {
e.target.value = ''
}
return
}
if (isTaskOtherMaterial && validFiles.length > 1) {
setError('任务-其他一次只能上传一个源文件')
if (e.target) {
e.target.value = ''
}
return
}
if (isTaskOtherMaterial) {
setZipFile(validFiles[0])
setBatchFiles([])
setIsBatchMode(false)
setError(ignoredFileError)
if (e.target) {
e.target.value = ''
}
return
}
// 如果已经有文件(单文件或批量模式),追加到批量模式
if (zipFile || batchFiles.length > 0) {
// 如果当前是单文件模式,先将 zipFile 转换为批量模式的第一项
if (zipFile && batchFiles.length === 0) {
const firstBatchItem = createBatchItemFromSingleFile(
zipFile,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer
)
setBatchFiles([firstBatchItem])
setZipFile(null) // 清空单文件
}
// 追加新文件到批量模式
handleBatchFilesAdd(validFiles, ignoredFileError)
// 清空input,允许重复选择
if (e.target) {
e.target.value = ''
}
return
}
// 如果选择多个文件,进入批量上传模式
if (validFiles.length > 1) {
handleBatchFilesAdd(validFiles, ignoredFileError)
// 清空input,允许重复选择
if (e.target) {
e.target.value = ''
}
return
}
// 单个文件,且没有已有文件,使用单文件模式
const file = validFiles[0]
setZipFile(file)
setIsBatchMode(false)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
setError(ignoredFileError)
// 清空input,允许重复选择
if (e.target) {
e.target.value = ''
}
}
// 拖拽处理 - 修复闪屏问题
const handleDragEnter = (e: React.DragEvent) => {
prepareFileDrag(e, interactionLocked)
if (interactionLocked) return
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (hasDragLeftDropZone(e)) {
setIsDragging(false)
}
}
const handleDragOver = (e: React.DragEvent) => {
prepareFileDrag(e, interactionLocked)
}
const handleDrop = (e: React.DragEvent) => {
prepareFileDrag(e, interactionLocked)
setIsDragging(false)
if (interactionLocked) {
setError(uploading ? '文件正在上传,请等待完成后再添加素材' : '视频预览图正在处理,请稍候再更换素材文件')
return
}
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
// 只处理素材文件(包括压缩包/视频/音频/PDF 视类型而定)
// 图片文件应该拖拽到对应的预览图或展示图区域
const materialFiles = files.filter((f) => isValidMainFileType(f, materialType))
const ignoredFileError = getIgnoredMainFileError(files, materialType)
if (materialFiles.length > 0) {
if (isTaskOtherMaterial) {
if (materialFiles.length > 1) {
setError('任务-其他一次只能上传一个源文件')
return
}
setZipFile(materialFiles[0])
setBatchFiles([])
setIsBatchMode(false)
setError(ignoredFileError)
return
}
// 如果已经有文件(单文件或批量模式),追加到批量模式
if (zipFile || batchFiles.length > 0) {
// 如果当前是单文件模式,先将 zipFile 转换为批量模式的第一项
if (zipFile && batchFiles.length === 0) {
const extractedSpu = extractSpuFromFileName(zipFile.name)
const firstBatchItem: BatchFileItem = {
id: `${Date.now()}-0`,
file: zipFile,
expanded: true,
productModel: extractedSpu,
categoryName: shouldAutoFillGeneral(materialType) ? '通用' : categoryName,
sceneName: shouldAutoFillGeneral(materialType) ? '通用' : sceneName,
platformName: platformName,
productCategoryName: productCategoryName,
renderer: requiresRenderer(materialType) ? renderer : undefined,
}
setBatchFiles([firstBatchItem])
setZipFile(null) // 清空单文件
}
// 追加新文件到批量模式
handleBatchFilesAdd(materialFiles, ignoredFileError)
return
}
// 如果拖入多个文件,进入批量上传模式
if (materialFiles.length > 1) {
handleBatchFilesAdd(materialFiles, ignoredFileError)
} else {
// 单个文件,且没有已有文件,使用单文件模式
const file = materialFiles[0]
setZipFile(file)
setIsBatchMode(false)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
setError(ignoredFileError)
}
} else {
setError('请拖拽素材文件到此区域,图片请拖拽到对应的图片上传区域')
}
}
// 处理批量文件添加
const handleBatchFilesAdd = (files: File[], preservedError = '') => {
// 过滤掉无效文件
const validFiles = files.filter(f => isValidMainFileType(f, materialType))
const ignoredFileError = getIgnoredMainFileError(files, materialType)
const validationMessage = [preservedError, ignoredFileError].filter(Boolean).join(';')
if (validFiles.length === 0) {
setError(`请选择有效的素材文件,支持:${getSupportedMainFileDescription(materialType)}`)
return
}
// 检查文件数量限制,追加时也要计算已经选择的文件
const limit = getBatchLimit(materialType)
const currentCount = batchFiles.length + (zipFile ? 1 : 0)
if (limit !== Infinity && currentCount + validFiles.length > limit) {
setError(`最多只能上传 ${limit} 个文件,当前已有 ${currentCount} 个`)
return
}
// 创建批量文件项
const newBatchFiles = createBatchFileItems(
validFiles,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer
)
setBatchFiles((current) => [...current, ...newBatchFiles])
setIsBatchMode(true)
setError(validationMessage)
}
// 预览图片拖拽处理
const handlePreviewDragEnter = (e: React.DragEvent) => {
prepareFileDrag(e, uploading)
if (uploading) return
setIsPreviewDragging(true)
}
const handlePreviewDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (hasDragLeftDropZone(e)) {
setIsPreviewDragging(false)
}
}
const handlePreviewDragOver = (e: React.DragEvent) => {
prepareFileDrag(e, uploading)
}
const handlePreviewDrop = (e: React.DragEvent) => {
prepareFileDrag(e, uploading)
setIsPreviewDragging(false)
if (uploading) {
setError('文件正在上传,请等待完成后再添加预览图')
return
}
const { acceptedFiles: imageFiles, errors } = validateImageFiles(e.dataTransfer.files)
if (imageFiles.length > 0) {
if (isTaskOtherMaterial && imageFiles.length > 1) {
setError('任务-其他一次只能上传一张预览图')
return
}
// 支持多图拖拽
const dt = { target: { files: imageFiles } } as unknown as React.ChangeEvent<HTMLInputElement>
void handlePreviewChange(dt, errors)
} else {
setError(errors.join(';') || '请拖拽图片文件')
}
}
// 删除预览图片
const removePreviewImage = (index: number) => {
const newImages = previewImages.filter((_, i) => i !== index)
const newUrls = previewUrls.filter((_, i) => i !== index)
// 释放被删除图片的URL
revokeObjectUrl(previewUrls[index])
setPreviewImages(newImages)
setPreviewUrls(newUrls)
}
// 详情模板展示图片拖拽处理
const handleDisplayImageDragEnter = (e: React.DragEvent) => {
prepareFileDrag(e, uploading)
if (uploading) return
setIsDisplayImageDragging(true)
}
const handleDisplayImageDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (hasDragLeftDropZone(e)) {
setIsDisplayImageDragging(false)
}
}
const handleDisplayImageDragOver = (e: React.DragEvent) => {
prepareFileDrag(e, uploading)
}
const handleDisplayImageDrop = (e: React.DragEvent) => {
prepareFileDrag(e, uploading)
setIsDisplayImageDragging(false)
if (uploading) {
setError('文件正在上传,请等待完成后再更换展示图')
return
}
const { acceptedFiles: imageFiles, errors } = validateImageFiles(
e.dataTransfer.files,
{ maxCount: 1 },
)
if (imageFiles.length > 0) {
const imgFile = imageFiles[0]
// 复用选择逻辑(包含压缩 + 切片)
const dt = { target: { files: [imgFile] } } as unknown as React.ChangeEvent<HTMLInputElement>
void handleDisplayImageChange(dt, errors)
} else {
setError(errors.join(';') || '请拖拽图片文件')
}
}
// 买家秀批量图片选择处理
const handleBatchBuyerShowImageSelect = (e: React.ChangeEvent<HTMLInputElement>, batchIndex: number) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
if (imageFiles.length === 0) {
setError('请选择图片文件')
return
}
const newFiles = [...batchBuyerShowFiles]
if (!newFiles[batchIndex]) {
newFiles[batchIndex] = { productModel: '', images: [] }
}
newFiles[batchIndex].images = [...newFiles[batchIndex].images, ...imageFiles]
setBatchBuyerShowFiles(newFiles)
setError('')
if (e.target) {
e.target.value = ''
}
}
// 小红书批量图片选择处理
const handleBatchXiaohongshuImageSelect = (e: React.ChangeEvent<HTMLInputElement>, batchIndex: number) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
if (imageFiles.length === 0) {
setError('请选择图片文件')
return
}
const newFiles = [...batchXiaohongshuFiles]
if (!newFiles[batchIndex]) {
newFiles[batchIndex] = { productModel: '', images: [] }
}
newFiles[batchIndex].images = [...newFiles[batchIndex].images, ...imageFiles]
setBatchXiaohongshuFiles(newFiles)
setError('')
if (e.target) {
e.target.value = ''
}
}
// 添加新的买家秀批次
const addBuyerShowBatch = () => {
setBatchBuyerShowFiles([...batchBuyerShowFiles, { productModel: '', images: [] }])
}
// 添加新的小红书批次
const addXiaohongshuBatch = () => {
setBatchXiaohongshuFiles([...batchXiaohongshuFiles, { productModel: '', images: [] }])
}
// 批量文件选择处理函数(统一走新的批量上传逻辑,与拖拽一致)
const handleBatchFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (uploading || previewProcessingCountRef.current > 0) {
setError(uploading ? '文件正在上传,请等待完成后再添加素材' : '视频预览图正在处理,请稍候再更换素材文件')
e.currentTarget.value = ''
return
}
const files = Array.from(e.target.files || [])
if (files.length === 0) return
// 统一使用 handleBatchFilesAdd 处理,保持与拖拽多文件逻辑一致
handleBatchFilesAdd(files)
// 清空 input 值,避免同名文件无法再次选择
if (e.target) {
e.target.value = ''
}
}
// 批量上传音乐文件
const handleBatchMusicUpload = async () => {
if (batchMusicFiles.length === 0) return
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
setError('')
try {
const results = await uploadBatchMusic(batchMusicFiles, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
// 检查是否有失败的文件
const failedFiles = results.filter(r => r.status === 'failed')
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
setUploading(false)
return
}
setUploadProgress(100)
setUploading(false)
setBatchMusicFiles([])
onSuccess()
onClose()
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
const { message } = parseApiError(error)
setError(message)
setUploading(false)
}
}
// 取消批量上传
const handleCancelBatchUpload = 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('上传已取消')
}
// 新的批量上传逻辑(使用batchFiles,支持队列管理和详细进度)
const handleBatchFilesUpload = async () => {
if (batchFiles.length === 0) return
if (materialType === '直播切片') {
const missingTitle = batchFiles.some((item) => !item.productModel.trim())
if (missingTitle) {
setError('请为每个切片填写标题')
return
}
const liveErr: string[] = []
if (!liveDate.trim()) liveErr.push('请选择直播日期')
if (livePlatform.trim() && !['抖音', '快手', '视频号', '淘宝直播', 'B站'].includes(livePlatform.trim())) {
liveErr.push('请选择有效的直播平台')
}
if (liveErr.length > 0) {
setError(liveErr.join(';'))
return
}
}
setUploading(true)
setIsProcessing(false)
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
let 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 || '',
...(materialType === '直播切片'
? { liveDate, liveHost, liveHostUserId, livePlatform }
: {}),
}
// 添加渲染器(3D场景)
if (requiresRenderer(materialType) && item.renderer) {
metadata.renderer = item.renderer
}
// 视频预览图在真正上传时串行压缩一次,避免选图阶段并发解码大图。
let previewImageForUpload = item.previewImage
if (item.previewImage && isVideoMaterialType(materialType)) {
previewImageForUpload = await compressVideoPreviewImage(item.previewImage)
}
if (previewImageForUpload) {
metadata.previewImage = await fileToBase64String(previewImageForUpload)
}
// 判断是否需要分片上传
const useChunk = item.file.size >= CHUNK_THRESHOLD
if (useChunk) {
// 分片上传(chunkSize使用默认配置:内网20MB,公网10MB)
const result = await chunkUpload(item.file, metadata, {
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)
}
},
// 取消检查回调:让 chunkUpload 内部能够感知取消状态
isCancelled: () => batchUploadCancelRef.current.cancelled,
// 活跃 XHR 集合回调:跟踪所有并发的 XHR 以便批量取消
onActiveXhrChange: (xhrs) => {
xhrs.forEach(xhr => {
if (!Array.from(batchUploadXhrRefs.current.values()).includes(xhr)) {
batchUploadXhrRefs.current.set(`${item.id}-chunk-${Date.now()}`, xhr)
}
})
},
skipComplete: true,
maxRetries: 5,
})
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 {
// 小文件直接上传(使用XMLHttpRequest以便取消和显示进度)
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 (sku) {
formData.append('skuNo', sku)
}
if (previewImageForUpload) {
formData.append('previewImage', previewImageForUpload)
}
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)
const { message } = parseApiError(error)
setBatchUploadStatus(prev => {
if (!prev) return prev
const newStatuses = [...prev.fileStatuses]
newStatuses[fileIndex] = {
...newStatuses[fileIndex],
status: 'failed',
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 = results.filter((result) => result.status === 'failed')
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
} else {
// 清理预览图URL
batchFiles.forEach(item => {
if (item.previewImageUrl) {
revokeObjectUrl(item.previewImageUrl)
}
})
setBatchFiles([])
setIsBatchMode(false)
setUploadProgress(100)
onSuccess()
onClose()
}
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
setError('批量上传失败,请重试')
} finally {
setUploading(false)
batchUploadXhrRefs.current.clear()
batchUploadIdsRef.current.clear() // ✅ 清理uploadId映射
}
}
// 批量上传视频文件(淘系-逛逛视频)
const handleBatchVideoUpload = async () => {
if (batchVideoFiles.length === 0) return
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
setError('')
try {
const results = await uploadBatchVideo(batchVideoFiles, platformName, renderer, productCategoryName, (progress) => {
setUploadProgress(progress)
})
// 检查是否有失败的文件
const failedFiles = results.filter(r => r.status === 'failed')
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
setUploading(false)
return
}
setUploadProgress(100)
setUploading(false)
batchVideoFiles.forEach((item) => {
revokeObjectUrl(item.previewImageUrl)
})
setBatchVideoFiles([])
onSuccess()
onClose()
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
const { message } = parseApiError(error)
setError(message)
setUploading(false)
}
}
// 批量上传3D场景文件
const handleBatch3DUpload = async () => {
if (batch3DFiles.length === 0) return
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
setError('')
try {
const results = await uploadBatch3D(batch3DFiles, categoryName, sceneName, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
// 检查是否有失败的文件
const failedFiles = results.filter(r => r.status === 'failed')
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
setUploading(false)
return
}
setUploadProgress(100)
setUploading(false)
setBatch3DFiles([])
onSuccess()
onClose()
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
const { message } = parseApiError(error)
setError(message)
setUploading(false)
}
}
// 批量上传买家秀
const handleBatchBuyerShowUpload = async () => {
if (batchBuyerShowFiles.length === 0) return
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
setError('')
try {
const results = await uploadBatchBuyerShow(batchBuyerShowFiles, categoryName, sceneName, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
// 检查是否有失败的文件
const failedFiles = results.filter(r => r.status === 'failed')
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
setUploading(false)
return
}
setUploadProgress(100)
setUploading(false)
setBatchBuyerShowFiles([])
onSuccess()
onClose()
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
const { message } = parseApiError(error)
setError(message)
setUploading(false)
}
}
// 批量上传小红书
const handleBatchXiaohongshuUpload = async () => {
if (batchXiaohongshuFiles.length === 0) return
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
setError('')
try {
const results = await uploadBatchXiaohongshu(batchXiaohongshuFiles, categoryName, sceneName, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
// 检查是否有失败的文件
const failedFiles = results.filter(r => r.status === 'failed')
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
setUploading(false)
return
}
setUploadProgress(100)
setUploading(false)
setBatchXiaohongshuFiles([])
onSuccess()
onClose()
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
const { message } = parseApiError(error)
setError(message)
setUploading(false)
}
}
// 批量上传品牌VI
const handleBatchBrandVIUpload = async () => {
if (batchBrandVIFiles.length === 0) return
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
setError('')
try {
const results = await uploadBatchBrandVI(batchBrandVIFiles, productCategoryName, (progress) => {
setUploadProgress(progress)
})
// 检查是否有失败的文件
const failedFiles = results.filter(r => r.status === 'failed')
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
setUploading(false)
return
}
setUploadProgress(100)
setUploading(false)
setBatchBrandVIFiles([])
onSuccess()
onClose()
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
const { message } = parseApiError(error)
setError(message)
setUploading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (previewProcessingCountRef.current > 0) {
setError('视频预览图仍在处理中,请稍候再上传')
return
}
// 新的批量上传模式(可折叠文件列表)
if (isBatchMode && batchFiles.length > 0) {
const invalidVideoSpu = isVideoMaterialType(materialType) && materialType !== '直播切片'
? batchFiles.find(item => !isPlausibleSpuProductModel(item.productModel))
: undefined
if (invalidVideoSpu) {
setError(`视频 SPU 不合规:${getSpuProductModelValidationError(invalidVideoSpu.productModel)}`)
return
}
// 验证所有文件的必填字段
const invalidFiles = batchFiles.filter(item => {
if (!item.productModel.trim()) return true
// 检查预览图(如果需要)
// 详情模板:预览图可选,不强制要求
const needsPreview = !isPreviewOptionalMaterialType(materialType)
if (needsPreview && !item.previewImage) return true
// 检查店铺和场景(如果不是自动通用类型)
if (requiresCategoryAndScene(materialType)) {
if (!item.categoryName || !item.sceneName) return true
}
return false
})
if (invalidFiles.length > 0) {
setError(`请为所有文件填写必填字段(SPU${!isPreviewOptionalMaterialType(materialType) ? '、预览图' : ''}${requiresCategoryAndScene(materialType) ? '、店铺、场景' : ''})`)
return
}
await handleBatchFilesUpload()
return
}
// 批量上传检查
if (materialType === '音乐' && batchMusicFiles.length > 0) {
// 验证SPU
const invalidFiles = batchMusicFiles.filter(item => !item.productModel.trim())
if (invalidFiles.length > 0) {
setError('请为所有音乐文件填写SPU型号')
return
}
await handleBatchMusicUpload()
return
}
if (isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) {
const invalidFiles = batchVideoFiles.filter(item => !isPlausibleSpuProductModel(item.productModel))
if (invalidFiles.length > 0) {
setError(`视频 SPU 不合规:${getSpuProductModelValidationError(invalidFiles[0].productModel)}`)
return
}
await handleBatchVideoUpload()
return
}
if (materialType === '3D场景' && batch3DFiles.length > 0) {
const invalidFiles = batch3DFiles.filter(item => !item.productModel.trim())
if (invalidFiles.length > 0) {
setError('请为所有3D场景文件填写SPU型号')
return
}
await handleBatch3DUpload()
return
}
if (materialType === '买家秀图片' && batchBuyerShowFiles.length > 0) {
// 验证买家秀批次
const invalidBatches = batchBuyerShowFiles.filter(batch => !batch.productModel.trim() || batch.images.length < 8)
if (invalidBatches.length > 0) {
const missingSpu = batchBuyerShowFiles.some(batch => !batch.productModel.trim())
const insufficientImages = batchBuyerShowFiles.some(batch => batch.images.length < 8)
if (missingSpu) {
setError('请为所有批次填写SPU型号')
return
}
if (insufficientImages) {
setError('每个买家秀批次至少需要8张图片')
return
}
}
await handleBatchBuyerShowUpload()
return
}
if (materialType === '小红书种草' && batchXiaohongshuFiles.length > 0) {
const invalidBatches = batchXiaohongshuFiles.filter(batch => !batch.productModel.trim() || batch.images.length === 0)
if (invalidBatches.length > 0) {
const missingSpu = batchXiaohongshuFiles.some(batch => !batch.productModel.trim())
const noImages = batchXiaohongshuFiles.some(batch => batch.images.length === 0)
if (missingSpu) {
setError('请为所有批次填写SPU型号')
return
}
if (noImages) {
setError('每个小红书批次至少需要1张图片')
return
}
}
await handleBatchXiaohongshuUpload()
return
}
if (materialType === '品牌VI灵感' && batchBrandVIFiles.length > 0) {
const invalidFiles = batchBrandVIFiles.filter(item => !item.productModel.trim())
if (invalidFiles.length > 0) {
setError('请为所有PDF文件填写SPU型号')
return
}
if (!productCategoryName) {
setError('请选择品类')
return
}
await handleBatchBrandVIUpload()
return
}
// Validation - 基础信息验证(单文件上传)
const normalizedTaskNo = taskNo.trim().toUpperCase()
const resolvedTaskNo = isTaskOtherMaterial ? normalizedTaskNo : taskNo
const resolvedProductModel = isTaskOtherMaterial ? normalizedTaskNo : productModel
const resolvedRenderer = isTaskOtherMaterial ? '' : renderer
if (!resolvedProductModel) {
setError(
isTaskOtherMaterial
? '请填写任务号'
: materialType === '材质'
? '请填写材质名称'
: materialType === '直播切片'
? '请填写标题'
: '请填写SPU型号'
)
return
}
if (isVideoMaterialType(materialType) && materialType !== '直播切片' && !isPlausibleSpuProductModel(resolvedProductModel)) {
setError(`视频 SPU 不合规:${getSpuProductModelValidationError(resolvedProductModel)}`)
return
}
// 任务号验证(可选,仅验证格式,不强制要求)
if (resolvedTaskNo.trim() && !validateTaskNo(resolvedTaskNo)) {
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)')
return
}
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo)
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError)
return
}
// 任务号为空时仅提醒,不阻止上传(已在UI中显示提醒)
// 使用统一的验证逻辑
if (requiresProductCategoryName(materialType) && !productCategoryName) {
setError('标准模型类型必须选择品类')
return
}
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
// 素材文件验证(模特类型和标准模型素材文件可选)
if (materialType !== '模特' && materialType !== '标准模型' && !zipFile) {
setError('请上传素材文件')
return
}
// 图片验证
if (materialType === '详情模板' && !displayImage) {
setError('详情模板素材必须上传展示图片')
return
}
if (materialType === '模特' && modelImages.length === 0) {
setError('模特素材必须上传至少一张展示图片')
return
}
const needsPreview = !isPreviewOptionalMaterialType(materialType)
if (needsPreview && previewImages.length === 0) {
setError('请上传预览图')
return
}
if (materialType === '标准模型' && previewImages.length === 0) {
setError('标准模型必须上传预览图')
return
}
// 重置重试计数和取消状态
setRetryCount(0)
setUploading(true)
setUploadProgress(0)
setUploadSpeed(0)
setIsProcessing(false)
singleUploadCancelRef.current.cancelled = false
singleUploadXhrsRef.current.clear()
singleUploadServerStateRef.current = null
try {
// 大文件走分片,小文件继续直传
const chunkResult = await uploadSingleFile({
zipFile,
previewImages,
displayImage,
modelImages,
productModel: resolvedProductModel,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer: resolvedRenderer,
onProgress: (progress, speed) => {
setUploadProgress(progress)
if (speed) setUploadSpeed(speed)
},
onStageChange: (stage) => {
if (stage === 'processing') {
setUploadProgress(100)
}
},
// 取消检查回调
isCancelled: () => singleUploadCancelRef.current.cancelled,
// 活跃XHR集合回调
onActiveXhrChange: (xhrs) => {
xhrs.forEach(xhr => singleUploadXhrsRef.current.add(xhr))
},
})
singleUploadServerStateRef.current = chunkResult
? {
uploadId: chunkResult.uploadId,
existingFilePath: chunkResult.existingFilePath,
}
: null
// 如果已取消,不继续后续操作
if (singleUploadCancelRef.current.cancelled) {
return
}
const formData = buildFormData({
previewImages,
displayImage,
modelImages,
zipFile: chunkResult ? null : zipFile, // 如果已分片上传,则不传文件
productModel: resolvedProductModel,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer: resolvedRenderer,
taskNo: resolvedTaskNo,
dingtalkApprovalNo: dingtalkApprovalNo.trim(),
completedChunkUpload: chunkResult || undefined,
liveDate,
liveHost,
liveHostUserId,
livePlatform,
})
await submitFormData(formData, {
onProgress: (progress, speed) => {
setUploadProgress(progress)
if (speed) setUploadSpeed(speed)
},
onProcessing: (isProc) => setIsProcessing(isProc),
onError: (error) => setError(error),
onSuccess: () => {
setUploading(false)
singleUploadServerStateRef.current = null
onSuccess()
onClose()
},
materialType,
// 暴露XHR以便取消
onRequest: (xhr) => {
if (xhr) {
xhrRef.current = xhr
singleUploadXhrsRef.current.add(xhr)
} else {
xhrRef.current = null
}
},
})
} catch (error) {
console.error('Upload error:', error)
// 如果使用了分片上传,清理可能残留的 IndexedDB 状态
if (zipFile && zipFile.size >= CHUNK_THRESHOLD) {
try {
const { deleteUploadState, generateFileId } = await import('@/lib/chunk-upload')
const fileId = generateFileId(zipFile)
await deleteUploadState(fileId)
console.log('[UploadModal] 已清理上传状态:', fileId)
} catch (cleanupError) {
console.warn('[UploadModal] 清理上传状态失败(忽略):', cleanupError)
}
try {
const cleanupPayload = singleUploadServerStateRef.current
if (cleanupPayload?.uploadId || cleanupPayload?.existingFilePath) {
await fetch('/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cleanupPayload),
})
}
} catch (cleanupError) {
console.warn('[UploadModal] 清理服务端临时文件失败(忽略):', cleanupError)
}
}
setError((error as Error)?.message || '上传失败,请稍后再试')
setUploading(false)
}
}
const handleCancelUpload = async () => {
if (isBatchMode) {
// 批量上传模式:调用批量取消逻辑
handleCancelBatchUpload()
} else if (uploading) {
// 单文件上传模式:设置取消标志并中止所有XHR请求
singleUploadCancelRef.current.cancelled = true
// 中止所有活跃的XHR(包括分片上传的)
singleUploadXhrsRef.current.forEach(xhr => {
try {
xhr.abort()
} catch (e) {
// 忽略abort错误
}
})
singleUploadXhrsRef.current.clear()
// 中止当前的XHR(如果有)
if (xhrRef.current) {
try {
xhrRef.current.abort()
} catch (e) {
// 忽略abort错误
}
xhrRef.current = null
}
// 清理上传状态
if (zipFile && zipFile.size >= CHUNK_THRESHOLD) {
try {
const { deleteUploadState, generateFileId } = await import('@/lib/chunk-upload')
const fileId = generateFileId(zipFile)
await deleteUploadState(fileId)
console.log('[UploadModal] 取消上传,已清理上传状态:', fileId)
} catch (cleanupError) {
console.warn('[UploadModal] 清理上传状态失败(忽略):', cleanupError)
}
try {
const cleanupPayload = singleUploadServerStateRef.current
if (cleanupPayload?.uploadId || cleanupPayload?.existingFilePath) {
await fetch('/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cleanupPayload),
})
console.log('[UploadModal] 取消上传,已请求清理服务端临时文件')
}
} catch (cleanupError) {
console.warn('[UploadModal] 清理服务端临时文件失败(忽略):', cleanupError)
}
}
singleUploadServerStateRef.current = null
setUploading(false)
setError('上传已取消')
} else {
closeAnimatedRef.current()
}
}
return (
<div ref={overlayRef} className="fixed inset-0 z-50 flex items-center justify-center bg-[#1a1c1d]/40 p-4">
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="upload-material-title"
className={`max-h-[90vh] w-full overflow-y-auto rounded-[12px] border border-[#e7e8ea] bg-white ${materialType === '任务-详情' ? 'max-w-4xl' : 'max-w-2xl'}`}
>
<div data-upload-reveal className="border-b border-[#e7e8ea] px-4 py-4 sm:px-6">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<h2
id="upload-material-title"
className="text-[24px] font-semibold tracking-[-0.03em] text-[#1a1c1d]"
>
上传素材
</h2>
<p className="mt-2 text-[13px] leading-relaxed text-[#6b7075]">
按素材类型填写信息。内网请切到顶部提示的地址,上传更快。
</p>
</div>
<button
type="button"
onClick={() => closeAnimatedRef.current()}
aria-label="关闭上传素材窗口"
title="关闭"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[8px] text-[22px] leading-none text-[#86898e] transition-colors hover:bg-[#fafafa] hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
>
×
</button>
</div>
</div>
<form data-upload-reveal onSubmit={handleSubmit} className="space-y-5 px-4 py-5 sm:px-6">
<div>
<label id="upload-material-type-label" htmlFor="upload-material-type" className={fieldLabelClass}>
素材类型 *
</label>
<MaterialTypeSelect
id="upload-material-type"
value={materialType}
onChange={setMaterialType}
disabled={interactionLocked}
options={availableMaterialTypes.length > 0
? availableMaterialTypes
: MATERIAL_TYPE_OPTIONS.filter((type) => !SPECIAL_UPLOAD_TYPES.includes(type as typeof SPECIAL_UPLOAD_TYPES[number]))}
counts={materialTypeCounts}
/>
</div>
{!usesDedicatedUploader && (
<TaskReferenceFields
idPrefix="generic-task-upload"
taskNo={taskNo}
onTaskNoChange={setTaskNo}
dingtalkApprovalNo={dingtalkApprovalNo}
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
taskNoRequired={isTaskOtherMaterial}
disabled={interactionLocked}
/>
)}
{/* 任务-详情上传组件 */}
{materialType === '任务-详情' && (
<div>
<ProductPackageUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
onRefreshCategories={fetchCategories}
onRefreshScenes={fetchScenes}
/>
</div>
)}
{/* 任务-视频截图上传组件 */}
{materialType === '任务-视频截图' && (
<div>
<VideoScreenshotUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
onRefreshCategories={fetchCategories}
onRefreshScenes={fetchScenes}
/>
</div>
)}
{/* 任务-页面上传组件 */}
{materialType === '任务-页面' && (
<div>
<TaskPageUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
onRefreshCategories={fetchCategories}
onRefreshScenes={fetchScenes}
/>
</div>
)}
{/* 证书-检测报告上传组件 */}
{materialType === '证书-检测报告' && (
<div>
<CertificateReportUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
productCategories={productCategories}
/>
</div>
)}
{/* 批量上传模式:可折叠文件列表 */}
{!usesDedicatedUploader && !isTaskOtherMaterial && isBatchMode && batchFiles.length > 0 && (
<BatchFileList
batchFiles={batchFiles}
materialType={materialType}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
renderer={renderer}
disabled={interactionLocked}
batchPreviewDragging={batchPreviewDragging}
onToggleExpand={(index) => {
const newFiles = [...batchFiles]
newFiles[index].expanded = !newFiles[index].expanded
setBatchFiles(newFiles)
}}
onDelete={(index) => {
const previewUrl = batchFiles[index]?.previewImageUrl
revokeObjectUrl(previewUrl)
setBatchFiles(batchFiles.filter((_, i) => i !== index))
if (batchFiles.length === 1) {
setIsBatchMode(false)
}
}}
onUpdateProductModel={(index, value) => {
const newFiles = [...batchFiles]
let processedValue = value
if (materialType === '材质') {
newFiles[index].productModel = value
newFiles[index].productModelSource = 'manual' // 用户手动输入
} else {
processedValue = normalizeSpuProductModel(value)
newFiles[index].productModel = processedValue
newFiles[index].productModelSource = 'manual' // 用户手动输入,最高优先级
// SPU 自动识别店铺和品类
const result = parseSPU(processedValue)
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName)
if (shopExists) {
newFiles[index].categoryName = result.shopName
}
}
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName)
if (catExists) {
newFiles[index].productCategoryName = result.categoryName
}
}
// 批量同步:将SPU填充到其他非手动输入的项目
// 优先级:manual(不覆盖) > sync(可覆盖) > filename(可覆盖) > 空(可覆盖)
if (processedValue) {
newFiles.forEach((file, i) => {
if (i !== index && file.productModelSource !== 'manual') {
// 覆盖 filename、sync 来源或空值的项目
file.productModel = processedValue
file.productModelSource = 'sync' // 标记为同步填充
// 同时为同步填充的项目自动识别店铺和品类
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName)
if (shopExists) {
file.categoryName = result.shopName
}
}
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName)
if (catExists) {
file.productCategoryName = result.categoryName
}
}
}
})
}
}
setBatchFiles(newFiles)
}}
onUpdatePreviewImage={(index, file) => {
const targetId = batchFiles[index]?.id
if (!targetId) return
const nextUrl = createTrackedObjectUrl(file)
if (!nextUrl) return
setBatchFiles((currentFiles) => {
const targetIndex = currentFiles.findIndex((item) => item.id === targetId)
if (targetIndex < 0) {
revokeObjectUrl(nextUrl)
return currentFiles
}
return currentFiles.map((item, itemIndex) => {
if (itemIndex !== targetIndex) return item
revokeObjectUrl(item.previewImageUrl)
return {
...item,
previewImage: file,
previewImageUrl: nextUrl,
}
})
})
setError('')
}}
onRemovePreviewImage={(index) => {
const newFiles = [...batchFiles]
newFiles[index].previewImage = undefined
if (newFiles[index].previewImageUrl) {
revokeObjectUrl(newFiles[index].previewImageUrl)
}
newFiles[index].previewImageUrl = undefined
setBatchFiles(newFiles)
}}
onUpdateCategory={(index, value) => {
const newFiles = [...batchFiles]
newFiles[index].categoryName = value
setBatchFiles(newFiles)
}}
onUpdateScene={(index, value) => {
const newFiles = [...batchFiles]
newFiles[index].sceneName = value
setBatchFiles(newFiles)
}}
onUpdatePlatform={(index, value) => {
const newFiles = [...batchFiles]
newFiles[index].platformName = value
setBatchFiles(newFiles)
}}
onUpdateProductCategory={(index, value) => {
const newFiles = [...batchFiles]
newFiles[index].productCategoryName = value
setBatchFiles(newFiles)
}}
onUpdateRenderer={(index, value) => {
const newFiles = [...batchFiles]
newFiles[index].renderer = value
setBatchFiles(newFiles)
}}
onExitBatchMode={() => {
batchFiles.forEach((item) => {
revokeObjectUrl(item.previewImageUrl)
})
setBatchFiles([])
setIsBatchMode(false)
}}
onPreviewDragEnter={(itemId) => {
setBatchPreviewDragging({ ...batchPreviewDragging, [itemId]: true })
}}
onPreviewDragLeave={(itemId) => {
setBatchPreviewDragging({ ...batchPreviewDragging, [itemId]: false })
}}
onValidationError={setError}
onPreviewProcessingChange={handlePreviewProcessingChange}
/>
)}
{/* 预览图片(有专用上传器的任务类型不显示,批量模式下隐藏) */}
{!usesDedicatedUploader
&& !isBatchMode
&& !(isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0)
&& (
<PreviewImageUpload
materialType={materialType}
previewImages={previewImages}
previewUrls={previewUrls}
isPreviewDragging={isPreviewDragging}
previewInputRef={previewInputRef}
previewDropZoneRef={previewDropZoneRef}
disabled={interactionLocked}
onPreviewChange={handlePreviewChange}
onPreviewDragEnter={handlePreviewDragEnter}
onPreviewDragLeave={handlePreviewDragLeave}
onPreviewDragOver={handlePreviewDragOver}
onPreviewDrop={handlePreviewDrop}
onRemovePreviewImage={removePreviewImage}
onVideoPreviewSelect={handleVideoPreviewSelect}
onValidationError={setError}
onVideoPreviewProcessingChange={handlePreviewProcessingChange}
/>
)}
{/* 详情模板展示图片(品牌VI灵感自动从PDF提取,不需要手动上传) */}
{materialType === '详情模板' && (
<DisplayImageUpload
displayImageUrl={displayImageUrl}
isDisplayImageDragging={isDisplayImageDragging}
displayImageDropZoneRef={displayImageDropZoneRef}
disabled={interactionLocked}
onDisplayImageChange={handleDisplayImageChange}
onDisplayImageDragEnter={handleDisplayImageDragEnter}
onDisplayImageDragLeave={handleDisplayImageDragLeave}
onDisplayImageDragOver={handleDisplayImageDragOver}
onDisplayImageDrop={handleDisplayImageDrop}
materialType={materialType}
/>
)}
{/* 品牌VI灵感提示:预览图会自动从PDF提取 */}
{materialType === '品牌VI灵感' && (
<p className={fieldHintClass}>未上传预览图时,系统会从 PDF 第一页自动提取。</p>
)}
{/* 模特展示图片(仅模特类型显示) */}
{materialType === '模特' && (
<ModelImagesUpload
modelImageUrls={modelImageUrls}
onModelImagesChange={handleModelImagesChange}
onRemoveModelImage={removeModelImage}
disabled={interactionLocked}
/>
)}
{/* 表单字段 - 使用 UploadForm 组件(有专用上传器的任务类型不显示) */}
{!usesDedicatedUploader && !isTaskOtherMaterial && (
<UploadForm
materialType={materialType}
productModel={productModel}
categoryName={categoryName}
sceneName={sceneName}
platformName={platformName}
productCategoryName={productCategoryName}
renderer={renderer}
sku={sku}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
isBatchMode={isBatchMode}
onProductModelChange={setProductModel}
onCategoryChange={setCategoryName}
onSceneChange={setSceneName}
onPlatformChange={setPlatformName}
onProductCategoryChange={setProductCategoryName}
onRendererChange={setRenderer}
liveDate={liveDate}
liveHost={liveHost}
liveHostUserId={liveHostUserId}
livePlatform={livePlatform}
onLiveDateChange={setLiveDate}
onLiveHostChange={setLiveHost}
onLiveHostUserIdChange={setLiveHostUserId}
onLivePlatformChange={setLivePlatform}
onSkuChange={setSku}
onAddCategory={async (name) => {
await addCategory(
name,
async (newCategory) => {
await fetchCategories()
setCategoryName(newCategory.name)
setError('')
},
(error) => setError(error)
)
}}
onAddScene={async (name) => {
await addScene(
name,
async (newScene) => {
await fetchScenes()
setSceneName(newScene.name)
setError('')
},
(error) => setError(error)
)
}}
/>
)}
{/* Material File(有专用上传器的任务类型不显示) */}
{!usesDedicatedUploader && materialType !== '标准模型' && (
<div>
<label className={fieldLabelClass}>
素材文件 {materialType === '模特' ? '(可选)' : '*'}
{materialType === '音乐' && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
(支持批量,最多100个)
</span>
)}
{isBatchVideoMaterialType(materialType) && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
(支持批量,最多8个)
</span>
)}
{materialType === '3D场景' && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
(支持批量,最多5个)
</span>
)}
{materialType === '买家秀图片' && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
(支持批量,每批次至少8张)
</span>
)}
{materialType === '小红书种草' && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
(支持批量)
</span>
)}
{materialType === '品牌VI灵感' && (
<span className={`ml-2 font-normal ${fieldHintClass}`}>
(支持批量,最多50个)
</span>
)}
</label>
{/* 批量文件列表显示 - 使用 SpecialBatchUploadUI 组件 */}
{(materialType === '音乐' && batchMusicFiles.length > 0) && (
<BatchMusicList
files={batchMusicFiles}
onUpdateProductModel={(index, value) => {
const newFiles = [...batchMusicFiles]
newFiles[index].productModel = value
setBatchMusicFiles(newFiles)
}}
onDelete={(index) => {
setBatchMusicFiles(batchMusicFiles.filter((_, i) => i !== index))
}}
/>
)}
{(isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) && (
<BatchVideoList
files={batchVideoFiles}
productCategories={productCategories}
disabled={interactionLocked}
onUpdateProductModel={(index, value) => {
const newFiles = [...batchVideoFiles]
const normalizedValue = normalizeSpuProductModel(value)
newFiles[index].productModel = normalizedValue
// SPU 自动识别品类
const result = parseSPU(normalizedValue)
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName)
if (catExists) {
newFiles[index].productCategoryName = result.categoryName
}
}
setBatchVideoFiles(newFiles)
}}
onUpdateProductCategory={(index, value) => {
const newFiles = [...batchVideoFiles]
newFiles[index].productCategoryName = value
setBatchVideoFiles(newFiles)
}}
onUpdatePreviewImage={(index, file) => {
const targetFile = batchVideoFiles[index]?.file
if (!targetFile) return
const nextUrl = createTrackedObjectUrl(file)
if (!nextUrl) return
setBatchVideoFiles((currentFiles) => {
const targetIndex = currentFiles.findIndex((item) => item.file === targetFile)
if (targetIndex < 0) {
revokeObjectUrl(nextUrl)
return currentFiles
}
return currentFiles.map((item, itemIndex) => {
if (itemIndex !== targetIndex) return item
revokeObjectUrl(item.previewImageUrl)
return {
...item,
previewImage: file,
previewImageUrl: nextUrl,
}
})
})
setError('')
}}
onRemovePreviewImage={(index) => {
setBatchVideoFiles((currentFiles) => currentFiles.map((item, itemIndex) => {
if (itemIndex !== index) return item
revokeObjectUrl(item.previewImageUrl)
return {
...item,
previewImage: undefined,
previewImageUrl: undefined,
}
}))
setError('')
}}
onValidationError={setError}
onPreviewProcessingChange={handlePreviewProcessingChange}
onDelete={(index) => {
const previewUrl = batchVideoFiles[index]?.previewImageUrl
revokeObjectUrl(previewUrl)
setBatchVideoFiles((currentFiles) => currentFiles.filter((_, i) => i !== index))
}}
/>
)}
{(materialType === '3D场景' && batch3DFiles.length > 0) && (
<Batch3DList
files={batch3DFiles}
onUpdateProductModel={(index, value) => {
const newFiles = [...batch3DFiles]
newFiles[index].productModel = value
setBatch3DFiles(newFiles)
}}
onUpdatePreviewImage={(index, file) => {
const newFiles = [...batch3DFiles]
newFiles[index].previewImage = file
setBatch3DFiles(newFiles)
}}
onDelete={(index) => {
setBatch3DFiles(batch3DFiles.filter((_, i) => i !== index))
}}
/>
)}
{(materialType === '品牌VI灵感' && batchBrandVIFiles.length > 0) && (
<>
<BatchBrandVIList
files={batchBrandVIFiles}
onUpdateProductModel={(index, value) => {
const newFiles = [...batchBrandVIFiles]
newFiles[index].productModel = value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
setBatchBrandVIFiles(newFiles)
}}
onDelete={(index) => {
setBatchBrandVIFiles(batchBrandVIFiles.filter((_, i) => i !== index))
}}
/>
{/* 批量上传时显示品类选择提示 */}
{productCategories.length > 0 && (
<div className="mb-4 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
<p className="text-[13px] text-[#6b7075]">
请在上方选择品类,这一批 PDF 会用同一个品类。
</p>
</div>
)}
</>
)}
{/* 买家秀批量上传UI - 使用 SpecialBatchUploadUI 组件 */}
{materialType === '买家秀图片' && (
<BatchBuyerShowUI
batches={batchBuyerShowFiles}
onUpdateProductModel={(batchIndex, value) => {
const newFiles = [...batchBuyerShowFiles]
newFiles[batchIndex].productModel = value
setBatchBuyerShowFiles(newFiles)
}}
onImageSelect={handleBatchBuyerShowImageSelect}
onRemoveImage={(batchIndex, imageIndex) => {
const newFiles = [...batchBuyerShowFiles]
newFiles[batchIndex].images = newFiles[batchIndex].images.filter((_, i) => i !== imageIndex)
setBatchBuyerShowFiles(newFiles)
}}
onDeleteBatch={(batchIndex) => {
setBatchBuyerShowFiles(batchBuyerShowFiles.filter((_, i) => i !== batchIndex))
}}
onAddBatch={addBuyerShowBatch}
/>
)}
{/* 小红书批量上传UI - 使用 SpecialBatchUploadUI 组件 */}
{materialType === '小红书种草' && (
<BatchXiaohongshuUI
batches={batchXiaohongshuFiles}
onUpdateProductModel={(batchIndex, value) => {
const newFiles = [...batchXiaohongshuFiles]
newFiles[batchIndex].productModel = value
setBatchXiaohongshuFiles(newFiles)
}}
onImageSelect={handleBatchXiaohongshuImageSelect}
onRemoveImage={(batchIndex, imageIndex) => {
const newFiles = [...batchXiaohongshuFiles]
newFiles[batchIndex].images = newFiles[batchIndex].images.filter((_, i) => i !== imageIndex)
setBatchXiaohongshuFiles(newFiles)
}}
onDeleteBatch={(batchIndex) => {
setBatchXiaohongshuFiles(batchXiaohongshuFiles.filter((_, i) => i !== batchIndex))
}}
onAddBatch={addXiaohongshuBatch}
/>
)}
{/* 单文件上传区域(非批量类型或批量列表为空时显示) */}
{!(
(materialType === '音乐' && batchMusicFiles.length > 0) ||
(isBatchVideoMaterialType(materialType) && batchVideoFiles.length > 0) ||
(materialType === '3D场景' && batch3DFiles.length > 0) ||
(materialType === '品牌VI灵感' && batchBrandVIFiles.length > 0) ||
(materialType === '买家秀图片' && batchBuyerShowFiles.length > 0) ||
(materialType === '小红书种草' && batchXiaohongshuFiles.length > 0)
) && materialType !== '标准模型' && (
<div
ref={dropZoneRef}
aria-disabled={interactionLocked}
className={`p-3 ${
interactionLocked ? 'cursor-not-allowed opacity-60' : ''
} ${isDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{zipFile ? (
<div className="flex min-h-12 flex-wrap items-center gap-x-3 gap-y-1">
<FileUp aria-hidden="true" className="h-5 w-5 shrink-0 text-[#6b7075]" />
<p className="min-w-0 flex-1 truncate font-medium text-[#1a1c1d]">{zipFile.name}</p>
<p className="text-[13px] text-[#6b7075]">{formatBytes(zipFile.size)}</p>
{detectMaterialType(zipFile.name) && (
<p className="text-[13px] text-[#157a4b]">
已识别类型:{detectMaterialType(zipFile.name)}
</p>
)}
<button
type="button"
disabled={interactionLocked}
onClick={() => setZipFile(null)}
className="text-[13px] text-[#b42318] hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
移除
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<input
ref={zipInputRef}
type="file"
onChange={handleZipChange}
accept={getMainFileAccept(materialType)}
className="hidden"
disabled={interactionLocked}
/>
{/* 批量上传文件选择 */}
{!isTaskOtherMaterial && (materialType === '音乐' || materialType === '直播切片' || isBatchVideoMaterialType(materialType) || materialType === '3D场景' || materialType === '品牌VI灵感') && (
<>
<input
ref={batchFileInputRef}
type="file"
onChange={handleBatchFileSelect}
accept={getMainFileAccept(materialType)}
multiple
className="hidden"
disabled={interactionLocked}
/>
<button
type="button"
disabled={interactionLocked}
onClick={() => batchFileInputRef.current?.click()}
className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
>
<FileUp aria-hidden="true" className="h-4 w-4" />
{materialType === '音乐' && '选择音乐文件(可多选)'}
{materialType === '直播切片' && '选择视频文件(可多选,最多20个)'}
{isBatchVideoMaterialType(materialType) && '选择视频文件(可多选,最多8个)'}
{materialType === '3D场景' && '选择3D文件或压缩包(可多选,最多5个)'}
{materialType === '品牌VI灵感' && '选择PDF文件(可多选,最多50个)'}
</button>
</>
)}
<button
type="button"
disabled={interactionLocked}
onClick={() => zipInputRef.current?.click()}
className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
>
<FileUp aria-hidden="true" className="h-4 w-4" />
选择素材文件
</button>
<p className={`w-full ${fieldHintClass}`}>
支持格式:
{materialType === '音乐' && '音频文件(.mp3 / .wav / .flac / .m4a / .aac / .ogg)'}
{isVideoMaterialType(materialType) &&
'视频文件(.mp4 / .mov / .avi / .mkv / .mts / .m4v)'}
{materialType === '品牌VI灵感' && 'PDF 文件(.pdf)'}
{materialType === '详情模板' &&
'PSD/TIF/PSB 文件或压缩包(.psd / .tif / .tiff / .psb / .zip / .rar / .7z)'}
{!['音乐', '品牌VI灵感', '详情模板'].includes(materialType) && !isVideoMaterialType(materialType) &&
'压缩包(.zip / .rar / .7z)'}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="mt-4 space-y-4 border-t border-[#e7e8ea] pt-4">
<UploadProgress
uploading={uploading}
isBatchMode={isBatchMode}
batchUploadStatus={batchUploadStatus}
batchFiles={batchFiles}
uploadProgress={uploadProgress}
uploadSpeed={uploadSpeed}
uploadTotalBytes={singleUploadTotalBytes || undefined}
isProcessing={isProcessing}
onCancel={handleCancelUpload}
/>
</div>
)}
{/* Error Message */}
{error && (
<div role="alert" className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] px-4 py-3 text-[13px] text-[#b42318]">
{error}
</div>
)}
{!usesDedicatedUploader && (
<div className="flex justify-end gap-3 border-t border-[#e7e8ea] pt-5">
<button
type="button"
onClick={handleCancelUpload}
className={outlineButtonClass}
>
{uploading ? '取消上传' : '取消'}
</button>
<button
type="submit"
className={inkButtonClass}
disabled={uploading || previewProcessingCount > 0}
>
{uploading
? (isProcessing ? '处理中...' : `上传中 ${uploadProgress}%`)
: previewProcessingCount > 0
? '正在处理预览图...'
: '上传'}
</button>
</div>
)}
</form>
</div>
</div>
)
}