bd1d3bde7c
- 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>
400 lines
15 KiB
TypeScript
400 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { CheckCircle2, PlusCircle, Trash2 } from "lucide-react";
|
||
import { api } from "../api";
|
||
import type { Category, KindField, SubmissionImage, SubmissionInput } from "../types";
|
||
|
||
const NUTRI_FIELDS: { 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)" },
|
||
];
|
||
|
||
function field(v: string): string | null {
|
||
const t = v.trim();
|
||
return t === "" ? null : t;
|
||
}
|
||
|
||
export default function Contribute({ onDone }: { onDone: () => void }) {
|
||
const [categories, setCategories] = useState<Category[]>([]);
|
||
const [done, setDone] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
const [name, setName] = useState("");
|
||
const [gtin, setGtin] = useState("");
|
||
const [brand, setBrand] = useState("");
|
||
const [categoryID, setCategoryID] = useState("");
|
||
const [netValue, setNetValue] = useState("");
|
||
const [netUnit, setNetUnit] = useState("");
|
||
const [country, setCountry] = useState("");
|
||
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("");
|
||
const [contact, setContact] = useState("");
|
||
const [note, setNote] = useState("");
|
||
|
||
useEffect(() => {
|
||
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() === "") {
|
||
setError("请填写商品名称");
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
setError("");
|
||
|
||
const nutriments: Record<string, number> = {};
|
||
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 = {
|
||
name: name.trim(),
|
||
gtin: field(gtin),
|
||
brand_name: field(brand),
|
||
category_id: categoryID || null,
|
||
net_content_value: field(netValue) ? parseFloat(netValue) : null,
|
||
net_content_unit: field(netUnit),
|
||
country_of_origin: field(country),
|
||
ingredients_text: kind === "food" ? field(ingredients) : null,
|
||
nutriments: Object.keys(nutriments).length ? nutriments : 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),
|
||
note: field(note),
|
||
};
|
||
|
||
try {
|
||
await api.submit(input);
|
||
setDone(true);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "提交失败");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
if (done) {
|
||
return (
|
||
<div className="max-w-xl mx-auto text-center py-16 animate-fade-up">
|
||
<span className="mx-auto grid h-16 w-16 place-items-center rounded-2xl bg-brand-50">
|
||
<CheckCircle2 className="w-9 h-9 text-brand-600" />
|
||
</span>
|
||
<h1 className="mt-4 text-xl font-semibold text-gray-800">已提交,等待审核</h1>
|
||
<p className="mt-2 text-gray-500">
|
||
感谢你的贡献!资料将由管理员人工审核,通过后会收录进公共商品库。
|
||
</p>
|
||
<button onClick={onDone} className="btn-primary mt-6">
|
||
返回首页
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>。除商品名称外均为选填,按品类填写对应资料即可。
|
||
</p>
|
||
|
||
{error && (
|
||
<div className="mt-4 rounded-xl bg-red-50 px-4 py-2.5 text-sm text-red-700">{error}</div>
|
||
)}
|
||
|
||
<div className="card p-5 mt-4">
|
||
<h2 className="font-medium text-gray-700 mb-3">基础信息</h2>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="sm:col-span-2">
|
||
<label className={label}>商品名称 *</label>
|
||
<input className={input} value={name} onChange={(e) => setName(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className={label}>条码 (GTIN)</label>
|
||
<input className={input} value={gtin} onChange={(e) => setGtin(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className={label}>品牌</label>
|
||
<input className={input} value={brand} onChange={(e) => setBrand(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className={label}>品类</label>
|
||
<select className={input} value={categoryID} onChange={(e) => setCategoryID(e.target.value)}>
|
||
<option value="">(未选择)</option>
|
||
{categories.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name_zh} ({c.path})
|
||
</option>
|
||
))}
|
||
</select>
|
||
{categoryID && (
|
||
<p className="mt-1 text-xs text-gray-400">
|
||
档案类型:{KIND_LABEL[kind] || kind}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className={label}>产地</label>
|
||
<input className={input} value={country} onChange={(e) => setCountry(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className={label}>净含量</label>
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
className={input}
|
||
value={netValue}
|
||
onChange={(e) => setNetValue(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={label}>净含量单位 (g/ml/cl…)</label>
|
||
<input className={input} value={netUnit} onChange={(e) => setNetUnit(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{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>
|
||
)}
|
||
|
||
{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 className="card p-5 mt-4">
|
||
<h2 className="font-medium text-gray-700 mb-3">图片(仅填 URL)</h2>
|
||
{images.length > 0 && (
|
||
<ul className="mb-3 space-y-1">
|
||
{images.map((im, i) => (
|
||
<li key={i} className="flex items-center gap-2 text-sm">
|
||
<span className="truncate text-gray-600 flex-1">{im.url}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setImages(images.filter((_, j) => j !== i))}
|
||
className="text-gray-400 hover:text-red-500"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<input
|
||
className={input}
|
||
placeholder="图片 URL"
|
||
value={imageURL}
|
||
onChange={(e) => setImageURL(e.target.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (imageURL.trim()) {
|
||
setImages([...images, { url: imageURL.trim(), kind: "front" }]);
|
||
setImageURL("");
|
||
}
|
||
}}
|
||
className="shrink-0 px-3 rounded-xl border border-gray-200 text-sm flex items-center gap-1 hover:bg-gray-50"
|
||
>
|
||
<PlusCircle className="w-4 h-4" /> 添加
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card p-5 mt-4">
|
||
<h2 className="font-medium text-gray-700 mb-3">联系方式(选填)</h2>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className={label}>你的称呼</label>
|
||
<input className={input} value={submitter} onChange={(e) => setSubmitter(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className={label}>联系方式(邮箱/微信)</label>
|
||
<input className={input} value={contact} onChange={(e) => setContact(e.target.value)} />
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<label className={label}>备注 / 资料来源</label>
|
||
<input className={input} value={note} onChange={(e) => setNote(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 flex items-center gap-3">
|
||
<button type="submit" disabled={submitting} className="btn-primary px-6">
|
||
{submitting ? "提交中…" : "提交审核"}
|
||
</button>
|
||
<button type="button" onClick={onDone} className="text-sm text-gray-500 hover:underline">
|
||
取消
|
||
</button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|