主要修复: - 修复批量上传闭包陷阱,防止文件丢失 - 添加 fileName 参数传递原始文件名,保留正确扩展名 - 添加取消检查回调,改进批量上传取消功能 其他改进: - 优化搜索功能和管理后台 - 添加 FRP 故障诊断文档 - 更新环境验证和网络检测
2266 lines
85 KiB
TypeScript
2266 lines
85 KiB
TypeScript
'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 { parseSPU } from '@/lib/spu-parser'
|
||
import UploadForm from '@/components/upload/UploadForm'
|
||
import {
|
||
BatchMusicList,
|
||
BatchVideoList,
|
||
Batch3DList,
|
||
BatchBrandVIList,
|
||
BatchBuyerShowUI,
|
||
BatchXiaohongshuUI,
|
||
} from '@/components/upload/SpecialBatchUploadUI'
|
||
import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
|
||
import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader'
|
||
import TaskPageUploader from '@/components/upload/TaskPageUploader'
|
||
|
||
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; productCategoryName?: 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场景')
|
||
|
||
// 可见的素材类型列表(从API动态加载)
|
||
const [availableMaterialTypes, setAvailableMaterialTypes] = useState<string[]>([])
|
||
|
||
// 渲染器(仅3D场景使用)
|
||
const [renderer, setRenderer] = useState('CR')
|
||
|
||
// 任务号(所有素材类型支持)
|
||
const [taskNo, setTaskNo] = useState('')
|
||
|
||
// SKU号(视频类素材支持)
|
||
const [sku, setSku] = 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 singleUploadCancelRef = useRef<{ cancelled: boolean }>({ cancelled: false }) // 单文件上传取消标志
|
||
const singleUploadXhrsRef = useRef<Set<XMLHttpRequest>>(new Set()) // 单文件上传的所有活跃XHR
|
||
const batchFileInputRef = useRef<HTMLInputElement>(null) // 批量文件选择input
|
||
|
||
// 加载当前用户可见的素材类型
|
||
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[]
|
||
setAvailableMaterialTypes(types)
|
||
|
||
// 如果当前选中的类型不在可见列表中,重置为第一个可见类型
|
||
if (types.length > 0 && !types.includes(materialType)) {
|
||
setMaterialType(types[0])
|
||
}
|
||
} else {
|
||
console.error('获取可见素材类型失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('获取可见素材类型失败:', error)
|
||
}
|
||
}
|
||
loadViewableTypes()
|
||
}, []) // 只在组件挂载时执行一次
|
||
|
||
// 自动填充"通用"店铺/场景
|
||
useEffect(() => {
|
||
if (shouldAutoFillGeneral(materialType)) {
|
||
setCategoryName('通用')
|
||
setSceneName('通用')
|
||
}
|
||
}, [materialType])
|
||
|
||
// 切换素材类型时清空批量上传数据
|
||
useEffect(() => {
|
||
setBatchFiles([])
|
||
setBatchBuyerShowFiles([])
|
||
setBatchXiaohongshuFiles([])
|
||
setZipFile(null)
|
||
setProductModel('')
|
||
setIsBatchMode(false)
|
||
setTaskNo('') // 切换素材类型时清空任务号
|
||
setSku('') // 切换素材类型时清空SKU号
|
||
}, [materialType])
|
||
|
||
// 验证任务号格式(与后端保持一致)
|
||
// 支持格式: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>) => {
|
||
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 === '详情模板'
|
||
? 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 {
|
||
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
|
||
|
||
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 === '详情模板' && item.previewImage) {
|
||
// 详情模板:如果用户上传了预览图,也处理
|
||
metadata.previewImage = await fileToBase64String(item.previewImage)
|
||
}
|
||
|
||
// 判断是否需要分片上传
|
||
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: 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 (sku) {
|
||
formData.append('skuNo', sku)
|
||
}
|
||
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()
|
||
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)
|
||
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 (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位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)')
|
||
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 = !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)
|
||
singleUploadCancelRef.current.cancelled = false
|
||
singleUploadXhrsRef.current.clear()
|
||
|
||
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)
|
||
}
|
||
},
|
||
// 取消检查回调
|
||
isCancelled: () => singleUploadCancelRef.current.cancelled,
|
||
// 活跃XHR集合回调
|
||
onActiveXhrChange: (xhrs) => {
|
||
xhrs.forEach(xhr => singleUploadXhrsRef.current.add(xhr))
|
||
},
|
||
})
|
||
|
||
// 如果已取消,不继续后续操作
|
||
if (singleUploadCancelRef.current.cancelled) {
|
||
return
|
||
}
|
||
|
||
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,
|
||
// 暴露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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
setUploading(false)
|
||
setError('上传已取消')
|
||
} 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"
|
||
>
|
||
{availableMaterialTypes.length > 0 ? (
|
||
availableMaterialTypes.map((type) => (
|
||
<option key={type} value={type}>{type}</option>
|
||
))
|
||
) : (
|
||
// 加载中或失败时的后备选项(排除有专用上传组件的类型)
|
||
<>
|
||
<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="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>
|
||
{/* 注意:任务-详情、任务-视频截图和任务-页面有专用上传组件,不在这里列出 */}
|
||
</>
|
||
)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* 任务号输入框(任务-详情、任务-视频截图和任务-页面不显示,因为它们有自己的任务号输入) */}
|
||
{materialType !== '任务-详情' && materialType !== '任务-视频截图' && 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 === '任务-视频截图' && (
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||
<VideoScreenshotUploader
|
||
onSuccess={() => {
|
||
onSuccess();
|
||
onClose();
|
||
}}
|
||
onCancel={onClose}
|
||
categories={categories}
|
||
scenes={scenes}
|
||
platforms={platforms}
|
||
productCategories={productCategories}
|
||
onRefreshCategories={fetchCategories}
|
||
onRefreshScenes={fetchScenes}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 任务-页面上传组件 */}
|
||
{materialType === '任务-页面' && (
|
||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||
<TaskPageUploader
|
||
onSuccess={() => {
|
||
onSuccess();
|
||
onClose();
|
||
}}
|
||
onCancel={onClose}
|
||
categories={categories}
|
||
scenes={scenes}
|
||
platforms={platforms}
|
||
productCategories={productCategories}
|
||
onRefreshCategories={fetchCategories}
|
||
onRefreshScenes={fetchScenes}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 批量上传模式:可折叠文件列表 */}
|
||
{materialType !== '任务-详情' && 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]
|
||
let processedValue = value
|
||
if (materialType === '材质') {
|
||
newFiles[index].productModel = value
|
||
newFiles[index].productModelSource = 'manual' // 用户手动输入
|
||
} else {
|
||
processedValue = value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
|
||
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 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 !== '任务-详情' && 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 === '详情模板' && (
|
||
<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 !== '任务-详情' && materialType !== '任务-视频截图' && (
|
||
<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}
|
||
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(任务-详情和任务-视频截图不显示) */}
|
||
{materialType !== '任务-详情' && 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}
|
||
productCategories={productCategories}
|
||
onUpdateProductModel={(index, value) => {
|
||
const newFiles = [...batchVideoFiles]
|
||
newFiles[index].productModel = value
|
||
// SPU 自动识别品类
|
||
const result = parseSPU(value)
|
||
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)
|
||
}}
|
||
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 === '详情模板' &&
|
||
'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}
|
||
batchFiles={batchFiles}
|
||
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 !== '任务-详情' && materialType !== '任务-视频截图' && 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>
|
||
)
|
||
}
|