1145 lines
42 KiB
TypeScript
1145 lines
42 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useRef, useEffect } from 'react'
|
||
import Image from 'next/image'
|
||
|
||
interface UploadModalProps {
|
||
onClose: () => void
|
||
onSuccess: () => void
|
||
}
|
||
|
||
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||
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 [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 [materialType, setMaterialType] = useState('3DMAX')
|
||
|
||
// 渲染器(仅3DMAX使用)
|
||
const [renderer, setRenderer] = useState('CR')
|
||
|
||
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 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)
|
||
|
||
// 加载分类和场景列表
|
||
useEffect(() => {
|
||
fetchCategories()
|
||
fetchScenes()
|
||
fetchPlatforms()
|
||
}, [])
|
||
|
||
const fetchCategories = async () => {
|
||
try {
|
||
const response = await fetch('/api/categories')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCategories(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching categories:', error)
|
||
}
|
||
}
|
||
|
||
const fetchScenes = async () => {
|
||
try {
|
||
const response = await fetch('/api/scenes')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setScenes(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching scenes:', error)
|
||
}
|
||
}
|
||
|
||
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 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',
|
||
'.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']
|
||
const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
|
||
|
||
if (!validExtensions.includes(fileExtension)) {
|
||
setError('请选择有效的文件格式(压缩包:.zip/.rar/.7z 或素材文件:.max/.psd/.ai/.cad/.dwg/.c4d)')
|
||
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'].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 {
|
||
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()
|
||
await fetchCategories()
|
||
setCategoryName(newCategory.name)
|
||
setNewCategoryName('')
|
||
setShowNewCategoryInput(false)
|
||
setError('')
|
||
} else {
|
||
const data = await response.json()
|
||
setError(data.error || '添加分类失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding category:', error)
|
||
setError('添加分类失败')
|
||
}
|
||
}
|
||
|
||
const handleAddNewScene = async () => {
|
||
if (!newSceneName.trim()) {
|
||
setError('请输入场景名称')
|
||
return
|
||
}
|
||
|
||
try {
|
||
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()
|
||
await fetchScenes()
|
||
setSceneName(newScene.name)
|
||
setNewSceneName('')
|
||
setShowNewSceneInput(false)
|
||
setError('')
|
||
} else {
|
||
const data = await response.json()
|
||
setError(data.error || '添加场景失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error adding scene:', error)
|
||
setError('添加场景失败')
|
||
}
|
||
}
|
||
|
||
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('')
|
||
|
||
// Validation - 基础信息验证
|
||
if (!productModel) {
|
||
setError(materialType === '材质' ? '请填写材质名称' : '请填写SPU型号')
|
||
return
|
||
}
|
||
|
||
// 材质类型不需要店铺和场景
|
||
if (materialType !== '材质' && (!categoryName || !sceneName)) {
|
||
setError('请填写所有必填字段(店铺、场景)')
|
||
return
|
||
}
|
||
|
||
// 素材文件验证(模特类型素材文件可选)
|
||
if (materialType !== '模特' && !zipFile) {
|
||
setError('请上传素材文件')
|
||
return
|
||
}
|
||
|
||
// 图片验证
|
||
if (materialType === 'PSD' && !displayImage) {
|
||
setError('PSD素材必须上传展示图片')
|
||
return
|
||
}
|
||
|
||
if (materialType === '模特' && modelImages.length === 0) {
|
||
setError('模特素材必须上传至少一张展示图片')
|
||
return
|
||
}
|
||
|
||
if (materialType !== 'PSD' && 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)
|
||
|
||
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) {
|
||
setRetryCount(0)
|
||
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 (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div
|
||
className="bg-white dark:bg-gray-800 rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg"
|
||
>
|
||
|
||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">上传素材</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="space-y-1 mt-2">
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
💡 提示:可拖拽文件到对应的虚线框内上传
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
提示:如在内网环境,请通过顶部提示切换到内网地址以获得更快速度
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||
{/* 素材类型 - 第一栏,让用户先选择类型 */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
素材类型 *
|
||
</label>
|
||
<select
|
||
value={materialType}
|
||
onChange={(e) => setMaterialType(e.target.value)}
|
||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||
>
|
||
<option value="3DMAX">3DMAX</option>
|
||
<option value="PSD">PSD</option>
|
||
<option value="AI">AI</option>
|
||
<option value="CAD">CAD</option>
|
||
<option value="C4D">C4D</option>
|
||
<option value="材质">材质</option>
|
||
<option value="模特">模特</option>
|
||
<option value="标准饰品">标准饰品</option>
|
||
<option value="其他">其他</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* 预览图片(非PSD类型才显示) */}
|
||
{materialType !== 'PSD' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-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-gray-300 dark:border-gray-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-gray-200 dark:border-gray-600">
|
||
<Image
|
||
src={previewUrl}
|
||
alt="Preview"
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||
<svg className="w-12 h-12 text-gray-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-gray-500 dark:text-gray-400 mt-2">
|
||
支持 JPG、PNG、WebP 等格式,可拖拽上传
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* PSD展示图片(仅PSD类型显示) */}
|
||
{materialType === 'PSD' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-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-gray-300 dark:border-gray-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-gray-200 dark:border-gray-600">
|
||
<Image
|
||
src={displayImageUrl}
|
||
alt="Display"
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||
<svg className="w-12 h-12 text-gray-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-gray-500 dark:text-gray-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-gray-700 dark:text-gray-300 mb-2">
|
||
模特展示图片 * (可上传多张)
|
||
</label>
|
||
<div className="border-2 border-dashed rounded-apple p-4 border-gray-300 dark:border-gray-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-gray-200 dark:border-gray-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-gray-100 dark:bg-gray-700 flex items-center justify-center">
|
||
<svg className="w-12 h-12 text-gray-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-gray-500 dark:text-gray-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-gray-700 dark:text-gray-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-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||
placeholder="例如:XY-2024-001"
|
||
/>
|
||
</div>
|
||
|
||
{/* 使用店铺(材质类型不需要) */}
|
||
{materialType !== '材质' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-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-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-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-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-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-gray-700 dark:text-gray-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-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-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-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-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-gray-700 dark:text-gray-300 mb-2">
|
||
平台分类(可选)
|
||
</label>
|
||
<select
|
||
value={platformName}
|
||
onChange={(e) => setPlatformName(e.target.value)}
|
||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||
>
|
||
<option value="">不选择平台</option>
|
||
{platforms.map((platform) => (
|
||
<option key={platform.id} value={platform.name}>
|
||
{platform.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 渲染器(仅3DMAX显示) */}
|
||
{materialType === '3DMAX' && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
渲染器 *
|
||
</label>
|
||
<select
|
||
value={renderer}
|
||
onChange={(e) => setRenderer(e.target.value)}
|
||
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-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-gray-700 dark:text-gray-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-gray-300 dark:border-gray-600'
|
||
}`}
|
||
onDragEnter={handleDragEnter}
|
||
onDragLeave={handleDragLeave}
|
||
onDragOver={handleDragOver}
|
||
onDrop={handleDrop}
|
||
>
|
||
{zipFile ? (
|
||
<div className="space-y-2">
|
||
<p className="text-gray-700 dark:text-gray-300 font-medium">{zipFile.name}</p>
|
||
<p className="text-sm text-gray-500 dark:text-gray-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"
|
||
className="hidden"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => zipInputRef.current?.click()}
|
||
className="btn-secondary"
|
||
>
|
||
选择素材文件
|
||
</button>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
支持格式:
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-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-gray-600 dark:text-gray-400">
|
||
<span>上传进度</span>
|
||
<span>{uploadProgress}%</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 dark:bg-gray-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-gray-500 dark:text-gray-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>
|
||
)
|
||
}
|