'use client' import { useState, useRef, useEffect } from 'react' import Image from 'next/image' interface UploadModalProps { onClose: () => void onSuccess: () => void } // 内网服务器地址 const INTERNAL_SERVER = 'http://192.168.58.20:4320' export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { const [previewImage, setPreviewImage] = useState(null) const [previewUrl, setPreviewUrl] = useState('') const [zipFile, setZipFile] = useState(null) const [productModel, setProductModel] = useState('') // 一级分类 const [categoryName, setCategoryName] = useState('') const [categories, setCategories] = useState>([]) const [showNewCategoryInput, setShowNewCategoryInput] = useState(false) const [newCategoryName, setNewCategoryName] = useState('') // 二级分类 const [sceneName, setSceneName] = useState('') const [scenes, setScenes] = useState>([]) const [showNewSceneInput, setShowNewSceneInput] = useState(false) const [newSceneName, setNewSceneName] = useState('') // 素材类型 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 [retryCount, setRetryCount] = useState(0) const MAX_RETRY = 2 const previewInputRef = useRef(null) const zipInputRef = useRef(null) const dropZoneRef = useRef(null) const xhrRef = useRef(null) // 加载分类和场景列表 useEffect(() => { fetchCategories() fetchScenes() }, []) 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 handlePreviewChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { if (!file.type.startsWith('image/')) { setError('请选择图片文件') return } setPreviewImage(file) setPreviewUrl(URL.createObjectURL(file)) setError('') } } // 根据文件扩展名自动识别素材类型 const detectMaterialType = (filename: string): string | null => { const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase() const typeMap: { [key: string]: string | null } = { '.max': '3DMAX', '.psd': 'PSD', '.ai': 'AI', '.cad': 'CAD', '.dwg': 'CAD', '.c4d': 'C4D', '.zip': null, // 压缩包需要用户手动选择 '.rar': null, '.7z': null, } return typeMap[ext] ?? '其他' } const handleZipChange = (e: React.ChangeEvent) => { 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 imageFiles = files.filter(f => f.type.startsWith('image/')) if (imageFiles.length > 0 && !previewImage) { const imgFile = imageFiles[0] setPreviewImage(imgFile) setPreviewUrl(URL.createObjectURL(imgFile)) } // 处理素材文件(包括压缩包和直接文件) const materialFiles = files.filter(f => { const ext = f.name.substring(f.name.lastIndexOf('.')).toLowerCase() return ['.zip', '.rar', '.7z', '.max', '.psd', '.ai', '.cad', '.dwg', '.c4d'].includes(ext) }) if (materialFiles.length > 0 && !zipFile) { const file = materialFiles[0] setZipFile(file) // 自动识别并设置素材类型 const detectedType = detectMaterialType(file.name) if (detectedType) { setMaterialType(detectedType) } } 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('') // Check if file is PSD const isPsdFile = zipFile && zipFile.name.toLowerCase().endsWith('.psd') // Validation - preview is optional for PSD files if (!zipFile || !productModel || !categoryName || !sceneName) { setError('请填写所有必填字段') return } // For non-PSD files, preview is required if (!isPsdFile && !previewImage) { setError('请上传预览图,或者上传PSD文件以自动生成预览图') 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) } formData.append('zipFile', zipFile) formData.append('productModel', productModel) formData.append('categoryName', categoryName) formData.append('sceneName', sceneName) 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 (
{/* 拖拽覆盖层 */} {isDragging && (

拖放文件到这里

支持图片和压缩包文件

)}

上传素材

💡 提示:可以直接拖拽图片和压缩包文件到窗口

提示:如在内网环境,请通过顶部提示切换到内网地址以获得更快速度

{/* 预览图片 */}
{previewUrl ? (
Preview
) : (
)}

支持 JPG、PNG、WebP 等格式 {zipFile?.name.toLowerCase().endsWith('.psd') && ( 💡 PSD文件可自动生成预览图,无需手动上传 )}

{/* SPU */}
{ // 只允许英文、数字和常见符号(- _ .),过滤中文,自动转大写 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" />
{/* 使用店铺 */}
{showNewCategoryInput ? (
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="输入新店铺名称" />
) : (
)}
{/* 场景类别 */}
{showNewSceneInput ? (
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="输入新场景类别" />
) : (
)}
{/* 素材类型 */}
{/* 渲染器(仅3DMAX显示) */} {materialType === '3DMAX' && (
)} {/* Material File */}
{zipFile ? (

{zipFile.name}

{formatBytes(zipFile.size)}

{detectMaterialType(zipFile.name) && (

已自动识别类型: {detectMaterialType(zipFile.name)}

)}
) : (

支持格式:

压缩包(.zip / .rar / .7z)或素材文件(.max / .psd / .ai / .cad / .dwg / .c4d)

💡 非压缩包文件会自动识别类型

)}
{/* 上传进度 */} {uploading && (
上传进度 {uploadProgress}%
速度: {formatSpeed(uploadSpeed)}
)} {/* Error Message */} {error && (
{error}
)} {/* Buttons */}
) }