gongxiangp2/components/SkuEditModal.tsx
2026-05-07 16:37:22 +08:00

1624 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 { formatSpeed } from '@/components/upload/upload-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<Sku[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const { toast, showToast } = useToast()
// 新增/编辑 SKU 表单
const [showAddForm, setShowAddForm] = useState(false)
const [editingSkuId, setEditingSkuId] = useState<string | null>(null) // 正在编辑的SKU ID
const [newSkuName, setNewSkuName] = useState('')
const [newSkuPreviewFile, setNewSkuPreviewFile] = useState<File | null>(null)
const [newSkuPreviewUrl, setNewSkuPreviewUrl] = useState('') // 本地预览URL或已有预览URL
const [newSkuZipFile, setNewSkuZipFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ zip: 0, preview: 0 })
const [activeWhiteBgSku, setActiveWhiteBgSku] = useState<Sku | null>(null)
const [activeModelSku, setActiveModelSku] = useState<Sku | null>(null)
const [modelZipFile, setModelZipFile] = useState<File | null>(null)
const [modelUploading, setModelUploading] = useState(false)
const [modelUploadProgress, setModelUploadProgress] = useState(0)
const [modelUploadError, setModelUploadError] = useState('')
const [whiteBgFiles, setWhiteBgFiles] = useState<Partial<Record<StandardModelWhiteBgKey, File | null>>>({})
const [whiteBgPreviewUrls, setWhiteBgPreviewUrls] = useState<Partial<Record<StandardModelWhiteBgKey, string>>>({})
const [whiteBgOtherImages, setWhiteBgOtherImages] = useState<WhiteBgOtherImage[]>([])
const [whiteBgUploading, setWhiteBgUploading] = useState(false)
const [whiteBgUploadProgress, setWhiteBgUploadProgress] = useState(0)
const [whiteBgUploadSpeed, setWhiteBgUploadSpeed] = useState(0)
const [whiteBgError, setWhiteBgError] = useState('')
const [whiteBgSourceType, setWhiteBgSourceType] = useState<StandardModelWhiteBackgroundSourceType | ''>('')
const fileInputRef = useRef<HTMLInputElement>(null)
const previewInputRef = useRef<HTMLInputElement>(null)
const whiteBgPreviewUrlsRef = useRef<Partial<Record<StandardModelWhiteBgKey, string>>>({})
const whiteBgOtherImagesRef = useRef<WhiteBgOtherImage[]>([])
// 加载 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<Sku[] | null> => {
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]}`
}
// 处理新增 SKU 的压缩包选择(立即显示,不上传)
const handleNewZipSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 立即存储文件对象,不立即上传
setNewSkuZipFile(file)
// 如果没有填写名称,用文件名(去掉扩展名)作为默认名称
if (!newSkuName) {
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '')
setNewSkuName(nameWithoutExt)
}
}
// 处理新增 SKU 的预览图选择(立即显示,不上传)
const handleNewPreviewSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// ⚠️ 内存泄漏修复:释放旧的 Object URL(R17)
if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(newSkuPreviewUrl)
}
// 立即存储文件对象,并创建本地预览URL
setNewSkuPreviewFile(file)
const previewUrl = URL.createObjectURL(file)
setNewSkuPreviewUrl(previewUrl)
}
const handleSelectWhiteBackgroundImage = (key: StandardModelWhiteBgKey, file: File) => {
setWhiteBgError('')
setWhiteBgFiles((prev) => ({
...prev,
[key]: file,
}))
setWhiteBgPreviewUrls((prev) => {
const previousUrl = prev[key]
if (previousUrl && previousUrl.startsWith('blob:')) {
URL.revokeObjectURL(previousUrl)
}
return {
...prev,
[key]: URL.createObjectURL(file),
}
})
}
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[]) => {
const fileList = Array.from(files || []).filter((file) => file.type.startsWith('image/'))
if (fileList.length === 0) {
return
}
setWhiteBgError('')
setWhiteBgOtherImages((prev) => [
...prev,
...fileList.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<XMLHttpRequest>((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<string | null> => {
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<string | null> => {
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<string | null>(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 (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-4 rounded-xl border p-4 ${
sku.whiteBackgroundUploaded
? 'border-emerald-200 bg-emerald-50/60 dark:border-emerald-900/40 dark:bg-emerald-900/10'
: 'border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-900/10'
}`}
>
{/* 拖拽手柄 */}
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="拖拽排序"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
{/* 预览图 */}
<div className="w-16 h-16 flex-shrink-0 rounded overflow-hidden">
<Image
src={sku.previewUrl}
alt={sku.skuName}
width={64}
height={64}
className="w-full h-full object-cover"
unoptimized
/>
</div>
{/* SKU 信息 */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{sku.skuName}
</p>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${
hasModelZip
? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'
: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300'
}`}
>
{hasModelZip ? '模型已上传' : '模型未上传'}
</span>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${
sku.whiteBackgroundUploaded
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
}`}
>
{sku.whiteBackgroundUploaded
? `白底图已上传 ${sku.whiteBackgroundCount || 0} 张${sku.whiteBackgroundComplete ? '(已补齐)' : ''}`
: '白底图未上传'}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{hasModelZip ? `${sku.zipFileName} · ${formatFileSize(sku.zipFileSize)}` : '当前还没有模型压缩包'}
</p>
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{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
? '当前可直接重传模型压缩包'
: '当前可补传模型压缩包'}
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1">
<button
onClick={() => handleOpenWhiteBackground(sku)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
title={sku.whiteBackgroundUploaded ? '继续补传白底图' : '上传白底图'}
type="button"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
白底图
</button>
<button
onClick={() => handleOpenModelUpload(sku)}
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
title={hasModelZip ? '重传模型压缩包' : '上传模型压缩包'}
type="button"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
模型
</button>
{/* 编辑 */}
<button
onClick={() => handleEditSku(sku)}
className="p-1.5 text-blue-400 hover:text-blue-600"
title="编辑"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{/* 删除 */}
<button
onClick={() => handleDeleteSku(sku.id)}
className="p-1.5 text-red-400 hover:text-red-600"
title="删除"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
)
}
// 关闭弹窗
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full max-w-3xl max-h-[90vh] flex flex-col">
{/* 标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
编辑 SKU
</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 工具栏 */}
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700">
{!showAddForm ? (
<button
onClick={() => {
handleCloseWhiteBackground()
setEditingSkuId(null)
setNewSkuName('')
setNewSkuPreviewFile(null)
setNewSkuPreviewUrl('')
setNewSkuZipFile(null)
setShowAddForm(true)
}}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
添加 SKU
</button>
) : (
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{editingSkuId ? '编辑 SKU' : '添加 SKU'}
</div>
)}
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="text-center py-8 text-gray-500">加载中...</div>
) : (
<div className="space-y-4">
{/* 新增 SKU 表单(一行布局,支持拖拽) */}
{showAddForm && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-3">
{/* SKU 名称和压缩包信息 */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="text"
value={newSkuName}
onChange={(e) => 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 && (
<div className="flex-1 min-w-0">
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{newSkuZipFile.name} ({formatFileSize(newSkuZipFile.size)})
</p>
{uploading && uploadProgress.zip > 0 && (
<div className="mt-1 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress.zip}%` }}
/>
</div>
)}
</div>
)}
</div>
{/* 右侧:预览图、压缩包、操作按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* 预览图(支持拖拽) */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('image/')) {
// ⚠️ 内存泄漏修复:释放旧的 Object URL(R17)
if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(newSkuPreviewUrl)
}
setNewSkuPreviewFile(file)
const previewUrl = URL.createObjectURL(file)
setNewSkuPreviewUrl(previewUrl)
}
}}
>
{newSkuPreviewUrl ? (
<div className="relative w-16 h-16 rounded overflow-hidden group">
<Image
src={newSkuPreviewUrl}
alt="预览图"
width={64}
height={64}
className="w-full h-full object-cover"
unoptimized
/>
<button
onClick={() => previewInputRef.current?.click()}
className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</button>
</div>
) : (
<button
onClick={() => previewInputRef.current?.click()}
disabled={uploading}
className="w-16 h-16 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded flex flex-col items-center justify-center transition-colors border-2 border-dashed border-gray-300 dark:border-gray-500"
>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-[10px] text-gray-500 mt-0.5">预览图</span>
</button>
)}
<input
ref={previewInputRef}
type="file"
accept="image/*"
onChange={handleNewPreviewSelect}
className="hidden"
/>
</div>
{/* 压缩包(支持拖拽) */}
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const file = e.dataTransfer.files?.[0]
if (file && ['.zip', '.rar', '.7z'].some(ext => file.name.toLowerCase().endsWith(ext))) {
setNewSkuZipFile(file)
// 如果没有填写名称,用文件名(去掉扩展名)作为默认名称
if (!newSkuName) {
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '')
setNewSkuName(nameWithoutExt)
}
}
}}
>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-16 h-16 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded flex flex-col items-center justify-center transition-colors border-2 border-dashed border-gray-300 dark:border-gray-500"
>
{newSkuZipFile ? (
<>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-[10px] text-gray-600 dark:text-gray-300 mt-0.5">已选择</span>
</>
) : (
<>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span className="text-[10px] text-gray-500 mt-0.5">压缩包(可后传)</span>
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
accept=".zip,.rar,.7z"
onChange={handleNewZipSelect}
className="hidden"
/>
</div>
</div>
</div>
</div>
)}
{activeWhiteBgSku && (
<div className="mb-6">
<div className="mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
白底图管理
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
当前 SKU:{activeWhiteBgSku.skuName}
</p>
</div>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-300">
固定角度支持分次补传,文件会按 SKU + 品类 + 视角 自动命名。
</p>
{whiteBgUploading && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>上传进度 {whiteBgUploadProgress}%</span>
<span>{whiteBgUploadSpeed > 0 ? formatSpeed(whiteBgUploadSpeed) : '计算中...'}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${whiteBgUploadProgress}%` }}
/>
</div>
</div>
)}
</div>
<div className="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/60">
<label className="block text-sm font-medium text-gray-900 dark:text-white">
出图方式 <span className="text-red-500">*</span>
</label>
<div className="mt-3 flex flex-wrap gap-2">
{STANDARD_MODEL_WHITE_BACKGROUND_SOURCE_TYPE_OPTIONS.map((option) => {
const checked = whiteBgSourceType === option
return (
<button
key={option}
type="button"
onClick={() => {
setWhiteBgSourceType(option)
setWhiteBgError('')
}}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
checked
? 'border-gray-700 bg-gray-700 text-white dark:border-gray-200 dark:bg-gray-200 dark:text-gray-900'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{option}
</button>
)
})}
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
白底图按 SKU 维度必填,后续补传会覆盖成最后一次保存的出图方式。
</p>
</div>
<StandardModelWhiteBackgroundUpload
imageUrls={whiteBgPreviewUrls}
otherImages={whiteBgOtherImages.map((image) => ({
id: image.id,
url: image.previewUrl,
name: image.name,
}))}
uploading={whiteBgUploading}
error={whiteBgError}
onSelectImage={handleSelectWhiteBackgroundImage}
onRemoveImage={handleRemoveWhiteBackgroundImage}
onSelectOtherImages={handleSelectWhiteBackgroundOthers}
onRemoveOtherImage={handleRemoveWhiteBackgroundOther}
/>
</div>
)}
{activeModelSku && (
<div className="mb-6">
<div className="mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
模型文件管理
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
当前 SKU:{activeModelSku.skuName}
</p>
</div>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-300">
底部按钮区会显示“保存模型”,保存成功后会自动收起。
</p>
{modelUploading && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>上传进度 {modelUploadProgress}%</span>
<span>{modelUploadProgress < 100 ? '上传中...' : '即将保存...'}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${modelUploadProgress}%` }}
/>
</div>
</div>
)}
</div>
<StandardModelSkuZipUpload
selectedFile={modelZipFile}
existingFileName={activeModelSku.zipFileName}
existingFileSize={activeModelSku.zipFileSize}
uploading={modelUploading}
error={modelUploadError}
onSelectFile={(file) => {
setModelUploadError('')
setModelZipFile(file)
}}
onRemoveFile={() => {
setModelZipFile(null)
setModelUploadError('')
}}
/>
</div>
)}
{/* SKU 列表(支持拖拽排序) */}
{skus.length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<p className="text-base mb-2">暂无 SKU</p>
<p className="text-sm text-gray-400 dark:text-gray-500">点击上方&ldquo;添加 SKU&rdquo;按钮开始添加</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
autoScroll={false}
>
<SortableContext
items={skus.map(sku => sku.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{skus.map((sku, index) => (
<SortableSkuItem key={sku.id} sku={sku} index={index} />
))}
</div>
</SortableContext>
<DragOverlay>
{activeId ? (
<div className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-xl opacity-90 shadow-lg">
<div className="w-16 h-16 flex-shrink-0 rounded overflow-hidden bg-gray-200 dark:bg-gray-600"></div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{skus.find(sku => sku.id === activeId)?.skuName}
</p>
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
)}
</div>
{/* 底部按钮 */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex flex-wrap justify-end gap-3">
{showAddForm ? (
<>
<button
type="button"
onClick={handleCancelEdit}
disabled={uploading}
className="px-5 py-2 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
取消
</button>
<button
type="button"
onClick={handleAddSku}
disabled={!canSubmitSkuForm}
className="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{uploading ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</>
) : saving ? (
'保存中...'
) : editingSkuId ? (
'保存 SKU'
) : (
'添加 SKU'
)}
</button>
</>
) : activeWhiteBgSku ? (
<>
<button
type="button"
onClick={handleCloseWhiteBackground}
disabled={whiteBgUploading}
className="px-5 py-2 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
收起
</button>
<button
type="button"
onClick={handleUploadWhiteBackground}
disabled={whiteBgUploading}
className="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{whiteBgUploading ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</>
) : (
'保存白底图'
)}
</button>
</>
) : activeModelSku ? (
<>
<button
type="button"
onClick={handleCloseModelUpload}
disabled={modelUploading}
className="px-5 py-2 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
收起
</button>
<button
type="button"
onClick={handleSaveModelZip}
disabled={modelUploading}
className="inline-flex items-center gap-2 rounded-lg bg-gray-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{modelUploading ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
上传中...
</>
) : (
'保存模型'
)}
</button>
</>
) : (
<button
onClick={handleClose}
className="px-6 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium transition-colors"
>
完成
</button>
)}
</div>
</div>
{/* 统一的Toast提示 */}
{toast && toast.show && (
<ToastBubble message={toast.message} variant={toast.variant} />
)}
</div>
)
}