916bdd9c7c
- 新增 category.archive_kind 与 kind_field 字段模板表(迁移 0010) - 种子 electronics 字段模板 + 3C 品类树(手机/笔记本/平板等) - 后端按档案模式动态计算完整度/合格:食品沿用 food_detail, 其它模式走 product.attributes + kind_field - 新增 GET /api/kind-fields?kind= 接口 - 后台编辑页按品类模式动态渲染规格参数表单 - 公开详情页/接口输出带标签的规格表 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
145 lines
5.1 KiB
TypeScript
145 lines
5.1 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} />
|
||
{(() => {
|
||
const others = (p.barcodes || []).filter(
|
||
(b) => !b.is_primary && b.gtin !== p.gtin,
|
||
);
|
||
return others.length ? (
|
||
<Row
|
||
label="其他条码"
|
||
value={
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{others.map((b) => (
|
||
<span
|
||
key={b.gtin}
|
||
className="font-mono text-xs bg-gray-100 text-gray-600 rounded px-1.5 py-0.5"
|
||
title={`${b.gtin_type} · ${b.pack_level}`}
|
||
>
|
||
{b.gtin}
|
||
</span>
|
||
))}
|
||
</div>
|
||
}
|
||
/>
|
||
) : null;
|
||
})()}
|
||
<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>
|
||
|
||
{p.specs && p.specs.length > 0 && (
|
||
<div className="bg-white border rounded-lg p-5 mt-4">
|
||
<h2 className="font-medium text-gray-700 mb-2">规格参数</h2>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||
{p.specs.map((s) => (
|
||
<div key={s.key} className="bg-gray-50 rounded px-3 py-2">
|
||
<div className="text-gray-400 text-xs">{s.label}</div>
|
||
<div className="text-gray-800">
|
||
{s.value}
|
||
{s.unit ? ` ${s.unit}` : ""}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|