/** * UploadModal 组件 * * 素材上传模态框组件 * 支持多种素材类型(3DMAX、PSD、AI、CAD、C4D、视频、材质、模特等) * 支持预览图、展示图、ZIP文件上传 * 支持断点续传、拖拽上传等功能 * 使用三层架构样式系统,支持深浅色主题 * * @created 2025-01-XX * @component */ 'use client' import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import Image from 'next/image' import { useThemeStyles, cn } from '@/lib/ui-styles' import Modal from '@/components/ui/Modal' import { parseAudioFile, isAudioFile, AUDIO_ACCEPT_TYPES, type AudioMetadataBrowser } from '@/lib/audio-parser-browser' import { filterVisibleFieldValues, getVisibleTemplateFields, hasFieldValue } from '@/lib/field-conditions' interface UploadModalProps { onClose: () => void onSuccess: () => void } type MaterialKind = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' type UploadTemplateOption = { id: string name: string description: string | null materialType?: string materialKind?: MaterialKind fields: any[] isBuiltin?: boolean } const normalizeMaterialKind = (value?: string | null): MaterialKind => { if (!value) return 'IMAGE' const upper = value.toUpperCase() if (upper === 'VIDEO' || upper === 'OTHER' || upper === 'IMAGE' || upper === 'AUDIO') { return upper as MaterialKind } return 'IMAGE' } export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { const styles = useThemeStyles() const modalControlClass = 'w-full px-4 py-2.5 text-sm rounded-lg outline-none transition-colors border bg-white dark:bg-slate-800 text-slate-900 dark:text-white border-slate-300 dark:border-slate-700 focus-visible:ring-2 focus-visible:ring-slate-200 dark:focus-visible:ring-slate-700 focus-visible:border-slate-900 dark:focus-visible:border-slate-500 placeholder:text-slate-400 dark:placeholder:text-slate-500' const requiredControlClass = 'bg-slate-50 dark:bg-slate-800 border-slate-400 dark:border-slate-600' const uploadZoneClass = 'border border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 transition-colors hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer' const uploadTileClass = 'flex-shrink-0 rounded-lg border border-dashed border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 flex items-center justify-center text-slate-500 dark:text-slate-400' const [previewImages, setPreviewImages] = useState([]) // 多预览图(最多5张) const [previewUrls, setPreviewUrls] = useState([]) // 多预览图预览URLs const [displayImage, setDisplayImage] = useState(null) // PSD展示图片 const [displayImageUrl, setDisplayImageUrl] = useState('') // PSD展示图片预览URL 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 [pendingMaterialType, setPendingMaterialType] = useState(null) 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 [draggingPreviewIndex, setDraggingPreviewIndex] = useState(null) const MAX_RETRY = 2 // 断点续传相关状态 const [showResumeDialog, setShowResumeDialog] = useState(false) const [savedUploadData, setSavedUploadData] = useState(null) // 音频解析相关状态 const [audioMetadata, setAudioMetadata] = useState(null) const [isParsingAudio, setIsParsingAudio] = useState(false) 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 previewDragTargetRef = useRef(null) const reorderPreviewItems = useCallback( (fromIndex: number, toIndex: number) => { if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return setPreviewImages((prev) => { const items = [...prev] if (fromIndex >= items.length || toIndex >= items.length) return prev const [moved] = items.splice(fromIndex, 1) items.splice(toIndex, 0, moved) return items }) setPreviewUrls((prev) => { const urls = [...prev] if (fromIndex >= urls.length || toIndex >= urls.length) return prev const [moved] = urls.splice(fromIndex, 1) urls.splice(toIndex, 0, moved) return urls }) }, [] ) const handlePreviewCardDragStart = (index: number) => (event: React.DragEvent) => { event.stopPropagation() setDraggingPreviewIndex(index) previewDragTargetRef.current = index event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/plain', String(index)) } const handlePreviewCardDragOver = (index: number) => (event: React.DragEvent) => { event.preventDefault() event.stopPropagation() if (draggingPreviewIndex === null || draggingPreviewIndex === index) { return } reorderPreviewItems(draggingPreviewIndex, index) setDraggingPreviewIndex(index) previewDragTargetRef.current = index } const handlePreviewCardDragEnd = () => { setDraggingPreviewIndex(null) previewDragTargetRef.current = null } const handlePreviewCardDrop = (event: React.DragEvent) => { event.preventDefault() event.stopPropagation() setDraggingPreviewIndex(null) previewDragTargetRef.current = null } const xhrRef = useRef(null) const VIDEO_ACCEPT_TYPES = 'video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/m4v,.mp4,.mov,.avi,.mkv,.webm,.m4v' const VIDEO_FILE_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v'] const isVideoFile = (file: File) => { if (!file) return false if (file.type && file.type.startsWith('video/')) { return true } const ext = file.name.split('.').pop()?.toLowerCase() || '' return VIDEO_FILE_EXTENSIONS.includes(ext) } // 保存上传状态到 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, previewImagesNames: previewImages.map(img => img.name), displayImageName: displayImage?.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, previewImages, displayImage, zipFile, uploadComplete, ]) // 清除保存的上传状态 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(() => { // 释放所有预览图URL previewUrls.forEach(url => URL.revokeObjectURL(url)) setPreviewImages([]) setPreviewUrls([]) setDisplayImage(null) setDisplayImageUrl('') 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('') }, [previewUrls]) // 检查是否有未完成的上传 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() const loadedTemplates = Array.isArray(data) ? data : [] setTemplates(loadedTemplates) console.log('[UploadModal] Templates loaded:', loadedTemplates.length) // 如果有模板且只有一个,自动选择 if (loadedTemplates.length === 1) { const template = loadedTemplates[0] setSelectedTemplateId(template.id) setSelectedTemplate(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 { 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 { // 使用 includeAll=true 获取所有分类字段,包括未启用筛选的字段 const response = await fetch('/api/template-categories?includeAll=true') 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, optionsMap) } 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 buildTemplateFieldsPayload = useCallback(() => { if (!selectedTemplate?.fields?.length) return {} return filterVisibleFieldValues(selectedTemplate.fields, templateFieldValues) }, [selectedTemplate, templateFieldValues]) const handlePreviewChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) if (files.length === 0) return // 验证文件类型 const imageFiles = files.filter(file => file.type.startsWith('image/')) if (imageFiles.length !== files.length) { setError('请选择图片文件') return } // 检查总数是否超过5张 const currentCount = previewImages.length const newCount = currentCount + imageFiles.length if (newCount > 5) { setError(`最多只能上传5张预览图(当前:${currentCount}张,尝试添加:${imageFiles.length}张)`) return } // 添加新图片 const newImages = [...previewImages, ...imageFiles] setPreviewImages(newImages) setPreviewUrls([...previewUrls, ...imageFiles.map(file => URL.createObjectURL(file))]) setError('') // 清空input,允许重复选择同一文件 if (previewInputRef.current) { previewInputRef.current.value = '' } } const removePreviewImage = (index: number) => { const newImages = previewImages.filter((_, i) => i !== index) const newUrls = previewUrls.filter((_, i) => i !== index) setPreviewImages(newImages) // 释放URL对象 URL.revokeObjectURL(previewUrls[index]) setPreviewUrls(newUrls) } 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 handleZipChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { if (currentMaterialKind === 'VIDEO' && !isVideoFile(file)) { setError('请上传视频文件(支持 MP4、MOV、AVI、MKV、WebM 等格式)') e.target.value = '' return } if (currentMaterialKind === 'AUDIO' && !isAudioFile(file)) { setError('请上传音频文件(支持 MP3、WAV、FLAC、AAC 等格式)') e.target.value = '' return } setZipFile(file) setError('') // 如果是音频文件,自动解析元数据 if (currentMaterialKind === 'AUDIO' && isAudioFile(file)) { setIsParsingAudio(true) try { const metadata = await parseAudioFile(file) setAudioMetadata(metadata) if (metadata) { // 自动填充字段 setTemplateFieldValues(prev => { const newValues = { ...prev } // 自动填充时长 if (metadata.durationFormatted) { newValues.audioDuration = metadata.durationFormatted } // 如果有内嵌标题且产品名称为空,自动填充 if (metadata.title && !prev.productModel) { newValues.productModel = metadata.title } // 如果有内嵌艺术家且作者为空,自动填充 if (metadata.artist && !prev.authorName) { newValues.authorName = metadata.artist } return newValues }) // 如果有内嵌封面图且没有预览图,自动设置 if (metadata.coverImage && previewImages.length === 0) { // 将 base64 转换为 File 对象 const response = await fetch(metadata.coverImage) const blob = await response.blob() const coverFile = new File([blob], 'cover.jpg', { type: blob.type }) setPreviewImages([coverFile]) setPreviewUrls([metadata.coverImage]) } } } catch (err) { console.error('[UploadModal] Failed to parse audio file:', err) } finally { setIsParsingAudio(false) } } } } // 拖拽处理 - 修复闪屏问题 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 = async (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 if (currentMaterialKind === 'VIDEO' && !isVideoFile(file)) { setError('请拖拽正确的视频文件,支持 MP4、MOV、AVI、MKV、WebM 等格式') return } if (currentMaterialKind === 'AUDIO' && !isAudioFile(file)) { setError('请拖拽正确的音频文件,支持 MP3、WAV、FLAC、AAC 等格式') return } setZipFile(file) setError('') // 如果是音频文件,自动解析元数据 if (currentMaterialKind === 'AUDIO' && isAudioFile(file)) { setIsParsingAudio(true) try { const metadata = await parseAudioFile(file) setAudioMetadata(metadata) if (metadata) { setTemplateFieldValues(prev => { const newValues = { ...prev } if (metadata.durationFormatted) { newValues.audioDuration = metadata.durationFormatted } if (metadata.title && !prev.productModel) { newValues.productModel = metadata.title } if (metadata.artist && !prev.authorName) { newValues.authorName = metadata.artist } return newValues }) if (metadata.coverImage && previewImages.length === 0) { const response = await fetch(metadata.coverImage) const blob = await response.blob() const coverFile = new File([blob], 'cover.jpg', { type: blob.type }) setPreviewImages([coverFile]) setPreviewUrls([metadata.coverImage]) } } } catch (err) { console.error('[UploadModal] Failed to parse audio file:', err) } finally { setIsParsingAudio(false) } } } // 预览图片拖拽处理 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) { setError('请拖拽图片文件') return } // 检查总数是否超过5张 const currentCount = previewImages.length const newCount = currentCount + imageFiles.length if (newCount > 5) { setError(`最多只能上传5张预览图(当前:${currentCount}张,尝试添加:${imageFiles.length}张)`) return } // 添加新图片 const newImages = [...previewImages, ...imageFiles] setPreviewImages(newImages) setPreviewUrls([...previewUrls, ...imageFiles.map(file => URL.createObjectURL(file))]) 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 const submittingMaterialKind = currentMaterialKind const submittingIsVideo = submittingMaterialKind === 'VIDEO' // 验证模板必填字段 if (visibleTemplateFields.length > 0) { for (const field of visibleTemplateFields) { if (field.required) { const value = templateFieldValues[field.key] if (!hasFieldValue(value, field.type)) { 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 || '' const submittingIsAudio = submittingMaterialKind === 'AUDIO' // 材质类型和音频类型不需要店铺和场景 const skipCategoryScene = currentMaterialType === '材质' || submittingIsAudio if (!skipCategoryScene && (!currentCategoryName || !currentSceneName)) { setError('请填写所有必填字段(店铺、场景)') return } // 素材文件验证(模特类型和材质类型素材文件可选,音频类型必须有文件) if (!submittingIsVideo && !submittingIsAudio && currentMaterialType !== '模特' && currentMaterialType !== '材质' && !zipFile) { setError('请上传素材文件') return } // 图片验证 if (currentMaterialType === 'PSD' && !displayImage) { setError('PSD素材必须上传展示图片') return } // 视频素材的封面图是可选的(如果没有上传,系统会自动从视频中截取) // 音频素材的预览图也是可选的(使用占位符) // 对于非视频、非音频、非PSD类型,必须有预览图 if (!submittingIsVideo && !submittingIsAudio && currentMaterialType !== 'PSD' && currentMaterialType !== '材质' && previewImages.length === 0) { setError('请上传预览图') return } // 视频类型验证 if (submittingIsVideo && !zipFile) { setError('请上传视频文件') return } // 音频类型验证 if (submittingIsAudio && !zipFile) { setError('请上传音频文件') return } // 重置重试计数和状态 setPendingMaterialType(currentMaterialType) setRetryCount(0) setUploading(true) setUploadProgress(0) setUploadSpeed(0) setUploadComplete(false) try { const formData = new FormData() // 添加预览图(多张,最多5张) if (previewImages.length > 0) { previewImages.forEach((img, index) => { formData.append('previewImages', img) }) // 第一张作为主预览图(向后兼容) formData.append('previewImage', previewImages[0]) } if (displayImage) { formData.append('displayImage', displayImage) } // 素材文件对于模特类型是可选的 if (zipFile) { formData.append('zipFile', zipFile) } formData.append('productModel', currentProductModel) // 材质类型和音频类型使用默认值 formData.append('categoryName', skipCategoryScene ? '通用' : currentCategoryName) formData.append('sceneName', skipCategoryScene ? '通用' : 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 || '') formData.append('materialKind', submittingMaterialKind) formData.append('templateId', selectedTemplateId) formData.append('templateFields', JSON.stringify(buildTemplateFieldsPayload())) // 提交作者名称(如果模板字段中有的话) if (templateFieldValues.authorName) { formData.append('authorName', templateFieldValues.authorName) } // 提交音频分类和使用场景(如果是音频素材) if (submittingMaterialKind === 'AUDIO') { if (templateFieldValues.audioCategory) { formData.append('audioCategory', templateFieldValues.audioCategory) } if (templateFieldValues.audioScene) { formData.append('audioScene', templateFieldValues.audioScene) } if (templateFieldValues.audioDuration) { formData.append('audioDuration', templateFieldValues.audioDuration) } } 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) setPendingMaterialType(null) // 清除上传状态和重置表单 clearUploadState() resetForm() setRetryCount(0) setPendingMaterialType(null) // 短暂延迟确保UI更新完成,然后关闭弹窗 setTimeout(() => { onSuccess() onClose() }, 300) // 300ms延迟,确保用户看到完成状态 } else { try { const data = JSON.parse(xhr.responseText) let errorMsg = data.error || '上传失败' // 如果有详细信息,添加到错误消息中 if (data.details) { errorMsg += `\n详细信息: ${data.details}` } if (data.errorType) { errorMsg += `\n错误类型: ${data.errorType}` } // 提供更详细的错误信息 if (xhr.status === 413) { errorMsg = '文件过大,请联系管理员增加上传限制' } else if (xhr.status === 408 || xhr.status === 504) { errorMsg = '上传超时,请压缩文件、使用内网地址或分批上传后重试' } else if (xhr.status >= 500) { errorMsg = `服务器错误 (${xhr.status}):${errorMsg}` } setError(errorMsg) } catch (parseError) { console.error('[UploadModal] Failed to parse error response:', parseError) 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 = '上传结果暂未确认,请刷新素材列表检查是否已经保存' // 3秒后自动关闭弹窗并刷新 setTimeout(() => { onClose() window.location.reload() }, 5000) } else if (isConnectionReset) { errorMsg = '网络连接中断,上传结果暂未确认。请刷新列表检查;如未出现,请使用内网地址或压缩后重试' } setError(errorMsg) setUploading(false) setUploadComplete(false) setPendingMaterialType(null) }) xhr.addEventListener('abort', () => { setError('上传已取消') setUploading(false) setUploadComplete(false) setRetryCount(0) setPendingMaterialType(null) xhrRef.current = null }) // 始终使用相对路径上传,避免跨域Cookie问题 // 用户应通过Header的提示切换到内网地址以获得更快速度 const uploadUrl = '/api/materials/upload' console.log(`[Upload] 上传地址: ${uploadUrl}`) xhr.open('POST', uploadUrl) xhr.send(formData) } catch (error) { console.error('Upload error:', error) setError('上传失败,请稍后再试') setUploading(false) setUploadComplete(false) setPendingMaterialType(null) } } const handleCancelUpload = () => { if (xhrRef.current && uploading) { xhrRef.current.abort() } else { onClose() } } const resolvedMaterialType = selectedTemplate?.materialType || materialType const currentMaterialKind = normalizeMaterialKind( selectedTemplate?.materialKind || (resolvedMaterialType === '视频' ? 'VIDEO' : resolvedMaterialType === '音频' ? 'AUDIO' : 'IMAGE') ) const visibleTemplateFields = useMemo(() => { if (!selectedTemplate?.fields?.length) return [] return getVisibleTemplateFields(selectedTemplate.fields, templateFieldValues) }, [selectedTemplate, templateFieldValues]) const visibleTemplateFieldKeys = useMemo( () => new Set(visibleTemplateFields.map((field: any) => field.key)), [visibleTemplateFields] ) const isVideoTemplate = currentMaterialKind === 'VIDEO' const isAudioTemplate = currentMaterialKind === 'AUDIO' // 视频模板和音频模板的预览图是可选的 // 只有非视频、非音频、非PSD类型才需要必填预览图 const previewRequiredInUi = !isVideoTemplate && !isAudioTemplate && resolvedMaterialType !== 'PSD' && resolvedMaterialType !== '材质' const isPostProcessing = uploading && uploadProgress === 100 && !uploadComplete const postProcessingMessage = pendingMaterialType === 'PSD' ? 'PSD 展示图较大,正在切割并生成长图预览,请稍等...' : '文件已上传,系统正在保存素材并刷新列表,请勿关闭窗口...' const submitButtonLabel = uploading ? isPostProcessing ? '处理中...' : `上传中 ${uploadProgress}%` : '上传' 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}

)}
{/* 预览图片和素材文件卡片 - 移到上方,样式与预览面板一致 */}
{resolvedMaterialType !== 'PSD' && (
previewInputRef.current?.click()} > {previewImages.length === 0 ? (

上传{isVideoTemplate ? '视频封面(可选,最多5张)' : '预览图(最多5张)'}

{isVideoTemplate ? '支持 JPG、PNG、WebP;不上传时系统会尝试自动截取封面' : '支持 JPG、PNG、WebP,可拖拽上传'}

) : ( <>
{previewImages.map((img, index) => (
{`Preview {index === 0 && (
封面
)}
))} {previewImages.length < 5 && ( )}

已上传 {previewImages.length}/5 张,第一张自动设为{isVideoTemplate ? '视频封面' : '卡片封面'}

支持 JPG、PNG、WebP,可拖拽调整顺序

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

上传 PSD 展示图

支持 JPG、PNG、WebP;超高图片会自动切割

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

{zipFile.name}

{formatBytes(zipFile.size)}

) : (

上传{isVideoTemplate ? '视频文件' : isAudioTemplate ? '音频文件' : '素材压缩包'}

{isVideoTemplate ? '支持 MP4、MOV、AVI、MKV、WebM 等常见视频格式' : isAudioTemplate ? '支持 MP3、WAV、FLAC、AAC、M4A 等常见音频格式' : '支持 ZIP、RAR、7Z 等压缩格式'}

)}
{/* 上传进度 - 移到素材文件上传区域下方 */} {(uploading || uploadComplete) && (
{uploadComplete ? '上传完成' : '上传进度'} {uploadProgress}%
{!uploadComplete && !isPostProcessing && (
速度: {formatSpeed(uploadSpeed)}
)} {isPostProcessing && (
{postProcessingMessage}
)} {uploadComplete && (
上传成功,正在关闭...
)}
)} {/* Error Message - 移到素材文件上传区域下方 */} {error && (
{error}
)}
{/* 动态渲染模板字段 */} {selectedTemplate && selectedTemplate.fields && selectedTemplate.fields.length > 0 && (

模板字段

{visibleTemplateFields.map((field: any) => { const fieldValue = templateFieldValues[field.key] || '' const isHalfWidth = field.layoutWidth === 'half' const fieldInputId = `upload-template-field-${field.id || field.key}` 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.key === 'imageResolution' ? '例如:5000 * 5000' : `请输入${field.label || field.key}` } className={cn( modalControlClass, field.required ? requiredControlClass : null, field.type === 'uppercase' && 'uppercase' )} /> ) : field.type === 'textarea' ? (