'use client' import { useState, useEffect, useRef } from 'react' import Image from 'next/image' import { useToast } from '@/hooks/useToast' import ToastBubble from './ToastBubble' import StandardModelWhiteBackgroundUpload from './upload/StandardModelWhiteBackgroundUpload' import StandardModelSkuZipUpload from './upload/StandardModelSkuZipUpload' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, DragStartEvent, DragOverlay, } from '@dnd-kit/core' import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { parseApiError, parseFetchError } from '@/lib/error-handler' import { formatTransferProgress } from '@/components/upload/upload-utils' import { hasAllowedFileExtension, prepareFileDrag, validateImageFiles, } from '@/components/upload/drag-drop-utils' import { STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, STANDARD_MODEL_WHITE_BG_VIEWS, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' import { STANDARD_MODEL_WHITE_BACKGROUND_SOURCE_TYPE_OPTIONS, type StandardModelWhiteBackgroundSourceType, } from '@/lib/standard-model-white-background-metadata' // SKU 数据类型 interface Sku { id: string skuName: string skuCode?: string previewUrl: string zipFileName: string zipFilePath: string zipFileSize: string sortOrder: number uploaderId: string whiteBackgroundUploaded?: boolean whiteBackgroundComplete?: boolean whiteBackgroundFixedCount?: number whiteBackgroundOtherCount?: number whiteBackgroundCount?: number whiteBackgroundSourceType?: StandardModelWhiteBackgroundSourceType | null whiteBackgroundUploadedBy?: string | null whiteBackgroundUploadedAt?: string | Date | null whiteBackgroundUploader?: { id: string username: string realName?: string | null } | null } interface WhiteBgOtherImage { id: string file: File previewUrl: string name: string } interface SkuEditModalProps { materialId: string isOpen: boolean onClose: () => void onSaved: () => void // 保存成功后的回调 } function normalizeSkuNameInput(value: string): string { return value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase() } export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: SkuEditModalProps) { const [skus, setSkus] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const { toast, showToast } = useToast() // 新增/编辑 SKU 表单 const [showAddForm, setShowAddForm] = useState(false) const [editingSkuId, setEditingSkuId] = useState(null) // 正在编辑的SKU ID const [newSkuName, setNewSkuName] = useState('') const [newSkuPreviewFile, setNewSkuPreviewFile] = useState(null) const [newSkuPreviewUrl, setNewSkuPreviewUrl] = useState('') // 本地预览URL或已有预览URL const [newSkuZipFile, setNewSkuZipFile] = useState(null) const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ zip: 0, preview: 0 }) const [activeWhiteBgSku, setActiveWhiteBgSku] = useState(null) const [activeModelSku, setActiveModelSku] = useState(null) const [modelZipFile, setModelZipFile] = useState(null) const [modelUploading, setModelUploading] = useState(false) const [modelUploadProgress, setModelUploadProgress] = useState(0) const [modelUploadError, setModelUploadError] = useState('') const [whiteBgFiles, setWhiteBgFiles] = useState>>({}) const [whiteBgPreviewUrls, setWhiteBgPreviewUrls] = useState>>({}) const [whiteBgOtherImages, setWhiteBgOtherImages] = useState([]) const [whiteBgUploading, setWhiteBgUploading] = useState(false) const [whiteBgUploadProgress, setWhiteBgUploadProgress] = useState(0) const [whiteBgUploadSpeed, setWhiteBgUploadSpeed] = useState(0) const [whiteBgError, setWhiteBgError] = useState('') const [whiteBgSourceType, setWhiteBgSourceType] = useState('') const whiteBgUploadTotalBytes = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((sum, view) => sum + (whiteBgFiles[view.key]?.size || 0), 0) + whiteBgOtherImages.reduce((sum, image) => sum + image.file.size, 0) const fileInputRef = useRef(null) const previewInputRef = useRef(null) const whiteBgPreviewUrlsRef = useRef>>({}) const whiteBgOtherImagesRef = useRef([]) // 加载 SKU 列表 useEffect(() => { if (isOpen) { fetchSkus() } }, [isOpen, materialId]) const fetchSkus = async () => { try { setLoading(true) const response = await fetch(`/api/materials/${materialId}/skus`) if (response.ok) { const data = await response.json() setSkus(data.skus || []) } } catch (error) { console.error('加载 SKU 列表失败:', error) } finally { setLoading(false) } } const loadSkusSnapshot = async (): Promise => { try { const response = await fetch(`/api/materials/${materialId}/skus`) if (!response.ok) { return null } const data = await response.json().catch(() => null) const nextSkus = Array.isArray(data?.skus) ? data.skus : [] setSkus(nextSkus) return nextSkus } catch (error) { console.error('刷新 SKU 快照失败:', error) return null } } const resetWhiteBackgroundForm = () => { Object.values(whiteBgPreviewUrlsRef.current).forEach((url) => { if (url && url.startsWith('blob:')) { URL.revokeObjectURL(url) } }) whiteBgOtherImagesRef.current.forEach((image) => { if (image.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(image.previewUrl) } }) setWhiteBgFiles({}) setWhiteBgPreviewUrls({}) setWhiteBgOtherImages([]) setWhiteBgError('') setWhiteBgUploading(false) setWhiteBgUploadProgress(0) setWhiteBgUploadSpeed(0) setWhiteBgSourceType('') } const handleOpenWhiteBackground = (sku: Sku) => { if (showAddForm) { handleCancelEdit() } handleCloseModelUpload() resetWhiteBackgroundForm() setWhiteBgSourceType(sku.whiteBackgroundSourceType || '') setActiveWhiteBgSku(sku) } const handleCloseWhiteBackground = () => { resetWhiteBackgroundForm() setActiveWhiteBgSku(null) } const resetModelUploadForm = () => { setModelZipFile(null) setModelUploadProgress(0) setModelUploadError('') setModelUploading(false) } const handleOpenModelUpload = (sku: Sku) => { if (showAddForm) { handleCancelEdit() } handleCloseWhiteBackground() resetModelUploadForm() setActiveModelSku(sku) } const handleCloseModelUpload = () => { resetModelUploadForm() setActiveModelSku(null) } useEffect(() => { whiteBgPreviewUrlsRef.current = whiteBgPreviewUrls }, [whiteBgPreviewUrls]) useEffect(() => { whiteBgOtherImagesRef.current = whiteBgOtherImages }, [whiteBgOtherImages]) useEffect(() => { return () => { Object.values(whiteBgPreviewUrlsRef.current).forEach((url) => { if (url && url.startsWith('blob:')) { URL.revokeObjectURL(url) } }) whiteBgOtherImagesRef.current.forEach((image) => { if (image.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(image.previewUrl) } }) } }, []) // 格式化文件大小 const formatFileSize = (bytes: string | number): string => { const size = typeof bytes === 'string' ? parseInt(bytes) : bytes if (size === 0 || isNaN(size)) return '未知' const units = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(size) / Math.log(1024)) return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` } const applyNewSkuZipFile = (file: File) => { if (uploading) { showToast('文件正在上传,请等待完成后再更换压缩包', { variant: 'error' }) return } if (!hasAllowedFileExtension(file, ['zip', 'rar', '7z'])) { showToast('压缩包仅支持 ZIP、RAR、7Z 格式', { variant: 'error' }) return } setNewSkuZipFile(file) // 如果没有填写名称,用文件名(去掉扩展名)作为默认名称 if (!newSkuName) { const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '') setNewSkuName(nameWithoutExt) } } // 处理新增 SKU 的压缩包选择(立即显示,不上传) const handleNewZipSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { applyNewSkuZipFile(file) } e.currentTarget.value = '' } const applyNewSkuPreviewFile = (file: File) => { if (uploading) { showToast('文件正在上传,请等待完成后再更换预览图', { variant: 'error' }) return } const { acceptedFiles, errors } = validateImageFiles([file]) if (acceptedFiles.length === 0) { showToast(errors.join(';') || '请选择有效的图片文件', { variant: 'error' }) return } // ⚠️ 内存泄漏修复:释放旧的 Object URL(R17) if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) { URL.revokeObjectURL(newSkuPreviewUrl) } // 立即存储文件对象,并创建本地预览URL setNewSkuPreviewFile(file) const previewUrl = URL.createObjectURL(file) setNewSkuPreviewUrl(previewUrl) } // 处理新增 SKU 的预览图选择(立即显示,不上传) const handleNewPreviewSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { applyNewSkuPreviewFile(file) } e.currentTarget.value = '' } const handleSelectWhiteBackgroundImage = (key: StandardModelWhiteBgKey, file: File) => { if (whiteBgUploading) { setWhiteBgError('图片正在上传,请等待完成后再更换') return } const { acceptedFiles, errors } = validateImageFiles([file]) if (acceptedFiles.length === 0) { setWhiteBgError(errors.join(';') || '请选择有效的图片文件') return } setWhiteBgError(errors.join(';')) setWhiteBgFiles((prev) => ({ ...prev, [key]: acceptedFiles[0], })) setWhiteBgPreviewUrls((prev) => { const previousUrl = prev[key] if (previousUrl && previousUrl.startsWith('blob:')) { URL.revokeObjectURL(previousUrl) } return { ...prev, [key]: URL.createObjectURL(acceptedFiles[0]), } }) } const handleRemoveWhiteBackgroundImage = (key: StandardModelWhiteBgKey) => { setWhiteBgFiles((prev) => { const next = { ...prev } delete next[key] return next }) setWhiteBgPreviewUrls((prev) => { const previousUrl = prev[key] if (previousUrl && previousUrl.startsWith('blob:')) { URL.revokeObjectURL(previousUrl) } const next = { ...prev } delete next[key] return next }) } const handleSelectWhiteBackgroundOthers = (files: FileList | File[]) => { if (whiteBgUploading) { setWhiteBgError('图片正在上传,请等待完成后再添加') return } const { acceptedFiles, errors } = validateImageFiles(files) setWhiteBgError(errors.join(';')) if (acceptedFiles.length === 0) return setWhiteBgOtherImages((prev) => [ ...prev, ...acceptedFiles.map((file, index) => ({ id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`, file, previewUrl: URL.createObjectURL(file), name: file.name, })), ]) } const handleRemoveWhiteBackgroundOther = (id: string) => { setWhiteBgOtherImages((prev) => { const target = prev.find((item) => item.id === id) if (target?.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(target.previewUrl) } return prev.filter((item) => item.id !== id) }) } const handleUploadWhiteBackground = async () => { if (!activeWhiteBgSku) { showToast('请先选择要管理白底图的 SKU', { variant: 'error' }) return } const selectedFixedCount = STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!whiteBgFiles[view.key]).length if (selectedFixedCount === 0 && whiteBgOtherImages.length === 0) { const errorMessage = '请至少选择 1 张白底图' setWhiteBgError(errorMessage) showToast(errorMessage, { variant: 'error' }) return } if (!whiteBgSourceType) { const errorMessage = '请选择白底图出图方式' setWhiteBgError(errorMessage) showToast(errorMessage, { variant: 'error' }) return } const formData = new FormData() formData.append('skuId', activeWhiteBgSku.id) formData.append('skuName', activeWhiteBgSku.skuName) formData.append('sourceType', whiteBgSourceType) STANDARD_MODEL_WHITE_BG_VIEWS.forEach((view) => { const file = whiteBgFiles[view.key] if (file) { formData.append(view.fieldName, file) } }) whiteBgOtherImages.forEach((image) => { formData.append(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, image.file) }) try { setWhiteBgUploading(true) setWhiteBgUploadProgress(0) setWhiteBgUploadSpeed(0) setWhiteBgError('') const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() let speedStartTime = Date.now() let speedStartLoaded = 0 xhr.upload.addEventListener('progress', (event) => { if (!event.lengthComputable) { return } const progress = Math.round((event.loaded / event.total) * 100) setWhiteBgUploadProgress(progress) const currentTime = Date.now() const elapsed = (currentTime - speedStartTime) / 1000 if (elapsed > 0) { const speed = (event.loaded - speedStartLoaded) / elapsed setWhiteBgUploadSpeed(speed) speedStartTime = currentTime speedStartLoaded = event.loaded } }) xhr.onload = () => resolve(xhr) xhr.onerror = () => reject(new Error('上传白底图失败')) xhr.onabort = () => reject(new Error('上传已取消')) xhr.open('POST', `/api/materials/${materialId}/white-background`) xhr.send(formData) }) if (response.status < 200 || response.status >= 300) { let message = '上传白底图失败' try { const error = JSON.parse(response.responseText || '{}') message = error.error || message } catch {} setWhiteBgError(message) showToast(message, { variant: 'error' }) return } const data = JSON.parse(response.responseText || '{}') const fixedCount = typeof data.fixedCount === 'number' ? data.fixedCount : selectedFixedCount const otherCount = typeof data.otherCount === 'number' ? data.otherCount : whiteBgOtherImages.length const whiteBackgroundCount = typeof data.whiteBackgroundCount === 'number' ? data.whiteBackgroundCount : fixedCount + otherCount const isUploaded = Boolean(data.whiteBackgroundUploaded ?? whiteBackgroundCount > 0) const isComplete = Boolean(data.isComplete) setSkus((prev) => prev.map((sku) => sku.id === activeWhiteBgSku.id ? { ...sku, whiteBackgroundUploaded: isUploaded, whiteBackgroundComplete: isComplete, whiteBackgroundFixedCount: fixedCount, whiteBackgroundOtherCount: otherCount, whiteBackgroundCount, whiteBackgroundSourceType: data.sourceType || whiteBgSourceType, whiteBackgroundUploadedBy: data.whiteBackgroundUploadedBy || sku.whiteBackgroundUploadedBy || null, whiteBackgroundUploadedAt: data.whiteBackgroundUploadedAt || new Date().toISOString(), } : sku, ), ) await fetchSkus() showToast( isComplete ? '白底图上传成功' : `白底图已保存,共 ${whiteBackgroundCount} 张,固定角度 ${fixedCount}/${STANDARD_MODEL_WHITE_BG_VIEWS.length}`, { variant: 'success' }, ) handleCloseWhiteBackground() } catch (error) { console.error('上传白底图失败:', error) const message = error instanceof Error ? error.message : '上传白底图失败' setWhiteBgError(message) showToast(message, { variant: 'error' }) } finally { setWhiteBgUploading(false) } } // 上传压缩包到本地存储(带进度) const handleUploadZip = async (file: File, skuName: string, onProgress?: (progress: number) => void): Promise<{ fileName: string; filePath: string; fileSize: number } | null> => { const formData = new FormData() formData.append('file', file) formData.append('skuName', skuName) // 添加skuName字段 return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() // 监听上传进度 xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable && onProgress) { const progress = Math.round((e.loaded / e.total) * 100) onProgress(progress) } }) xhr.addEventListener('load', () => { if (xhr.status === 200 || xhr.status === 201) { try { const data = JSON.parse(xhr.responseText) // API返回的是相对路径,需要转换为前端期望的格式 resolve({ fileName: file.name, // 使用原始文件名 filePath: data.filePath || `skus/${materialId}/${data.id || Date.now()}_${file.name}`, // 相对路径 fileSize: parseInt(data.fileSize || file.size) }) } catch (error) { reject(new Error('解析响应失败')) } } else { try { const error = JSON.parse(xhr.responseText) const { message } = parseApiError(error) showToast(message, { variant: 'error' }) reject(new Error(message)) } catch { showToast('上传失败', { variant: 'error' }) reject(new Error('上传失败')) } } }) xhr.addEventListener('error', () => { showToast('上传失败,请稍后重试', { variant: 'error' }) reject(new Error('网络错误')) }) xhr.open('POST', `/api/materials/${materialId}/skus/upload`) xhr.send(formData) }) } // 上传预览图到 S3(带进度) const handleUploadPreview = async (file: File, onProgress?: (progress: number) => void): Promise => { const formData = new FormData() formData.append('file', file) return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() // 监听上传进度 xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable && onProgress) { const progress = Math.round((e.loaded / e.total) * 100) onProgress(progress) } }) xhr.addEventListener('load', () => { if (xhr.status === 200) { try { const data = JSON.parse(xhr.responseText) resolve(data.url) } catch (error) { reject(new Error('解析响应失败')) } } else { try { const error = JSON.parse(xhr.responseText) showToast(error.error || '上传预览图失败', { variant: 'error' }) reject(new Error(error.error || '上传预览图失败')) } catch { showToast('上传预览图失败', { variant: 'error' }) reject(new Error('上传预览图失败')) } } }) xhr.addEventListener('error', () => { showToast('上传预览图失败,请稍后重试', { variant: 'error' }) reject(new Error('网络错误')) }) xhr.open('POST', '/api/upload/preview') xhr.send(formData) }) } // 开始编辑SKU const handleEditSku = (sku: Sku) => { handleCloseWhiteBackground() handleCloseModelUpload() setEditingSkuId(sku.id) setNewSkuName(sku.skuName) setNewSkuPreviewUrl(sku.previewUrl || '') setNewSkuPreviewFile(null) // 编辑时不清除已有预览图 setNewSkuZipFile(null) // 编辑时不要求重新上传压缩包 setShowAddForm(true) setUploadProgress({ zip: 0, preview: 0 }) } const handleSaveModelZip = async () => { if (!activeModelSku) { showToast('请先选择要管理模型的 SKU', { variant: 'error' }) return } if (!modelZipFile) { const errorMessage = '请先选择模型压缩包' setModelUploadError(errorMessage) showToast(errorMessage, { variant: 'error' }) return } try { setModelUploading(true) setModelUploadProgress(0) setModelUploadError('') const uploadedZipResult = await handleUploadZip(modelZipFile, activeModelSku.skuName, (progress) => { setModelUploadProgress(progress) }) if (!uploadedZipResult) { setModelUploadError('上传模型压缩包失败') return } const response = await fetch(`/api/materials/${materialId}/skus/${activeModelSku.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skuName: activeModelSku.skuName, previewUrl: activeModelSku.previewUrl, zipFileName: uploadedZipResult.fileName, zipFilePath: uploadedZipResult.filePath, zipFileSize: uploadedZipResult.fileSize, }), }) if (!response.ok) { const error = await response.json().catch(() => ({})) const message = error.error || '保存模型压缩包失败' setModelUploadError(message) showToast(message, { variant: 'error' }) return } const updatedSku = await response.json() setSkus((prev) => prev.map((sku) => sku.id === activeModelSku.id ? { ...sku, zipFileName: updatedSku.zipFileName || '', zipFilePath: updatedSku.zipFilePath || '', zipFileSize: updatedSku.zipFileSize || '0', } : sku, ), ) showToast(activeModelSku.zipFilePath ? '模型压缩包已更新' : '模型压缩包上传成功', { variant: 'success' }) handleCloseModelUpload() } catch (error) { console.error('保存模型压缩包失败:', error) const message = error instanceof Error ? error.message : '保存模型压缩包失败' setModelUploadError(message) showToast(message, { variant: 'error' }) } finally { setModelUploading(false) } } // 取消编辑/添加 const handleCancelEdit = () => { // 清理本地预览URL if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) { URL.revokeObjectURL(newSkuPreviewUrl) } setShowAddForm(false) setEditingSkuId(null) setNewSkuName('') setNewSkuPreviewFile(null) setNewSkuPreviewUrl('') setNewSkuZipFile(null) setUploadProgress({ zip: 0, preview: 0 }) } const validateSkuNameBeforeUpload = async (rawSkuName: string): Promise => { const normalizedSkuName = normalizeSkuNameInput(rawSkuName).trim() if (!normalizedSkuName) { showToast('请输入 SKU 名称', { variant: 'error' }) return null } try { const params = new URLSearchParams({ validateSkuName: normalizedSkuName, }) if (editingSkuId) { params.set('excludeSkuId', editingSkuId) } const response = await fetch(`/api/materials/${materialId}/skus?${params.toString()}`) const result = await response.json().catch(() => ({})) if (!response.ok || result.valid === false) { showToast(result.error || '所有标准模型下 SKU 名称不能重复', { variant: 'error' }) return null } return result.normalizedSkuName || normalizedSkuName } catch (error) { console.error('校验 SKU 唯一性失败:', error) showToast('校验 SKU 唯一性失败,请稍后重试', { variant: 'error' }) return null } } const createSkuRecord = async (payload: { skuName: string previewUrl: string zipFileName: string zipFilePath: string zipFileSize: number }) => { let lastError: Error | null = null for (let attempt = 0; attempt < 2; attempt += 1) { try { const response = await fetch(`/api/materials/${materialId}/skus`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!response.ok) { const parsed = await parseFetchError(response) throw new Error(parsed.message || `创建 SKU 失败 (${response.status})`) } const contentType = response.headers.get('content-type') || '' if (contentType.includes('application/json')) { return await response.json().catch(() => ({})) } return {} } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) const isTransientNetworkError = error instanceof TypeError || lastError.message.includes('network') || lastError.message.includes('Network') || lastError.message.includes('Failed to fetch') if (!isTransientNetworkError || attempt === 1) { throw lastError } } } throw lastError || new Error('创建 SKU 失败') } // 添加或更新 SKU(此时才开始上传文件) const handleAddSku = async () => { const normalizedSkuName = await validateSkuNameBeforeUpload(newSkuName) if (!normalizedSkuName) { return } // 编辑模式:预览图或压缩包可以只更新其中一个,也可以都不更新(只改名称) // 新增模式:只要求 SKU 名称 + 预览图,压缩包可后传 if (!editingSkuId) { if (!newSkuPreviewFile) { showToast('请选择预览图', { variant: 'error' }) return } } // 编辑模式:允许只修改名称,不更新文件 setSaving(true) setUploading(true) setUploadProgress({ zip: 0, preview: 0 }) try { let previewUrl = newSkuPreviewUrl // 使用已有预览URL(编辑模式) let zipResult: { fileName: string; filePath: string; fileSize: number } | null = null // 如果有新预览图,上传它 if (newSkuPreviewFile) { const uploadedPreviewUrl = await handleUploadPreview(newSkuPreviewFile, (progress) => { setUploadProgress(prev => ({ ...prev, preview: progress })) }) if (!uploadedPreviewUrl) { showToast('上传预览图失败', { variant: 'error' }) return } previewUrl = uploadedPreviewUrl setNewSkuPreviewUrl(uploadedPreviewUrl) } // 如果有新压缩包,上传它 if (newSkuZipFile) { const uploadedZipResult = await handleUploadZip(newSkuZipFile, normalizedSkuName, (progress) => { setUploadProgress(prev => ({ ...prev, zip: progress })) }) if (!uploadedZipResult) { showToast('上传压缩包失败', { variant: 'error' }) return } zipResult = uploadedZipResult } // 编辑模式:更新SKU if (editingSkuId) { // 获取当前SKU信息,用于填充未更新的字段 const currentSku = skus.find(s => s.id === editingSkuId) if (!currentSku) { showToast('SKU不存在', { variant: 'error' }) return } const response = await fetch(`/api/materials/${materialId}/skus/${editingSkuId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skuName: normalizedSkuName, previewUrl: previewUrl || currentSku.previewUrl, zipFileName: zipResult?.fileName || currentSku.zipFileName, zipFilePath: zipResult?.filePath || currentSku.zipFilePath, zipFileSize: zipResult?.fileSize || parseInt(currentSku.zipFileSize) }) }) if (response.ok) { // 清理本地预览URL if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) { URL.revokeObjectURL(newSkuPreviewUrl) } // 重置表单 handleCancelEdit() // 刷新列表 await fetchSkus() showToast('更新成功', { variant: 'success' }) } else { const error = await response.json() showToast(error.error || '更新失败', { variant: 'error' }) } return } // 新增模式:创建SKU(模型压缩包可后传) if (!previewUrl) { showToast('上传失败', { variant: 'error' }) return } const createdSku = await createSkuRecord({ skuName: normalizedSkuName, previewUrl: previewUrl, zipFileName: zipResult?.fileName || '', zipFilePath: zipResult?.filePath || '', zipFileSize: zipResult?.fileSize || 0, }) // 清理本地预览URL if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) { URL.revokeObjectURL(newSkuPreviewUrl) } // 刷新列表 await fetchSkus() showToast(createdSku?.id ? '添加成功' : 'SKU 已创建成功', { variant: 'success' }) // 重置表单字段(但不关闭表单,可以继续添加) setNewSkuName('') setNewSkuPreviewFile(null) setNewSkuPreviewUrl('') setNewSkuZipFile(null) setUploadProgress({ zip: 0, preview: 0 }) } catch (error) { console.error('添加 SKU 失败:', error) const recoveredSkus = await loadSkusSnapshot() const recoveredSku = recoveredSkus?.find((sku) => sku.skuName === normalizedSkuName) if (recoveredSku) { showToast('SKU 已创建成功,页面已自动恢复', { variant: 'success' }) setNewSkuName('') setNewSkuPreviewFile(null) setNewSkuPreviewUrl('') setNewSkuZipFile(null) setUploadProgress({ zip: 0, preview: 0 }) return } const { message } = parseApiError(error) showToast(message || '添加失败,请稍后重试', { variant: 'error' }) } finally { setSaving(false) setUploading(false) } } // 删除 SKU const handleDeleteSku = async (skuId: string) => { if (!confirm('确定要删除这个 SKU 吗?')) return try { const response = await fetch(`/api/materials/${materialId}/skus/${skuId}`, { method: 'DELETE' }) if (response.ok) { if (activeWhiteBgSku?.id === skuId) { handleCloseWhiteBackground() } if (activeModelSku?.id === skuId) { handleCloseModelUpload() } setSkus(skus.filter(sku => sku.id !== skuId)) } else { const error = await response.json() showToast(error.error || '删除失败', { variant: 'error' }) } } catch (error) { console.error('删除 SKU 失败:', error) showToast('删除失败,请稍后重试', { variant: 'error' }) } } // 拖拽排序传感器(禁用自动滚动以避免无限滚动问题) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // 需要移动8px才开始拖拽 }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }) ) const [activeId, setActiveId] = useState(null) // 拖拽开始 const handleDragStart = (event: DragStartEvent) => { setActiveId(event.active.id as string) } // 拖拽结束处理 const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event setActiveId(null) // 重置拖拽状态 if (over && active.id !== over.id) { const oldIndex = skus.findIndex((sku) => sku.id === active.id) const newIndex = skus.findIndex((sku) => sku.id === over.id) const newSkus = arrayMove(skus, oldIndex, newIndex) // 更新排序值 newSkus.forEach((sku, idx) => { sku.sortOrder = idx }) setSkus(newSkus) // 保存新排序 try { await fetch(`/api/materials/${materialId}/skus/reorder`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skuIds: newSkus.map(sku => sku.id) }) }) showToast('排序已更新', { variant: 'success' }) } catch (error) { console.error('更新排序失败:', error) showToast('更新排序失败', { variant: 'error' }) // 恢复原顺序 fetchSkus() } } } // 可拖拽的SKU项组件 function SortableSkuItem({ sku, index }: { sku: Sku; index: number }) { const hasModelZip = !!sku.zipFilePath?.trim() const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: sku.id }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, } return (
{/* 拖拽手柄 */}
{/* 预览图 */}
{sku.skuName}
{/* SKU 信息 */}

{sku.skuName}

{hasModelZip ? '模型已上传' : '模型未上传'} {sku.whiteBackgroundUploaded ? `白底图已上传 ${sku.whiteBackgroundCount || 0} 张${sku.whiteBackgroundComplete ? '(已补齐)' : ''}` : '白底图未上传'}

{hasModelZip ? `${sku.zipFileName} · ${formatFileSize(sku.zipFileSize)}` : '当前还没有模型压缩包'}

{sku.whiteBackgroundUploaded ? [ sku.whiteBackgroundSourceType ? `出图方式:${sku.whiteBackgroundSourceType}` : null, sku.whiteBackgroundUploadedAt ? `最后更新:${new Date(sku.whiteBackgroundUploadedAt).toLocaleString('zh-CN', { hour12: false })}` : null, sku.whiteBackgroundUploader ? `上传人:${sku.whiteBackgroundUploader.realName || sku.whiteBackgroundUploader.username}` : null, ].filter(Boolean).join(' · ') || '当前可继续补传白底图' : hasModelZip ? '当前可直接重传模型压缩包' : '当前可补传模型压缩包'}

{/* 操作按钮 */}
{/* 编辑 */} {/* 删除 */}
) } // 关闭弹窗 const handleClose = () => { if ((showAddForm || activeWhiteBgSku || activeModelSku) && !uploading && !whiteBgUploading && !modelUploading) { const confirmed = window.confirm('当前还有未保存的编辑内容,确定直接关闭吗?') if (!confirmed) { return } } handleCloseWhiteBackground() handleCloseModelUpload() // 清理本地预览URL if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) { URL.revokeObjectURL(newSkuPreviewUrl) } setShowAddForm(false) setNewSkuName('') setNewSkuPreviewFile(null) setNewSkuPreviewUrl('') setNewSkuZipFile(null) setUploadProgress({ zip: 0, preview: 0 }) onSaved() onClose() } const canSubmitSkuForm = editingSkuId ? !!newSkuName.trim() && !uploading && !saving : !!newSkuName.trim() && !!newSkuPreviewFile && !uploading && !saving if (!isOpen) return null return (
{/* 标题栏 */}

