gongxiangp2/components/UploadModal.tsx

2002 lines
74 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from '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,
formatSpeed,
calculateRemainingTime,
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 { 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 { useCategoryData } from '@/hooks/useCategoryData'
import { parseApiError } from '@/lib/error-handler'
import UploadForm from '@/components/upload/UploadForm'
import {
BatchMusicList,
BatchVideoList,
Batch3DList,
BatchBrandVIList,
BatchBuyerShowUI,
BatchXiaohongshuUI,
} from '@/components/upload/SpecialBatchUploadUI'
import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
interface UploadModalProps {
onClose: () => void
onSuccess: () => void
}
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
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<Array<{ file: File; productModel: string }>>([])
// 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场景')
// 渲染器(仅3D场景使用)
const [renderer, setRenderer] = useState('CR')
// 任务号(所有素材类型支持)
const [taskNo, setTaskNo] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadSpeed, setUploadSpeed] = useState(0)
const [isProcessing, setIsProcessing] = useState(false) // 服务器处理阶段
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 [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 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 batchFileInputRef = useRef<HTMLInputElement>(null) // 批量文件选择input
// 自动填充"通用"店铺/场景
useEffect(() => {
if (shouldAutoFillGeneral(materialType)) {
setCategoryName('通用')
setSceneName('通用')
}
}, [materialType])
// 切换素材类型时清空批量上传数据
useEffect(() => {
setBatchFiles([])
setBatchBuyerShowFiles([])
setBatchXiaohongshuFiles([])
setZipFile(null)
setProductModel('')
setIsBatchMode(false)
setTaskNo('') // 切换素材类型时清空任务号
}, [materialType])
// 验证任务号格式(与后端保持一致)
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>) => {
const files = Array.from(e.target.files || [])
await handlePreviewImageInput(
files,
(compressedFiles, urls) => {
setPreviewImages((prev) => [...prev, ...compressedFiles])
setPreviewUrls((prev) => [...prev, ...urls])
setError('')
// 清空 input,允许重复选择同一文件
if (e.target) {
e.target.value = ''
}
},
(error) => setError(error)
)
}
const handleDisplayImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (displayImageUrl) URL.revokeObjectURL(displayImageUrl)
await handleDisplayImageInput(
file,
(file, url) => {
setDisplayImage(file)
setDisplayImageUrl(url)
setError('')
},
(error) => setError(error)
)
}
// 处理模特类型的多图上传(压缩 + 切片)
const handleModelImagesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
await handleModelImagesInput(
files,
(processedFiles, urls) => {
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
URL.revokeObjectURL(modelImageUrls[index])
setModelImages(newImages)
setModelImageUrls(newUrls)
}
// 根据文件扩展名自动识别素材类型
const handleZipChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
// 过滤有效文件
const validFiles = files.filter(f => isValidMainFileType(f, materialType))
if (validFiles.length === 0) {
const common = '压缩包格式(.zip / .rar / .7z)'
const videoTip = '视频文件(.mp4/.mov/.avi/.mkv/.mts/.m4v)'
const audioTip = '音频文件(.mp3/.wav/.flac/.m4a/.aac/.ogg)'
const brandTip = 'PDF 文件(.pdf)'
const detailTip = 'PSD/TIF/PSB 文件或压缩包(.psd / .tif / .tiff / .psb / .zip / .rar / .7z)'
const message =
materialType === '音乐'
? audioTip
: ['主图视频', '安装视频', '种草视频'].includes(materialType)
? videoTip
: materialType === '品牌VI灵感'
? brandTip
: (materialType === '详情页' || materialType === '详情模板')
? detailTip
: common
setError(`请选择有效的素材文件,支持:${message}`)
// 清空input,允许重复选择
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)
// 清空input,允许重复选择
if (e.target) {
e.target.value = ''
}
return
}
// 如果选择多个文件,进入批量上传模式
if (validFiles.length > 1) {
handleBatchFilesAdd(validFiles)
// 清空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('')
// 清空input,允许重复选择
if (e.target) {
e.target.value = ''
}
}
// 拖拽处理 - 修复闪屏问题
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
// 只有当鼠标真正离开容器时才设置为false,避免经过子元素时闪屏
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
setIsDragging(false)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
// 只处理素材文件(包括压缩包/视频/音频/PDF 视类型而定)
// 图片文件应该拖拽到对应的预览图或展示图区域
const materialFiles = files.filter((f) => isValidMainFileType(f, materialType))
if (materialFiles.length > 0) {
// 如果已经有文件(单文件或批量模式),追加到批量模式
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)
return
}
// 如果拖入多个文件,进入批量上传模式
if (materialFiles.length > 1) {
handleBatchFilesAdd(materialFiles)
} else {
// 单个文件,且没有已有文件,使用单文件模式
const file = materialFiles[0]
setZipFile(file)
setIsBatchMode(false)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
setError('')
}
} else {
setError('请拖拽素材文件到此区域,图片请拖拽到对应的图片上传区域')
}
}
// 处理批量文件添加
const handleBatchFilesAdd = (files: File[]) => {
// 检查文件数量限制
const limit = getBatchLimit(materialType)
if (limit !== Infinity && files.length > limit) {
setError(`最多只能上传 ${limit} 个文件`)
return
}
// 过滤掉无效文件
const validFiles = files.filter(f => isValidMainFileType(f, materialType))
if (validFiles.length === 0) {
setError('请选择有效的素材文件')
return
}
// 创建批量文件项
const newBatchFiles = createBatchFileItems(
validFiles,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer
)
setBatchFiles([...batchFiles, ...newBatchFiles])
setIsBatchMode(true)
setError('')
}
// 预览图片拖拽处理
const handlePreviewDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsPreviewDragging(true)
}
const handlePreviewDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
setIsPreviewDragging(false)
}
}
const handlePreviewDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handlePreviewDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsPreviewDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
if (imageFiles.length > 0) {
// 支持多图拖拽
const dt = { target: { files: imageFiles } } as unknown as React.ChangeEvent<HTMLInputElement>
void handlePreviewChange(dt)
setError('')
} else {
setError('请拖拽图片文件')
}
}
// 删除预览图片
const removePreviewImage = (index: number) => {
const newImages = previewImages.filter((_, i) => i !== index)
const newUrls = previewUrls.filter((_, i) => i !== index)
// 释放被删除图片的URL
URL.revokeObjectURL(previewUrls[index])
setPreviewImages(newImages)
setPreviewUrls(newUrls)
}
// 详情页展示图片拖拽处理
const handleDisplayImageDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDisplayImageDragging(true)
}
const handleDisplayImageDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
setIsDisplayImageDragging(false)
}
}
const handleDisplayImageDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDisplayImageDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDisplayImageDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
if (imageFiles.length > 0) {
const imgFile = imageFiles[0]
// 复用选择逻辑(包含压缩 + 切片)
const dt = { target: { files: [imgFile] } } as unknown as React.ChangeEvent<HTMLInputElement>
void handleDisplayImageChange(dt)
setError('')
} else {
setError('请拖拽图片文件')
}
}
// 买家秀批量图片选择处理
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>) => {
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 {
await uploadBatchMusic(batchMusicFiles, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
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
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 || '',
}
// 添加渲染器(3D场景)
if (requiresRenderer(materialType) && item.renderer) {
metadata.renderer = item.renderer
}
// 处理预览图(如果需要)
// 详情页/详情模板:预览图可选,如果上传了则使用
const needsPreview = !PREVIEW_OPTIONAL_TYPES.includes(materialType)
if (needsPreview && item.previewImage) {
metadata.previewImage = await fileToBase64String(item.previewImage)
} else if ((materialType === '详情页' || materialType === '详情模板') && item.previewImage) {
// 详情页/详情模板:如果用户上传了预览图,也处理
metadata.previewImage = await fileToBase64String(item.previewImage)
}
// 判断是否需要分片上传
const useChunk = item.file.size >= CHUNK_THRESHOLD
if (useChunk) {
// 分片上传
const result = await chunkUpload(item.file, metadata, {
chunkSize: 5 * 1024 * 1024,
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)
}
},
skipComplete: true,
maxRetries: Infinity,
})
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 (item.previewImage) {
formData.append('previewImage', item.previewImage)
}
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 = batchUploadStatus?.fileStatuses.filter(s => s.status === 'failed') || []
if (failedFiles.length > 0) {
setError(`${failedFiles.length} 个文件上传失败,请检查后重试`)
} else {
// 清理预览图URL
batchFiles.forEach(item => {
if (item.previewImageUrl) {
URL.revokeObjectURL(item.previewImageUrl)
}
})
setBatchFiles([])
setIsBatchMode(false)
setUploadProgress(100)
onSuccess()
onClose()
}
} catch (error) {
console.error('[Upload] 批量上传失败:', error)
setError('批量上传失败,请重试')
} finally {
setUploading(false)
batchUploadXhrRefs.current.clear()
}
}
// 批量上传视频文件(种草视频)
const handleBatchVideoUpload = async () => {
if (batchVideoFiles.length === 0) return
setUploading(true)
setIsProcessing(false)
setUploadProgress(0)
setError('')
try {
await uploadBatchVideo(batchVideoFiles, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
setUploadProgress(100)
setUploading(false)
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 {
await uploadBatch3D(batch3DFiles, categoryName, sceneName, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
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 {
await uploadBatchBuyerShow(batchBuyerShowFiles, categoryName, sceneName, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
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 {
await uploadBatchXiaohongshu(batchXiaohongshuFiles, categoryName, sceneName, platformName, renderer, (progress) => {
setUploadProgress(progress)
})
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 {
await uploadBatchBrandVI(batchBrandVIFiles, productCategoryName, (progress) => {
setUploadProgress(progress)
})
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 (isBatchMode && batchFiles.length > 0) {
// 验证所有文件的必填字段
const invalidFiles = batchFiles.filter(item => {
if (!item.productModel.trim()) return true
// 检查预览图(如果需要)
// 详情页/详情模板:预览图可选,不强制要求
const needsPreview = !PREVIEW_OPTIONAL_TYPES.includes(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${!PREVIEW_OPTIONAL_TYPES.includes(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 (materialType === '种草视频' && batchVideoFiles.length > 0) {
const invalidFiles = batchVideoFiles.filter(item => !item.productModel.trim())
if (invalidFiles.length > 0) {
setError('请为所有视频文件填写SPU型号')
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 - 基础信息验证(单文件上传)
if (!productModel) {
setError(materialType === '材质' ? '请填写材质名称' : '请填写SPU型号')
return
}
// 任务号验证(可选,仅验证格式,不强制要求)
if (taskNo.trim() && !validateTaskNo(taskNo)) {
setError('任务号格式错误,应为 TASK + 12-18位数字,或 TASK + 12-18位数字 + -2位子任务号')
return
}
// 任务号为空时仅提醒,不阻止上传(已在UI中显示提醒)
// 使用统一的验证逻辑
if (requiresProductCategoryName(materialType) && !productCategoryName) {
const errorMsg = materialType === '标准模型'
? '标准模型类型必须选择品类'
: '品牌VI灵感类型必须选择品类'
setError(errorMsg)
return
}
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
// 素材文件验证(模特类型和标准模型素材文件可选)
if (materialType !== '模特' && materialType !== '标准模型' && !zipFile) {
setError('请上传素材文件')
return
}
// 图片验证
if ((materialType === '详情页' || materialType === '详情模板') && !displayImage) {
setError('详情页/详情模板素材必须上传展示图片')
return
}
if (materialType === '模特' && modelImages.length === 0) {
setError('模特素材必须上传至少一张展示图片')
return
}
const needsPreview = !PREVIEW_OPTIONAL_TYPES.includes(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)
try {
// 大文件走分片,小文件继续直传
const chunkResult = await uploadSingleFile({
zipFile,
previewImages,
displayImage,
modelImages,
productModel,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer,
onProgress: (progress, speed) => {
setUploadProgress(progress)
if (speed) setUploadSpeed(speed)
},
onStageChange: (stage) => {
if (stage === 'processing') {
setUploadProgress(100)
}
},
})
const formData = buildFormData({
previewImages,
displayImage,
modelImages,
zipFile: chunkResult ? null : zipFile, // 如果已分片上传,则不传文件
productModel,
materialType,
categoryName,
sceneName,
platformName,
productCategoryName,
renderer,
taskNo,
})
// 分片后主文件已在服务器,添加路径信息
if (chunkResult?.existingFilePath) {
formData.append('existingFilePath', chunkResult.existingFilePath)
formData.append('originalFileName', zipFile?.name || '')
}
await submitFormData(formData, {
onProgress: (progress, speed) => {
setUploadProgress(progress)
if (speed) setUploadSpeed(speed)
},
onProcessing: (isProc) => setIsProcessing(isProc),
onError: (error) => setError(error),
onSuccess: () => {
setUploading(false)
onSuccess()
onClose()
},
materialType,
})
} 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)
}
}
setError((error as Error)?.message || '上传失败,请稍后再试')
setUploading(false)
}
}
const handleCancelUpload = () => {
if (xhrRef.current && uploading) {
xhrRef.current.abort()
} else {
onClose()
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="bg-white dark:bg-gray-800 rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg"
>
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">上传素材</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
×
</button>
</div>
<div className="space-y-1 mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
💡 提示:可拖拽文件到对应的虚线框内上传
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
提示:如在内网环境,请通过顶部提示切换到内网地址以获得更快速度
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 素材类型 - 第一栏,让用户先选择类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
素材类型 *
</label>
<select
value={materialType}
onChange={(e) => setMaterialType(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
>
<option value="3D场景">3D场景</option>
<option value="详情页">详情页</option>
<option value="详情模板">详情模板</option>
<option value="材质">材质</option>
<option value="模特">模特</option>
<option value="实拍图片">实拍图片</option>
<option value="买家秀图片">买家秀图片</option>
<option value="小红书种草">小红书种草</option>
<option value="主图模版">主图模版</option>
<option value="SKU模版">SKU模版</option>
<option value="主图视频">主图视频</option>
<option value="安装视频">安装视频</option>
<option value="种草视频">种草视频</option>
<option value="音乐">音乐</option>
<option value="C4D模型">C4D模型</option>
<option value="标准饰品">标准饰品</option>
<option value="标准模型">标准模型</option>
<option value="品牌VI灵感">品牌VI灵感</option>
<option value="矢量文件">矢量文件</option>
<option value="产品全案">产品全案</option>
</select>
</div>
{/* 任务号输入框(产品全案不显示,因为产品全案有自己的任务号输入) */}
{materialType !== '产品全案' && (
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01"
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
)}
{/* 产品全案上传组件 */}
{materialType === '产品全案' && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<ProductPackageUploader
onSuccess={() => {
onSuccess();
onClose();
}}
onCancel={onClose}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
onRefreshCategories={fetchCategories}
onRefreshScenes={fetchScenes}
/>
</div>
)}
{/* 批量上传模式:可折叠文件列表 */}
{materialType !== '产品全案' && isBatchMode && batchFiles.length > 0 && (
<BatchFileList
batchFiles={batchFiles}
materialType={materialType}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
renderer={renderer}
batchPreviewDragging={batchPreviewDragging}
onToggleExpand={(index) => {
const newFiles = [...batchFiles]
newFiles[index].expanded = !newFiles[index].expanded
setBatchFiles(newFiles)
}}
onDelete={(index) => {
setBatchFiles(batchFiles.filter((_, i) => i !== index))
if (batchFiles.length === 1) {
setIsBatchMode(false)
}
}}
onUpdateProductModel={(index, value) => {
const newFiles = [...batchFiles]
if (materialType === '材质') {
newFiles[index].productModel = value
} else {
newFiles[index].productModel = value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
}
setBatchFiles(newFiles)
}}
onUpdatePreviewImage={(index, file) => {
const newFiles = [...batchFiles]
newFiles[index].previewImage = file
newFiles[index].previewImageUrl = URL.createObjectURL(file)
setBatchFiles(newFiles)
}}
onRemovePreviewImage={(index) => {
const newFiles = [...batchFiles]
newFiles[index].previewImage = undefined
if (newFiles[index].previewImageUrl) {
URL.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={() => {
setBatchFiles([])
setIsBatchMode(false)
}}
onPreviewDragEnter={(itemId) => {
setBatchPreviewDragging({ ...batchPreviewDragging, [itemId]: true })
}}
onPreviewDragLeave={(itemId) => {
setBatchPreviewDragging({ ...batchPreviewDragging, [itemId]: false })
}}
/>
)}
{/* 预览图片(产品全案不显示,批量模式下隐藏) */}
{materialType !== '产品全案' && !isBatchMode && (
<PreviewImageUpload
materialType={materialType}
previewImages={previewImages}
previewUrls={previewUrls}
isPreviewDragging={isPreviewDragging}
previewInputRef={previewInputRef}
previewDropZoneRef={previewDropZoneRef}
onPreviewChange={handlePreviewChange}
onPreviewDragEnter={handlePreviewDragEnter}
onPreviewDragLeave={handlePreviewDragLeave}
onPreviewDragOver={handlePreviewDragOver}
onPreviewDrop={handlePreviewDrop}
onRemovePreviewImage={removePreviewImage}
/>
)}
{/* 详情页展示图片(详情页/详情模板类型显示,品牌VI灵感自动从PDF提取,不需要手动上传) */}
{(materialType === '详情页' || materialType === '详情模板') && (
<DisplayImageUpload
displayImageUrl={displayImageUrl}
isDisplayImageDragging={isDisplayImageDragging}
displayImageDropZoneRef={displayImageDropZoneRef}
onDisplayImageChange={handleDisplayImageChange}
onDisplayImageDragEnter={handleDisplayImageDragEnter}
onDisplayImageDragLeave={handleDisplayImageDragLeave}
onDisplayImageDragOver={handleDisplayImageDragOver}
onDisplayImageDrop={handleDisplayImageDrop}
materialType={materialType}
/>
)}
{/* 品牌VI灵感提示:预览图会自动从PDF提取 */}
{materialType === '品牌VI灵感' && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 提示:系统会自动从PDF文件的第一页提取预览图,无需手动上传预览图或展示图。
</p>
</div>
)}
{/* 模特展示图片(仅模特类型显示) */}
{materialType === '模特' && (
<ModelImagesUpload
modelImageUrls={modelImageUrls}
onModelImagesChange={handleModelImagesChange}
onRemoveModelImage={removeModelImage}
/>
)}
{/* 表单字段 - 使用 UploadForm 组件(产品全案不显示) */}
{materialType !== '产品全案' && (
<UploadForm
materialType={materialType}
productModel={productModel}
categoryName={categoryName}
sceneName={sceneName}
platformName={platformName}
productCategoryName={productCategoryName}
renderer={renderer}
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
isBatchMode={isBatchMode}
onProductModelChange={setProductModel}
onCategoryChange={setCategoryName}
onSceneChange={setSceneName}
onPlatformChange={setPlatformName}
onProductCategoryChange={setProductCategoryName}
onRendererChange={setRenderer}
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(产品全案不显示) */}
{materialType !== '产品全案' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
素材文件 {(materialType === '模特' || materialType === '标准模型') ? '(可选)' : '*'}
{/* 批量上传提示 */}
{materialType === '音乐' && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
(支持批量上传,最多100个)
</span>
)}
{materialType === '种草视频' && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
(支持批量上传,最多8个)
</span>
)}
{materialType === '3D场景' && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
(支持批量上传,最多5个)
</span>
)}
{materialType === '买家秀图片' && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
(支持批量上传,每批次至少8张)
</span>
)}
{materialType === '小红书种草' && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
(支持批量上传)
</span>
)}
{materialType === '品牌VI灵感' && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
(支持批量上传,最多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))
}}
/>
)}
{(materialType === '种草视频' && batchVideoFiles.length > 0) && (
<BatchVideoList
files={batchVideoFiles}
onUpdateProductModel={(index, value) => {
const newFiles = [...batchVideoFiles]
newFiles[index].productModel = value
setBatchVideoFiles(newFiles)
}}
onDelete={(index) => {
setBatchVideoFiles(batchVideoFiles.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 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
⚠️ 请在上方选择品类(所有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) ||
(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}
className={`border-2 border-dashed rounded-apple p-6 text-center transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{zipFile ? (
<div className="space-y-2">
<p className="text-gray-700 dark:text-gray-300 font-medium">{zipFile.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{formatBytes(zipFile.size)}
</p>
{detectMaterialType(zipFile.name) && (
<p className="text-sm text-green-600 dark:text-green-400">
已自动识别类型: {detectMaterialType(zipFile.name)}
</p>
)}
<button
type="button"
onClick={() => setZipFile(null)}
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
移除
</button>
</div>
) : (
<div>
<input
ref={zipInputRef}
type="file"
onChange={handleZipChange}
accept={getMainFileAccept(materialType)}
className="hidden"
/>
{/* 批量上传文件选择 */}
{(materialType === '音乐' || materialType === '种草视频' || materialType === '3D场景' || materialType === '品牌VI灵感') && (
<>
<input
ref={batchFileInputRef}
type="file"
onChange={handleBatchFileSelect}
accept={getMainFileAccept(materialType)}
multiple
className="hidden"
/>
<button
type="button"
onClick={() => batchFileInputRef.current?.click()}
className="btn-secondary mr-2"
>
{materialType === '音乐' && '选择音乐文件(可多选)'}
{materialType === '种草视频' && '选择视频文件(可多选,最多8个)'}
{materialType === '3D场景' && '选择3D文件或压缩包(可多选,最多5个)'}
{materialType === '品牌VI灵感' && '选择PDF文件(可多选,最多50个)'}
</button>
</>
)}
<button
type="button"
onClick={() => zipInputRef.current?.click()}
className="btn-secondary"
>
选择素材文件
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持格式:
{materialType === '音乐' && '音频文件(.mp3 / .wav / .flac / .m4a / .aac / .ogg)'}
{['主图视频', '安装视频', '种草视频'].includes(materialType) &&
'视频文件(.mp4 / .mov / .avi / .mkv / .mts / .m4v)'}
{materialType === '品牌VI灵感' && 'PDF 文件(.pdf)'}
{(materialType === '详情页' || materialType === '详情模板') &&
'PSD/TIF/PSB 文件或压缩包(.psd / .tif / .tiff / .psb / .zip / .rar / .7z)'}
{!['音乐', '主图视频', '安装视频', '种草视频', '品牌VI灵感', '详情页', '详情模板'].includes(materialType) &&
'压缩包(.zip / .rar / .7z)'}
</p>
</div>
)}
</div>
)}
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<UploadProgress
uploading={uploading}
isBatchMode={isBatchMode}
batchUploadStatus={batchUploadStatus}
uploadProgress={uploadProgress}
uploadSpeed={uploadSpeed}
isProcessing={isProcessing}
onCancel={handleCancelUpload}
/>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-apple">
{error}
</div>
)}
{/* Buttons(产品全案不显示,因为有自己的按钮) */}
{materialType !== '产品全案' && (
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={handleCancelUpload}
className="btn-secondary"
>
{uploading ? '取消上传' : '取消'}
</button>
<button
type="submit"
className="btn-primary"
disabled={uploading}
>
{uploading ? (isProcessing ? '处理中...' : `上传中 ${uploadProgress}%`) : '上传'}
</button>
</div>
)}
</form>
</div>
</div>
)
}