2771 lines
102 KiB
TypeScript
2771 lines
102 KiB
TypeScript
'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>
|
||
)
|
||
}
|