gongxiangp2/components/SkuEditModal.tsx
chenpei 0ce59be34e 备份:组织架构设计方案讨论前的代码快照
- 包含所有当前修改和新功能
- 准备开始组织架构优化实施方案
2025-12-30 13:20:26 +08:00

846 lines
32 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 {
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'
// SKU 数据类型
interface Sku {
id: string
skuName: string
skuCode?: string
previewUrl: string
zipFileName: string
zipFilePath: string
zipFileSize: string
sortOrder: number
uploaderId: string
}
interface SkuEditModalProps {
materialId: string
isOpen: boolean
onClose: () => void
onSaved: () => void // 保存成功后的回调
}
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 [uploadingSkuId, setUploadingSkuId] = useState<string | null>(null)
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 fileInputRef = useRef<HTMLInputElement>(null)
const previewInputRef = useRef<HTMLInputElement>(null)
// 加载 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 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
// 立即存储文件对象,并创建本地预览URL
setNewSkuPreviewFile(file)
const previewUrl = URL.createObjectURL(file)
setNewSkuPreviewUrl(previewUrl)
}
// 上传压缩包到本地存储(带进度)
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)
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/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) => {
setEditingSkuId(sku.id)
setNewSkuName(sku.skuName)
setNewSkuPreviewUrl(sku.previewUrl || '')
setNewSkuPreviewFile(null) // 编辑时不清除已有预览图
setNewSkuZipFile(null) // 编辑时不要求重新上传压缩包
setShowAddForm(true)
setUploadProgress({ zip: 0, preview: 0 })
}
// 取消编辑/添加
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 })
}
// 添加或更新 SKU(此时才开始上传文件)
const handleAddSku = async () => {
if (!newSkuName.trim()) {
showToast('请输入 SKU 名称', { variant: 'error' })
return
}
// 编辑模式:预览图或压缩包可以只更新其中一个,也可以都不更新(只改名称)
// 新增模式:必须都有
if (!editingSkuId) {
if (!newSkuPreviewFile) {
showToast('请选择预览图', { variant: 'error' })
return
}
if (!newSkuZipFile) {
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
}
// 如果有新压缩包,上传它
if (newSkuZipFile) {
const uploadedZipResult = await handleUploadZip(newSkuZipFile, newSkuName.trim(), (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: newSkuName.trim(),
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 || !zipResult) {
showToast('上传失败', { variant: 'error' })
return
}
const response = await fetch(`/api/materials/${materialId}/skus`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skuName: newSkuName.trim(),
previewUrl: previewUrl,
zipFileName: zipResult.fileName,
zipFilePath: zipResult.filePath,
zipFileSize: zipResult.fileSize
})
})
if (response.ok) {
// 清理本地预览URL
if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(newSkuPreviewUrl)
}
// 刷新列表
await fetchSkus()
showToast('添加成功', { variant: 'success' })
// 重置表单字段(但不关闭表单,可以继续添加)
setNewSkuName('')
setNewSkuPreviewFile(null)
setNewSkuPreviewUrl('')
setNewSkuZipFile(null)
setUploadProgress({ zip: 0, preview: 0 })
} else {
const error = await response.json()
showToast(error.error || '添加失败', { variant: 'error' })
}
} catch (error) {
console.error('添加 SKU 失败:', error)
showToast('添加失败,请稍后重试', { 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) {
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 {
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 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-xl"
>
{/* 拖拽手柄 */}
<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">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{sku.skuName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{sku.zipFileName} · {formatFileSize(sku.zipFileSize)}
</p>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1">
{/* 编辑 */}
<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 = () => {
// 清理本地预览URL
if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(newSkuPreviewUrl)
}
setShowAddForm(false)
setNewSkuName('')
setNewSkuPreviewFile(null)
setNewSkuPreviewUrl('')
setNewSkuZipFile(null)
setUploadProgress({ zip: 0, preview: 0 })
onSaved()
onClose()
}
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={() => {
setEditingSkuId(null)
setNewSkuName('')
setNewSkuPreviewFile(null)
setNewSkuPreviewUrl('')
setNewSkuZipFile(null)
setShowAddForm(true)
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-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(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/')) {
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>
{/* 操作按钮 */}
<button
onClick={handleAddSku}
disabled={saving || uploading || !newSkuName || !newSkuPreviewFile || !newSkuZipFile}
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors relative"
>
{uploading ? (
<span className="flex items-center gap-2">
<svg className="w-4 h-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>
上传中...
</span>
) : saving ? (
'保存中...'
) : editingSkuId ? (
'更新'
) : (
'添加'
)}
</button>
<button
onClick={handleCancelEdit}
disabled={uploading}
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 disabled:opacity-50"
>
<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>
</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 justify-end">
<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>
)
}