diff --git a/admin-frontend/src/api.ts b/admin-frontend/src/api.ts index 478bbe5..83be9ec 100644 --- a/admin-frontend/src/api.ts +++ b/admin-frontend/src/api.ts @@ -124,6 +124,10 @@ export const api = { }), deleteBrand: (id: string) => request<{ status: string }>(`/brands/${id}`, { method: "DELETE" }), + listKindFields: (kind: string) => + request<{ items: import("./types").KindField[]; kind: string }>( + `/kind-fields?kind=${encodeURIComponent(kind)}`, + ), listCategories: () => request<{ items: import("./types").Category[] }>("/categories"), createCategory: (body: import("./types").CategoryInput) => diff --git a/admin-frontend/src/components/ProductDetail.tsx b/admin-frontend/src/components/ProductDetail.tsx index 7075ee6..a1c4283 100644 --- a/admin-frontend/src/components/ProductDetail.tsx +++ b/admin-frontend/src/components/ProductDetail.tsx @@ -5,6 +5,7 @@ import { Brand, Category, FIELD_LABELS, + KindField, ProductDetail as Detail, } from "../types"; import { @@ -127,6 +128,8 @@ export default function ProductDetail({ 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); @@ -149,6 +152,13 @@ export default function ProductDetail({ 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() { @@ -171,6 +181,41 @@ export default function ProductDetail({ 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(",") @@ -187,6 +232,22 @@ export default function ProductDetail({ 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(), @@ -204,6 +265,7 @@ export default function ProductDetail({ nutrition_basis: basis || null, serving_size: serving.trim() || null, nutri_score: nutriScore || null, + ...(attributes !== undefined ? { attributes } : {}), }; try { const updated = await api.updateProduct(id, body); @@ -284,7 +346,8 @@ export default function ProductDetail({ {missing.length > 0 && (
- 待补全字段:{missing.map((f) => FIELD_LABELS[f] || f).join("、")} + 待补全字段: + {missing.map((f) => FIELD_LABELS[f] || attrLabels[f] || f).join("、")}
)} @@ -371,6 +434,7 @@ export default function ProductDetail({ + {selectedKind === "food" && (
@@ -443,6 +507,75 @@ export default function ProductDetail({ ))}
+ )} + + {selectedKind !== "food" && kindFields.length > 0 && ( + + {specGroups.map((grp) => ( +
+ {grp.label && ( +

+ {grp.label} +

+ )} +
+ {grp.fields.map((f) => ( + + {f.field_type === "select" ? ( + + ) : f.field_type === "textarea" ? ( +