From 916bdd9c7cb3033f1b9411d1f68d38991ee7ad63 Mon Sep 17 00:00:00 2001 From: sulaimaannaasif6866 Date: Sun, 21 Jun 2026 06:13:42 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=AF=E6=89=A9=E5=B1=95=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E6=A8=A1=E5=BC=8F=E6=A1=86=E6=9E=B6=20+=20=E5=86=85?= =?UTF-8?q?=E7=BD=AE=203C(=E7=94=B5=E5=AD=90)=20=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 category.archive_kind 与 kind_field 字段模板表(迁移 0010) - 种子 electronics 字段模板 + 3C 品类树(手机/笔记本/平板等) - 后端按档案模式动态计算完整度/合格:食品沿用 food_detail, 其它模式走 product.attributes + kind_field - 新增 GET /api/kind-fields?kind= 接口 - 后台编辑页按品类模式动态渲染规格参数表单 - 公开详情页/接口输出带标签的规格表 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin-frontend/src/api.ts | 4 + .../src/components/ProductDetail.tsx | 135 +++++++++++++++++- admin-frontend/src/types.ts | 16 +++ api/internal/adminhandler/handler.go | 15 ++ api/internal/adminstore/adminstore.go | 44 +++++- api/internal/adminstore/category.go | 17 +-- api/internal/adminstore/kind.go | 101 +++++++++++++ api/internal/adminstore/kind_test.go | 119 +++++++++++++++ api/internal/adminstore/quality.go | 52 +++++-- api/internal/adminstore/write.go | 58 +++++--- api/internal/store/store.go | 90 +++++++++++- migrations/0010_archive_kinds.down.sql | 9 ++ migrations/0010_archive_kinds.up.sql | 64 +++++++++ .../src/components/ProductView.tsx | 17 +++ public-frontend/src/types.ts | 9 ++ 15 files changed, 695 insertions(+), 55 deletions(-) create mode 100644 api/internal/adminstore/kind.go create mode 100644 api/internal/adminstore/kind_test.go create mode 100644 migrations/0010_archive_kinds.down.sql create mode 100644 migrations/0010_archive_kinds.up.sql 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" ? ( +