c9a4404052
- 公开前端 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>
105 lines
3.6 KiB
TypeScript
105 lines
3.6 KiB
TypeScript
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>
|
||
);
|
||
}
|