276 lines
9.5 KiB
TypeScript
276 lines
9.5 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import Image from 'next/image'
|
|
import { useToast } from '@/hooks/useToast'
|
|
import ToastBubble from './ToastBubble'
|
|
import { parseDownloadError } from '@/lib/download-errors'
|
|
import { validateAndTriggerDownload } from '@/lib/client-download'
|
|
|
|
// SKU 数据类型
|
|
interface Sku {
|
|
id: string
|
|
skuName: string
|
|
skuCode?: string
|
|
previewUrl: string
|
|
zipFileName: string
|
|
zipFilePath: string
|
|
zipFileSize: string
|
|
sortOrder: number
|
|
uploaderId: string
|
|
uploader?: {
|
|
id: string
|
|
username: string
|
|
realName?: string
|
|
}
|
|
}
|
|
|
|
interface SkuListProps {
|
|
materialId: string
|
|
canEdit: boolean // 是否可以编辑(上传者或管理员)
|
|
onEditClick: () => void // 点击编辑按钮的回调
|
|
onSelectionChange?: (selectedIds: Set<string>) => void // 选中状态变化回调
|
|
}
|
|
|
|
export default function SkuList({ materialId, canEdit, onEditClick, onSelectionChange }: SkuListProps) {
|
|
const [skus, setSkus] = useState<Sku[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const { toast, showToast } = useToast()
|
|
const [selectedSkuIds, setSelectedSkuIds] = useState<Set<string>>(new Set())
|
|
const [downloadingSkuId, setDownloadingSkuId] = useState<string | null>(null)
|
|
// 移除批量下载状态,由父组件处理
|
|
const [previewImage, setPreviewImage] = useState<string | null>(null) // 大图预览
|
|
|
|
// 加载 SKU 列表
|
|
useEffect(() => {
|
|
fetchSkus()
|
|
}, [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): string => {
|
|
const size = parseInt(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 handleSelectAll = () => {
|
|
const newSelected = selectedSkuIds.size === skus.length
|
|
? new Set<string>()
|
|
: new Set(skus.map(sku => sku.id))
|
|
setSelectedSkuIds(newSelected)
|
|
onSelectionChange?.(newSelected)
|
|
}
|
|
|
|
// 单选/取消单选
|
|
const handleSelectSku = (skuId: string) => {
|
|
const newSelected = new Set(selectedSkuIds)
|
|
if (newSelected.has(skuId)) {
|
|
newSelected.delete(skuId)
|
|
} else {
|
|
newSelected.add(skuId)
|
|
}
|
|
setSelectedSkuIds(newSelected)
|
|
onSelectionChange?.(newSelected)
|
|
}
|
|
|
|
// 下载单个 SKU
|
|
const handleDownloadSku = async (sku: Sku) => {
|
|
try {
|
|
setDownloadingSkuId(sku.id)
|
|
|
|
const directDownloadUrl = `/api/materials/${materialId}/skus/${sku.id}/download`
|
|
await validateAndTriggerDownload(directDownloadUrl)
|
|
} catch (error) {
|
|
console.error('下载 SKU 失败:', error)
|
|
const { message } = parseDownloadError(error)
|
|
showToast(message, { variant: 'error' })
|
|
} finally {
|
|
setDownloadingSkuId(null)
|
|
}
|
|
}
|
|
|
|
// 批量下载功能已移至父组件
|
|
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="py-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
|
加载中...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (skus.length === 0) {
|
|
return (
|
|
<div className="py-4 flex items-center justify-between">
|
|
<span className="text-gray-500 dark:text-gray-400 text-sm">暂无 SKU</span>
|
|
{canEdit && (
|
|
<button
|
|
onClick={onEditClick}
|
|
className="inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
|
>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
{/* 标题栏 - 编辑按钮与标题同一行 */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
|
SKU 列表 ({skus.length}个)
|
|
</span>
|
|
{canEdit && (
|
|
<button
|
|
onClick={onEditClick}
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded-lg transition-colors"
|
|
>
|
|
<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="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>
|
|
)}
|
|
</div>
|
|
|
|
{/* 全选栏 */}
|
|
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg mb-3">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSkuIds.size === skus.length && skus.length > 0}
|
|
onChange={handleSelectAll}
|
|
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-xs text-gray-600 dark:text-gray-300">全选</span>
|
|
</label>
|
|
{selectedSkuIds.size > 0 && (
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
已选 {selectedSkuIds.size} 个
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* SKU 列表(可滚动区域) */}
|
|
<div
|
|
className="overflow-y-auto space-y-2 pr-1"
|
|
style={{ maxHeight: 'calc(100vh - 500px)' }}
|
|
>
|
|
{skus.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
|
<svg className="w-12 h-12 mx-auto mb-3 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>暂无 SKU</p>
|
|
{canEdit && (
|
|
<p className="text-xs mt-1 text-gray-400 dark:text-gray-500">点击上方“编辑”按钮添加</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
skus.map(sku => (
|
|
<div
|
|
key={sku.id}
|
|
className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/30 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
|
>
|
|
{/* 复选框 */}
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSkuIds.has(sku.id)}
|
|
onChange={() => handleSelectSku(sku.id)}
|
|
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 flex-shrink-0"
|
|
/>
|
|
|
|
{/* 预览图(点击放大) */}
|
|
<div
|
|
className="w-10 h-10 flex-shrink-0 rounded overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={() => setPreviewImage(sku.previewUrl)}
|
|
>
|
|
<Image
|
|
src={sku.previewUrl}
|
|
alt={sku.skuName}
|
|
width={40}
|
|
height={40}
|
|
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 truncate">
|
|
{sku.skuName}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{formatFileSize(sku.zipFileSize)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* 下载按钮 */}
|
|
<button
|
|
onClick={() => handleDownloadSku(sku)}
|
|
disabled={downloadingSkuId === sku.id}
|
|
className="flex-shrink-0 px-2.5 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 transition-colors"
|
|
>
|
|
{downloadingSkuId === sku.id ? '下载中' : '下载'}
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* 批量下载按钮已移至父组件统一处理 */}
|
|
|
|
{/* 大图预览弹窗 */}
|
|
{previewImage && (
|
|
<div
|
|
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
|
|
onClick={() => setPreviewImage(null)}
|
|
>
|
|
<div className="relative max-w-4xl max-h-[90vh]">
|
|
<Image
|
|
src={previewImage}
|
|
alt="预览图"
|
|
width={800}
|
|
height={800}
|
|
className="max-w-full max-h-[90vh] object-contain"
|
|
unoptimized
|
|
/>
|
|
<button
|
|
onClick={() => setPreviewImage(null)}
|
|
className="absolute top-2 right-2 w-8 h-8 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center"
|
|
>
|
|
<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>
|
|
)
|
|
}
|