321 lines
12 KiB
TypeScript
321 lines
12 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'
|
||
import type { StandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
|
||
|
||
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
|
||
}
|
||
whiteBackgroundUploaded?: boolean
|
||
whiteBackgroundComplete?: boolean
|
||
whiteBackgroundFixedCount?: number
|
||
whiteBackgroundOtherCount?: number
|
||
whiteBackgroundCount?: number
|
||
whiteBackgroundSourceType?: StandardModelWhiteBackgroundSourceType | null
|
||
whiteBackgroundUploadedBy?: string | null
|
||
whiteBackgroundUploadedAt?: string | Date | null
|
||
whiteBackgroundUploader?: {
|
||
id: string
|
||
username: string
|
||
realName?: string | null
|
||
} | null
|
||
}
|
||
|
||
interface SkuListProps {
|
||
materialId: string
|
||
canEdit: boolean
|
||
onEditClick: () => void
|
||
onSelectionChange?: (selectedIds: Set<string>) => void
|
||
refreshKey?: number
|
||
}
|
||
|
||
export default function SkuList({ materialId, canEdit, onEditClick, onSelectionChange, refreshKey = 0 }: 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 [downloadingWhiteBgSkuId, setDownloadingWhiteBgSkuId] = useState<string | null>(null)
|
||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
fetchSkus()
|
||
}, [materialId, refreshKey])
|
||
|
||
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 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)
|
||
}
|
||
|
||
const handleDownloadSku = async (sku: Sku) => {
|
||
if (!sku.zipFilePath || !sku.zipFilePath.trim()) {
|
||
showToast('该 SKU 暂无模型压缩包', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
const handleDownloadWhiteBackground = async (sku: Sku) => {
|
||
if (!sku.whiteBackgroundUploaded) {
|
||
showToast('该 SKU 暂无白底图', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
try {
|
||
setDownloadingWhiteBgSkuId(sku.id)
|
||
showToast('正在打包白底图,请稍候', { variant: 'success', duration: 1800 })
|
||
const directDownloadUrl = `/api/materials/${materialId}/skus/${sku.id}/download?downloadType=white-background`
|
||
const response = await fetch(directDownloadUrl, {
|
||
method: 'GET',
|
||
cache: 'no-store',
|
||
credentials: 'include',
|
||
})
|
||
|
||
if (!response.ok) {
|
||
let errorData: unknown = null
|
||
try {
|
||
errorData = await response.json()
|
||
} catch {}
|
||
throw errorData || { error: '下载 SKU 白底图失败' }
|
||
}
|
||
|
||
const blob = await response.blob()
|
||
const url = window.URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
|
||
const contentDisposition = response.headers.get('Content-Disposition')
|
||
let fileName = `${sku.skuName}_白底图.zip`
|
||
if (contentDisposition) {
|
||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||
if (filenameMatch && filenameMatch[1]) {
|
||
fileName = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
|
||
}
|
||
}
|
||
|
||
link.download = fileName
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
setTimeout(() => {
|
||
link.remove()
|
||
window.URL.revokeObjectURL(url)
|
||
}, 100)
|
||
} catch (error) {
|
||
console.error('下载 SKU 白底图失败:', error)
|
||
const { message } = parseDownloadError(error)
|
||
showToast(message, { variant: 'error' })
|
||
} finally {
|
||
setDownloadingWhiteBgSkuId(null)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="py-4 text-center text-[13px] text-[#86898e]">加载中...</div>
|
||
}
|
||
|
||
if (skus.length === 0) {
|
||
return (
|
||
<div className="py-4 flex items-center justify-between">
|
||
<span className="text-[13px] text-[#86898e]">暂无 SKU</span>
|
||
{canEdit && (
|
||
<button
|
||
onClick={onEditClick}
|
||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-600 hover:bg-gray-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-[13px] font-medium text-[#1a1c1d]">SKU 列表 ({skus.length}个)</span>
|
||
{canEdit && (
|
||
<button
|
||
onClick={onEditClick}
|
||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-gray-600 hover:bg-gray-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="mb-3 flex items-center justify-between rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] px-3 py-2">
|
||
<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-[12px] text-[#1a1c1d]">全选</span>
|
||
</label>
|
||
{selectedSkuIds.size > 0 && (
|
||
<span className="text-[12px] text-[#86898e]">已选 {selectedSkuIds.size} 个</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="overflow-y-auto space-y-2 pr-1" style={{ maxHeight: 'calc(100vh - 500px)' }}>
|
||
{skus.map((sku) => (
|
||
<div
|
||
key={sku.id}
|
||
className="flex items-center gap-2 rounded-[8px] border border-[#e7e8ea] p-2 transition-colors hover:bg-[#fafafa]"
|
||
>
|
||
<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>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-[13px] font-medium text-[#1a1c1d]">{sku.skuName}</p>
|
||
<p className="truncate text-[12px] text-[#86898e]">
|
||
模型:{sku.zipFilePath && sku.zipFilePath.trim() ? '已上传' : '未上传'}
|
||
<span className="mx-1.5 text-[#e7e8ea]">·</span>
|
||
白底图:{sku.whiteBackgroundUploaded ? `${sku.whiteBackgroundCount || 0}张` : '未上传'}
|
||
{sku.whiteBackgroundUploaded && sku.whiteBackgroundComplete === false ? '(未补齐)' : ''}
|
||
</p>
|
||
{sku.whiteBackgroundUploaded && (
|
||
<p className="mt-0.5 truncate text-[12px] text-[#86898e]">
|
||
{[
|
||
sku.whiteBackgroundSourceType ? `出图:${sku.whiteBackgroundSourceType}` : null,
|
||
sku.whiteBackgroundUploadedAt
|
||
? `时间:${new Date(sku.whiteBackgroundUploadedAt).toLocaleDateString('zh-CN')}`
|
||
: null,
|
||
].filter(Boolean).join(' · ')}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||
<button
|
||
onClick={() => handleDownloadWhiteBackground(sku)}
|
||
disabled={!sku.whiteBackgroundUploaded || downloadingWhiteBgSkuId === sku.id}
|
||
className="px-2.5 py-1.5 text-xs bg-gray-600 hover:bg-gray-700 text-white rounded-lg disabled:opacity-50 transition-colors"
|
||
>
|
||
{downloadingWhiteBgSkuId === sku.id ? '打包白底图...' : '白底图'}
|
||
</button>
|
||
<button
|
||
onClick={() => handleDownloadSku(sku)}
|
||
disabled={downloadingSkuId === sku.id || !sku.zipFilePath || !sku.zipFilePath.trim()}
|
||
className="px-2.5 py-1.5 text-xs bg-gray-600 hover:bg-gray-700 text-white rounded-lg disabled:opacity-50 transition-colors"
|
||
>
|
||
{downloadingSkuId === sku.id ? '下载中' : '模型'}
|
||
</button>
|
||
</div>
|
||
</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-4 right-4 w-10 h-10 bg-black/50 hover:bg-black/70 text-white rounded-full flex items-center justify-center transition-colors"
|
||
>
|
||
<svg className="w-6 h-6" 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>
|
||
)}
|
||
|
||
{toast && toast.show && <ToastBubble message={toast.message} variant={toast.variant} />}
|
||
</div>
|
||
)
|
||
}
|