import { useEffect, useMemo, useState } from "react"; import { api } from "../api"; import { AuditEntry, Brand, Category, FIELD_LABELS, KindField, ProductDetail as Detail, } from "../types"; import { ArrowLeft, ChevronLeft, ChevronRight, Plus, Save, Trash2, AlertCircle, History, Star, } from "lucide-react"; const GTIN_TYPES = ["EAN13", "EAN8", "UPC", "ITF14", "GTIN14"]; const PACK_LEVELS: { value: string; label: string }[] = [ { value: "each", label: "消费单元" }, { value: "case", label: "箱" }, { value: "pallet", label: "托盘" }, ]; const NUTRIMENT_KEYS: { key: string; label: string }[] = [ { key: "energy_kcal", label: "能量 (kcal)" }, { key: "energy_kj", label: "能量 (kJ)" }, { key: "fat", label: "脂肪 (g)" }, { key: "saturated_fat", label: "饱和脂肪 (g)" }, { key: "carbohydrates", label: "碳水 (g)" }, { key: "sugars", label: "糖 (g)" }, { key: "proteins", label: "蛋白质 (g)" }, { key: "salt", label: "盐 (g)" }, ]; const STATUS_OPTIONS = [ { value: "active", label: "在用" }, { value: "merged", label: "已合并" }, { value: "deprecated", label: "已停用" }, ]; const ACTION_LABEL: Record = { update: "编辑", add_image: "新增图片", delete_image: "删除图片", add_msrp: "新增建议零售价", delete_msrp: "删除建议零售价", add_barcode: "新增条码", delete_barcode: "删除条码", set_primary_barcode: "设为主条码", }; function Card({ title, children, }: { title: string; children: React.ReactNode; }) { return (

{title}

{children}
); } function Field({ label, children, }: { label: string; children: React.ReactNode; }) { return ( ); } const inputCls = "w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none"; export default function ProductDetail({ id, onBack, ids = [], onNavigate, }: { id: string; onBack: () => void; ids?: string[]; onNavigate?: (id: string) => void; }) { const navIndex = ids.indexOf(id); const prevId = navIndex > 0 ? ids[navIndex - 1] : null; const nextId = navIndex >= 0 && navIndex < ids.length - 1 ? ids[navIndex + 1] : null; const [d, setD] = useState(null); const [brands, setBrands] = useState([]); const [categories, setCategories] = useState([]); const [audit, setAudit] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(""); const [error, setError] = useState(""); // editable form state const [name, setName] = useState(""); const [gtin, setGtin] = useState(""); const [brandName, setBrandName] = useState(""); const [categoryId, setCategoryId] = useState(""); const [netValue, setNetValue] = useState(""); const [netUnit, setNetUnit] = useState(""); const [country, setCountry] = useState(""); const [status, setStatus] = useState("active"); const [ingredients, setIngredients] = useState(""); const [allergens, setAllergens] = useState(""); const [additives, setAdditives] = useState(""); const [nutriments, setNutriments] = useState>({}); const [basis, setBasis] = useState(""); const [serving, setServing] = useState(""); const [nutriScore, setNutriScore] = useState(""); const [kindFields, setKindFields] = useState([]); const [attrs, setAttrs] = useState>({}); function hydrate(detail: Detail) { setD(detail); setName(detail.name); setGtin(detail.gtin || ""); setBrandName(detail.brand || ""); setCategoryId(detail.category_id || ""); setNetValue(detail.net_content_value?.toString() || ""); setNetUnit(detail.net_content_unit || ""); setCountry(detail.country_of_origin || ""); setStatus(detail.status); setIngredients(detail.ingredients_text || ""); setAllergens(detail.allergens.join(", ")); setAdditives(detail.additives.join(", ")); const nm: Record = {}; if (detail.nutriments) { for (const [k, v] of Object.entries(detail.nutriments)) nm[k] = String(v); } setNutriments(nm); setBasis(detail.nutrition_basis || ""); setServing(detail.serving_size || ""); setNutriScore(detail.nutri_score || ""); const am: Record = {}; if (detail.attributes) { for (const [k, v] of Object.entries(detail.attributes)) { am[k] = v == null ? "" : Array.isArray(v) ? v.join(", ") : String(v); } } setAttrs(am); } function reload() { setLoading(true); Promise.all([api.getProduct(id), api.listAudit(id)]) .then(([detail, a]) => { hydrate(detail); setAudit(a.items); }) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); } useEffect(() => { reload(); api.listBrands().then((r) => setBrands(r.items)).catch(() => {}); api.listCategories().then((r) => setCategories(r.items)).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); const missing = useMemo(() => d?.missing ?? [], [d]); const selectedKind = useMemo(() => { const c = categories.find((x) => x.id === categoryId); return c?.archive_kind || d?.archive_kind || "generic"; }, [categories, categoryId, d]); useEffect(() => { if (selectedKind && selectedKind !== "food") { api .listKindFields(selectedKind) .then((r) => setKindFields(r.items)) .catch(() => setKindFields([])); } else { setKindFields([]); } }, [selectedKind]); const specGroups = useMemo(() => { const groups: { label: string; fields: KindField[] }[] = []; for (const f of kindFields) { let g = groups.find((x) => x.label === f.group_label); if (!g) { g = { label: f.group_label, fields: [] }; groups.push(g); } g.fields.push(f); } return groups; }, [kindFields]); const attrLabels = useMemo(() => { const m: Record = {}; for (const f of kindFields) m[f.field_key] = f.label_zh; return m; }, [kindFields]); function parseList(s: string): string[] { return s .split(",") .map((x) => x.trim()) .filter(Boolean); } async function save() { setSaving(true); setMsg(""); setError(""); const nm: Record = {}; for (const [k, v] of Object.entries(nutriments)) { const n = parseFloat(v); if (!Number.isNaN(n)) nm[k] = n; } let attributes: Record | undefined; if (selectedKind !== "food") { attributes = {}; for (const f of kindFields) { const raw = (attrs[f.field_key] ?? "").trim(); if (raw === "") continue; if (f.field_type === "number") { const n = parseFloat(raw); if (!Number.isNaN(n)) attributes[f.field_key] = n; } else if (f.field_type === "list") { attributes[f.field_key] = parseList(raw); } else { attributes[f.field_key] = raw; } } } const body = { gtin: gtin.trim() || null, name: name.trim(), brand_name: brandName.trim() || null, brand_id: brandName.trim() ? undefined : null, category_id: categoryId || null, net_content_value: netValue.trim() ? parseFloat(netValue) : null, net_content_unit: netUnit.trim() || null, country_of_origin: country.trim() || null, status, ingredients_text: ingredients.trim() || null, allergens: parseList(allergens), additives: parseList(additives), nutriments: nm, nutrition_basis: basis || null, serving_size: serving.trim() || null, nutri_score: nutriScore || null, ...(attributes !== undefined ? { attributes } : {}), }; try { const updated = await api.updateProduct(id, body); hydrate(updated); const a = await api.listAudit(id); setAudit(a.items); setMsg("已保存"); setTimeout(() => setMsg(""), 2500); } catch (e) { setError(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } } if (loading) { return
加载中…
; } if (!d) { return (

{error || "未找到商品"}

); } return (
{ids.length > 1 && navIndex >= 0 && (
{navIndex + 1} / {ids.length}
)}
{msg && {msg}} {error && {error}} 质量分 {Math.round(d.quality_score * 100)}
{missing.length > 0 && (
待补全字段: {missing.map((f) => FIELD_LABELS[f] || attrLabels[f] || f).join("、")}
)}
setName(e.target.value)} /> setGtin(e.target.value)} /> setBrandName(e.target.value)} /> {brands.map((b) => ( setNetValue(e.target.value)} /> setNetUnit(e.target.value)} /> setCountry(e.target.value)} />
{selectedKind === "food" && (