2704 lines
108 KiB
TypeScript
2704 lines
108 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,
|
||
convertFileToBase64,
|
||
detectMaterialType,
|
||
} from '@/components/upload/upload-utils'
|
||
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'
|
||
|
||
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[] }>>([])
|
||
|
||
// 一级分类
|
||
const [categoryName, setCategoryName] = useState('')
|
||
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
|
||
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
|
||
const [newCategoryName, setNewCategoryName] = useState('')
|
||
|
||
// 二级分类
|
||
const [sceneName, setSceneName] = useState('')
|
||
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
|
||
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
|
||
const [newSceneName, setNewSceneName] = useState('')
|
||
|
||
// 平台分类(可选)
|
||
const [platformName, setPlatformName] = useState('')
|
||
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string }>>([])
|
||
|
||
// 品类分类(标准模型必填)
|
||
const [productCategoryName, setProductCategoryName] = useState('')
|
||
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string }>>([])
|
||
|
||
// 素材类型
|
||
const [materialType, setMaterialType] = useState('3D场景')
|
||
|
||
// 渲染器(仅3D场景使用)
|
||
const [renderer, setRenderer] = useState('CR')
|
||
|
||
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(() => {
|
||
fetchCategories()
|
||
fetchScenes()
|
||
fetchPlatforms()
|
||
fetchProductCategories()
|
||
}, [])
|
||
|
||
const fetchCategories = async () => {
|
||
try {
|
||
const response = await fetch('/api/categories')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCategories(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching categories:', error)
|
||
}
|
||
}
|
||
|
||
const fetchScenes = async () => {
|
||
try {
|
||
const response = await fetch('/api/scenes')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setScenes(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching scenes:', error)
|
||
}
|
||
}
|
||
|
||
const fetchPlatforms = async () => {
|
||
try {
|
||
const response = await fetch('/api/platforms')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setPlatforms(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching platforms:', error)
|
||
}
|
||
}
|
||
|
||
const fetchProductCategories = async () => {
|
||
try {
|
||
const response = await fetch('/api/product-categories')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setProductCategories(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching product categories:', error)
|
||
}
|
||
}
|
||
|
||
|
||
// 自动填充"通用"店铺/场景
|
||
useEffect(() => {
|
||
if (shouldAutoFillGeneral(materialType)) {
|
||
setCategoryName('通用')
|
||
setSceneName('通用')
|
||
}
|
||
}, [materialType])
|
||
|
||
// 切换素材类型时清空批量上传数据
|
||
useEffect(() => {
|
||
setBatchFiles([])
|
||
setBatchBuyerShowFiles([])
|
||
setBatchXiaohongshuFiles([])
|
||
setZipFile(null)
|
||
setProductModel('')
|
||
setIsBatchMode(false)
|
||
}, [materialType])
|
||
|
||
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,
|
||
(processedFile, url) => {
|
||
setDisplayImage(processedFile)
|
||
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 message =
|
||
materialType === '音乐'
|
||
? audioTip
|
||
: ['主图视频', '安装视频', '种草视频'].includes(materialType)
|
||
? videoTip
|
||
: materialType === '品牌VI灵感'
|
||
? brandTip
|
||
: 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 handleAddNewCategory = async () => {
|
||
await addCategory(
|
||
newCategoryName,
|
||
async (newCategory) => {
|
||
await fetchCategories()
|
||
setCategoryName(newCategory.name)
|
||
setNewCategoryName('')
|
||
setShowNewCategoryInput(false)
|
||
setError('')
|
||
},
|
||
(error) => setError(error)
|
||
)
|
||
}
|
||
|
||
const handleAddNewScene = async () => {
|
||
await addScene(
|
||
newSceneName,
|
||
async (newScene) => {
|
||
await fetchScenes()
|
||
setSceneName(newScene.name)
|
||
setNewSceneName('')
|
||
setShowNewSceneInput(false)
|
||
setError('')
|
||
},
|
||
(error) => setError(error)
|
||
)
|
||
}
|
||
|
||
|
||
// 买家秀批量图片选择处理
|
||
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
|
||
|
||
const validFiles = files.filter(f => isValidMainFileType(f, materialType))
|
||
if (validFiles.length === 0) {
|
||
setError('请选择有效的素材文件')
|
||
return
|
||
}
|
||
|
||
// 根据素材类型处理批量文件
|
||
if (materialType === '音乐') {
|
||
if (batchMusicFiles.length + validFiles.length > BATCH_LIMITS.music) {
|
||
setError(`最多只能上传${BATCH_LIMITS.music}个音乐文件`)
|
||
return
|
||
}
|
||
const newFiles = validFiles.map(file => ({
|
||
file,
|
||
productModel: file.name.substring(0, file.name.lastIndexOf('.')) || '', // 从文件名提取SPU
|
||
}))
|
||
setBatchMusicFiles(prev => [...prev, ...newFiles])
|
||
} else if (materialType === '种草视频') {
|
||
if (batchVideoFiles.length + validFiles.length > BATCH_LIMITS.video) {
|
||
setError(`最多只能上传${BATCH_LIMITS.video}个视频文件`)
|
||
return
|
||
}
|
||
const newFiles = validFiles.map(file => ({
|
||
file,
|
||
productModel: file.name.substring(0, file.name.lastIndexOf('.')) || '',
|
||
}))
|
||
setBatchVideoFiles(prev => [...prev, ...newFiles])
|
||
} else if (materialType === '3D场景') {
|
||
if (batch3DFiles.length + validFiles.length > BATCH_LIMITS['3d']) {
|
||
setError(`最多只能上传${BATCH_LIMITS['3d']}个3D场景文件`)
|
||
return
|
||
}
|
||
const newFiles = validFiles.map(file => ({
|
||
file,
|
||
productModel: file.name.substring(0, file.name.lastIndexOf('.')) || '',
|
||
}))
|
||
setBatch3DFiles(prev => [...prev, ...newFiles])
|
||
} else if (materialType === '品牌VI灵感') {
|
||
if (batchBrandVIFiles.length + validFiles.length > BATCH_LIMITS.brandVI) {
|
||
setError(`最多只能上传${BATCH_LIMITS.brandVI}个PDF文件`)
|
||
return
|
||
}
|
||
const newFiles = validFiles.map(file => ({
|
||
file,
|
||
productModel: file.name.substring(0, file.name.lastIndexOf('.')) || '',
|
||
}))
|
||
setBatchBrandVIFiles(prev => [...prev, ...newFiles])
|
||
}
|
||
|
||
setError('')
|
||
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)
|
||
setError('批量上传失败,请重试')
|
||
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) && materialType !== '详情页'
|
||
if (needsPreview && item.previewImage) {
|
||
metadata.previewImage = await convertFileToBase64(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)
|
||
|
||
setBatchUploadStatus(prev => {
|
||
if (!prev) return prev
|
||
const newStatuses = [...prev.fileStatuses]
|
||
newStatuses[fileIndex] = {
|
||
...newStatuses[fileIndex],
|
||
status: 'failed',
|
||
error: 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)
|
||
setError('批量上传失败,请重试')
|
||
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)
|
||
setError('批量上传失败,请重试')
|
||
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)
|
||
setError('批量上传失败,请重试')
|
||
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)
|
||
setError('批量上传失败,请重试')
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
// 批量上传品牌VI
|
||
const handleBatchBrandVIUpload = async () => {
|
||
if (batchBrandVIFiles.length === 0) return
|
||
|
||
setUploading(true)
|
||
setIsProcessing(false)
|
||
setUploadProgress(0)
|
||
setError('')
|
||
|
||
try {
|
||
await uploadBatchBrandVI(batchBrandVIFiles, platformName, renderer, (progress) => {
|
||
setUploadProgress(progress)
|
||
})
|
||
|
||
setUploadProgress(100)
|
||
setUploading(false)
|
||
setBatchBrandVIFiles([])
|
||
onSuccess()
|
||
onClose()
|
||
} catch (error) {
|
||
console.error('[Upload] 批量上传失败:', error)
|
||
setError('批量上传失败,请重试')
|
||
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) && 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) && 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
|
||
}
|
||
await handleBatchBrandVIUpload()
|
||
return
|
||
}
|
||
|
||
// Validation - 基础信息验证(单文件上传)
|
||
if (!productModel) {
|
||
setError(materialType === '材质' ? '请填写材质名称' : '请填写SPU型号')
|
||
return
|
||
}
|
||
|
||
// 使用统一的验证逻辑
|
||
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)
|
||
|
||
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,
|
||
})
|
||
|
||
// 分片后主文件已在服务器,添加路径信息
|
||
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)
|
||
setError((error as Error)?.message || '上传失败,请稍后再试')
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
|
||
const handleCancelUpload = () => {
|
||
if (xhrRef.current && uploading) {
|
||
xhrRef.current.abort()
|
||
} else {
|
||
onClose()
|
||
}
|
||
}
|
||
|
||
const isAutoGeneralType = shouldAutoFillGeneral(materialType)
|
||
|
||
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="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>
|
||
|
||
{/* 批量上传模式:可折叠文件列表 */}
|
||
{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 })
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 临时保留旧代码用于兼容 */}
|
||
{false && batchFiles.length > 0 && (
|
||
<div className="mb-4 space-y-2">
|
||
<div className="space-y-2 max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{batchFiles.map((item, index) => (
|
||
<div key={item.id} className="border border-gray-200 dark:border-gray-700 rounded-apple overflow-hidden">
|
||
{/* 文件行(可点击展开/收起) */}
|
||
<div
|
||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||
onClick={() => {
|
||
const newFiles = [...batchFiles]
|
||
newFiles[index].expanded = !newFiles[index].expanded
|
||
setBatchFiles(newFiles)
|
||
}}
|
||
>
|
||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||
<svg
|
||
className={`w-5 h-5 text-gray-400 transition-transform ${item.expanded ? 'rotate-90' : ''}`}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||
</svg>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)} {item.productModel && `· SPU: ${item.productModel}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
setBatchFiles(batchFiles.filter((_, i) => i !== index))
|
||
if (batchFiles.length === 1) {
|
||
setIsBatchMode(false)
|
||
}
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm ml-2"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
|
||
{/* 展开的配置区域 */}
|
||
{item.expanded && (
|
||
<div className="p-4 bg-white dark:bg-gray-900 space-y-4 border-t border-gray-200 dark:border-gray-700">
|
||
{/* SPU型号 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchFiles]
|
||
if (materialType === '材质') {
|
||
newFiles[index].productModel = e.target.value
|
||
} else {
|
||
newFiles[index].productModel = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
|
||
}
|
||
setBatchFiles(newFiles)
|
||
}}
|
||
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"
|
||
placeholder="例如:XY-2024-001"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
|
||
{/* 预览图片(如果需要) */}
|
||
{!PREVIEW_OPTIONAL_TYPES.includes(materialType) && materialType !== '详情页' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
预览图片 *
|
||
</label>
|
||
{item.previewImageUrl ? (
|
||
<div className="relative w-20 h-20 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||
<Image
|
||
src={item.previewImageUrl}
|
||
alt="Preview"
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
const newFiles = [...batchFiles]
|
||
newFiles[index].previewImage = undefined
|
||
if (newFiles[index].previewImageUrl) {
|
||
URL.revokeObjectURL(newFiles[index].previewImageUrl)
|
||
}
|
||
newFiles[index].previewImageUrl = undefined
|
||
setBatchFiles(newFiles)
|
||
}}
|
||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={`border-2 border-dashed rounded-apple p-3 transition-colors h-20 flex items-center justify-center ${
|
||
batchPreviewDragging[item.id]
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'border-gray-300 dark:border-gray-600'
|
||
}`}
|
||
onDragEnter={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setBatchPreviewDragging({ ...batchPreviewDragging, [item.id]: true })
|
||
}}
|
||
onDragLeave={(e) => {
|
||
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) {
|
||
setBatchPreviewDragging({ ...batchPreviewDragging, [item.id]: false })
|
||
}
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
}}
|
||
onDrop={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setBatchPreviewDragging({ ...batchPreviewDragging, [item.id]: false })
|
||
const files = Array.from(e.dataTransfer.files)
|
||
const imageFile = files.find(f => f.type.startsWith('image/'))
|
||
if (imageFile) {
|
||
const newFiles = [...batchFiles]
|
||
newFiles[index].previewImage = imageFile
|
||
newFiles[index].previewImageUrl = URL.createObjectURL(imageFile)
|
||
setBatchFiles(newFiles)
|
||
}
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
document.getElementById(`batch-preview-${item.id}`)?.click()
|
||
}}
|
||
>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
const newFiles = [...batchFiles]
|
||
newFiles[index].previewImage = file
|
||
newFiles[index].previewImageUrl = URL.createObjectURL(file)
|
||
setBatchFiles(newFiles)
|
||
}
|
||
}}
|
||
className="hidden"
|
||
id={`batch-preview-${item.id}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
<div className="text-center">
|
||
<svg className="w-6 h-6 text-gray-400 mx-auto mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">拖拽或点击上传</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 使用店铺和场景类别(如果不是自动通用类型) */}
|
||
{requiresCategoryAndScene(materialType) && (
|
||
<>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
使用店铺 *
|
||
</label>
|
||
<select
|
||
value={item.categoryName}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchFiles]
|
||
newFiles[index].categoryName = e.target.value
|
||
setBatchFiles(newFiles)
|
||
}}
|
||
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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<option value="">选择使用店铺</option>
|
||
{categories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
场景类别 *
|
||
</label>
|
||
<select
|
||
value={item.sceneName}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchFiles]
|
||
newFiles[index].sceneName = e.target.value
|
||
setBatchFiles(newFiles)
|
||
}}
|
||
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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<option value="">选择场景类别</option>
|
||
{scenes.map((scene) => (
|
||
<option key={scene.id} value={scene.name}>
|
||
{scene.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 渲染器(仅3D场景) */}
|
||
{requiresRenderer(materialType) && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
渲染器 *
|
||
</label>
|
||
<select
|
||
value={item.renderer || 'CR'}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchFiles]
|
||
newFiles[index].renderer = e.target.value
|
||
setBatchFiles(newFiles)
|
||
}}
|
||
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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<option value="CR">CR</option>
|
||
<option value="VR">VR</option>
|
||
<option value="其它">其它</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 预览图片(非详情页类型才显示,批量模式下隐藏) */}
|
||
{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}
|
||
/>
|
||
)}
|
||
|
||
{/* 详情页展示图片(仅详情页类型显示) */}
|
||
{materialType === '详情页' && (
|
||
<DisplayImageUpload
|
||
displayImageUrl={displayImageUrl}
|
||
isDisplayImageDragging={isDisplayImageDragging}
|
||
displayImageDropZoneRef={displayImageDropZoneRef}
|
||
onDisplayImageChange={handleDisplayImageChange}
|
||
onDisplayImageDragEnter={handleDisplayImageDragEnter}
|
||
onDisplayImageDragLeave={handleDisplayImageDragLeave}
|
||
onDisplayImageDragOver={handleDisplayImageDragOver}
|
||
onDisplayImageDrop={handleDisplayImageDrop}
|
||
/>
|
||
)}
|
||
|
||
{/* 模特展示图片(仅模特类型显示) */}
|
||
{materialType === '模特' && (
|
||
<ModelImagesUpload
|
||
modelImageUrls={modelImageUrls}
|
||
onModelImagesChange={handleModelImagesChange}
|
||
onRemoveModelImage={removeModelImage}
|
||
/>
|
||
)}
|
||
|
||
{/* SPU 或材质名称(批量模式下隐藏) */}
|
||
{!isBatchMode && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={productModel}
|
||
onChange={(e) => {
|
||
// 材质类型支持中文,其他类型只允许英文、数字和常见符号
|
||
if (materialType === '材质') {
|
||
setProductModel(e.target.value)
|
||
} else {
|
||
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
|
||
setProductModel(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"
|
||
placeholder="例如:XY-2024-001"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 使用店铺(材质类型和标准模型不需要,批量模式下隐藏) */}
|
||
{!isBatchMode && requiresCategoryAndScene(materialType) && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
使用店铺 *
|
||
</label>
|
||
{showNewCategoryInput ? (
|
||
<div className="flex space-x-2">
|
||
<input
|
||
type="text"
|
||
value={newCategoryName}
|
||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||
className="flex-1 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"
|
||
placeholder="输入新店铺名称"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddNewCategory}
|
||
className="btn-primary"
|
||
>
|
||
添加
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowNewCategoryInput(false)
|
||
setNewCategoryName('')
|
||
}}
|
||
className="btn-secondary"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex space-x-2">
|
||
<select
|
||
value={categoryName}
|
||
onChange={(e) => setCategoryName(e.target.value)}
|
||
disabled={isAutoGeneralType}
|
||
className={`flex-1 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 ${isAutoGeneralType ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||
>
|
||
{isAutoGeneralType ? (
|
||
<option value="通用">通用</option>
|
||
) : (
|
||
<>
|
||
<option value="">选择使用店铺</option>
|
||
{categories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</>
|
||
)}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowNewCategoryInput(true)}
|
||
className="btn-secondary whitespace-nowrap"
|
||
disabled={isAutoGeneralType}
|
||
>
|
||
+ 新建店铺
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 场景类别(材质类型和标准模型不需要,批量模式下隐藏) */}
|
||
{!isBatchMode && requiresCategoryAndScene(materialType) && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
场景类别 *
|
||
</label>
|
||
{showNewSceneInput ? (
|
||
<div className="flex space-x-2">
|
||
<input
|
||
type="text"
|
||
value={newSceneName}
|
||
onChange={(e) => setNewSceneName(e.target.value)}
|
||
className="flex-1 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"
|
||
placeholder="输入新场景类别"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddNewScene}
|
||
className="btn-primary"
|
||
>
|
||
添加
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowNewSceneInput(false)
|
||
setNewSceneName('')
|
||
}}
|
||
className="btn-secondary"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex space-x-2">
|
||
<select
|
||
value={sceneName}
|
||
onChange={(e) => setSceneName(e.target.value)}
|
||
disabled={isAutoGeneralType}
|
||
className={`flex-1 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 ${isAutoGeneralType ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||
>
|
||
{isAutoGeneralType ? (
|
||
<option value="通用">通用</option>
|
||
) : (
|
||
<>
|
||
<option value="">选择场景类别</option>
|
||
{scenes.map((scene) => (
|
||
<option key={scene.id} value={scene.name}>
|
||
{scene.name}
|
||
</option>
|
||
))}
|
||
</>
|
||
)}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowNewSceneInput(true)}
|
||
className="btn-secondary whitespace-nowrap"
|
||
disabled={isAutoGeneralType}
|
||
>
|
||
+ 新建场景类别
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 品类分类(标准模型必填,其他类型可选,批量模式下隐藏) */}
|
||
{!isBatchMode && productCategories.length > 0 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
品类{requiresProductCategoryName(materialType) ? ' *' : '(可选)'}
|
||
</label>
|
||
<select
|
||
value={productCategoryName}
|
||
onChange={(e) => setProductCategoryName(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="">{requiresProductCategoryName(materialType) ? '请选择品类' : '不选择品类'}</option>
|
||
{productCategories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 平台分类(可选,仅在后台配置了平台时显示,标准模型不显示,批量模式下隐藏) */}
|
||
{!isBatchMode && platforms.length > 0 && !requiresProductCategoryName(materialType) && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
平台分类(可选)
|
||
</label>
|
||
<select
|
||
value={platformName}
|
||
onChange={(e) => setPlatformName(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="">不选择平台</option>
|
||
{platforms.map((platform) => (
|
||
<option key={platform.id} value={platform.name}>
|
||
{platform.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 渲染器(仅3D场景显示,批量模式下隐藏) */}
|
||
{!isBatchMode && requiresRenderer(materialType) && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
渲染器 *
|
||
</label>
|
||
<select
|
||
value={renderer}
|
||
onChange={(e) => setRenderer(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="CR">CR</option>
|
||
<option value="VR">VR</option>
|
||
<option value="其它">其它</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 标准模型说明 */}
|
||
{requiresProductCategoryName(materialType) && (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-apple p-4">
|
||
<p className="text-sm text-blue-700 dark:text-blue-300 font-medium mb-2">
|
||
💡 标准模型类型说明:
|
||
</p>
|
||
<ul className="text-xs text-blue-600 dark:text-blue-400 space-y-1 list-disc list-inside">
|
||
<li>以产品型号(SPU)为基准创建模型合集</li>
|
||
<li>上传时只需填写 SPU 型号和预览图</li>
|
||
<li>创建后在详情页添加各个 SKU 的模型文件</li>
|
||
<li>每个 SKU 需要单独的预览图和压缩包</li>
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Material File */}
|
||
<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>
|
||
|
||
{/* 批量文件列表显示 */}
|
||
{(materialType === '音乐' && batchMusicFiles.length > 0) && (
|
||
<div className="mb-4 space-y-3 max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{batchMusicFiles.map((item, index) => (
|
||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchMusicFiles]
|
||
newFiles[index].productModel = e.target.value
|
||
setBatchMusicFiles(newFiles)
|
||
}}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setBatchMusicFiles(batchMusicFiles.filter((_, i) => i !== index))
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{(materialType === '种草视频' && batchVideoFiles.length > 0) && (
|
||
<div className="mb-4 space-y-3 max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{batchVideoFiles.map((item, index) => (
|
||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchVideoFiles]
|
||
newFiles[index].productModel = e.target.value
|
||
setBatchVideoFiles(newFiles)
|
||
}}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setBatchVideoFiles(batchVideoFiles.filter((_, i) => i !== index))
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{(materialType === '3D场景' && batch3DFiles.length > 0) && (
|
||
<div className="mb-4 space-y-3 max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{batch3DFiles.map((item, index) => (
|
||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => {
|
||
const newFiles = [...batch3DFiles]
|
||
newFiles[index].productModel = e.target.value
|
||
setBatch3DFiles(newFiles)
|
||
}}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
const newFiles = [...batch3DFiles]
|
||
newFiles[index].previewImage = file
|
||
setBatch3DFiles(newFiles)
|
||
}
|
||
}}
|
||
className="hidden"
|
||
id={`3d-preview-${index}`}
|
||
/>
|
||
<label
|
||
htmlFor={`3d-preview-${index}`}
|
||
className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer"
|
||
>
|
||
{item.previewImage ? '已选预览图' : '选择预览图'}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setBatch3DFiles(batch3DFiles.filter((_, i) => i !== index))
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{(materialType === '品牌VI灵感' && batchBrandVIFiles.length > 0) && (
|
||
<div className="mb-4 space-y-3 max-h-96 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{batchBrandVIFiles.map((item, index) => (
|
||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchBrandVIFiles]
|
||
newFiles[index].productModel = e.target.value
|
||
setBatchBrandVIFiles(newFiles)
|
||
}}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setBatchBrandVIFiles(batchBrandVIFiles.filter((_, i) => i !== index))
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 买家秀批量上传UI */}
|
||
{materialType === '买家秀图片' && (
|
||
<div className="mb-4 space-y-4">
|
||
{batchBuyerShowFiles.map((batch, batchIndex) => (
|
||
<div key={batchIndex} className="border border-gray-200 dark:border-gray-700 rounded-apple p-4 bg-gray-50 dark:bg-gray-800">
|
||
<div className="flex items-center space-x-3 mb-3">
|
||
<input
|
||
type="text"
|
||
value={batch.productModel}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchBuyerShowFiles]
|
||
newFiles[batchIndex].productModel = e.target.value
|
||
setBatchBuyerShowFiles(newFiles)
|
||
}}
|
||
className="flex-1 px-3 py-2 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号 *"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handleBatchBuyerShowImageSelect(e, batchIndex)}
|
||
className="hidden"
|
||
id={`buyer-show-images-${batchIndex}`}
|
||
/>
|
||
<label
|
||
htmlFor={`buyer-show-images-${batchIndex}`}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
选择图片(至少8张)
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setBatchBuyerShowFiles(batchBuyerShowFiles.filter((_, i) => i !== batchIndex))
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除批次
|
||
</button>
|
||
</div>
|
||
{batch.images.length > 0 && (
|
||
<div className="grid grid-cols-4 gap-2 mt-3">
|
||
{batch.images.map((img, imgIndex) => (
|
||
<div key={imgIndex} className="relative group">
|
||
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||
<Image
|
||
src={URL.createObjectURL(img)}
|
||
alt={`图片 ${imgIndex + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newFiles = [...batchBuyerShowFiles]
|
||
newFiles[batchIndex].images = newFiles[batchIndex].images.filter((_, i) => i !== imgIndex)
|
||
setBatchBuyerShowFiles(newFiles)
|
||
}}
|
||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{batch.images.length > 0 && batch.images.length < 8 && (
|
||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||
每批次至少需要8张图片(当前:{batch.images.length}张)
|
||
</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={addBuyerShowBatch}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
+ 添加新批次
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 小红书批量上传UI */}
|
||
{materialType === '小红书种草' && (
|
||
<div className="mb-4 space-y-4">
|
||
{batchXiaohongshuFiles.map((batch, batchIndex) => (
|
||
<div key={batchIndex} className="border border-gray-200 dark:border-gray-700 rounded-apple p-4 bg-gray-50 dark:bg-gray-800">
|
||
<div className="flex items-center space-x-3 mb-3">
|
||
<input
|
||
type="text"
|
||
value={batch.productModel}
|
||
onChange={(e) => {
|
||
const newFiles = [...batchXiaohongshuFiles]
|
||
newFiles[batchIndex].productModel = e.target.value
|
||
setBatchXiaohongshuFiles(newFiles)
|
||
}}
|
||
className="flex-1 px-3 py-2 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号 *"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handleBatchXiaohongshuImageSelect(e, batchIndex)}
|
||
className="hidden"
|
||
id={`xiaohongshu-images-${batchIndex}`}
|
||
/>
|
||
<label
|
||
htmlFor={`xiaohongshu-images-${batchIndex}`}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
选择图片
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setBatchXiaohongshuFiles(batchXiaohongshuFiles.filter((_, i) => i !== batchIndex))
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除批次
|
||
</button>
|
||
</div>
|
||
{batch.images.length > 0 && (
|
||
<div className="grid grid-cols-4 gap-2 mt-3">
|
||
{batch.images.map((img, imgIndex) => (
|
||
<div key={imgIndex} className="relative group">
|
||
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||
<Image
|
||
src={URL.createObjectURL(img)}
|
||
alt={`图片 ${imgIndex + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newFiles = [...batchXiaohongshuFiles]
|
||
newFiles[batchIndex].images = newFiles[batchIndex].images.filter((_, i) => i !== imgIndex)
|
||
setBatchXiaohongshuFiles(newFiles)
|
||
}}
|
||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={addXiaohongshuBatch}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
+ 添加新批次
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 单文件上传区域(非批量类型或批量列表为空时显示) */}
|
||
{!(
|
||
(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)
|
||
) && (
|
||
<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)'}
|
||
{!['音乐', '主图视频', '安装视频', '种草视频', '品牌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}
|
||
/>
|
||
|
||
{/* 单文件上传进度(非批量模式) */}
|
||
{!batchUploadStatus && (
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||
<span>{isProcessing ? '处理中...' : '上传进度'}</span>
|
||
<span>{uploadProgress}%</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
|
||
<div
|
||
className={`h-2.5 rounded-full transition-all duration-300 ${
|
||
isProcessing ? 'bg-yellow-500 animate-pulse' : 'bg-blue-600'
|
||
}`}
|
||
style={{ width: `${uploadProgress}%` }}
|
||
></div>
|
||
</div>
|
||
{!isProcessing && uploadSpeed > 0 && (
|
||
<div className="text-sm text-gray-500 dark:text-gray-400 text-right">
|
||
速度: {formatSpeed(uploadSpeed)}
|
||
</div>
|
||
)}
|
||
{isProcessing && (
|
||
<div className="text-sm text-yellow-600 dark:text-yellow-400 text-center">
|
||
⏳ 正在处理预览图并上传到存储服务器,请稍候...
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</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 */}
|
||
<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>
|
||
)
|
||
}
|