1671 lines
64 KiB
TypeScript
1671 lines
64 KiB
TypeScript
'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<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 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<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]}`
|
||
}
|
||
|
||
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<HTMLInputElement>) => {
|
||
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<HTMLInputElement>) => {
|
||
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<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) => prepareFileDrag(e, uploading)}
|
||
onDrop={(e) => {
|
||
prepareFileDrag(e, uploading)
|
||
const file = e.dataTransfer.files?.[0]
|
||
if (file) {
|
||
applyNewSkuPreviewFile(file)
|
||
}
|
||
}}
|
||
>
|
||
{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"
|
||
disabled={uploading}
|
||
/>
|
||
</div>
|
||
|
||
{/* 压缩包(支持拖拽) */}
|
||
<div
|
||
onDragOver={(e) => prepareFileDrag(e, uploading)}
|
||
onDrop={(e) => {
|
||
prepareFileDrag(e, uploading)
|
||
const file = e.dataTransfer.files?.[0]
|
||
if (file) {
|
||
applyNewSkuZipFile(file)
|
||
}
|
||
}}
|
||
>
|
||
<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"
|
||
disabled={uploading}
|
||
/>
|
||
</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
|
||
? formatTransferProgress(
|
||
whiteBgUploadSpeed,
|
||
whiteBgUploadTotalBytes * (whiteBgUploadProgress / 100),
|
||
whiteBgUploadTotalBytes || undefined,
|
||
)
|
||
: '计算中...'}
|
||
</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">点击上方“添加 SKU”按钮开始添加</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>
|
||
)
|
||
}
|