feat: 公开首页(搜索+商品详情) + 好心人投稿 + 后台审核收纳
CI / Go (api) (pull_request) Failing after 20s
CI / Python (ingestion) (pull_request) Successful in 8s
CI / Migrations (postgres) (pull_request) Failing after 17s

- 公开前端 SPA(根路径 /):首页大搜索框、检索结果、只读商品详情、贡献档案表单
- 公开写入端点 POST /api/public/submissions(无需登录,基础频率限流),投稿进入 submission 待审核队列,不直接写 product
- 迁移 0006:submission 投稿表 + community 来源(trust=0.50)
- 后台审核队列:列表(待审核/已通过/已驳回) → 查看投稿 → 通过(创建/补全商品 + 记 source=community + 字段级溯源 + 审计 + 重算质量分) / 驳回(记原因)
- 公开只读 api 服务内嵌公开 SPA;Dockerfile.prod 增加 node 构建阶段 + 内嵌 dist

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
oyaegeli98668
2026-06-20 05:11:07 +00:00
parent bad771e172
commit c9a4404052
36 changed files with 4615 additions and 28 deletions
@@ -0,0 +1,276 @@
import { useEffect, useState } from "react";
import { CheckCircle2, PlusCircle, Trash2 } from "lucide-react";
import { api } from "../api";
import type { Category, 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 [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);
}, []);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (name.trim() === "") {
setError("请填写商品名称");
return;
}
setSubmitting(true);
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;
}
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: field(ingredients),
nutriments: Object.keys(nutriments).length ? nutriments : null,
nutrition_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">
<CheckCircle2 className="w-14 h-14 text-emerald-500 mx-auto" />
<h1 className="mt-4 text-xl font-semibold text-gray-800"></h1>
<p className="mt-2 text-gray-500">
</p>
<button
onClick={onDone}
className="mt-6 px-5 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700"
>
</button>
</div>
);
}
const input =
"w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400";
const label = "block text-xs text-gray-500 mb-1";
return (
<form onSubmit={submit} className="max-w-3xl mx-auto">
<h1 className="text-xl font-semibold text-gray-800"></h1>
<p className="mt-1 text-sm text-gray-500">
<b></b> *
</p>
{error && (
<div className="mt-4 bg-red-50 text-red-700 text-sm rounded-md px-4 py-2">{error}</div>
)}
<div className="bg-white border rounded-lg 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>
</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>
<div className="bg-white border rounded-lg 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="bg-white border rounded-lg 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-md border text-sm flex items-center gap-1 hover:bg-gray-50"
>
<PlusCircle className="w-4 h-4" />
</button>
</div>
</div>
<div className="bg-white border rounded-lg 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="px-6 py-2.5 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60"
>
{submitting ? "提交中…" : "提交审核"}
</button>
<button type="button" onClick={onDone} className="text-sm text-gray-500 hover:underline">
</button>
</div>
</form>
);
}