flnxi-Wu/components/UploadModal.tsx
WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

1829 lines
78 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

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

/**
* UploadModal 组件
*
* 素材上传模态框组件
* 支持多种素材类型(3DMAX、PSD、AI、CAD、C4D、视频、材质、模特等)
* 支持预览图、展示图、ZIP文件上传
* 支持断点续传、拖拽上传等功能
* 使用三层架构样式系统,支持深浅色主题
*
* @created 2025-01-XX
* @component
*/
'use client'
import { useState, useRef, useEffect, useCallback } 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'
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 [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,
])
// 清除保存的上传状态
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('')
}, [])
// 检查是否有未完成的上传
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()
setTemplates(data || [])
console.log('[UploadModal] Templates loaded:', data?.length || 0)
// 如果有模板且只有一个,自动选择
if (data && data.length === 1) {
handleTemplateChange(data[0].id)
}
} 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 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 (selectedTemplate && selectedTemplate.fields) {
for (const field of selectedTemplate.fields) {
if (field.required) {
const value = templateFieldValues[field.key]
if (!value || (typeof value === 'string' && value.trim() === '')) {
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' && 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)
// 提交作者名称(如果模板字段中有的话)
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 = '上传超时,建议:\n1. 压缩文件后再上传\n2. 使用内网地址(更快)\n3. 分批上传大文件'
} 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})\n响应内容: ${xhr.responseText.substring(0, 200)}`)
}
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 = '⚠️ 上传可能已完成!\n\n由于网络原因,无法确认最终状态。\n\n请执行以下操作:\n1. 刷新页面查看文件是否已上传\n2. 如果文件已存在,说明上传成功\n3. 如果未看到文件,请重新上传\n\n提示:使用内网地址上传更稳定'
// 3秒后自动关闭弹窗并刷新
setTimeout(() => {
onClose()
window.location.reload()
}, 5000)
} else if (isConnectionReset) {
errorMsg = '网络连接中断,可能原因:\n• 文件过大导致超时\n• 网络不稳定\n• 服务器响应慢\n\n建议:\n1. 检查文件大小(压缩包建议<1GB)\n2. 使用内网地址上传(更快更稳定)\n3. 如果是大文件,尝试压缩后再上传\n4. 刷新页面检查文件是否已上传成功'
}
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(`🚀 上传地址: ${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 isVideoTemplate = currentMaterialKind === 'VIDEO'
const isAudioTemplate = currentMaterialKind === 'AUDIO'
// 视频模板和音频模板的预览图是可选的
// 只有非视频、非音频、非PSD类型才需要必填预览图
const previewRequiredInUi = !isVideoTemplate && !isAudioTemplate && resolvedMaterialType !== 'PSD'
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-xl font-bold text-slate-900 dark:text-white">
📎 检测到未完成的上传
</h3>
<div className="space-y-2 rounded-2xl border border-slate-200/70 dark:border-slate-700/70 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(
'rounded-apple max-h-[80vh] shadow-apple-lg flex flex-col relative w-full max-w-2xl mx-auto',
styles.cardStyles.base
)}
style={{ overflow: 'hidden' }}
>
<div className="absolute top-0 left-0 right-0 px-6 py-2.5 border-b border-white/20 dark:border-slate-700/30 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl z-10 rounded-t-apple">
<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-blue-600 dark:text-blue-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-apple border border-slate-300 dark:border-slate-600 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-apple bg-blue-600 text-white hover:bg-blue-700 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 className="pt-[72px] sm:pt-[80px]">
<form id="upload-form" onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 素材模板 - 第一栏,让用户先选择模板 */}
<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-apple 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="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md cursor-pointer"
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="w-16 h-16 sm:w-20 sm:h-20 flex-shrink-0 rounded-xl border-2 border-dashed border-slate-300/80 dark:border-slate-600/80 flex items-center justify-center text-slate-500 dark:text-slate-400 bg-white/40 dark:bg-slate-800/40">
<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-blue-600 hover:text-blue-500 dark:text-blue-300 dark:hover:text-blue-200"
>
上传预览图
<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-indigo-500 rounded-xl'
)}
>
<div className="relative w-full aspect-square rounded-xl overflow-hidden border-2 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 bg-blue-600 text-white text-[10px] px-1.5 py-0.5 rounded">
封面
</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-full 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-xl border-2 border-dashed border-slate-300/80 dark:border-slate-600/80 flex flex-col items-center justify-center gap-2 text-slate-500 dark:text-slate-400 bg-white/40 dark:bg-slate-800/40 hover:border-blue-400 hover:text-blue-500 dark:hover:border-blue-500 dark:hover:text-blue-400 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(
'border-2 border-dashed rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md cursor-pointer',
isDisplayImageDragging ? 'border-blue-500 bg-blue-50/50 dark:bg-blue-900/20' : 'border-slate-300/60 dark:border-slate-600/60'
)}
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-20 h-20 md:w-24 md:h-24 rounded-2xl overflow-hidden border-2 border-slate-200 dark:border-slate-600 flex-shrink-0">
<Image src={displayImageUrl} alt="PSD 展示图" fill unoptimized className="object-cover" />
</div>
) : (
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-gradient-to-br from-slate-100 to-slate-50 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center flex-shrink-0 shadow-inner">
<svg className="w-10 h-10 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>
<p className="text-[10px] text-blue-600 dark:text-blue-400 mt-1">💡 第一张图会自动作为预览图,超高图片会自动切割</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 === '模特' ? '(可选)' : <span className="text-red-500">*</span>
})()}
{isParsingAudio && <span className="ml-2 text-blue-500 text-[10px]">正在解析音频信息...</span>}
</label>
<div
ref={dropZoneRef}
className="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md cursor-pointer"
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="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
uploadComplete
? 'bg-green-500'
: 'bg-blue-600'
}`}
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-apple 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">
{selectedTemplate.fields.map((field: any) => {
const fieldValue = templateFieldValues[field.key] || ''
const isHalfWidth = field.layoutWidth === 'half'
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 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-full text-[10px] font-semibold uppercase tracking-[0.18em] text-white bg-gradient-to-r from-purple-500 to-indigo-600 shadow-[0_8px_16px_rgba(79,70,229,0.35)]">
必填
</span>
)}
</div>
{field.type === 'text' || field.type === 'uppercase' ? (
<input
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(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 placeholder:text-slate-400 dark:placeholder:text-slate-500',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80',
field.type === 'uppercase' && 'uppercase'
)}
/>
) : field.type === 'textarea' ? (
<textarea
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
placeholder={`请输入${field.label || field.key}`}
rows={2}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 resize-none placeholder:text-slate-400 dark:placeholder:text-slate-500',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
/>
) : field.type === 'number' ? (
<input
type="number"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
placeholder={`请输入${field.label || field.key}`}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60 placeholder:text-slate-400 dark:placeholder:text-slate-500',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
/>
) : field.type === 'select' ? (
<select
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
>
<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
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
type="date"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className={cn(
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 text-slate-900 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60',
field.required
? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
: 'bg-white/90 dark:bg-slate-800/80'
)}
/>
) : null}
</div>
</div>
)
})}
</div>
</div>
</div>
)}
</form>
</div>
</div>
</div>
</Modal>
</>
)
}