flnxi-Wu/components/UploadModal.tsx
2026-05-28 20:59:57 +08:00

1863 lines
76 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.

/**
* UploadModal 组件
*
* 素材上传模态框组件
* 支持多种素材类型(3DMAX、PSD、AI、CAD、C4D、视频、材质、模特等)
* 支持预览图、展示图、ZIP文件上传
* 支持断点续传、拖拽上传等功能
* 使用三层架构样式系统,支持深浅色主题
*
* @created 2025-01-XX
* @component
*/
'use client'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import Image from 'next/image'
import { useThemeStyles, cn } from '@/lib/ui-styles'
import Modal from '@/components/ui/Modal'
import { parseAudioFile, isAudioFile, AUDIO_ACCEPT_TYPES, type AudioMetadataBrowser } from '@/lib/audio-parser-browser'
import { filterVisibleFieldValues, getVisibleTemplateFields, hasFieldValue } from '@/lib/field-conditions'
interface UploadModalProps {
onClose: () => void
onSuccess: () => void
}
type MaterialKind = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'
type UploadTemplateOption = {
id: string
name: string
description: string | null
materialType?: string
materialKind?: MaterialKind
fields: any[]
isBuiltin?: boolean
}
const normalizeMaterialKind = (value?: string | null): MaterialKind => {
if (!value) return 'IMAGE'
const upper = value.toUpperCase()
if (upper === 'VIDEO' || upper === 'OTHER' || upper === 'IMAGE' || upper === 'AUDIO') {
return upper as MaterialKind
}
return 'IMAGE'
}
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const styles = useThemeStyles()
const modalControlClass =
'w-full px-4 py-2.5 text-sm rounded-lg outline-none transition-colors border bg-white dark:bg-slate-800 text-slate-900 dark:text-white border-slate-300 dark:border-slate-700 focus-visible:ring-2 focus-visible:ring-slate-200 dark:focus-visible:ring-slate-700 focus-visible:border-slate-900 dark:focus-visible:border-slate-500 placeholder:text-slate-400 dark:placeholder:text-slate-500'
const requiredControlClass = 'bg-slate-50 dark:bg-slate-800 border-slate-400 dark:border-slate-600'
const uploadZoneClass = 'border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 transition-colors hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer'
const uploadTileClass = 'flex-shrink-0 rounded-lg border border-dashed border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 flex items-center justify-center text-slate-500 dark:text-slate-400'
const [previewImages, setPreviewImages] = useState<File[]>([]) // 多预览图(最多5张)
const [previewUrls, setPreviewUrls] = useState<string[]>([]) // 多预览图预览URLs
const [displayImage, setDisplayImage] = useState<File | null>(null) // PSD展示图片
const [displayImageUrl, setDisplayImageUrl] = useState<string>('') // PSD展示图片预览URL
const [zipFile, setZipFile] = useState<File | null>(null)
const [productModel, setProductModel] = useState('')
const [brandName, setBrandName] = useState('')
const [seriesName, setSeriesName] = useState('')
const [skuCode, setSkuCode] = useState('')
const [materialTexture, setMaterialTexture] = useState('')
// 一级分类
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 [templates, setTemplates] = useState<UploadTemplateOption[]>([])
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('')
const [selectedTemplate, setSelectedTemplate] = useState<UploadTemplateOption | null>(null)
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, any>>({})
// 模板分类选项(用于勾选了 addToCategory 的字段)
const [templateCategoryOptions, setTemplateCategoryOptions] = useState<Record<string, string[]>>({})
// 素材类型(保留用于向后兼容,但优先使用模板)
const [materialType, setMaterialType] = useState('3DMAX')
// 渲染器(仅3DMAX使用)
const [renderer, setRenderer] = useState('CR')
const [renderTime, setRenderTime] = useState('')
const [imageResolution, setImageResolution] = useState('')
const [colorMode, setColorMode] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadSpeed, setUploadSpeed] = useState(0)
const [uploadComplete, setUploadComplete] = useState(false) // 上传完成状态
const [pendingMaterialType, setPendingMaterialType] = useState<string | null>(null)
const [error, setError] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [isDisplayImageDragging, setIsDisplayImageDragging] = useState(false) // PSD展示图片拖拽状态
const [isPreviewDragging, setIsPreviewDragging] = useState(false) // 预览图片拖拽状态
const [retryCount, setRetryCount] = useState(0)
const [draggingPreviewIndex, setDraggingPreviewIndex] = useState<number | null>(null)
const MAX_RETRY = 2
// 断点续传相关状态
const [showResumeDialog, setShowResumeDialog] = useState(false)
const [savedUploadData, setSavedUploadData] = useState<any>(null)
// 音频解析相关状态
const [audioMetadata, setAudioMetadata] = useState<AudioMetadataBrowser | null>(null)
const [isParsingAudio, setIsParsingAudio] = useState(false)
const previewInputRef = useRef<HTMLInputElement>(null)
const zipInputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const displayImageDropZoneRef = useRef<HTMLDivElement>(null) // PSD展示图片拖拽区域
const displayImageInputRef = useRef<HTMLInputElement>(null) // PSD展示图片输入
const previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区域
const previewDragTargetRef = useRef<number | null>(null)
const reorderPreviewItems = useCallback(
(fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return
setPreviewImages((prev) => {
const items = [...prev]
if (fromIndex >= items.length || toIndex >= items.length) return prev
const [moved] = items.splice(fromIndex, 1)
items.splice(toIndex, 0, moved)
return items
})
setPreviewUrls((prev) => {
const urls = [...prev]
if (fromIndex >= urls.length || toIndex >= urls.length) return prev
const [moved] = urls.splice(fromIndex, 1)
urls.splice(toIndex, 0, moved)
return urls
})
},
[]
)
const handlePreviewCardDragStart = (index: number) => (event: React.DragEvent) => {
event.stopPropagation()
setDraggingPreviewIndex(index)
previewDragTargetRef.current = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(index))
}
const handlePreviewCardDragOver = (index: number) => (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
if (draggingPreviewIndex === null || draggingPreviewIndex === index) {
return
}
reorderPreviewItems(draggingPreviewIndex, index)
setDraggingPreviewIndex(index)
previewDragTargetRef.current = index
}
const handlePreviewCardDragEnd = () => {
setDraggingPreviewIndex(null)
previewDragTargetRef.current = null
}
const handlePreviewCardDrop = (event: React.DragEvent) => {
event.preventDefault()
event.stopPropagation()
setDraggingPreviewIndex(null)
previewDragTargetRef.current = null
}
const xhrRef = useRef<XMLHttpRequest | null>(null)
const VIDEO_ACCEPT_TYPES =
'video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/m4v,.mp4,.mov,.avi,.mkv,.webm,.m4v'
const VIDEO_FILE_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']
const isVideoFile = (file: File) => {
if (!file) return false
if (file.type && file.type.startsWith('video/')) {
return true
}
const ext = file.name.split('.').pop()?.toLowerCase() || ''
return VIDEO_FILE_EXTENSIONS.includes(ext)
}
// 保存上传状态到 localStorage(使用 useCallback 确保函数稳定)
const saveUploadState = useCallback(() => {
if (uploading || uploadComplete || (uploadProgress > 0 && uploadProgress < 100)) {
const uploadState = {
productModel,
brandName,
seriesName,
skuCode,
materialTexture,
categoryName,
sceneName,
platformName,
materialType,
renderer,
renderTime,
imageResolution,
colorMode,
previewImagesNames: previewImages.map(img => img.name),
displayImageName: displayImage?.name || '',
zipFileName: zipFile?.name || '',
uploadProgress,
timestamp: Date.now()
}
try {
localStorage.setItem('uploadState', JSON.stringify(uploadState))
} catch (e) {
console.error('Failed to save upload state to localStorage:', e)
}
}
}, [
uploading,
uploadProgress,
productModel,
brandName,
seriesName,
skuCode,
materialTexture,
categoryName,
sceneName,
platformName,
materialType,
renderer,
renderTime,
imageResolution,
colorMode,
previewImages,
displayImage,
zipFile,
uploadComplete,
])
// 清除保存的上传状态
const clearUploadState = useCallback(() => {
localStorage.removeItem('uploadState')
setSavedUploadData(null)
}, [])
// 恢复上传状态
const restoreUploadState = (data: any) => {
setProductModel(data.productModel || '')
setBrandName(data.brandName || '')
setSeriesName(data.seriesName || '')
setSkuCode(data.skuCode || '')
setMaterialTexture(data.materialTexture || '')
setCategoryName(data.categoryName || '')
setSceneName(data.sceneName || '')
setPlatformName(data.platformName || '')
setMaterialType(data.materialType || '3DMAX')
setRenderer(data.renderer || 'CR')
setRenderTime(data.renderTime || '')
setImageResolution(data.imageResolution || '')
setColorMode(data.colorMode || '')
setUploadProgress(data.uploadProgress || 0)
// 注意:文件对象无法直接恢复,需要用户重新选择
}
const resetForm = useCallback(() => {
// 释放所有预览图URL
previewUrls.forEach(url => URL.revokeObjectURL(url))
setPreviewImages([])
setPreviewUrls([])
setDisplayImage(null)
setDisplayImageUrl('')
setZipFile(null)
setProductModel('')
setBrandName('')
setSeriesName('')
setSkuCode('')
setMaterialTexture('')
setCategoryName('')
setSceneName('')
setPlatformName('')
setMaterialType('3DMAX')
setRenderer('CR')
setRenderTime('')
setImageResolution('')
setColorMode('')
setUploadProgress(0)
setUploadSpeed(0)
setError('')
setShowNewCategoryInput(false)
setShowNewSceneInput(false)
setNewCategoryName('')
setNewSceneName('')
}, [previewUrls])
// 检查是否有未完成的上传
useEffect(() => {
const savedState = localStorage.getItem('uploadState')
if (savedState) {
try {
const data = JSON.parse(savedState)
// 检查是否在24小时内(避免过期数据)
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000
if (data.timestamp && data.timestamp > oneDayAgo) {
setSavedUploadData(data)
setShowResumeDialog(true)
} else {
// 超过24小时,清除过期数据
clearUploadState()
}
} catch (e) {
console.error('Failed to parse saved upload state:', e)
clearUploadState()
}
}
}, [clearUploadState])
// 在上传状态变化时保存
useEffect(() => {
const timer = setTimeout(() => {
if (uploading || uploadComplete || (uploadProgress > 0 && uploadProgress < 100)) {
saveUploadState()
} else if (uploadProgress === 100 || uploadComplete) {
// 上传完成,清除保存的状态
clearUploadState()
}
}, 1000) // 延迟1秒保存,避免频繁写入localStorage
return () => clearTimeout(timer)
}, [uploading, uploadComplete, uploadProgress, saveUploadState, clearUploadState])
// 页面关闭前保存状态
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (uploading) {
saveUploadState()
e.preventDefault()
e.returnValue = '上传正在进行中,确定要离开吗?'
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [uploading, saveUploadState])
// 加载分类、场景列表和模板列表
useEffect(() => {
fetchCategories()
fetchScenes()
fetchPlatforms()
fetchTemplates()
fetchTemplateCategoryOptions() // 获取模板分类选项
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// 定期刷新模板分类选项(每30秒),确保后台添加的分类能同步到前台
useEffect(() => {
const interval = setInterval(() => {
fetchTemplateCategoryOptions()
}, 30000) // 30秒刷新一次
return () => clearInterval(interval)
}, [])
const fetchCategories = async () => {
try {
const response = await fetch('/api/categories')
if (response.ok) {
const data = await response.json()
setCategories(data || [])
console.log('[UploadModal] Categories loaded:', data?.length || 0)
} else {
const errorData = await response.json().catch(() => ({ error: '未知错误' }))
console.error('[UploadModal] Failed to fetch categories:', response.status, errorData)
setError(`加载店铺列表失败: ${errorData.error || '未知错误'}`)
}
} catch (error) {
console.error('[UploadModal] Error fetching categories:', error)
setError('加载店铺列表失败,请刷新页面重试')
}
}
const fetchScenes = async () => {
try {
const response = await fetch('/api/scenes')
if (response.ok) {
const data = await response.json()
setScenes(data || [])
console.log('[UploadModal] Scenes loaded:', data?.length || 0)
} else {
const errorData = await response.json().catch(() => ({ error: '未知错误' }))
console.error('[UploadModal] Failed to fetch scenes:', response.status, errorData)
setError(`加载场景列表失败: ${errorData.error || '未知错误'}`)
}
} catch (error) {
console.error('[UploadModal] Error fetching scenes:', error)
setError('加载场景列表失败,请刷新页面重试')
}
}
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 fetchTemplates = async () => {
try {
const response = await fetch('/api/upload-templates')
if (response.ok) {
const data = await response.json()
const loadedTemplates = Array.isArray(data) ? data : []
setTemplates(loadedTemplates)
console.log('[UploadModal] Templates loaded:', loadedTemplates.length)
// 如果有模板且只有一个,自动选择
if (loadedTemplates.length === 1) {
const template = loadedTemplates[0]
setSelectedTemplateId(template.id)
setSelectedTemplate(template)
const initialValues: Record<string, any> = {}
template.fields.forEach((field: any) => {
if (field.type === 'checkbox') {
initialValues[field.key] = false
} else {
initialValues[field.key] = ''
}
})
setTemplateFieldValues(initialValues)
setMaterialType(template.materialType || '其他')
}
} else {
const errorData = await response.json().catch(() => ({ error: '未知错误' }))
console.error('[UploadModal] Failed to fetch templates:', response.status, errorData)
setError(`加载模板列表失败: ${errorData.error || '未知错误'}`)
}
} catch (error) {
console.error('[UploadModal] Error fetching templates:', error)
setError('加载模板列表失败,请刷新页面重试')
}
}
// 获取模板分类选项(用于勾选了 addToCategory 的字段)
const fetchTemplateCategoryOptions = async () => {
try {
// 使用 includeAll=true 获取所有分类字段,包括未启用筛选的字段
const response = await fetch('/api/template-categories?includeAll=true')
if (response.ok) {
const data = await response.json()
const optionsMap: Record<string, string[]> = {}
data.forEach((cat: { fieldKey: string; options: string[] }) => {
optionsMap[cat.fieldKey] = cat.options
})
setTemplateCategoryOptions(optionsMap)
console.log('[UploadModal] Template category options loaded:', Object.keys(optionsMap).length, optionsMap)
} else {
console.error('[UploadModal] Failed to fetch template category options:', response.status)
}
} catch (error) {
console.error('[UploadModal] Error fetching template category options:', error)
}
}
// 处理模板选择变化
const handleTemplateChange = (templateId: string) => {
setSelectedTemplateId(templateId)
const template = templates.find(t => t.id === templateId) || null
setSelectedTemplate(template)
// 清空之前的字段值
setTemplateFieldValues({})
if (template) {
const initialValues: Record<string, any> = {}
template.fields.forEach((field: any) => {
if (field.type === 'checkbox') {
initialValues[field.key] = false
} else {
initialValues[field.key] = ''
}
})
setTemplateFieldValues(initialValues)
setMaterialType(template.materialType || '其他')
} else {
setMaterialType('其他')
}
}
const buildTemplateFieldsPayload = useCallback(() => {
if (!selectedTemplate?.fields?.length) return {}
return filterVisibleFieldValues(selectedTemplate.fields, templateFieldValues)
}, [selectedTemplate, templateFieldValues])
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
// 验证文件类型
const imageFiles = files.filter(file => file.type.startsWith('image/'))
if (imageFiles.length !== files.length) {
setError('请选择图片文件')
return
}
// 检查总数是否超过5张
const currentCount = previewImages.length
const newCount = currentCount + imageFiles.length
if (newCount > 5) {
setError(`最多只能上传5张预览图(当前:${currentCount}张,尝试添加:${imageFiles.length}张)`)
return
}
// 添加新图片
const newImages = [...previewImages, ...imageFiles]
setPreviewImages(newImages)
setPreviewUrls([...previewUrls, ...imageFiles.map(file => URL.createObjectURL(file))])
setError('')
// 清空input,允许重复选择同一文件
if (previewInputRef.current) {
previewInputRef.current.value = ''
}
}
const removePreviewImage = (index: number) => {
const newImages = previewImages.filter((_, i) => i !== index)
const newUrls = previewUrls.filter((_, i) => i !== index)
setPreviewImages(newImages)
// 释放URL对象
URL.revokeObjectURL(previewUrls[index])
setPreviewUrls(newUrls)
}
const handleDisplayImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
setError('请选择图片文件')
return
}
setDisplayImage(file)
setDisplayImageUrl(URL.createObjectURL(file))
setError('')
}
}
const handleZipChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (currentMaterialKind === 'VIDEO' && !isVideoFile(file)) {
setError('请上传视频文件(支持 MP4、MOV、AVI、MKV、WebM 等格式)')
e.target.value = ''
return
}
if (currentMaterialKind === 'AUDIO' && !isAudioFile(file)) {
setError('请上传音频文件(支持 MP3、WAV、FLAC、AAC 等格式)')
e.target.value = ''
return
}
setZipFile(file)
setError('')
// 如果是音频文件,自动解析元数据
if (currentMaterialKind === 'AUDIO' && isAudioFile(file)) {
setIsParsingAudio(true)
try {
const metadata = await parseAudioFile(file)
setAudioMetadata(metadata)
if (metadata) {
// 自动填充字段
setTemplateFieldValues(prev => {
const newValues = { ...prev }
// 自动填充时长
if (metadata.durationFormatted) {
newValues.audioDuration = metadata.durationFormatted
}
// 如果有内嵌标题且产品名称为空,自动填充
if (metadata.title && !prev.productModel) {
newValues.productModel = metadata.title
}
// 如果有内嵌艺术家且作者为空,自动填充
if (metadata.artist && !prev.authorName) {
newValues.authorName = metadata.artist
}
return newValues
})
// 如果有内嵌封面图且没有预览图,自动设置
if (metadata.coverImage && previewImages.length === 0) {
// 将 base64 转换为 File 对象
const response = await fetch(metadata.coverImage)
const blob = await response.blob()
const coverFile = new File([blob], 'cover.jpg', { type: blob.type })
setPreviewImages([coverFile])
setPreviewUrls([metadata.coverImage])
}
}
} catch (err) {
console.error('[UploadModal] Failed to parse audio file:', err)
} finally {
setIsParsingAudio(false)
}
}
}
}
// 拖拽处理 - 修复闪屏问题
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 = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
const file = files[0]
if (!file) return
if (currentMaterialKind === 'VIDEO' && !isVideoFile(file)) {
setError('请拖拽正确的视频文件,支持 MP4、MOV、AVI、MKV、WebM 等格式')
return
}
if (currentMaterialKind === 'AUDIO' && !isAudioFile(file)) {
setError('请拖拽正确的音频文件,支持 MP3、WAV、FLAC、AAC 等格式')
return
}
setZipFile(file)
setError('')
// 如果是音频文件,自动解析元数据
if (currentMaterialKind === 'AUDIO' && isAudioFile(file)) {
setIsParsingAudio(true)
try {
const metadata = await parseAudioFile(file)
setAudioMetadata(metadata)
if (metadata) {
setTemplateFieldValues(prev => {
const newValues = { ...prev }
if (metadata.durationFormatted) {
newValues.audioDuration = metadata.durationFormatted
}
if (metadata.title && !prev.productModel) {
newValues.productModel = metadata.title
}
if (metadata.artist && !prev.authorName) {
newValues.authorName = metadata.artist
}
return newValues
})
if (metadata.coverImage && previewImages.length === 0) {
const response = await fetch(metadata.coverImage)
const blob = await response.blob()
const coverFile = new File([blob], 'cover.jpg', { type: blob.type })
setPreviewImages([coverFile])
setPreviewUrls([metadata.coverImage])
}
}
} catch (err) {
console.error('[UploadModal] Failed to parse audio file:', err)
} finally {
setIsParsingAudio(false)
}
}
}
// 预览图片拖拽处理
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) {
setError('请拖拽图片文件')
return
}
// 检查总数是否超过5张
const currentCount = previewImages.length
const newCount = currentCount + imageFiles.length
if (newCount > 5) {
setError(`最多只能上传5张预览图(当前:${currentCount}张,尝试添加:${imageFiles.length}张)`)
return
}
// 添加新图片
const newImages = [...previewImages, ...imageFiles]
setPreviewImages(newImages)
setPreviewUrls([...previewUrls, ...imageFiles.map(file => URL.createObjectURL(file))])
setError('')
}
// PSD展示图片拖拽处理
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]
setDisplayImage(imgFile)
setDisplayImageUrl(URL.createObjectURL(imgFile))
setError('')
} else {
setError('请拖拽图片文件')
}
}
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) {
setError('请输入分类名称')
return
}
try {
console.log('[UploadModal] Creating category:', newCategoryName.trim())
const response = await fetch('/api/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newCategoryName.trim() }),
})
if (response.ok) {
const newCategory = await response.json()
console.log('[UploadModal] Category created:', newCategory)
await fetchCategories()
setCategoryName(newCategory.name)
setNewCategoryName('')
setShowNewCategoryInput(false)
setError('')
} else {
const data = await response.json().catch(() => ({ error: '未知错误' }))
console.error('[UploadModal] Failed to create category:', response.status, data)
setError(data.error || '添加分类失败')
}
} catch (error) {
console.error('[UploadModal] Error adding category:', error)
setError(`添加分类失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
const handleAddNewScene = async () => {
if (!newSceneName.trim()) {
setError('请输入场景名称')
return
}
try {
console.log('[UploadModal] Creating scene:', newSceneName.trim())
const response = await fetch('/api/scenes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newSceneName.trim() }),
})
if (response.ok) {
const newScene = await response.json()
console.log('[UploadModal] Scene created:', newScene)
await fetchScenes()
setSceneName(newScene.name)
setNewSceneName('')
setShowNewSceneInput(false)
setError('')
} else {
const data = await response.json().catch(() => ({ error: '未知错误' }))
console.error('[UploadModal] Failed to create scene:', response.status, data)
setError(data.error || '添加场景失败')
}
} catch (error) {
console.error('[UploadModal] Error adding scene:', error)
setError(`添加场景失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const formatSpeed = (bytesPerSecond: number) => {
return formatBytes(bytesPerSecond) + '/s'
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// 验证模板选择
if (!selectedTemplateId) {
setError('请选择素材模板')
return
}
const currentMaterialType = selectedTemplate?.materialType || materialType
const submittingMaterialKind = currentMaterialKind
const submittingIsVideo = submittingMaterialKind === 'VIDEO'
// 验证模板必填字段
if (visibleTemplateFields.length > 0) {
for (const field of visibleTemplateFields) {
if (field.required) {
const value = templateFieldValues[field.key]
if (!hasFieldValue(value, field.type)) {
setError(`请填写必填字段:${field.label}`)
return
}
}
}
}
// 从模板字段中提取productModel
const currentProductModel = templateFieldValues.productModel || ''
if (!currentProductModel) {
setError(currentMaterialType === '材质' ? '请填写材质名称' : '请填写SPU型号')
return
}
// 从模板字段中提取categoryName和sceneName
const currentCategoryName = templateFieldValues.categoryName || ''
const currentSceneName = templateFieldValues.sceneName || ''
const submittingIsAudio = submittingMaterialKind === 'AUDIO'
// 材质类型和音频类型不需要店铺和场景
const skipCategoryScene = currentMaterialType === '材质' || submittingIsAudio
if (!skipCategoryScene && (!currentCategoryName || !currentSceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
// 素材文件验证(模特类型和材质类型素材文件可选,音频类型必须有文件)
if (!submittingIsVideo && !submittingIsAudio && currentMaterialType !== '模特' && currentMaterialType !== '材质' && !zipFile) {
setError('请上传素材文件')
return
}
// 图片验证
if (currentMaterialType === 'PSD' && !displayImage) {
setError('PSD素材必须上传展示图片')
return
}
// 视频素材的封面图是可选的(如果没有上传,系统会自动从视频中截取)
// 音频素材的预览图也是可选的(使用占位符)
// 对于非视频、非音频、非PSD类型,必须有预览图
if (!submittingIsVideo && !submittingIsAudio && currentMaterialType !== 'PSD' && currentMaterialType !== '材质' && previewImages.length === 0) {
setError('请上传预览图')
return
}
// 视频类型验证
if (submittingIsVideo && !zipFile) {
setError('请上传视频文件')
return
}
// 音频类型验证
if (submittingIsAudio && !zipFile) {
setError('请上传音频文件')
return
}
// 重置重试计数和状态
setPendingMaterialType(currentMaterialType)
setRetryCount(0)
setUploading(true)
setUploadProgress(0)
setUploadSpeed(0)
setUploadComplete(false)
try {
const formData = new FormData()
// 添加预览图(多张,最多5张)
if (previewImages.length > 0) {
previewImages.forEach((img, index) => {
formData.append('previewImages', img)
})
// 第一张作为主预览图(向后兼容)
formData.append('previewImage', previewImages[0])
}
if (displayImage) {
formData.append('displayImage', displayImage)
}
// 素材文件对于模特类型是可选的
if (zipFile) {
formData.append('zipFile', zipFile)
}
formData.append('productModel', currentProductModel)
// 材质类型和音频类型使用默认值
formData.append('categoryName', skipCategoryScene ? '通用' : currentCategoryName)
formData.append('sceneName', skipCategoryScene ? '通用' : currentSceneName)
formData.append('platformName', templateFieldValues.platformName || '') // 平台分类可选
formData.append('materialType', currentMaterialType)
formData.append('renderer', templateFieldValues.renderer || 'CR')
formData.append('brandName', templateFieldValues.brandName || '')
formData.append('seriesName', templateFieldValues.seriesName || '')
formData.append('skuCode', templateFieldValues.skuCode || '')
formData.append('materialTexture', templateFieldValues.materialTexture || '')
formData.append('renderTime', templateFieldValues.renderTime || '')
formData.append('imageResolution', templateFieldValues.imageResolution || '')
formData.append('colorMode', templateFieldValues.colorMode || '')
formData.append('materialKind', submittingMaterialKind)
formData.append('templateId', selectedTemplateId)
formData.append('templateFields', JSON.stringify(buildTemplateFieldsPayload()))
// 提交作者名称(如果模板字段中有的话)
if (templateFieldValues.authorName) {
formData.append('authorName', templateFieldValues.authorName)
}
// 提交音频分类和使用场景(如果是音频素材)
if (submittingMaterialKind === 'AUDIO') {
if (templateFieldValues.audioCategory) {
formData.append('audioCategory', templateFieldValues.audioCategory)
}
if (templateFieldValues.audioScene) {
formData.append('audioScene', templateFieldValues.audioScene)
}
if (templateFieldValues.audioDuration) {
formData.append('audioDuration', templateFieldValues.audioDuration)
}
}
const xhr = new XMLHttpRequest()
xhrRef.current = xhr // 保存引用以便取消
// 禁用超时限制,适用于大文件上传
xhr.timeout = 0
let startTime = Date.now()
let startLoaded = 0
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100)
setUploadProgress(percentComplete)
// 计算上传速度
const currentTime = Date.now()
const timeElapsed = (currentTime - startTime) / 1000 // 秒
if (timeElapsed > 0) {
const speed = (e.loaded - startLoaded) / timeElapsed
setUploadSpeed(speed)
startTime = currentTime
startLoaded = e.loaded
}
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 201) {
// 立即设置进度为100%并显示完成状态
setUploadProgress(100)
setUploadComplete(true)
setUploading(false)
setPendingMaterialType(null)
// 清除上传状态和重置表单
clearUploadState()
resetForm()
setRetryCount(0)
setPendingMaterialType(null)
// 短暂延迟确保UI更新完成,然后关闭弹窗
setTimeout(() => {
onSuccess()
onClose()
}, 300) // 300ms延迟,确保用户看到完成状态
} else {
try {
const data = JSON.parse(xhr.responseText)
let errorMsg = data.error || '上传失败'
// 如果有详细信息,添加到错误消息中
if (data.details) {
errorMsg += `\n详细信息: ${data.details}`
}
if (data.errorType) {
errorMsg += `\n错误类型: ${data.errorType}`
}
// 提供更详细的错误信息
if (xhr.status === 413) {
errorMsg = '文件过大,请联系管理员增加上传限制'
} else if (xhr.status === 408 || xhr.status === 504) {
errorMsg = '上传超时,请压缩文件、使用内网地址或分批上传后重试'
} else if (xhr.status >= 500) {
errorMsg = `服务器错误 (${xhr.status}):${errorMsg}`
}
setError(errorMsg)
} catch (parseError) {
console.error('[UploadModal] Failed to parse error response:', parseError)
setError(`上传失败,服务器返回异常(状态码:${xhr.status}),请稍后重试或联系管理员`)
}
setUploading(false)
setRetryCount(0)
}
})
xhr.addEventListener('error', (e) => {
console.error('Upload XHR error:', e)
console.error('XHR status:', xhr.status)
console.error('XHR readyState:', xhr.readyState)
console.error('Upload progress:', uploadProgress)
// 检测是否是连接重置错误
const isConnectionReset = xhr.status === 0
let errorMsg = '上传失败,请稍后重试'
// 如果上传进度已达到100%,可能实际已成功
if (uploadProgress === 100 && isConnectionReset) {
errorMsg = '上传结果暂未确认,请刷新素材列表检查是否已经保存'
// 3秒后自动关闭弹窗并刷新
setTimeout(() => {
onClose()
window.location.reload()
}, 5000)
} else if (isConnectionReset) {
errorMsg = '网络连接中断,上传结果暂未确认。请刷新列表检查;如未出现,请使用内网地址或压缩后重试'
}
setError(errorMsg)
setUploading(false)
setUploadComplete(false)
setPendingMaterialType(null)
})
xhr.addEventListener('abort', () => {
setError('上传已取消')
setUploading(false)
setUploadComplete(false)
setRetryCount(0)
setPendingMaterialType(null)
xhrRef.current = null
})
// 始终使用相对路径上传,避免跨域Cookie问题
// 用户应通过Header的提示切换到内网地址以获得更快速度
const uploadUrl = '/api/materials/upload'
console.log(`[Upload] 上传地址: ${uploadUrl}`)
xhr.open('POST', uploadUrl)
xhr.send(formData)
} catch (error) {
console.error('Upload error:', error)
setError('上传失败,请稍后再试')
setUploading(false)
setUploadComplete(false)
setPendingMaterialType(null)
}
}
const handleCancelUpload = () => {
if (xhrRef.current && uploading) {
xhrRef.current.abort()
} else {
onClose()
}
}
const resolvedMaterialType = selectedTemplate?.materialType || materialType
const currentMaterialKind = normalizeMaterialKind(
selectedTemplate?.materialKind || (resolvedMaterialType === '视频' ? 'VIDEO' : resolvedMaterialType === '音频' ? 'AUDIO' : 'IMAGE')
)
const visibleTemplateFields = useMemo(() => {
if (!selectedTemplate?.fields?.length) return []
return getVisibleTemplateFields(selectedTemplate.fields, templateFieldValues)
}, [selectedTemplate, templateFieldValues])
const visibleTemplateFieldKeys = useMemo(
() => new Set(visibleTemplateFields.map((field: any) => field.key)),
[visibleTemplateFields]
)
const isVideoTemplate = currentMaterialKind === 'VIDEO'
const isAudioTemplate = currentMaterialKind === 'AUDIO'
// 视频模板和音频模板的预览图是可选的
// 只有非视频、非音频、非PSD类型才需要必填预览图
const previewRequiredInUi = !isVideoTemplate && !isAudioTemplate && resolvedMaterialType !== 'PSD' && resolvedMaterialType !== '材质'
const isPostProcessing = uploading && uploadProgress === 100 && !uploadComplete
const postProcessingMessage =
pendingMaterialType === 'PSD'
? 'PSD 展示图较大,正在切割并生成长图预览,请稍等...'
: '文件已上传,系统正在保存素材并刷新列表,请勿关闭窗口...'
const submitButtonLabel = uploading
? isPostProcessing
? '处理中...'
: `上传中 ${uploadProgress}%`
: '上传'
return (
<>
<Modal
isOpen={showResumeDialog && !!savedUploadData}
onClose={() => {
clearUploadState()
setShowResumeDialog(false)
}}
size="small"
showCloseButton={false}
closeOnOverlayClick={false}
closeOnEscape={false}
>
{savedUploadData && (
<div className="space-y-4">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
检测到未完成的上传
</h3>
<div className="space-y-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 p-4">
<p className="text-xs text-slate-600 dark:text-slate-400">
<strong>素材类型:</strong>{savedUploadData.materialType || '未知'}
</p>
<p className="text-xs text-slate-600 dark:text-slate-400">
<strong>SPU型号:</strong>{savedUploadData.productModel || '未填写'}
</p>
<p className="text-xs text-slate-600 dark:text-slate-400">
<strong>上传进度:</strong>{savedUploadData.uploadProgress || 0}%
</p>
<p className="text-xs text-yellow-600 dark:text-yellow-400">
文件需要重新选择,但表单内容已保存
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
clearUploadState()
setShowResumeDialog(false)
}}
className="btn-secondary"
>
放弃恢复
</button>
<button
onClick={() => {
restoreUploadState(savedUploadData)
setShowResumeDialog(false)
}}
className="btn-primary"
>
恢复上传
</button>
</div>
</div>
)}
</Modal>
<Modal
isOpen={true}
onClose={handleCancelUpload}
size="large"
showCloseButton={false}
closeOnOverlayClick={false}
closeOnEscape={false}
>
<div
className={cn(
'max-h-[80vh] flex flex-col relative w-full max-w-2xl mx-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900'
)}
style={{ overflow: 'hidden' }}
>
<div className="flex-shrink-0 px-6 py-3 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 z-10">
<div className="flex items-center justify-between">
<div className="flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-white">上传素材</h2>
<div className="space-y-0 mt-1">
<p className="text-[10px] text-slate-500 dark:text-slate-400">
提示:可拖拽文件到对应区域上传
</p>
{uploading && (
<p className="text-[10px] text-slate-600 dark:text-slate-400">
上传进度已自动保存,关闭网页后可恢复
</p>
)}
</div>
</div>
<div className="flex items-center gap-3 ml-4">
<button
type="button"
onClick={handleCancelUpload}
className="px-4 py-2 text-base font-medium rounded-lg border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
{uploading ? '取消上传' : '取消'}
</button>
<button
type="submit"
form="upload-form"
className="px-4 py-2 text-base font-medium rounded-lg bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={uploading}
>
{submitButtonLabel}
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<div>
<form id="upload-form" onSubmit={handleSubmit} className="p-6 space-y-6" noValidate>
{/* 素材模板 - 第一栏,让用户先选择模板 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
素材模板 *
</label>
{templates.length > 0 ? (
<select
value={selectedTemplateId}
onChange={(e) => handleTemplateChange(e.target.value)}
className={cn(
'w-full px-4 py-2 rounded-lg border outline-none',
styles.inputStyles.base
)}
required
>
<option value="">请选择模板</option>
{/* 自定义模板(放上面) */}
{templates
.filter(t => !(t as any).isBuiltin)
.map((template) => (
<option key={template.id} value={template.id}>
{template.name} [自定义]
</option>
))}
{/* 预制模板(放下面) */}
{templates
.filter(t => (t as any).isBuiltin)
.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
) : (
<div className="text-sm text-slate-500 dark:text-slate-400">
暂无可用模板,请联系管理员
</div>
)}
{selectedTemplate?.description && (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{selectedTemplate.description}
</p>
)}
</div>
{/* 预览图片和素材文件卡片 - 移到上方,样式与预览面板一致 */}
<div className="space-y-5">
{resolvedMaterialType !== 'PSD' && (
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{isVideoTemplate ? '视频封面(预览图)' : '预览图片'}{' '}
{previewRequiredInUi ? (
<span className="text-red-500">*</span>
) : (
<span className="text-[10px] text-slate-400">(可选)</span>
)}
</label>
<div
ref={previewDropZoneRef}
className={uploadZoneClass}
onDragEnter={handlePreviewDragEnter}
onDragLeave={handlePreviewDragLeave}
onDragOver={handlePreviewDragOver}
onDrop={handlePreviewDrop}
onClick={() => previewInputRef.current?.click()}
>
<input
ref={previewInputRef}
type="file"
onChange={handlePreviewChange}
accept="image/*"
multiple
className="hidden"
/>
{previewImages.length === 0 ? (
<div className="flex flex-wrap items-center gap-4 sm:flex-nowrap">
<div className={cn(uploadTileClass, 'w-14 h-14 sm:w-16 sm:h-16')}>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="text-left flex-1 min-w-[200px] space-y-1">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">
上传{isVideoTemplate ? '视频封面(可选,最多5张)' : '预览图(最多5张)'}
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500">
{isVideoTemplate
? '支持 JPG、PNG、WebP;不上传时系统会尝试自动截取封面'
: '支持 JPG、PNG、WebP,可拖拽上传'}
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
previewInputRef.current?.click()
}}
className="inline-flex items-center text-[11px] font-medium text-slate-700 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
>
上传预览图
<svg className="w-3.5 h-3.5 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l3-3m-3 3l-3-3M5 20h14" />
</svg>
</button>
</div>
</div>
) : (
<>
<div className="space-y-3">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
{previewImages.map((img, index) => (
<div
key={index}
draggable
onDragStart={handlePreviewCardDragStart(index)}
onDragOver={handlePreviewCardDragOver(index)}
onDrop={handlePreviewCardDrop}
onDragEnd={handlePreviewCardDragEnd}
className={cn(
'relative group cursor-grab active:cursor-grabbing',
draggingPreviewIndex === index &&
'ring-2 ring-slate-500 rounded-lg'
)}
>
<div className="relative w-full aspect-square rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800">
<Image
src={previewUrls[index]}
alt={`Preview ${index + 1}`}
fill
unoptimized
className="object-cover"
/>
{index === 0 && (
<div className="absolute top-1 left-1 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-white dark:bg-slate-100 dark:text-slate-900">
封面
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
removePreviewImage(index)
}}
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-md opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
>
×
</button>
</div>
</div>
))}
{previewImages.length < 5 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
previewInputRef.current?.click()
}}
className="w-full aspect-square rounded-lg border border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-2 text-slate-500 dark:text-slate-400 bg-white dark:bg-slate-800 hover:border-slate-400 hover:text-slate-700 dark:hover:border-slate-500 dark:hover:text-slate-200 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="text-[10px] font-medium">
{isVideoTemplate ? '添加封面' : '继续添加'}
</span>
</button>
)}
</div>
</div>
<div className="text-left mt-3">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">
已上传 {previewImages.length}/5 张,第一张自动设为{isVideoTemplate ? '视频封面' : '卡片封面'}
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">
支持 JPG、PNG、WebP,可拖拽调整顺序
</p>
</div>
</>
)}
</div>
</div>
)}
{/* PSD展示图片(仅PSD类型显示) */}
{resolvedMaterialType === 'PSD' && (
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
PSD展示图片 <span className="text-red-500">*</span>
</label>
<div
ref={displayImageDropZoneRef}
className={cn(
uploadZoneClass,
isDisplayImageDragging ? 'border-slate-500 bg-slate-50 dark:bg-slate-800' : ''
)}
onDragEnter={handleDisplayImageDragEnter}
onDragLeave={handleDisplayImageDragLeave}
onDragOver={handleDisplayImageDragOver}
onDrop={handleDisplayImageDrop}
onClick={() => displayImageInputRef.current?.click()}
>
<div className="flex items-center justify-center space-x-4">
{displayImageUrl ? (
<div className="relative w-16 h-16 md:w-20 md:h-20 rounded-lg overflow-hidden border border-slate-200 dark:border-slate-600 flex-shrink-0">
<Image src={displayImageUrl} alt="PSD 展示图" fill unoptimized className="object-cover" />
</div>
) : (
<div className="w-16 h-16 md:w-20 md:h-20 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
<div className="text-left flex-1 min-w-0">
<input
ref={displayImageInputRef}
type="file"
onChange={handleDisplayImageChange}
accept="image/*"
className="hidden"
/>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">上传 PSD 展示图</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 JPG、PNG、WebP;超高图片会自动切割</p>
</div>
</div>
</div>
</div>
)}
{/* 素材文件 */}
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{isVideoTemplate ? '视频文件' : isAudioTemplate ? '音频文件' : '素材文件'}{' '}
{(() => {
if (isVideoTemplate || isAudioTemplate) {
return <span className="text-red-500">*</span>
}
const currentMaterialType = selectedTemplate?.materialType || materialType
return currentMaterialType === '模特' || currentMaterialType === '材质' ? '(可选)' : <span className="text-red-500">*</span>
})()}
{isParsingAudio && <span className="ml-2 text-slate-500 text-[10px]">正在解析音频信息...</span>}
</label>
<div
ref={dropZoneRef}
className={uploadZoneClass}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => zipInputRef.current?.click()}
>
{zipFile ? (
<div className="flex items-center justify-center space-x-3">
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div className="text-left flex-1 min-w-0">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">{zipFile.name}</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">{formatBytes(zipFile.size)}</p>
<button
type="button"
onClick={() => setZipFile(null)}
className="text-[10px] text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 mt-1"
>
移除
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center space-x-3">
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div className="text-left flex-1 min-w-0">
<input
ref={zipInputRef}
type="file"
onChange={handleZipChange}
accept={isVideoTemplate ? VIDEO_ACCEPT_TYPES : isAudioTemplate ? AUDIO_ACCEPT_TYPES : '.zip,.rar,.7z,.7zip,.tar,.gz,.bz2,.pak'}
className="hidden"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
上传{isVideoTemplate ? '视频文件' : isAudioTemplate ? '音频文件' : '素材压缩包'}
</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">
{isVideoTemplate ? '支持 MP4、MOV、AVI、MKV、WebM 等常见视频格式' : isAudioTemplate ? '支持 MP3、WAV、FLAC、AAC、M4A 等常见音频格式' : '支持 ZIP、RAR、7Z 等压缩格式'}
</p>
</div>
</div>
)}
</div>
</div>
{/* 上传进度 - 移到素材文件上传区域下方 */}
{(uploading || uploadComplete) && (
<div className="space-y-2 mt-4">
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-400">
<span>{uploadComplete ? '上传完成' : '上传进度'}</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
<div
className={`h-2.5 rounded-sm transition-[width] duration-300 ${
uploadComplete
? 'bg-green-500'
: 'bg-slate-600 dark:bg-slate-400'
}`}
style={{ width: `${uploadProgress}%` }}
></div>
</div>
{!uploadComplete && !isPostProcessing && (
<div className="text-sm text-slate-500 dark:text-slate-400 text-right">
速度: {formatSpeed(uploadSpeed)}
</div>
)}
{isPostProcessing && (
<div className="text-xs text-amber-600 dark:text-amber-300 text-center font-medium">
{postProcessingMessage}
</div>
)}
{uploadComplete && (
<div className="text-sm text-green-600 dark:text-green-400 text-center font-medium">
上传成功,正在关闭...
</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-lg mt-4">
{error}
</div>
)}
</div>
{/* 动态渲染模板字段 */}
{selectedTemplate && selectedTemplate.fields && selectedTemplate.fields.length > 0 && (
<div className="space-y-4">
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-4">
模板字段
</h3>
<div className="space-y-4">
{visibleTemplateFields.map((field: any) => {
const fieldValue = templateFieldValues[field.key] || ''
const isHalfWidth = field.layoutWidth === 'half'
const fieldInputId = `upload-template-field-${field.id || field.key}`
return (
<div
key={field.id}
className={isHalfWidth ? 'w-full sm:w-1/2 inline-block sm:pr-2' : 'w-full'}
>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={field.type === 'radio' ? undefined : fieldInputId}
className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight"
>
{field.label}
</label>
{field.required && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-[11px] font-medium text-slate-600 dark:text-slate-300">
必填
</span>
)}
</div>
{field.type === 'text' || field.type === 'uppercase' ? (
<input
id={fieldInputId}
type="text"
value={fieldValue}
onChange={(e) => {
const value = field.type === 'uppercase'
? e.target.value.toUpperCase()
: e.target.value
setTemplateFieldValues({
...templateFieldValues,
[field.key]: value,
})
}}
required={field.required}
placeholder={
field.key === 'imageResolution'
? '例如:5000 * 5000'
: `请输入${field.label || field.key}`
}
className={cn(
modalControlClass,
field.required ? requiredControlClass : null,
field.type === 'uppercase' && 'uppercase'
)}
/>
) : field.type === 'textarea' ? (
<textarea
id={fieldInputId}
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
placeholder={`请输入${field.label || field.key}`}
rows={2}
className={cn(
modalControlClass,
'resize-none',
field.required ? requiredControlClass : null
)}
/>
) : field.type === 'number' ? (
<input
id={fieldInputId}
type="number"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
placeholder={`请输入${field.label || field.key}`}
className={cn(
modalControlClass,
field.required ? requiredControlClass : null
)}
/>
) : field.type === 'select' ? (
<select
id={fieldInputId}
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className={cn(
modalControlClass,
field.required ? requiredControlClass : null
)}
>
<option value="">请选择</option>
{(() => {
// 如果字段勾选了 addToCategory,优先使用从模板分类API获取的选项
if (field.addToCategory && templateCategoryOptions[field.key]) {
return templateCategoryOptions[field.key].map((opt: string) => (
<option key={opt} value={opt}>
{opt}
</option>
))
}
// 否则使用模板中的 optionsStatic
if (field.optionsStatic) {
try {
const options = typeof field.optionsStatic === 'string'
? JSON.parse(field.optionsStatic)
: field.optionsStatic
return Array.isArray(options)
? options.map((opt: string) => (
<option key={opt} value={opt}>
{opt}
</option>
))
: null
} catch {
return null
}
}
return null
})()}
</select>
) : field.type === 'checkbox' ? (
<label className="flex items-center space-x-2">
<input
id={fieldInputId}
type="checkbox"
checked={fieldValue || false}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.checked,
})
}}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">
{field.label}
</span>
</label>
) : field.type === 'radio' ? (
<div className="space-y-2">
{field.optionsStatic ? (
(() => {
try {
const options = typeof field.optionsStatic === 'string'
? JSON.parse(field.optionsStatic)
: field.optionsStatic
return Array.isArray(options)
? options.map((opt: string) => (
<label key={opt} className="flex items-center space-x-2">
<input
type="radio"
name={field.key}
value={opt}
checked={fieldValue === opt}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className="w-4 h-4"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">
{opt}
</span>
</label>
))
: null
} catch {
return null
}
})()
) : null}
</div>
) : field.type === 'date' ? (
<input
id={fieldInputId}
type="date"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className={cn(
modalControlClass,
field.required ? requiredControlClass : null
)}
/>
) : null}
</div>
</div>
)
})}
</div>
</div>
</div>
)}
</form>
</div>
</div>
</div>
</Modal>
</>
)
}