/** * UploadModal 组件 * * 素材上传模态框组件 * 支持多种素材类型(3DMAX、PSD、AI、CAD、C4D、视频、材质、模特等) * 支持预览图、展示图、ZIP文件上传 * 支持断点续传、拖拽上传等功能 * 使用三层架构样式系统,支持深浅色主题 * * @created 2025-01-XX * @component */ 'use client' import { useState, useRef, useEffect, useCallback } from 'react' import Image from 'next/image' import { useThemeStyles, cn } from '@/lib/ui-styles' import Modal from '@/components/ui/Modal' interface UploadModalProps { onClose: () => void onSuccess: () => void } type UploadTemplateOption = { id: string name: string description: string | null materialType?: string fields: any[] isBuiltin?: boolean } export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { const styles = useThemeStyles() const [previewImage, setPreviewImage] = useState(null) const [previewUrl, setPreviewUrl] = useState('') const [displayImage, setDisplayImage] = useState(null) // PSD展示图片 const [displayImageUrl, setDisplayImageUrl] = useState('') // PSD展示图片预览URL const [modelImages, setModelImages] = useState([]) // 模特类型的多张展示图片 const [modelImageUrls, setModelImageUrls] = useState([]) // 模特图片预览URLs const [zipFile, setZipFile] = useState(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>([]) 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 [platformName, setPlatformName] = useState('') const [platforms, setPlatforms] = useState>([]) // 模板相关 const [templates, setTemplates] = useState([]) const [selectedTemplateId, setSelectedTemplateId] = useState('') const [selectedTemplate, setSelectedTemplate] = useState(null) const [templateFieldValues, setTemplateFieldValues] = useState>({}) // 模板分类选项(用于勾选了 addToCategory 的字段) const [templateCategoryOptions, setTemplateCategoryOptions] = useState>({}) // 素材类型(保留用于向后兼容,但优先使用模板) const [materialType, setMaterialType] = useState('3DMAX') // 渲染器(仅3DMAX使用) const [renderer, setRenderer] = useState('CR') const [renderTime, setRenderTime] = useState('') const [imageResolution, setImageResolution] = useState('') const [colorMode, setColorMode] = useState('') const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState(0) const [uploadSpeed, setUploadSpeed] = useState(0) const [uploadComplete, setUploadComplete] = useState(false) // 上传完成状态 const [error, setError] = useState('') const [isDragging, setIsDragging] = useState(false) const [isDisplayImageDragging, setIsDisplayImageDragging] = useState(false) // PSD展示图片拖拽状态 const [isPreviewDragging, setIsPreviewDragging] = useState(false) // 预览图片拖拽状态 const [retryCount, setRetryCount] = useState(0) const MAX_RETRY = 2 // 断点续传相关状态 const [showResumeDialog, setShowResumeDialog] = useState(false) const [savedUploadData, setSavedUploadData] = useState(null) const previewInputRef = useRef(null) const zipInputRef = useRef(null) const dropZoneRef = useRef(null) const displayImageDropZoneRef = useRef(null) // PSD展示图片拖拽区域 const displayImageInputRef = useRef(null) // PSD展示图片输入 const previewDropZoneRef = useRef(null) // 预览图片拖拽区域 const xhrRef = useRef(null) // 保存上传状态到 localStorage(使用 useCallback 确保函数稳定) const saveUploadState = useCallback(() => { if (uploading || uploadComplete || (uploadProgress > 0 && uploadProgress < 100)) { const uploadState = { productModel, brandName, seriesName, skuCode, materialTexture, categoryName, sceneName, platformName, materialType, renderer, renderTime, imageResolution, colorMode, previewImageName: previewImage?.name || '', displayImageName: displayImage?.name || '', modelImagesNames: modelImages.map(img => img.name), zipFileName: zipFile?.name || '', uploadProgress, timestamp: Date.now() } try { localStorage.setItem('uploadState', JSON.stringify(uploadState)) } catch (e) { console.error('Failed to save upload state to localStorage:', e) } } }, [uploading, uploadProgress, productModel, brandName, seriesName, skuCode, materialTexture, categoryName, sceneName, platformName, materialType, renderer, renderTime, imageResolution, colorMode, previewImage, displayImage, modelImages, zipFile]) // 清除保存的上传状态 const clearUploadState = useCallback(() => { localStorage.removeItem('uploadState') setSavedUploadData(null) }, []) // 恢复上传状态 const restoreUploadState = (data: any) => { setProductModel(data.productModel || '') setBrandName(data.brandName || '') setSeriesName(data.seriesName || '') setSkuCode(data.skuCode || '') setMaterialTexture(data.materialTexture || '') setCategoryName(data.categoryName || '') setSceneName(data.sceneName || '') setPlatformName(data.platformName || '') setMaterialType(data.materialType || '3DMAX') setRenderer(data.renderer || 'CR') setRenderTime(data.renderTime || '') setImageResolution(data.imageResolution || '') setColorMode(data.colorMode || '') setUploadProgress(data.uploadProgress || 0) // 注意:文件对象无法直接恢复,需要用户重新选择 } const resetForm = useCallback(() => { setPreviewImage(null) setPreviewUrl('') setDisplayImage(null) setDisplayImageUrl('') setModelImages([]) setModelImageUrls([]) setZipFile(null) setProductModel('') setBrandName('') setSeriesName('') setSkuCode('') setMaterialTexture('') setCategoryName('') setSceneName('') setPlatformName('') setMaterialType('3DMAX') setRenderer('CR') setRenderTime('') setImageResolution('') setColorMode('') setUploadProgress(0) setUploadSpeed(0) setError('') setShowNewCategoryInput(false) setShowNewSceneInput(false) setNewCategoryName('') setNewSceneName('') }, []) // 检查是否有未完成的上传 useEffect(() => { const savedState = localStorage.getItem('uploadState') if (savedState) { try { const data = JSON.parse(savedState) // 检查是否在24小时内(避免过期数据) const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000 if (data.timestamp && data.timestamp > oneDayAgo) { setSavedUploadData(data) setShowResumeDialog(true) } else { // 超过24小时,清除过期数据 clearUploadState() } } catch (e) { console.error('Failed to parse saved upload state:', e) clearUploadState() } } }, [clearUploadState]) // 在上传状态变化时保存 useEffect(() => { const timer = setTimeout(() => { if (uploading || uploadComplete || (uploadProgress > 0 && uploadProgress < 100)) { saveUploadState() } else if (uploadProgress === 100 || uploadComplete) { // 上传完成,清除保存的状态 clearUploadState() } }, 1000) // 延迟1秒保存,避免频繁写入localStorage return () => clearTimeout(timer) }, [uploading, uploadComplete, uploadProgress, saveUploadState, clearUploadState]) // 页面关闭前保存状态 useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (uploading) { saveUploadState() e.preventDefault() e.returnValue = '上传正在进行中,确定要离开吗?' } } window.addEventListener('beforeunload', handleBeforeUnload) return () => { window.removeEventListener('beforeunload', handleBeforeUnload) } }, [uploading, saveUploadState]) // 加载分类、场景列表和模板列表 useEffect(() => { fetchCategories() fetchScenes() fetchPlatforms() fetchTemplates() fetchTemplateCategoryOptions() // 获取模板分类选项 // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 定期刷新模板分类选项(每30秒),确保后台添加的分类能同步到前台 useEffect(() => { const interval = setInterval(() => { fetchTemplateCategoryOptions() }, 30000) // 30秒刷新一次 return () => clearInterval(interval) }, []) const fetchCategories = async () => { try { const response = await fetch('/api/categories') if (response.ok) { const data = await response.json() setCategories(data || []) console.log('[UploadModal] Categories loaded:', data?.length || 0) } else { const errorData = await response.json().catch(() => ({ error: '未知错误' })) console.error('[UploadModal] Failed to fetch categories:', response.status, errorData) setError(`加载店铺列表失败: ${errorData.error || '未知错误'}`) } } catch (error) { console.error('[UploadModal] Error fetching categories:', error) setError('加载店铺列表失败,请刷新页面重试') } } const fetchScenes = async () => { try { const response = await fetch('/api/scenes') if (response.ok) { const data = await response.json() setScenes(data || []) console.log('[UploadModal] Scenes loaded:', data?.length || 0) } else { const errorData = await response.json().catch(() => ({ error: '未知错误' })) console.error('[UploadModal] Failed to fetch scenes:', response.status, errorData) setError(`加载场景列表失败: ${errorData.error || '未知错误'}`) } } catch (error) { console.error('[UploadModal] Error fetching scenes:', error) setError('加载场景列表失败,请刷新页面重试') } } const fetchPlatforms = async () => { try { const response = await fetch('/api/platforms') if (response.ok) { const data = await response.json() setPlatforms(data) } } catch (error) { console.error('Error fetching platforms:', error) } } // 获取启用的模板列表 const fetchTemplates = async () => { try { const response = await fetch('/api/upload-templates') if (response.ok) { const data = await response.json() setTemplates(data || []) console.log('[UploadModal] Templates loaded:', data?.length || 0) // 如果有模板且只有一个,自动选择 if (data && data.length === 1) { handleTemplateChange(data[0].id) } } else { const errorData = await response.json().catch(() => ({ error: '未知错误' })) console.error('[UploadModal] Failed to fetch templates:', response.status, errorData) setError(`加载模板列表失败: ${errorData.error || '未知错误'}`) } } catch (error) { console.error('[UploadModal] Error fetching templates:', error) setError('加载模板列表失败,请刷新页面重试') } } // 获取模板分类选项(用于勾选了 addToCategory 的字段) const fetchTemplateCategoryOptions = async () => { try { const response = await fetch('/api/template-categories') if (response.ok) { const data = await response.json() const optionsMap: Record = {} data.forEach((cat: { fieldKey: string; options: string[] }) => { optionsMap[cat.fieldKey] = cat.options }) setTemplateCategoryOptions(optionsMap) console.log('[UploadModal] Template category options loaded:', Object.keys(optionsMap).length) } else { console.error('[UploadModal] Failed to fetch template category options:', response.status) } } catch (error) { console.error('[UploadModal] Error fetching template category options:', error) } } // 处理模板选择变化 const handleTemplateChange = (templateId: string) => { setSelectedTemplateId(templateId) const template = templates.find(t => t.id === templateId) || null setSelectedTemplate(template) // 清空之前的字段值 setTemplateFieldValues({}) if (template) { const initialValues: Record = {} template.fields.forEach((field: any) => { if (field.type === 'checkbox') { initialValues[field.key] = false } else { initialValues[field.key] = '' } }) setTemplateFieldValues(initialValues) setMaterialType(template.materialType || '其他') } else { setMaterialType('其他') } } const handlePreviewChange = (e: React.ChangeEvent) => { 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) => { 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) => { const files = Array.from(e.target.files || []) if (files.length === 0) return // 验证所有文件都是图片 const invalidFiles = files.filter(file => !file.type.startsWith('image/')) if (invalidFiles.length > 0) { setError('请只选择图片文件') return } // 添加新图片到现有列表 const newImages = [...modelImages, ...files] const newUrls = [...modelImageUrls, ...files.map(file => URL.createObjectURL(file))] setModelImages(newImages) setModelImageUrls(newUrls) setError('') } // 删除模特图片 const removeModelImage = (index: number) => { const newImages = modelImages.filter((_, i) => i !== index) const newUrls = modelImageUrls.filter((_, i) => i !== index) // 释放被删除图片的URL URL.revokeObjectURL(modelImageUrls[index]) setModelImages(newImages) setModelImageUrls(newUrls) } const handleZipChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { setZipFile(file) setError('') } } // 拖拽处理 - 修复闪屏问题 const handleDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() // 只有当鼠标真正离开容器时才设置为false,避免经过子元素时闪屏 const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const x = e.clientX const y = e.clientY if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { setIsDragging(false) } } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() } const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDragging(false) const files = Array.from(e.dataTransfer.files) if (files.length === 0) return const file = files[0] if (!file) return setZipFile(file) setError('') } // 预览图片拖拽处理 const handlePreviewDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsPreviewDragging(true) } const handlePreviewDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const x = e.clientX const y = e.clientY if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { setIsPreviewDragging(false) } } const handlePreviewDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() } const handlePreviewDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsPreviewDragging(false) const files = Array.from(e.dataTransfer.files) if (files.length === 0) return const imageFiles = files.filter(f => f.type.startsWith('image/')) if (imageFiles.length > 0) { const imgFile = imageFiles[0] setPreviewImage(imgFile) setPreviewUrl(URL.createObjectURL(imgFile)) setError('') } else { setError('请拖拽图片文件') } } // PSD展示图片拖拽处理 const handleDisplayImageDragEnter = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDisplayImageDragging(true) } const handleDisplayImageDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const x = e.clientX const y = e.clientY if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { setIsDisplayImageDragging(false) } } const handleDisplayImageDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() } const handleDisplayImageDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() setIsDisplayImageDragging(false) const files = Array.from(e.dataTransfer.files) if (files.length === 0) return const imageFiles = files.filter(f => f.type.startsWith('image/')) if (imageFiles.length > 0) { const imgFile = imageFiles[0] setDisplayImage(imgFile) setDisplayImageUrl(URL.createObjectURL(imgFile)) setError('') } else { setError('请拖拽图片文件') } } const handleAddNewCategory = async () => { if (!newCategoryName.trim()) { setError('请输入分类名称') return } try { console.log('[UploadModal] Creating category:', newCategoryName.trim()) const response = await fetch('/api/categories', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: newCategoryName.trim() }), }) if (response.ok) { const newCategory = await response.json() console.log('[UploadModal] Category created:', newCategory) await fetchCategories() setCategoryName(newCategory.name) setNewCategoryName('') setShowNewCategoryInput(false) setError('') } else { const data = await response.json().catch(() => ({ error: '未知错误' })) console.error('[UploadModal] Failed to create category:', response.status, data) setError(data.error || '添加分类失败') } } catch (error) { console.error('[UploadModal] Error adding category:', error) setError(`添加分类失败: ${error instanceof Error ? error.message : '未知错误'}`) } } const handleAddNewScene = async () => { if (!newSceneName.trim()) { setError('请输入场景名称') return } try { console.log('[UploadModal] Creating scene:', newSceneName.trim()) const response = await fetch('/api/scenes', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: newSceneName.trim() }), }) if (response.ok) { const newScene = await response.json() console.log('[UploadModal] Scene created:', newScene) await fetchScenes() setSceneName(newScene.name) setNewSceneName('') setShowNewSceneInput(false) setError('') } else { const data = await response.json().catch(() => ({ error: '未知错误' })) console.error('[UploadModal] Failed to create scene:', response.status, data) setError(data.error || '添加场景失败') } } catch (error) { console.error('[UploadModal] Error adding scene:', error) setError(`添加场景失败: ${error instanceof Error ? error.message : '未知错误'}`) } } const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] } const formatSpeed = (bytesPerSecond: number) => { return formatBytes(bytesPerSecond) + '/s' } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') // 验证模板选择 if (!selectedTemplateId) { setError('请选择素材模板') return } const currentMaterialType = selectedTemplate?.materialType || materialType // 验证模板必填字段 if (selectedTemplate && selectedTemplate.fields) { for (const field of selectedTemplate.fields) { if (field.required) { const value = templateFieldValues[field.key] if (!value || (typeof value === 'string' && value.trim() === '')) { setError(`请填写必填字段:${field.label}`) return } } } } // 从模板字段中提取productModel const currentProductModel = templateFieldValues.productModel || '' if (!currentProductModel) { setError(currentMaterialType === '材质' ? '请填写材质名称' : '请填写SPU型号') return } // 从模板字段中提取categoryName和sceneName const currentCategoryName = templateFieldValues.categoryName || '' const currentSceneName = templateFieldValues.sceneName || '' // 材质类型不需要店铺和场景 if (currentMaterialType !== '材质' && (!currentCategoryName || !currentSceneName)) { setError('请填写所有必填字段(店铺、场景)') return } // 素材文件验证(模特类型和材质类型素材文件可选) if (currentMaterialType !== '模特' && currentMaterialType !== '材质' && !zipFile) { setError('请上传素材文件') return } // 图片验证 if (currentMaterialType === 'PSD' && !displayImage) { setError('PSD素材必须上传展示图片') return } if (currentMaterialType === '模特' && modelImages.length === 0) { setError('模特素材必须上传至少一张展示图片') return } if (currentMaterialType === '视频' && !previewImage) { setError('视频素材需要上传预览图(封面)') return } if (currentMaterialType !== '模特' && !previewImage) { setError('请上传预览图') return } // 视频类型验证 if (currentMaterialType === '视频' && !zipFile) { setError('请上传视频文件') return } // 重置重试计数和状态 setRetryCount(0) setUploading(true) setUploadProgress(0) setUploadSpeed(0) setUploadComplete(false) try { const formData = new FormData() // Only append preview image if it exists if (previewImage) { formData.append('previewImage', previewImage) } if (displayImage) { formData.append('displayImage', displayImage) } // 添加模特类型的多张图片 if (currentMaterialType === '模特' && modelImages.length > 0) { modelImages.forEach((image, index) => { formData.append('modelImages', image) }) } // 素材文件对于模特类型是可选的 if (zipFile) { formData.append('zipFile', zipFile) } formData.append('productModel', currentProductModel) // 材质类型使用默认值 formData.append('categoryName', currentMaterialType === '材质' ? '通用' : currentCategoryName) formData.append('sceneName', currentMaterialType === '材质' ? '通用' : currentSceneName) formData.append('platformName', templateFieldValues.platformName || '') // 平台分类可选 formData.append('materialType', currentMaterialType) formData.append('renderer', templateFieldValues.renderer || 'CR') formData.append('brandName', templateFieldValues.brandName || '') formData.append('seriesName', templateFieldValues.seriesName || '') formData.append('skuCode', templateFieldValues.skuCode || '') formData.append('materialTexture', templateFieldValues.materialTexture || '') formData.append('renderTime', templateFieldValues.renderTime || '') formData.append('imageResolution', templateFieldValues.imageResolution || '') formData.append('colorMode', templateFieldValues.colorMode || '') const xhr = new XMLHttpRequest() xhrRef.current = xhr // 保存引用以便取消 // 禁用超时限制,适用于大文件上传 xhr.timeout = 0 let startTime = Date.now() let startLoaded = 0 xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = Math.round((e.loaded / e.total) * 100) setUploadProgress(percentComplete) // 计算上传速度 const currentTime = Date.now() const timeElapsed = (currentTime - startTime) / 1000 // 秒 if (timeElapsed > 0) { const speed = (e.loaded - startLoaded) / timeElapsed setUploadSpeed(speed) startTime = currentTime startLoaded = e.loaded } } }) xhr.addEventListener('load', () => { if (xhr.status === 201) { // 立即设置进度为100%并显示完成状态 setUploadProgress(100) setUploadComplete(true) setUploading(false) // 清除上传状态和重置表单 clearUploadState() resetForm() setRetryCount(0) // 短暂延迟确保UI更新完成,然后关闭弹窗 setTimeout(() => { onSuccess() onClose() }, 300) // 300ms延迟,确保用户看到完成状态 } else { try { const data = JSON.parse(xhr.responseText) let errorMsg = data.error || '上传失败' // 提供更详细的错误信息 if (xhr.status === 413) { errorMsg = '文件过大,请联系管理员增加上传限制' } else if (xhr.status === 408 || xhr.status === 504) { errorMsg = '上传超时,建议:\n1. 压缩文件后再上传\n2. 使用内网地址(更快)\n3. 分批上传大文件' } else if (xhr.status >= 500) { errorMsg = `服务器错误 (${xhr.status}):${errorMsg}` } setError(errorMsg) } catch { setError(`上传失败 (状态码: ${xhr.status})`) } setUploading(false) setRetryCount(0) } }) xhr.addEventListener('error', (e) => { console.error('Upload XHR error:', e) console.error('XHR status:', xhr.status) console.error('XHR readyState:', xhr.readyState) console.error('Upload progress:', uploadProgress) // 检测是否是连接重置错误 const isConnectionReset = xhr.status === 0 let errorMsg = '上传失败,请稍后重试' // 如果上传进度已达到100%,可能实际已成功 if (uploadProgress === 100 && isConnectionReset) { errorMsg = '⚠️ 上传可能已完成!\n\n由于网络原因,无法确认最终状态。\n\n请执行以下操作:\n1. 刷新页面查看文件是否已上传\n2. 如果文件已存在,说明上传成功\n3. 如果未看到文件,请重新上传\n\n提示:使用内网地址上传更稳定' // 3秒后自动关闭弹窗并刷新 setTimeout(() => { onClose() window.location.reload() }, 5000) } else if (isConnectionReset) { errorMsg = '网络连接中断,可能原因:\n• 文件过大导致超时\n• 网络不稳定\n• 服务器响应慢\n\n建议:\n1. 检查文件大小(压缩包建议<1GB)\n2. 使用内网地址上传(更快更稳定)\n3. 如果是大文件,尝试压缩后再上传\n4. 刷新页面检查文件是否已上传成功' } setError(errorMsg) setUploading(false) setUploadComplete(false) }) xhr.addEventListener('abort', () => { setError('上传已取消') setUploading(false) setUploadComplete(false) setRetryCount(0) xhrRef.current = null }) // 始终使用相对路径上传,避免跨域Cookie问题 // 用户应通过Header的提示切换到内网地址以获得更快速度 const uploadUrl = '/api/materials/upload' console.log(`🚀 上传地址: ${uploadUrl}`) xhr.open('POST', uploadUrl) xhr.send(formData) } catch (error) { console.error('Upload error:', error) setError('上传失败,请稍后再试') setUploading(false) setUploadComplete(false) } } const handleCancelUpload = () => { if (xhrRef.current && uploading) { xhrRef.current.abort() } else { onClose() } } const resolvedMaterialType = selectedTemplate?.materialType || materialType const previewRequiredInUi = resolvedMaterialType !== '模特' return ( <> { clearUploadState() setShowResumeDialog(false) }} size="small" showCloseButton={false} closeOnOverlayClick={false} closeOnEscape={false} > {savedUploadData && (

📎 检测到未完成的上传

素材类型:{savedUploadData.materialType || '未知'}

SPU型号:{savedUploadData.productModel || '未填写'}

上传进度:{savedUploadData.uploadProgress || 0}%

⚠️ 文件需要重新选择,但表单内容已保存

)}

