gongxiangp2/components/SkuEditModal.tsx

541 lines
22 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'
// 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>
)
}