编辑 SKU

{/* 工具栏 */}
{!showAddForm ? ( ) : (
{editingSkuId ? '编辑 SKU' : '添加 SKU'}
)}
{/* 内容区域 */}
{loading ? (
加载中...
) : (
{/* 新增 SKU 表单(一行布局,支持拖拽) */} {showAddForm && (
{/* SKU 名称和压缩包信息 */}
setNewSkuName(normalizeSkuNameInput(e.target.value))} placeholder="SKU 名称(如:白色款)" className="w-72 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 flex-shrink-0" /> {newSkuZipFile && (

{newSkuZipFile.name} ({formatFileSize(newSkuZipFile.size)})

{uploading && uploadProgress.zip > 0 && (
)}
)}
{/* 右侧:预览图、压缩包、操作按钮 */}
{/* 预览图(支持拖拽) */}
prepareFileDrag(e, uploading)} onDrop={(e) => { prepareFileDrag(e, uploading) const file = e.dataTransfer.files?.[0] if (file) { applyNewSkuPreviewFile(file) } }} > {newSkuPreviewUrl ? (
预览图
) : ( )}
{/* 压缩包(支持拖拽) */}
prepareFileDrag(e, uploading)} onDrop={(e) => { prepareFileDrag(e, uploading) const file = e.dataTransfer.files?.[0] if (file) { applyNewSkuZipFile(file) } }} >
)} {activeWhiteBgSku && (

白底图管理

当前 SKU:{activeWhiteBgSku.skuName}

固定角度支持分次补传,文件会按 SKU + 品类 + 视角 自动命名。

{whiteBgUploading && (
上传进度 {whiteBgUploadProgress}% {whiteBgUploadSpeed > 0 ? formatTransferProgress( whiteBgUploadSpeed, whiteBgUploadTotalBytes * (whiteBgUploadProgress / 100), whiteBgUploadTotalBytes || undefined, ) : '计算中...'}
)}
{STANDARD_MODEL_WHITE_BACKGROUND_SOURCE_TYPE_OPTIONS.map((option) => { const checked = whiteBgSourceType === option return ( ) })}

白底图按 SKU 维度必填,后续补传会覆盖成最后一次保存的出图方式。

({ id: image.id, url: image.previewUrl, name: image.name, }))} uploading={whiteBgUploading} error={whiteBgError} onSelectImage={handleSelectWhiteBackgroundImage} onRemoveImage={handleRemoveWhiteBackgroundImage} onSelectOtherImages={handleSelectWhiteBackgroundOthers} onRemoveOtherImage={handleRemoveWhiteBackgroundOther} />
)} {activeModelSku && (

模型文件管理

当前 SKU:{activeModelSku.skuName}

底部按钮区会显示“保存模型”,保存成功后会自动收起。

{modelUploading && (
上传进度 {modelUploadProgress}% {modelUploadProgress < 100 ? '上传中...' : '即将保存...'}
)}
{ setModelUploadError('') setModelZipFile(file) }} onRemoveFile={() => { setModelZipFile(null) setModelUploadError('') }} />
)} {/* SKU 列表(支持拖拽排序) */} {skus.length === 0 ? (

暂无 SKU

点击上方“添加 SKU”按钮开始添加

) : ( sku.id)} strategy={verticalListSortingStrategy} >
{skus.map((sku, index) => ( ))}
{activeId ? (

{skus.find(sku => sku.id === activeId)?.skuName}

) : null}
)}
)}
{/* 底部按钮 */}
{showAddForm ? ( <> ) : activeWhiteBgSku ? ( <> ) : activeModelSku ? ( <> ) : ( )}
{/* 统一的Toast提示 */} {toast && toast.show && ( )}
) }