feat: 公开首页(搜索+商品详情) + 好心人投稿 + 后台审核收纳
- 公开前端 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user