gongxiangp2/components/UploadModal.tsx

2704 lines
108 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useRef, useEffect } 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>
)
}