import { useEffect, useState } from "react"; import { api, ApiError } from "../api"; import { Brand, Category, FIELD_LABELS, ProductRow } from "../types"; import { Search, AlertCircle, Plus, ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react"; type SortKey = | "name" | "brand" | "gtin" | "category_path" | "status" | "quality_score"; const STATUS_LABEL: Record = { active: "在用", merged: "已合并", deprecated: "已停用", }; function QualityBadge({ score }: { score: number }) { const pct = Math.round(score * 100); const color = score >= 0.8 ? "bg-emerald-100 text-emerald-700" : score >= 0.5 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"; return ( {pct} ); } function SortableTh({ label, sortKey, sort, order, onSort, }: { label: string; sortKey: SortKey; sort: SortKey | ""; order: "asc" | "desc"; onSort: (key: SortKey) => void; }) { const active = sort === sortKey; return ( ); } export default function ProductList({ onOpen, }: { onOpen: (id: string, ids: string[]) => void; }) { const [q, setQ] = useState(""); const [input, setInput] = useState(""); const [page, setPage] = useState(1); const [size, setSize] = useState(20); const [sort, setSort] = useState(""); const [order, setOrder] = useState<"asc" | "desc">("asc"); const [jump, setJump] = useState(""); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [creating, setCreating] = useState(false); const [selected, setSelected] = useState>(new Set()); const [categories, setCategories] = useState([]); const [bulkStatus, setBulkStatus] = useState(""); const [bulkCategory, setBulkCategory] = useState(""); const [bulkBusy, setBulkBusy] = useState(false); function reload() { setLoading(true); setError(""); api .listProducts(q, page, size, sort || undefined, order) .then((r) => { setRows(r.items); setTotal(r.total); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); } useEffect(() => { setSelected(new Set()); reload(); }, [q, page, size, sort, order]); function toggleSort(key: SortKey) { setPage(1); if (sort !== key) { setSort(key); setOrder("asc"); } else if (order === "asc") { setOrder("desc"); } else { setSort(""); setOrder("asc"); } } useEffect(() => { api.listCategories().then((r) => setCategories(r.items)).catch(() => {}); }, []); function toggle(id: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function toggleAll() { setSelected((prev) => prev.size === rows.length ? new Set() : new Set(rows.map((r) => r.id)), ); } async function applyBulkStatus() { if (!bulkStatus || selected.size === 0) return; setBulkBusy(true); setError(""); try { await api.bulkProducts({ ids: [...selected], action: "status", status: bulkStatus, }); setSelected(new Set()); setBulkStatus(""); reload(); } catch (e) { setError(e instanceof ApiError ? e.message : "批量操作失败"); } finally { setBulkBusy(false); } } async function applyBulkCategory() { if (selected.size === 0) return; setBulkBusy(true); setError(""); try { await api.bulkProducts({ ids: [...selected], action: "category", category_id: bulkCategory || null, }); setSelected(new Set()); setBulkCategory(""); reload(); } catch (e) { setError(e instanceof ApiError ? e.message : "批量操作失败"); } finally { setBulkBusy(false); } } const pages = Math.max(1, Math.ceil(total / size)); return (

商品档案 共 {total} 条

{ e.preventDefault(); setPage(1); setQ(input.trim()); }} className="flex items-center gap-2" >
setInput(e.target.value)} placeholder="按名称 / 条码搜索" className="w-64 rounded border border-gray-300 py-2 pl-8 pr-3 text-sm focus:border-emerald-500 focus:outline-none" />
{creating && ( setCreating(false)} onCreated={(id) => { setCreating(false); onOpen(id, [id]); }} /> )} {error && (
{error}
)} {selected.size > 0 && (
已选 {selected.size} 项
改状态
改分类
)}
{loading ? ( ) : rows.length === 0 ? ( ) : ( rows.map((r) => ( onOpen(r.id, rows.map((x) => x.id))} className={`cursor-pointer hover:bg-emerald-50/50 ${ selected.has(r.id) ? "bg-emerald-50/60" : "" }`} > )) )}
0 && selected.size === rows.length} onChange={toggleAll} aria-label="全选" /> 缺失字段
加载中…
暂无数据
e.stopPropagation()} > toggle(r.id)} aria-label="选择" /> {r.name} {r.brand || "—"} {r.gtin || "—"} {r.category_path || "—"} {STATUS_LABEL[r.status] || r.status} {r.missing.length === 0 ? ( 完整 ) : ( {r.missing .map((f) => FIELD_LABELS[f] || f) .join("、")} )}
每页 条 · 共 {total} 条
{page} / {pages}
{ e.preventDefault(); const n = Number(jump); if (Number.isFinite(n) && n >= 1) { setPage(Math.min(Math.max(1, Math.trunc(n)), pages)); setJump(""); } }} className="flex items-center gap-1" > 跳至 setJump(e.target.value.replace(/[^0-9]/g, ""))} placeholder={String(page)} className="w-14 rounded border border-gray-300 px-2 py-1 text-center" aria-label="跳转页码" />
); } function CreateProductModal({ onClose, onCreated, }: { onClose: () => void; onCreated: (id: string) => void; }) { const [name, setName] = useState(""); const [gtin, setGtin] = useState(""); const [brand, setBrand] = useState(""); const [categoryId, setCategoryId] = useState(""); const [brands, setBrands] = useState([]); const [categories, setCategories] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); useEffect(() => { api.listBrands().then((r) => setBrands(r.items)).catch(() => {}); api.listCategories().then((r) => setCategories(r.items)).catch(() => {}); }, []); async function submit() { if (!name.trim()) { setError("名称不能为空"); return; } setBusy(true); setError(""); try { const created = await api.createProduct({ name: name.trim(), gtin: gtin.trim() || null, brand_name: brand.trim() || null, category_id: categoryId || null, status: "active", }); onCreated(created.id); } catch (e) { setError(e instanceof ApiError ? e.message : "新建失败"); } finally { setBusy(false); } } return (

新建商品

{error && (
{error}
)}

创建后将进入详情页,可继续补全营养、图片、多条码等信息。

); }