541 lines
22 KiB
TypeScript
541 lines
22 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useRef } from 'react'
|
||
import Image from 'next/image'
|
||
import { useToast } from '@/hooks/useToast'
|
||
import ToastBubble from './ToastBubble'
|
||
|
||
// 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 [newSkuName, setNewSkuName] = useState('')
|
||
const [newSkuPreviewUrl, setNewSkuPreviewUrl] = useState('')
|
||
const [newSkuFile, setNewSkuFile] = useState<{ fileName: string; filePath: string; fileSize: number } | null>(null)
|
||
const [uploadingNew, setUploadingNew] = useState(false)
|
||
const [uploadingPreview, setUploadingPreview] = useState(false)
|
||
|
||
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]}`
|
||
}
|
||
|
||
// 上传压缩包
|
||
const handleUploadZip = async (file: File): Promise<{ fileName: string; filePath: string; fileSize: number } | null> => {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
try {
|
||
const response = await fetch(`/api/materials/${materialId}/skus/upload`, {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
return {
|
||
fileName: data.fileName,
|
||
filePath: data.filePath,
|
||
fileSize: data.fileSize
|
||
}
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '上传失败', { variant: 'error' })
|
||
return null
|
||
}
|
||
} catch (error) {
|
||
console.error('上传失败:', error)
|
||
showToast('上传失败,请稍后重试', { variant: 'error' })
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 上传预览图到 S3
|
||
const handleUploadPreview = async (file: File): Promise<string | null> => {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
try {
|
||
const response = await fetch('/api/upload/preview', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
return data.url
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '上传预览图失败', { variant: 'error' })
|
||
return null
|
||
}
|
||
} catch (error) {
|
||
console.error('上传预览图失败:', error)
|
||
showToast('上传预览图失败,请稍后重试', { variant: 'error' })
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 处理新增 SKU 的压缩包选择
|
||
const handleNewZipSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
|
||
setUploadingNew(true)
|
||
const result = await handleUploadZip(file)
|
||
if (result) {
|
||
setNewSkuFile(result)
|
||
// 如果没有填写名称,用文件名(去掉扩展名)作为默认名称
|
||
if (!newSkuName) {
|
||
const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '')
|
||
setNewSkuName(nameWithoutExt)
|
||
}
|
||
}
|
||
setUploadingNew(false)
|
||
}
|
||
|
||
// 处理新增 SKU 的预览图选择
|
||
const handleNewPreviewSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
|
||
setUploadingPreview(true)
|
||
const url = await handleUploadPreview(file)
|
||
if (url) {
|
||
setNewSkuPreviewUrl(url)
|
||
}
|
||
setUploadingPreview(false)
|
||
}
|
||
|
||
// 添加新 SKU
|
||
const handleAddSku = async () => {
|
||
if (!newSkuName.trim()) {
|
||
showToast('请输入 SKU 名称', { variant: 'error' })
|
||
return
|
||
}
|
||
if (!newSkuPreviewUrl) {
|
||
showToast('请上传预览图', { variant: 'error' })
|
||
return
|
||
}
|
||
if (!newSkuFile) {
|
||
showToast('请上传压缩包', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
setSaving(true)
|
||
try {
|
||
const response = await fetch(`/api/materials/${materialId}/skus`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
skuName: newSkuName.trim(),
|
||
previewUrl: newSkuPreviewUrl,
|
||
zipFileName: newSkuFile.fileName,
|
||
zipFilePath: newSkuFile.filePath,
|
||
zipFileSize: newSkuFile.fileSize
|
||
})
|
||
})
|
||
|
||
if (response.ok) {
|
||
// 重置表单
|
||
setNewSkuName('')
|
||
setNewSkuPreviewUrl('')
|
||
setNewSkuFile(null)
|
||
setShowAddForm(false)
|
||
// 刷新列表
|
||
await fetchSkus()
|
||
} else {
|
||
const error = await response.json()
|
||
showToast(error.error || '添加失败', { variant: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('添加 SKU 失败:', error)
|
||
showToast('添加失败,请稍后重试', { variant: 'error' })
|
||
} finally {
|
||
setSaving(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' })
|
||
}
|
||
}
|
||
|
||
// 移动 SKU(上移/下移)
|
||
const handleMoveSku = async (index: number, direction: 'up' | 'down') => {
|
||
const newIndex = direction === 'up' ? index - 1 : index + 1
|
||
if (newIndex < 0 || newIndex >= skus.length) return
|
||
|
||
const newSkus = [...skus]
|
||
const temp = newSkus[index]
|
||
newSkus[index] = newSkus[newIndex]
|
||
newSkus[newIndex] = temp
|
||
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)
|
||
})
|
||
})
|
||
} catch (error) {
|
||
console.error('更新排序失败:', error)
|
||
}
|
||
}
|
||
|
||
// 关闭弹窗
|
||
const handleClose = () => {
|
||
setShowAddForm(false)
|
||
setNewSkuName('')
|
||
setNewSkuPreviewUrl('')
|
||
setNewSkuFile(null)
|
||
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">
|
||
<button
|
||
onClick={() => 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>
|
||
|
||
{/* 内容区域 */}
|
||
<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"
|
||
/>
|
||
{newSkuFile && (
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||
{newSkuFile.fileName} ({formatFileSize(newSkuFile.fileSize)})
|
||
</p>
|
||
)}
|
||
</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/')) {
|
||
handleNewPreviewSelect({ target: { files: [file] } } as any)
|
||
}
|
||
}}
|
||
>
|
||
{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={uploadingPreview}
|
||
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">{uploadingPreview ? '上传中' : '预览图'}</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))) {
|
||
handleNewZipSelect({ target: { files: [file] } } as any)
|
||
}
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={uploadingNew}
|
||
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"
|
||
>
|
||
{newSkuFile ? (
|
||
<>
|
||
<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">{uploadingNew ? '上传中' : '压缩包'}</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".zip,.rar,.7z"
|
||
onChange={handleNewZipSelect}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<button
|
||
onClick={handleAddSku}
|
||
disabled={saving || !newSkuName || !newSkuPreviewUrl || !newSkuFile}
|
||
className="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors"
|
||
>
|
||
{saving ? '保存中...' : '添加'}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
setShowAddForm(false)
|
||
setNewSkuName('')
|
||
setNewSkuPreviewUrl('')
|
||
setNewSkuFile(null)
|
||
}}
|
||
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>
|
||
</div>
|
||
)}
|
||
|
||
{/* SKU 列表 */}
|
||
{skus.length === 0 ? (
|
||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||
暂无 SKU,点击上方按钮添加
|
||
</div>
|
||
) : (
|
||
skus.map((sku, index) => (
|
||
<div
|
||
key={sku.id}
|
||
className="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-xl"
|
||
>
|
||
{/* 预览图 */}
|
||
<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={() => handleMoveSku(index, 'up')}
|
||
disabled={index === 0}
|
||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30"
|
||
title="上移"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
{/* 下移 */}
|
||
<button
|
||
onClick={() => handleMoveSku(index, 'down')}
|
||
disabled={index === skus.length - 1}
|
||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-30"
|
||
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 9l-7 7-7-7" />
|
||
</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>
|
||
))
|
||
)}
|
||
</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>
|
||
)
|
||
}
|