Files
goods/public-frontend/src/components/ProductView.tsx
T
oyaegeli98668 c9a4404052
CI / Go (api) (pull_request) Failing after 20s
CI / Python (ingestion) (pull_request) Successful in 8s
CI / Migrations (postgres) (pull_request) Failing after 17s
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>
2026-06-20 05:11:07 +00:00

105 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}