Files
goods/public-frontend/src/components/ProductView.tsx
T
rosemariejebbjtxbfp d58f46bc80
CI / Go (api) (pull_request) Failing after 32s
CI / Python (ingestion) (pull_request) Failing after 31s
CI / Migrations (postgres) (pull_request) Failing after 32s
feat(public-frontend): 公开前端 UI 视觉升级
引入统一品牌主题(色阶/字体/阴影/动效),重做首页 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>
2026-06-24 03:16:41 +00:00

145 lines
5.1 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="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>
);
}