flnxi-Wu/components/UploadModal.tsx
WuYongJie 492b97213e
Some checks failed
CI Check / check (push) Has been cancelled
fix: 修复模板分类API的Set迭代问题和UploadModal的useEffect依赖
2025-11-22 18:16:51 +08:00

1844 lines
73 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 Input from '@/components/ui/Input'
import Button from '@/components/ui/Button'
interface UploadModalProps {
onClose: () => void
onSuccess: () => void
}
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<Array<{ id: string; name: string; description: string | null; fields: any[] }>>([])
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('')
const [selectedTemplate, setSelectedTemplate] = useState<any>(null)
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, any>>({})
// 素材类型(保留用于向后兼容,但优先使用模板)
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 [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 previewDropZoneRef = useRef<HTMLDivElement>(null) // 预览图片拖拽区域
const xhrRef = useRef<XMLHttpRequest | null>(null)
// 保存上传状态到 localStorage(使用 useCallback 确保函数稳定)
const saveUploadState = useCallback(() => {
if (uploading || (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 || (uploadProgress > 0 && uploadProgress < 100)) {
saveUploadState()
} else if (uploadProgress === 100) {
// 上传完成,清除保存的状态
clearUploadState()
}
}, 1000) // 延迟1秒保存,避免频繁写入localStorage
return () => clearTimeout(timer)
}, [uploading, 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()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
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('加载模板列表失败,请刷新页面重试')
}
}
// 处理模板选择变化
const handleTemplateChange = (templateId: string) => {
setSelectedTemplateId(templateId)
const template = templates.find(t => t.id === templateId)
setSelectedTemplate(template || null)
// 清空之前的字段值
setTemplateFieldValues({})
// 如果有模板,初始化字段值
if (template) {
const initialValues: Record<string, any> = {}
template.fields.forEach((field: any) => {
if (field.type === 'checkbox') {
initialValues[field.key] = false
} else if (field.type === 'select' || field.type === 'radio') {
initialValues[field.key] = ''
} else {
initialValues[field.key] = ''
}
})
setTemplateFieldValues(initialValues)
}
}
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 detectMaterialType = (filename: string): string | null => {
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase()
const typeMap: { [key: string]: string | null } = {
'.max': '3DMAX',
'.psd': 'PSD',
'.tif': 'PSD',
'.tiff': 'PSD',
'.psb': 'PSD',
'.ai': 'AI',
'.cad': 'CAD',
'.dwg': 'CAD',
'.c4d': 'C4D',
'.mp4': '视频',
'.avi': '视频',
'.mov': '视频',
'.wmv': '视频',
'.flv': '视频',
'.mkv': '视频',
'.webm': '视频',
'.zip': null, // 压缩包需要用户手动选择
'.rar': null,
'.7z': null,
}
return typeMap[ext] ?? '其他'
}
const handleZipChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const validExtensions = ['.zip', '.rar', '.7z', '.max', '.psd', '.ai', '.cad', '.dwg', '.c4d', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm']
const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
if (!validExtensions.includes(fileExtension)) {
setError('请选择有效的文件格式(压缩包:.zip/.rar/.7z 或素材文件:.max/.psd/.ai/.cad/.dwg/.c4d 或视频文件:.mp4/.avi/.mov/.wmv/.flv/.mkv/.webm)')
return
}
setZipFile(file)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
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 materialFiles = files.filter(f => {
const ext = f.name.substring(f.name.lastIndexOf('.')).toLowerCase()
return ['.zip', '.rar', '.7z', '.max', '.psd', '.tif', '.tiff', '.psb', '.ai', '.cad', '.dwg', '.c4d', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm'].includes(ext)
})
if (materialFiles.length > 0) {
const file = materialFiles[0]
setZipFile(file)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
setError('')
} else {
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
}
// 验证模板必填字段
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
}
}
}
}
// Validation - 基础信息验证
if (!productModel) {
setError(materialType === '材质' ? '请填写材质名称' : '请填写SPU型号')
return
}
// 材质类型不需要店铺和场景
if (materialType !== '材质' && (!categoryName || !sceneName)) {
setError('请填写所有必填字段(店铺、场景)')
return
}
// 素材文件验证(模特类型和材质类型素材文件可选)
if (materialType !== '模特' && materialType !== '材质' && !zipFile) {
setError('请上传素材文件')
return
}
// 图片验证
if (materialType === 'PSD' && !displayImage) {
setError('PSD素材必须上传展示图片')
return
}
if (materialType === '模特' && modelImages.length === 0) {
setError('模特素材必须上传至少一张展示图片')
return
}
if (materialType !== 'PSD' && materialType !== '模特' && materialType !== '视频' && !previewImage) {
setError('请上传预览图')
return
}
// 视频类型验证
if (materialType === '视频' && !zipFile) {
setError('请上传视频文件')
return
}
// 视频类型需要预览图(视频封面)
if (materialType === '视频' && !previewImage) {
setError('视频素材需要上传预览图(封面)')
return
}
// 重置重试计数
setRetryCount(0)
setUploading(true)
setUploadProgress(0)
setUploadSpeed(0)
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 (materialType === '模特' && modelImages.length > 0) {
modelImages.forEach((image, index) => {
formData.append('modelImages', image)
})
}
// 素材文件对于模特类型是可选的
if (zipFile) {
formData.append('zipFile', zipFile)
}
formData.append('productModel', productModel)
// 材质类型使用默认值
formData.append('categoryName', materialType === '材质' ? '通用' : categoryName)
formData.append('sceneName', materialType === '材质' ? '通用' : sceneName)
formData.append('platformName', platformName || '') // 平台分类可选
formData.append('materialType', materialType)
formData.append('renderer', renderer)
formData.append('brandName', brandName || '')
formData.append('seriesName', seriesName || '')
formData.append('skuCode', skuCode || '')
formData.append('materialTexture', materialTexture || '')
formData.append('renderTime', renderTime || '')
formData.append('imageResolution', imageResolution || '')
formData.append('colorMode', 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) {
setUploadProgress(100)
clearUploadState()
resetForm()
setRetryCount(0)
setUploading(false)
onSuccess()
onClose()
} 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)
})
xhr.addEventListener('abort', () => {
setError('上传已取消')
setUploading(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)
}
}
const handleCancelUpload = () => {
if (xhrRef.current && uploading) {
xhrRef.current.abort()
} else {
onClose()
}
}
return (
<>
{/* 断点续传对话框 */}
{showResumeDialog && savedUploadData && (
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-[60] p-4">
<div className={cn('rounded-apple max-w-md w-full shadow-apple-lg p-6', styles.cardStyles.base)}>
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4">
📎 检测到未完成的上传
</h3>
<div className="space-y-3 mb-6">
<p className="text-sm text-slate-600 dark:text-slate-400">
发现您之前有未完成的上传,是否恢复?
</p>
<div className="bg-slate-50 dark:bg-slate-700 rounded-lg p-4 space-y-2">
<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>
<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>
</div>
)}
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className={cn(
'rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg',
styles.cardStyles.base
)}
>
<div className="p-6 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">上传素材</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-2xl leading-none"
>
×
</button>
</div>
<div className="space-y-1 mt-2">
<p className="text-sm text-slate-500 dark:text-slate-400">
💡 提示:可拖拽文件到对应的虚线框内上传
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
提示:如在内网环境,请通过顶部提示切换到内网地址以获得更快速度
</p>
{uploading && (
<p className="text-xs text-blue-600 dark:text-blue-400">
💾 上传进度已自动保存,关闭网页后可恢复
</p>
)}
</div>
</div>
<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.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>
{/* 动态渲染模板字段 */}
{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'}
>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{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}
className="w-full"
/>
) : field.type === 'textarea' ? (
<textarea
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className={cn(
'w-full px-4 py-2 rounded-apple border outline-none',
styles.inputStyles.base
)}
rows={3}
/>
) : field.type === 'number' ? (
<Input
type="number"
value={fieldValue}
onChange={(e) => {
setTemplateFieldValues({
...templateFieldValues,
[field.key]: e.target.value,
})
}}
required={field.required}
className="w-full"
/>
) : 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 rounded-apple border outline-none',
styles.inputStyles.base
)}
>
<option value="">请选择</option>
{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
}
})()
) : 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="w-full"
/>
) : null}
</div>
)
})}
</div>
</div>
</div>
)}
{/* 视频类型预览图(视频封面) */}
{materialType === '视频' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
视频封面(预览图) *
</label>
<div
ref={previewDropZoneRef}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600'
}`}
onDragEnter={handlePreviewDragEnter}
onDragLeave={handlePreviewDragLeave}
onDragOver={handlePreviewDragOver}
onDrop={handlePreviewDrop}
>
<div className="flex items-center space-x-4">
{previewUrl ? (
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-slate-200 dark:border-slate-600">
<Image
src={previewUrl}
alt="Preview"
fill
unoptimized
className="object-cover"
/>
</div>
) : (
<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="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="flex-1">
<input
ref={previewInputRef}
type="file"
onChange={handlePreviewChange}
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
className="btn-secondary"
>
选择封面图
</button>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
支持 JPG、PNG、WebP 等格式,可拖拽上传
</p>
</div>
</div>
</div>
</div>
)}
{/* 预览图片(非PSD和非视频类型才显示) */}
{materialType !== 'PSD' && materialType !== '视频' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
预览图片 *
</label>
<div
ref={previewDropZoneRef}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600'
}`}
onDragEnter={handlePreviewDragEnter}
onDragLeave={handlePreviewDragLeave}
onDragOver={handlePreviewDragOver}
onDrop={handlePreviewDrop}
>
<div className="flex items-center space-x-4">
{previewUrl ? (
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-slate-200 dark:border-slate-600">
<Image
src={previewUrl}
alt="Preview"
fill
unoptimized
className="object-cover"
/>
</div>
) : (
<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="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="flex-1">
<input
ref={previewInputRef}
type="file"
onChange={handlePreviewChange}
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
className="btn-secondary"
>
选择图片
</button>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
支持 JPG、PNG、WebP 等格式,可拖拽上传
</p>
</div>
</div>
</div>
</div>
)}
{/* PSD展示图片(仅PSD类型显示) */}
{materialType === 'PSD' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
PSD展示图片 *
</label>
<div
ref={displayImageDropZoneRef}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isDisplayImageDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600'
}`}
onDragEnter={handleDisplayImageDragEnter}
onDragLeave={handleDisplayImageDragLeave}
onDragOver={handleDisplayImageDragOver}
onDrop={handleDisplayImageDrop}
>
<div className="flex items-center space-x-4">
{displayImageUrl ? (
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-slate-200 dark:border-slate-600">
<Image
src={displayImageUrl}
alt="Display"
fill
unoptimized
className="object-cover"
/>
</div>
) : (
<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="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="flex-1">
<input
type="file"
onChange={handleDisplayImageChange}
accept="image/*"
className="hidden"
id="displayImageInput"
/>
<button
type="button"
onClick={() => document.getElementById('displayImageInput')?.click()}
className="btn-secondary"
>
选择展示图片
</button>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
上传PSD详情页的展示图片,或拖拽图片到此区域
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 第一张图会自动作为预览图,高度超过1500px会自动切割
</p>
</div>
</div>
</div>
</div>
)}
{/* 模特展示图片(仅模特类型显示) */}
{materialType === '模特' && (
<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>
)}
{/* SPU 或材质名称 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
</label>
<input
type="text"
value={productModel}
onChange={(e) => {
// 材质类型支持中文,其他类型只允许英文、数字和常见符号
if (materialType === '材质') {
setProductModel(e.target.value)
} else {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
setProductModel(value)
}
}}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="例如:XY-2024-001"
/>
</div>
{/* 商品 / 品牌信息 */}
<div>
<p className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500 mb-2">商品 / 品牌信息</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
品牌名称
</label>
<input
type="text"
value={brandName}
onChange={(e) => setBrandName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="例如:Apple"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
系列 / 产品线
</label>
<input
type="text"
value={seriesName}
onChange={(e) => setSeriesName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="例如:iPhone 15 Pro"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
SKU 编码
</label>
<input
type="text"
value={skuCode}
onChange={(e) => setSkuCode(e.target.value.toUpperCase())}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="例如:SKU-0001"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
材质 / 工艺
</label>
<input
type="text"
value={materialTexture}
onChange={(e) => setMaterialTexture(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="例如:钛金属、皮革、磨砂玻璃等"
/>
</div>
</div>
</div>
{/* 使用店铺(材质类型不需要) */}
{materialType !== '材质' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="输入新店铺名称"
/>
<button
type="button"
onClick={handleAddNewCategory}
className="btn-primary"
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false)
setNewCategoryName('')
}}
className="btn-secondary"
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
className="btn-secondary whitespace-nowrap"
>
+ 新建店铺
</button>
</div>
)}
</div>
)}
{/* 场景类别(材质类型不需要) */}
{materialType !== '材质' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="输入新场景类别"
/>
<button
type="button"
onClick={handleAddNewScene}
className="btn-primary"
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false)
setNewSceneName('')
}}
className="btn-secondary"
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
className="btn-secondary whitespace-nowrap"
>
+ 新建场景类别
</button>
</div>
)}
</div>
)}
{/* 平台分类(可选,仅在后台配置了平台时显示) */}
{platforms.length > 0 && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
平台分类(可选)
</label>
<select
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</select>
</div>
)}
{/* 渲染 / 制作信息 */}
<div>
<p className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500 mb-2">渲染 / 制作信息</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
渲染耗时
</label>
<input
type="text"
value={renderTime}
onChange={(e) => setRenderTime(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="例如:2 小时 / 120 分钟"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
输出分辨率
</label>
<input
type="text"
value={imageResolution}
onChange={(e) => setImageResolution(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
placeholder="例如:5000 x 5000"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
颜色模式
</label>
<select
value={colorMode}
onChange={(e) => setColorMode(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
>
<option value="">请选择</option>
<option value="RGB">RGB</option>
<option value="CMYK">CMYK</option>
<option value="LAB">Lab</option>
<option value="其他">其他</option>
</select>
</div>
</div>
</div>
{/* 渲染器(仅3DMAX显示) */}
{materialType === '3DMAX' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
渲染器 *
</label>
<select
value={renderer}
onChange={(e) => setRenderer(e.target.value)}
className="w-full px-4 py-2 rounded-apple border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-white focus:border-slate-900 dark:focus:border-slate-400 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-600 outline-none"
>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
{/* Material File */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
素材文件 {materialType === '模特' ? '(可选)' : '*'}
</label>
<div
ref={dropZoneRef}
className={`border-2 border-dashed rounded-apple p-6 text-center transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600'
}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{zipFile ? (
<div className="space-y-2">
<p className="text-slate-700 dark:text-slate-300 font-medium">{zipFile.name}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{formatBytes(zipFile.size)}
</p>
{detectMaterialType(zipFile.name) && (
<p className="text-sm text-green-600 dark:text-green-400">
已自动识别类型: {detectMaterialType(zipFile.name)}
</p>
)}
<button
type="button"
onClick={() => setZipFile(null)}
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
移除
</button>
</div>
) : (
<div>
<input
ref={zipInputRef}
type="file"
onChange={handleZipChange}
accept=".zip,.rar,.7z,.max,.psd,.tif,.tiff,.psb,.ai,.cad,.dwg,.c4d,.mp4,.avi,.mov,.wmv,.flv,.mkv,.webm"
className="hidden"
/>
<button
type="button"
onClick={() => zipInputRef.current?.click()}
className="btn-secondary"
>
选择素材文件
</button>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
支持格式:
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
压缩包(.zip / .rar / .7z)或素材文件(.max / .psd / .tif / .psb / .ai / .cad / .dwg / .c4d)
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 PSD格式支持:.psd / .tif / .tiff / .psb
</p>
</div>
)}
</div>
</div>
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-400">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
<div className="text-sm text-slate-500 dark:text-slate-400 text-right">
速度: {formatSpeed(uploadSpeed)}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-apple">
{error}
</div>
)}
{/* Buttons */}
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={handleCancelUpload}
className="btn-secondary"
>
{uploading ? '取消上传' : '取消'}
</button>
<button
type="submit"
className="btn-primary"
disabled={uploading}
>
{uploading ? `上传中 ${uploadProgress}%` : '上传'}
</button>
</div>
</form>
</div>
</div>
</>
)
}