846 lines
32 KiB
TypeScript
846 lines
32 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 {
|
||
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">点击上方“添加 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 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>
|
||
)
|
||
}
|