183 lines
6.9 KiB
TypeScript
183 lines
6.9 KiB
TypeScript
"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>
|
||
)
|
||
}
|