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,104 @@
import { useEffect, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { api } from "../api";
import type { Product } from "../types";
import { NUTRIMENT_LABELS } from "../types";
function Row({ label, value }: { label: string; value: React.ReactNode }) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="flex py-2 border-b last:border-0 text-sm">
<div className="w-32 shrink-0 text-gray-400">{label}</div>
<div className="text-gray-800">{value}</div>
</div>
);
}
export default function ProductView({ id, onBack }: { id: string; onBack: () => void }) {
const [p, setP] = useState<Product | null>(null);
const [error, setError] = useState("");
useEffect(() => {
api.product(id).then(setP).catch((e) => setError(e.message));
}, [id]);
if (error) {
return (
<div>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="bg-red-50 text-red-700 text-sm rounded-md px-4 py-3">{error}</div>
</div>
);
}
if (!p) return <div className="text-gray-400"></div>;
const basisLabel: Record<string, string> = {
per_100g: "每 100g",
per_100ml: "每 100ml",
per_serving: "每份",
};
const nutriEntries = Object.entries(p.nutriments || {}).filter(
([, v]) => v !== null && v !== undefined,
);
return (
<div>
<button onClick={onBack} className="text-sm text-gray-500 flex items-center gap-1 mb-4">
<ArrowLeft className="w-4 h-4" />
</button>
<div className="bg-white border rounded-lg p-5">
<div className="flex items-start justify-between gap-4">
<h1 className="text-xl font-semibold text-gray-800">{p.name}</h1>
<span className="shrink-0 text-xs bg-emerald-50 text-emerald-700 rounded px-2 py-1">
{Math.round(p.quality_score * 100)}
</span>
</div>
<div className="mt-4">
<Row label="品牌" value={p.brand} />
<Row label="条码 (GTIN)" value={p.gtin} />
<Row label="品类" value={p.category_path} />
<Row
label="净含量"
value={
p.net_content_value != null
? `${p.net_content_value} ${p.net_content_unit || ""}`
: null
}
/>
<Row label="产地" value={p.country_of_origin} />
<Row label="Nutri-Score" value={p.nutri_score} />
<Row label="配料" value={p.ingredients_text} />
<Row
label="过敏原"
value={p.allergens && p.allergens.length ? p.allergens.join("、") : null}
/>
<Row
label="添加剂"
value={p.additives && p.additives.length ? p.additives.join("、") : null}
/>
</div>
</div>
{nutriEntries.length > 0 && (
<div className="bg-white border rounded-lg p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-2">
{p.nutrition_basis ? `${basisLabel[p.nutrition_basis] || p.nutrition_basis}` : ""}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
{nutriEntries.map(([k, v]) => (
<div key={k} className="bg-gray-50 rounded px-3 py-2">
<div className="text-gray-400 text-xs">{NUTRIMENT_LABELS[k] || k}</div>
<div className="text-gray-800">{String(v)}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}