1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
1829 lines
78 KiB
TypeScript
1829 lines
78 KiB
TypeScript
/**
|
||
* 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>
|
||
</>
|
||
)
|
||
}
|