gongxiangp2/components/SkuList.tsx

321 lines
12 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 } 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>
)
}