Files
20260512-skg-tk/web/components/product-library-picker.tsx
2026-05-14 07:06:43 +08:00

183 lines
6.9 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 { useEffect, useMemo, useState } from "react"
import { Copy, Loader2, Plus, Search } from "lucide-react"
import {
apiAssetUrl,
copyProductLibraryAsset,
listProductLibrary,
type ImageRef,
type ProductLibraryItem,
} from "@/lib/api"
import { toast } from "sonner"
interface ProductLibraryPickerProps {
jobId: string
onPick: (ref: ImageRef, item: ProductLibraryItem) => void
disabled?: boolean
buttonLabel?: string
title?: string
compact?: boolean
maxItems?: number
className?: string
}
export function ProductLibraryPicker({
jobId,
onPick,
disabled = false,
buttonLabel = "加入",
title = "内置白底产品库",
compact = false,
maxItems,
className = "",
}: ProductLibraryPickerProps) {
const [items, setItems] = useState<ProductLibraryItem[]>([])
const [loading, setLoading] = useState(false)
const [query, setQuery] = useState("")
const [productType, setProductType] = useState("all")
const [addingId, setAddingId] = useState<string | null>(null)
useEffect(() => {
let alive = true
setLoading(true)
listProductLibrary()
.then((next) => {
if (alive) setItems(next)
})
.catch((e) => {
if (alive) toast.error("产品库读取失败:" + (e instanceof Error ? e.message : String(e)))
})
.finally(() => {
if (alive) setLoading(false)
})
return () => {
alive = false
}
}, [])
const productTypes = useMemo(() => {
return Array.from(new Set(items.map((item) => item.product_type).filter(Boolean))).sort()
}, [items])
const filteredItems = useMemo(() => {
const q = query.trim().toLowerCase()
const next = items.filter((item) => {
if (productType !== "all" && item.product_type !== productType) return false
if (!q) return true
const haystack = [
item.title,
item.handle,
item.product_type,
item.source_path,
String(item.image_index),
].join(" ").toLowerCase()
return haystack.includes(q)
})
return typeof maxItems === "number" ? next.slice(0, maxItems) : next
}, [items, maxItems, productType, query])
const handlePick = async (item: ProductLibraryItem) => {
if (disabled || addingId) return
setAddingId(item.id)
try {
const ref = await copyProductLibraryAsset(jobId, item.id)
onPick(ref, item)
toast.success(`${buttonLabel}${item.title}`)
} catch (e) {
toast.error("产品图加入失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setAddingId(null)
}
}
return (
<section className={`rounded-lg border border-amber-300/18 bg-amber-500/[0.07] p-2.5 ${className}`}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[12px] font-semibold text-white">
{title}
<span className="ml-1.5 font-mono text-[10px] text-white/35">{filteredItems.length}/{items.length}</span>
</div>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin text-amber-200/80" />}
</div>
<div className={`mb-2 grid gap-1.5 ${compact ? "grid-cols-1" : "grid-cols-[1fr_150px]"}`}>
<label className="relative block">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-white/35" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索型号 / 品类"
className="h-8 w-full rounded-md border border-white/10 bg-black/35 pl-7 pr-2 text-[11px] text-white outline-none placeholder:text-white/25 focus:border-amber-300/45"
/>
</label>
<select
value={productType}
onChange={(e) => setProductType(e.target.value)}
className="h-8 rounded-md border border-white/10 bg-black/35 px-2 text-[11px] text-white/75 outline-none focus:border-amber-300/45"
>
<option value="all"></option>
{productTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
{loading ? (
<div className="flex h-28 items-center justify-center rounded-md border border-white/10 bg-black/25 text-[11px] text-white/40">
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
</div>
) : filteredItems.length === 0 ? (
<div className="rounded-md border border-dashed border-white/15 bg-black/25 px-3 py-6 text-center text-[11px] text-white/35">
</div>
) : (
<div
className={`grid gap-2 ${compact ? "max-h-80 overflow-y-auto pr-1" : ""}`}
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${compact ? 104 : 132}px, 1fr))` }}
>
{filteredItems.map((item) => {
const busy = addingId === item.id
return (
<div key={item.id} className="overflow-hidden rounded-md border border-white/10 bg-black/30">
<div className="relative bg-white" style={{ aspectRatio: "1/1" }}>
<img
src={apiAssetUrl(item.url)}
alt={item.title}
className="absolute inset-0 h-full w-full object-contain"
loading="lazy"
/>
<div className="absolute left-1 top-1 rounded bg-black/65 px-1 py-0.5 text-[8.5px] font-mono text-white">
{item.width}×{item.height}
</div>
</div>
<div className="space-y-1 border-t border-white/10 p-1.5">
<div className="truncate text-[10px] font-medium text-white/80" title={item.title}>
{item.title}
</div>
<div className="flex items-center justify-between gap-1">
<span className="truncate text-[9px] text-white/35" title={item.handle}>
#{item.image_index} · {item.product_type || "SKG"}
</span>
<button
type="button"
onClick={() => handlePick(item)}
disabled={disabled || !!addingId}
className="inline-flex h-7 min-w-12 items-center justify-center gap-1 rounded bg-amber-500/75 px-2 text-[10px] font-medium text-white transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
title={disabled ? "产品参考已满" : buttonLabel}
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : buttonLabel.includes("复制") ? <Copy className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
{buttonLabel}
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</section>
)
}