d58f46bc80
引入统一品牌主题(色阶/字体/阴影/动效),重做首页 hero、卡片、导航与页脚,统一各页面的卡片/输入框/按钮样式。 - tailwind: 新增 brand 色阶、Inter 字体、card/glow 阴影与 fade-up 动效 - index.html: 引入 Inter 字体与 theme-color/description meta - index.css: 双径向渐变背景 + @layer 组件类(.card/.input/.btn-primary 等) - App/Home/ProductView/Contribute/ApiDocs/Account: 套用新设计系统 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="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (!p) return <div className="text-gray-400 animate-pulse">加载中…</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="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||
<ArrowLeft className="w-4 h-4" /> 返回检索
|
||
</button>
|
||
|
||
<div className="card p-6 animate-fade-up">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<h1 className="text-2xl font-semibold tracking-tight text-gray-900">{p.name}</h1>
|
||
<span className="shrink-0 rounded-full border border-brand-100 bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
|
||
质量分 {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="card p-6 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="card p-6 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>
|
||
);
|
||
}
|