From bd1d3bde7c4fc0e50e889d97ab3f7ceb6801f10a Mon Sep 17 00:00:00 2001 From: rosemariejebbjtxbfp Date: Wed, 24 Jun 2026 10:02:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(web+api):=20=E8=B4=A1=E7=8C=AE=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E6=8C=89=20archive=5Fkind=20=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - categories 接口返回 archive_kind - 新增公开只读接口 /api/v1/kind-fields?kind=X 返回字段模板 - 投稿 payload 支持通用 attributes,审核通过写入 product.attributes (JSONB 合并) - food_detail 仅在含食品数据时才 upsert (药品/3C/通用不再产生空行) - 前端 Contribute 按所选品类 archive_kind 动态渲染字段 (食品营养 / 药品 18 字段 / 3C / 通用),除商品名外均选填 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- api/internal/adminstore/submission.go | 44 +++- api/internal/handler/handler.go | 16 ++ api/internal/store/store.go | 44 +++- public-frontend/src/api.ts | 6 +- public-frontend/src/components/Contribute.tsx | 203 ++++++++++++++---- public-frontend/src/types.ts | 15 ++ 6 files changed, 278 insertions(+), 50 deletions(-) diff --git a/api/internal/adminstore/submission.go b/api/internal/adminstore/submission.go index c63c5c0..3ab94c0 100644 --- a/api/internal/adminstore/submission.go +++ b/api/internal/adminstore/submission.go @@ -31,6 +31,7 @@ type SubmissionInput struct { CountryOfOrigin *string `json:"country_of_origin"` IngredientsText *string `json:"ingredients_text"` Nutriments map[string]any `json:"nutriments"` + Attributes map[string]any `json:"attributes"` NutritionBasis *string `json:"nutrition_basis"` ServingSize *string `json:"serving_size"` NutriScore *string `json:"nutri_score"` @@ -278,14 +279,19 @@ func (s *Store) ApproveSubmission(ctx context.Context, id, actor string) (*Produ fields := submissionFields(in) + var attrJSON []byte + if len(in.Attributes) > 0 { + attrJSON, _ = json.Marshal(in.Attributes) + } + if productID == "" { // Create a new product from the contribution. err = tx.QueryRow(ctx, ` INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code, - net_content_value, net_content_unit, net_content_canonical, country_of_origin, status) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'active') RETURNING id`, + net_content_value, net_content_unit, net_content_canonical, country_of_origin, attributes, status) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,COALESCE($10::jsonb,'{}'::jsonb),'active') RETURNING id`, in.GTIN, in.Name, brandID, in.CategoryID, gpc, - in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin).Scan(&productID) + in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, attrJSON).Scan(&productID) if err != nil { return nil, err } @@ -302,21 +308,29 @@ UPDATE product SET net_content_unit=COALESCE($7, net_content_unit), net_content_canonical=COALESCE($8, net_content_canonical), country_of_origin=COALESCE($9, country_of_origin), - gtin=COALESCE($10, gtin) + gtin=COALESCE($10, gtin), + attributes=product.attributes || COALESCE($11::jsonb,'{}'::jsonb) WHERE id=$1`, productID, in.Name, brandID, in.CategoryID, gpc, - in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, in.GTIN) + in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, in.GTIN, attrJSON) if err != nil { return nil, err } } - // food_detail: upsert, preserving existing values where not provided. + // food_detail: upsert only when the contribution carries food data, so a + // non-food submission (drug/3C/generic) doesn't create an empty row. var nutriJSON []byte if len(in.Nutriments) > 0 { nutriJSON, _ = json.Marshal(in.Nutriments) } - _, err = tx.Exec(ctx, ` + foodPresent := len(in.Nutriments) > 0 || + (in.IngredientsText != nil && *in.IngredientsText != "") || + (in.NutritionBasis != nil && *in.NutritionBasis != "") || + (in.ServingSize != nil && *in.ServingSize != "") || + (in.NutriScore != nil && *in.NutriScore != "") + if foodPresent { + _, err = tx.Exec(ctx, ` INSERT INTO food_detail (product_id, ingredients_text, nutriments, nutrition_basis, serving_size, nutri_score) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (product_id) DO UPDATE SET @@ -325,9 +339,10 @@ ON CONFLICT (product_id) DO UPDATE SET nutrition_basis=COALESCE(EXCLUDED.nutrition_basis, food_detail.nutrition_basis), serving_size=COALESCE(EXCLUDED.serving_size, food_detail.serving_size), nutri_score=COALESCE(EXCLUDED.nutri_score, food_detail.nutri_score)`, - productID, in.IngredientsText, nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore) - if err != nil { - return nil, err + productID, in.IngredientsText, nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore) + if err != nil { + return nil, err + } } for _, im := range in.Images { @@ -417,5 +432,14 @@ func submissionFields(in SubmissionInput) []string { add("ingredients", in.IngredientsText != nil && *in.IngredientsText != "") add("nutriments", len(in.Nutriments) > 0) add("image", len(in.Images) > 0) + for k, v := range in.Attributes { + if v == nil { + continue + } + if s, ok := v.(string); ok && s == "" { + continue + } + fields = append(fields, k) + } return fields } diff --git a/api/internal/handler/handler.go b/api/internal/handler/handler.go index 84de445..fbc37a9 100644 --- a/api/internal/handler/handler.go +++ b/api/internal/handler/handler.go @@ -127,6 +127,7 @@ func (h *Handler) Router() http.Handler { }) r.Get("/brands", h.ListBrands) r.Get("/categories", h.ListCategories) + r.Get("/kind-fields", h.ListKindFields) r.Get("/sources/{id}", h.SourceByID) r.Get("/stats", h.Stats) }) @@ -275,6 +276,21 @@ func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +// ListKindFields returns the read-only spec field template for an archive kind, +// used by the contribution form to render kind-specific inputs. +func (h *Handler) ListKindFields(w http.ResponseWriter, r *http.Request) { + kind := strings.TrimSpace(r.URL.Query().Get("kind")) + if kind == "" { + writeError(w, r, http.StatusBadRequest, "bad_request", "missing kind") + return + } + items, err := h.store.ListKindFields(r.Context(), kind) + if h.handleErr(w, r, err) { + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items, "kind": kind}) +} + // SourceByID returns a single data source. func (h *Handler) SourceByID(w http.ResponseWriter, r *http.Request) { src, err := h.store.SourceByID(r.Context(), chi.URLParam(r, "id")) diff --git a/api/internal/store/store.go b/api/internal/store/store.go index f9bafad..1f31e6e 100644 --- a/api/internal/store/store.go +++ b/api/internal/store/store.go @@ -501,12 +501,13 @@ type Category struct { Path string `json:"path"` GPCBrickCode *string `json:"gpc_brick_code"` Level int `json:"level"` + ArchiveKind string `json:"archive_kind"` } // ListCategories returns the full category tree ordered by path. func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { rows, err := s.pool.Query(ctx, - "SELECT id, name_zh, name_en, path::text, gpc_brick_code, level FROM category ORDER BY path") + "SELECT id, name_zh, name_en, path::text, gpc_brick_code, level, COALESCE(archive_kind, 'generic') FROM category ORDER BY path") if err != nil { return nil, err } @@ -514,7 +515,7 @@ func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { out := []Category{} for rows.Next() { var c Category - if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.GPCBrickCode, &c.Level); err != nil { + if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.GPCBrickCode, &c.Level, &c.ArchiveKind); err != nil { return nil, err } out = append(out, c) @@ -522,6 +523,45 @@ func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { return out, rows.Err() } +// KindField describes one editable spec field for an archive kind. It drives +// the dynamic contribution form (public, read-only view of the template). +type KindField struct { + Kind string `json:"kind"` + FieldKey string `json:"field_key"` + GroupLabel string `json:"group_label"` + LabelZH string `json:"label_zh"` + FieldType string `json:"field_type"` + Unit *string `json:"unit"` + Options []string `json:"options"` + Placeholder *string `json:"placeholder"` + SortOrder int `json:"sort_order"` + Qualified bool `json:"qualified"` +} + +// ListKindFields returns the ordered field template for one archive kind so the +// public contribution form can render kind-specific inputs. +func (s *Store) ListKindFields(ctx context.Context, kind string) ([]KindField, error) { + rows, err := s.pool.Query(ctx, ` +SELECT kind, field_key, group_label, label_zh, field_type, unit, options, + placeholder, sort_order, qualified +FROM kind_field WHERE kind = $1 ORDER BY sort_order, field_key`, kind) + if err != nil { + return nil, err + } + defer rows.Close() + out := []KindField{} + for rows.Next() { + var f KindField + if err := rows.Scan(&f.Kind, &f.FieldKey, &f.GroupLabel, &f.LabelZH, + &f.FieldType, &f.Unit, &f.Options, &f.Placeholder, &f.SortOrder, + &f.Qualified); err != nil { + return nil, err + } + out = append(out, f) + } + return out, rows.Err() +} + // APIKey is the minimal metadata the public API needs to authorize a caller. type APIKey struct { ID string diff --git a/public-frontend/src/api.ts b/public-frontend/src/api.ts index 086d830..3a4de52 100644 --- a/public-frontend/src/api.ts +++ b/public-frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { Category, Product, ProductSummary, SubmissionInput } from "./types"; +import type { Category, KindField, Product, ProductSummary, SubmissionInput } from "./types"; async function req(path: string, init?: RequestInit): Promise { const res = await fetch(path, { @@ -67,6 +67,10 @@ export const api = { }, product: (id: string) => req(`/api/v1/products/${id}`), categories: () => req<{ items: Category[] }>(`/api/v1/categories`), + kindFields: (kind: string) => + req<{ items: KindField[]; kind: string }>( + `/api/v1/kind-fields?kind=${encodeURIComponent(kind)}`, + ), submit: (input: SubmissionInput) => req<{ id: string; status: string }>(`/api/public/submissions`, { method: "POST", diff --git a/public-frontend/src/components/Contribute.tsx b/public-frontend/src/components/Contribute.tsx index 64efaa9..ff8c877 100644 --- a/public-frontend/src/components/Contribute.tsx +++ b/public-frontend/src/components/Contribute.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { CheckCircle2, PlusCircle, Trash2 } from "lucide-react"; import { api } from "../api"; -import type { Category, SubmissionImage, SubmissionInput } from "../types"; +import type { Category, KindField, SubmissionImage, SubmissionInput } from "../types"; const NUTRI_FIELDS: { key: string; label: string }[] = [ { key: "energy_kcal", label: "能量 (kcal)" }, @@ -35,6 +35,8 @@ export default function Contribute({ onDone }: { onDone: () => void }) { const [ingredients, setIngredients] = useState(""); const [basis, setBasis] = useState(""); const [nutri, setNutri] = useState>({}); + const [kindFields, setKindFields] = useState([]); + const [attrs, setAttrs] = useState>({}); const [images, setImages] = useState([]); const [imageURL, setImageURL] = useState(""); const [submitter, setSubmitter] = useState(""); @@ -45,6 +47,33 @@ export default function Contribute({ onDone }: { onDone: () => void }) { api.categories().then((r) => setCategories(r.items)).catch(() => undefined); }, []); + // Derive the archive kind from the selected category. No category => generic. + const kind = useMemo(() => { + const c = categories.find((x) => x.id === categoryID); + return c?.archive_kind || "generic"; + }, [categories, categoryID]); + + // Fetch the kind-specific field template for non-food kinds. + useEffect(() => { + setAttrs({}); + if (kind === "food" || kind === "generic") { + setKindFields([]); + return; + } + let alive = true; + api + .kindFields(kind) + .then((r) => { + if (alive) setKindFields(r.items); + }) + .catch(() => { + if (alive) setKindFields([]); + }); + return () => { + alive = false; + }; + }, [kind]); + async function submit(e: React.FormEvent) { e.preventDefault(); if (name.trim() === "") { @@ -55,9 +84,31 @@ export default function Contribute({ onDone }: { onDone: () => void }) { setError(""); const nutriments: Record = {}; - for (const [k, v] of Object.entries(nutri)) { - const n = parseFloat(v); - if (!Number.isNaN(n)) nutriments[k] = n; + if (kind === "food") { + for (const [k, v] of Object.entries(nutri)) { + const n = parseFloat(v); + if (!Number.isNaN(n)) nutriments[k] = n; + } + } + + const attributes: Record = {}; + if (kind !== "food" && kind !== "generic") { + 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") { + const parts = raw + .split(/[\n,,、]/) + .map((s) => s.trim()) + .filter((s) => s !== ""); + if (parts.length) attributes[f.field_key] = parts; + } else { + attributes[f.field_key] = raw; + } + } } const input: SubmissionInput = { @@ -68,9 +119,10 @@ export default function Contribute({ onDone }: { onDone: () => void }) { net_content_value: field(netValue) ? parseFloat(netValue) : null, net_content_unit: field(netUnit), country_of_origin: field(country), - ingredients_text: field(ingredients), + ingredients_text: kind === "food" ? field(ingredients) : null, nutriments: Object.keys(nutriments).length ? nutriments : null, - nutrition_basis: basis || null, + attributes: Object.keys(attributes).length ? attributes : null, + nutrition_basis: kind === "food" && basis ? basis : null, images: images.length ? images : undefined, submitter_name: field(submitter), submitter_contact: field(contact), @@ -107,11 +159,66 @@ export default function Contribute({ onDone }: { onDone: () => void }) { const input = "input"; const label = "block text-xs font-medium text-gray-500 mb-1"; + // Group the kind fields by their group label, preserving template order. + 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); + } + + const KIND_LABEL: Record = { + drug: "药品", + electronics: "数码 3C", + food: "食品", + generic: "通用", + }; + + function renderField(f: KindField) { + const val = attrs[f.field_key] || ""; + const set = (v: string) => setAttrs((p) => ({ ...p, [f.field_key]: v })); + const lbl = f.unit ? `${f.label_zh} (${f.unit})` : f.label_zh; + return ( +
+ + {f.field_type === "select" ? ( + + ) : f.field_type === "textarea" ? ( +