feat(web+api): 贡献表单按 archive_kind 动态生成字段
- 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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { Category, Product, ProductSummary, SubmissionInput } from "./types";
|
||||
import type { Category, KindField, Product, ProductSummary, SubmissionInput } from "./types";
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
@@ -67,6 +67,10 @@ export const api = {
|
||||
},
|
||||
product: (id: string) => req<Product>(`/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",
|
||||
|
||||
@@ -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<Record<string, string>>({});
|
||||
const [kindFields, setKindFields] = useState<KindField[]>([]);
|
||||
const [attrs, setAttrs] = useState<Record<string, string>>({});
|
||||
const [images, setImages] = useState<SubmissionImage[]>([]);
|
||||
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<string, number> = {};
|
||||
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<string, unknown> = {};
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div key={f.field_key} className={f.field_type === "textarea" || f.field_type === "list" ? "sm:col-span-2" : ""}>
|
||||
<label className={label}>
|
||||
{lbl}
|
||||
{f.qualified && <span className="text-brand-500"> *</span>}
|
||||
</label>
|
||||
{f.field_type === "select" ? (
|
||||
<select className={input} value={val} onChange={(e) => set(e.target.value)}>
|
||||
<option value="">(未选择)</option>
|
||||
{(f.options || []).map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.field_type === "textarea" ? (
|
||||
<textarea className={input} rows={3} value={val} placeholder={f.placeholder || ""} onChange={(e) => set(e.target.value)} />
|
||||
) : f.field_type === "list" ? (
|
||||
<textarea className={input} rows={2} value={val} placeholder={f.placeholder || "每行一项,或用、逗号分隔"} onChange={(e) => set(e.target.value)} />
|
||||
) : (
|
||||
<input
|
||||
type={f.field_type === "number" ? "number" : "text"}
|
||||
step={f.field_type === "number" ? "any" : undefined}
|
||||
className={input}
|
||||
value={val}
|
||||
placeholder={f.placeholder || ""}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="max-w-3xl mx-auto animate-fade-up">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-gray-900">贡献商品档案</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
任何人都可以提交新商品资料。提交后会进入审核队列,<b>通过人工审核后才会收纳</b>。带 * 为必填。
|
||||
任何人都可以提交新商品资料。提交后会进入审核队列,<b>通过人工审核后才会收纳</b>。除商品名称外均为选填,按品类填写对应资料即可。
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
@@ -143,6 +250,11 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{categoryID && (
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
档案类型:{KIND_LABEL[kind] || kind}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>产地</label>
|
||||
@@ -165,39 +277,56 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 mt-4">
|
||||
<h2 className="font-medium text-gray-700 mb-3">配料与营养</h2>
|
||||
<label className={label}>配料表</label>
|
||||
<textarea
|
||||
className={input}
|
||||
rows={3}
|
||||
value={ingredients}
|
||||
onChange={(e) => setIngredients(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<label className={label}>营养基准</label>
|
||||
<select className={input} value={basis} onChange={(e) => setBasis(e.target.value)}>
|
||||
<option value="">(未设置)</option>
|
||||
<option value="per_100g">每 100g</option>
|
||||
<option value="per_100ml">每 100ml</option>
|
||||
<option value="per_serving">每份</option>
|
||||
</select>
|
||||
{kind === "food" && (
|
||||
<div className="card p-5 mt-4">
|
||||
<h2 className="font-medium text-gray-700 mb-3">配料与营养</h2>
|
||||
<label className={label}>配料表</label>
|
||||
<textarea
|
||||
className={input}
|
||||
rows={3}
|
||||
value={ingredients}
|
||||
onChange={(e) => setIngredients(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<label className={label}>营养基准</label>
|
||||
<select className={input} value={basis} onChange={(e) => setBasis(e.target.value)}>
|
||||
<option value="">(未设置)</option>
|
||||
<option value="per_100g">每 100g</option>
|
||||
<option value="per_100ml">每 100ml</option>
|
||||
<option value="per_serving">每份</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
|
||||
{NUTRI_FIELDS.map((f) => (
|
||||
<div key={f.key}>
|
||||
<label className={label}>{f.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className={input}
|
||||
value={nutri[f.key] || ""}
|
||||
onChange={(e) => setNutri({ ...nutri, [f.key]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
|
||||
{NUTRI_FIELDS.map((f) => (
|
||||
<div key={f.key}>
|
||||
<label className={label}>{f.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
className={input}
|
||||
value={nutri[f.key] || ""}
|
||||
onChange={(e) => setNutri({ ...nutri, [f.key]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{kind !== "food" && kind !== "generic" && kindFields.length > 0 && (
|
||||
<div className="card p-5 mt-4">
|
||||
<h2 className="font-medium text-gray-700 mb-1">{KIND_LABEL[kind] || kind}资料</h2>
|
||||
<p className="text-xs text-gray-400 mb-3">带 * 为该品类的关键字段,建议尽量填写。</p>
|
||||
{groups.map((g) => (
|
||||
<div key={g.label} className="mt-3 first:mt-0">
|
||||
{g.label && <h3 className="text-xs font-semibold text-gray-400 mb-2">{g.label}</h3>}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{g.fields.map(renderField)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-5 mt-4">
|
||||
<h2 className="font-medium text-gray-700 mb-3">图片(仅填 URL)</h2>
|
||||
|
||||
@@ -51,6 +51,20 @@ export interface Category {
|
||||
name_en: string | null;
|
||||
path: string;
|
||||
level: number;
|
||||
archive_kind: string;
|
||||
}
|
||||
|
||||
export interface KindField {
|
||||
kind: string;
|
||||
field_key: string;
|
||||
group_label: string;
|
||||
label_zh: string;
|
||||
field_type: "text" | "number" | "textarea" | "select" | "list";
|
||||
unit: string | null;
|
||||
options: string[] | null;
|
||||
placeholder: string | null;
|
||||
sort_order: number;
|
||||
qualified: boolean;
|
||||
}
|
||||
|
||||
export interface SubmissionImage {
|
||||
@@ -68,6 +82,7 @@ export interface SubmissionInput {
|
||||
country_of_origin?: string | null;
|
||||
ingredients_text?: string | null;
|
||||
nutriments?: Record<string, number> | null;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
nutrition_basis?: string | null;
|
||||
serving_size?: string | null;
|
||||
nutri_score?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user