上传素材

💡 提示:可拖拽文件到对应的虚线框内上传

{uploading && (

💾 上传进度已自动保存,关闭网页后可恢复

)}
{/* 素材模板 - 第一栏,让用户先选择模板 */}
{templates.length > 0 ? ( ) : (
暂无可用模板,请联系管理员
)} {selectedTemplate?.description && (

{selectedTemplate.description}

)}
{/* 预览图片和素材文件卡片 - 移到上方,样式与预览面板一致 */}
previewInputRef.current?.click()} >
{previewUrl ? (
Preview
) : (
)}

点击或拖拽上传预览图

支持 JPG、PNG、WebP 等格式

{/* PSD展示图片(仅PSD类型显示) */} {resolvedMaterialType === 'PSD' && (
displayImageInputRef.current?.click()} >
{displayImageUrl ? (
PSD 展示图
) : (
)}

点击或拖拽上传 PSD 展示图

建议上传详情页第一屏,支持 JPG/PNG/WebP

💡 第一张图会自动作为预览图,超高图片会自动切割

)} {/* 素材文件 */}
!zipFile && zipInputRef.current?.click()} > {zipFile ? (

{zipFile.name}

{formatBytes(zipFile.size)}

) : (

点击或拖拽上传素材文件

支持 ZIP、RAR 等压缩格式

)}
{/* 动态渲染模板字段 */} {selectedTemplate && selectedTemplate.fields && selectedTemplate.fields.length > 0 && (

模板字段

{selectedTemplate.fields.map((field: any) => { const fieldValue = templateFieldValues[field.key] || '' const isHalfWidth = field.layoutWidth === 'half' return (
{field.required && ( 必填 )}
{field.type === 'text' || field.type === 'uppercase' ? ( { const value = field.type === 'uppercase' ? e.target.value.toUpperCase() : e.target.value setTemplateFieldValues({ ...templateFieldValues, [field.key]: value, }) }} required={field.required} placeholder={`请输入${field.label || field.key}`} className={cn( 'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60', field.required ? 'bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]' : 'bg-white/90 dark:bg-slate-800/80', field.type === 'uppercase' && 'uppercase' )} /> ) : field.type === 'textarea' ? (