flnxi-Wu/components/UploadModal.tsx
WuYongJie 8705b9846a
Some checks failed
CI Check / check (push) Has been cancelled
feat: 统一弹窗样式系统与分类管理优化
- 创建统一的Modal组件,采用Apple风格设计
- 实现毛玻璃效果、圆角、动画和细边框描边
- 修复弹窗打开时背景滚动问题
- 修复分类管理中重复卡片和数组字符串显示问题
- 优化选项解析逻辑,添加去重和过滤机制
2025-11-25 09:37:00 +08:00

1554 lines
65 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'
interface UploadModalProps {
onClose: () => void
onSuccess: () => void
}
type UploadTemplateOption = {
id: string
name: string
description: string | null
materialType?: string
fields: any[]
isBuiltin?: boolean
}
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const styles = useThemeStyles()
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string>('')
const [displayImage, setDisplayImage] = useState<File | null>(null) // PSD展示图片
const [displayImageUrl, setDisplayImageUrl] = useState<string>('') // PSD展示图片预览URL
const [modelImages, setModelImages] = useState<File[]>([]) // 模特类型的多张展示图片
const [modelImageUrls, setModelImageUrls] = useState<string[]>([]) // 模特图片预览URLs
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 [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 MAX_RETRY = 2
// 断点续传相关状态
const [showResumeDialog, setShowResumeDialog] = useState(false)
const [savedUploadData, setSavedUploadData] = useState<any>(null)
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 xhrRef = useRef<XMLHttpRequest | null>(null)
// 保存上传状态到 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,
previewImageName: previewImage?.name || '',
displayImageName: displayImage?.name || '',
modelImagesNames: modelImages.map(img => img.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, previewImage, displayImage, modelImages, 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(() => {
setPreviewImage(null)
setPreviewUrl('')
setDisplayImage(null)
setDisplayImageUrl('')
setModelImages([])
setModelImageUrls([])
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 {
const response = await fetch('/api/template-categories')
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)
} 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 file = e.target.files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
setError('请选择图片文件')
return
}
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setError('')
}
}
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 handleModelImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
if (files.length === 0) return
// 验证所有文件都是图片
const invalidFiles = files.filter(file => !file.type.startsWith('image/'))
if (invalidFiles.length > 0) {
setError('请只选择图片文件')
return
}
// 添加新图片到现有列表
const newImages = [...modelImages, ...files]
const newUrls = [...modelImageUrls, ...files.map(file => URL.createObjectURL(file))]
setModelImages(newImages)
setModelImageUrls(newUrls)
setError('')
}
// 删除模特图片
const removeModelImage = (index: number) => {
const newImages = modelImages.filter((_, i) => i !== index)
const newUrls = modelImageUrls.filter((_, i) => i !== index)
// 释放被删除图片的URL
URL.revokeObjectURL(modelImageUrls[index])
setModelImages(newImages)
setModelImageUrls(newUrls)
}
const handleZipChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setZipFile(file)
setError('')
}
}
// 拖拽处理 - 修复闪屏问题
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
// 只有当鼠标真正离开容器时才设置为false,避免经过子元素时闪屏
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
setIsDragging(false)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
const file = files[0]
if (!file) return
setZipFile(file)
setError('')
}
// 预览图片拖拽处理
const handlePreviewDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsPreviewDragging(true)
}
const handlePreviewDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
setIsPreviewDragging(false)
}
}
const handlePreviewDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handlePreviewDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsPreviewDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
const imageFiles = files.filter(f => f.type.startsWith('image/'))
if (imageFiles.length > 0) {
const imgFile = imageFiles[0]
setPreviewImage(imgFile)
setPreviewUrl(URL.createObjectURL(imgFile))
setError('')
} else {
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
// 验证模板必填字段
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 || ''
// 材质类型不需要店铺和场景
if (currentMaterialType !== '材质' && (!currentCategoryName || !currentSceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
// 素材文件验证(模特类型和材质类型素材文件可选)
if (currentMaterialType !== '模特' && currentMaterialType !== '材质' && !zipFile) {
setError('请上传素材文件')
return
}
// 图片验证
if (currentMaterialType === 'PSD' && !displayImage) {
setError('PSD素材必须上传展示图片')
return
}
if (currentMaterialType === '模特' && modelImages.length === 0) {
setError('模特素材必须上传至少一张展示图片')
return
}
if (currentMaterialType === '视频' && !previewImage) {
setError('视频素材需要上传预览图(封面)')
return
}
if (currentMaterialType !== '模特' && !previewImage) {
setError('请上传预览图')
return
}
// 视频类型验证
if (currentMaterialType === '视频' && !zipFile) {
setError('请上传视频文件')
return
}
// 重置重试计数和状态
setRetryCount(0)
setUploading(true)
setUploadProgress(0)
setUploadSpeed(0)
setUploadComplete(false)
try {
const formData = new FormData()
// Only append preview image if it exists
if (previewImage) {
formData.append('previewImage', previewImage)
}
if (displayImage) {
formData.append('displayImage', displayImage)
}
// 添加模特类型的多张图片
if (currentMaterialType === '模特' && modelImages.length > 0) {
modelImages.forEach((image, index) => {
formData.append('modelImages', image)
})
}
// 素材文件对于模特类型是可选的
if (zipFile) {
formData.append('zipFile', zipFile)
}
formData.append('productModel', currentProductModel)
// 材质类型使用默认值
formData.append('categoryName', currentMaterialType === '材质' ? '通用' : currentCategoryName)
formData.append('sceneName', currentMaterialType === '材质' ? '通用' : 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 || '')
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)
// 清除上传状态和重置表单
clearUploadState()
resetForm()
setRetryCount(0)
// 短暂延迟确保UI更新完成,然后关闭弹窗
setTimeout(() => {
onSuccess()
onClose()
}, 300) // 300ms延迟,确保用户看到完成状态
} else {
try {
const data = JSON.parse(xhr.responseText)
let errorMsg = data.error || '上传失败'
// 提供更详细的错误信息
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 {
setError(`上传失败 (状态码: ${xhr.status})`)
}
setUploading(false)
setRetryCount(0)
}
})
xhr.addEventListener('error', (e) => {
console.error('Upload XHR error:', e)
console.error('XHR status:', xhr.status)
console.error('XHR readyState:', xhr.readyState)
console.error('Upload progress:', uploadProgress)
// 检测是否是连接重置错误
const isConnectionReset = xhr.status === 0
let errorMsg = '上传失败,请稍后重试'
// 如果上传进度已达到100%,可能实际已成功
if (uploadProgress === 100 && isConnectionReset) {
errorMsg = '⚠️ 上传可能已完成!\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)
})
xhr.addEventListener('abort', () => {
setError('上传已取消')
setUploading(false)
setUploadComplete(false)
setRetryCount(0)
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)
}
}
const handleCancelUpload = () => {
if (xhrRef.current && uploading) {
xhrRef.current.abort()
} else {
onClose()
}
}
const resolvedMaterialType = selectedTemplate?.materialType || materialType
const previewRequiredInUi = resolvedMaterialType !== '模特'
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}
>
{uploading ? `上传中 ${uploadProgress}%` : '上传'}
</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">
<div className="relative">
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{resolvedMaterialType === '视频' ? '视频封面(预览图)' : '预览图片'}{' '}
{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()}
>
<div className="flex items-center justify-center space-x-3">
{previewUrl ? (
<div className="relative w-16 h-16 rounded-xl overflow-hidden border-2 border-slate-200 dark:border-slate-600 flex-shrink-0">
<Image
src={previewUrl}
alt="Preview"
fill
unoptimized
className="object-cover"
/>
</div>
) : (
<div className="w-16 h-16 rounded-xl 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-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
<div className="text-left flex-1 min-w-0">
<input
ref={previewInputRef}
type="file"
onChange={handlePreviewChange}
accept="image/*"
className="hidden"
/>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">点击或拖拽上传预览图</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 JPG、PNG、WebP 等格式</p>
</div>
</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">
素材文件 {(() => {
const currentMaterialType = selectedTemplate?.materialType || materialType
return currentMaterialType === '模特' ? '(可选)' : <span className="text-red-500">*</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={() => !zipFile && 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}
className="hidden"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">点击或拖拽上传素材文件</p>
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 ZIP、RAR 等压缩格式</p>
</div>
</div>
)}
</div>
</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.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 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',
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 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',
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 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'
)}
/>
) : 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 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 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>
)}
{/* 模特展示图片(仅模特类型显示) */}
{(() => {
const currentMaterialType = selectedTemplate?.materialType || materialType
return currentMaterialType === '模特'
})() && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
模特展示图片 * (可上传多张)
</label>
<div className="border-2 border-dashed rounded-apple p-4 border-slate-300 dark:border-slate-600">
{/* 已上传的图片预览 */}
{modelImageUrls.length > 0 && (
<div className="grid grid-cols-4 gap-4 mb-4">
{modelImageUrls.map((url, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-slate-200 dark:border-slate-600">
<Image
src={url}
alt={`模特图 ${index + 1}`}
fill
unoptimized
className="object-cover"
/>
{index === 0 && (
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs px-2 py-1 rounded">
预览图
</div>
)}
</div>
<button
type="button"
onClick={() => removeModelImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
{/* 上传按钮 */}
<div className="flex items-center space-x-4">
<div className="w-32 h-32 rounded-apple bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<svg className="w-12 h-12 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="flex-1">
<input
type="file"
onChange={handleModelImagesChange}
accept="image/*"
multiple
className="hidden"
id="modelImagesInput"
/>
<button
type="button"
onClick={() => document.getElementById('modelImagesInput')?.click()}
className="btn-secondary"
>
{modelImages.length > 0 ? '继续添加图片' : '选择图片'}
</button>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
可一次选择多张图片上传
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 第一张图会自动作为预览图,高度超过1500px会自动切割
</p>
</div>
</div>
</div>
</div>
)}
{/* 上传进度 */}
{(uploading || uploadComplete) && (
<div className="space-y-2">
<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 && (
<div className="text-sm text-slate-500 dark:text-slate-400 text-right">
速度: {formatSpeed(uploadSpeed)}
</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">
{error}
</div>
)}
</form>
</div>
</div>
</div>
</Modal>
</>
)
}