173 lines
6.9 KiB
TypeScript
173 lines
6.9 KiB
TypeScript
import { useState } from "react";
|
||
import { Search, PlusCircle, Code2, ScanBarcode, ShieldCheck, ArrowRight } from "lucide-react";
|
||
import { api } from "../api";
|
||
import type { ProductSummary } from "../types";
|
||
|
||
const EXAMPLES = ["可乐", "Nutella", "牛奶", "5449000000996"];
|
||
|
||
const FEATURES = [
|
||
{
|
||
icon: Search,
|
||
title: "客观可查",
|
||
desc: "按名称或条码检索商品的成分、营养、规格等官方事实。",
|
||
},
|
||
{
|
||
icon: ShieldCheck,
|
||
title: "可溯源",
|
||
desc: "每条资料标注数据来源与质量分,公开透明、人人可核。",
|
||
},
|
||
{
|
||
icon: Code2,
|
||
title: "开放 API",
|
||
desc: "免鉴权只读 REST 接口,开发者可直接接入商品档案。",
|
||
},
|
||
];
|
||
|
||
export default function Home({
|
||
onOpen,
|
||
onContribute,
|
||
}: {
|
||
onOpen: (id: string) => void;
|
||
onContribute: () => void;
|
||
}) {
|
||
const [q, setQ] = useState("");
|
||
const [items, setItems] = useState<ProductSummary[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [searched, setSearched] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
async function run(term?: string, e?: React.FormEvent) {
|
||
e?.preventDefault();
|
||
const keyword = (term ?? q).trim();
|
||
if (term !== undefined) setQ(term);
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const res = await api.search(keyword, 1, 30);
|
||
setItems(res.items);
|
||
setTotal(res.total);
|
||
setSearched(true);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "搜索失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="animate-fade-up">
|
||
<section className="relative overflow-hidden rounded-3xl border border-gray-100 bg-gradient-to-b from-white to-brand-50/40 px-6 py-14 text-center shadow-card">
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-brand-200/30 blur-3xl"
|
||
/>
|
||
<div className="relative">
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-brand-200 bg-white/70 px-3 py-1 text-xs font-medium text-brand-700">
|
||
<ShieldCheck className="w-3.5 h-3.5" /> 公益 · 开放 · 可溯源
|
||
</span>
|
||
<h1 className="mt-5 text-4xl sm:text-5xl font-extrabold tracking-tight text-gray-900">
|
||
天<span className="bg-gradient-to-r from-brand-600 to-emerald-500 bg-clip-text text-transparent">工</span>
|
||
</h1>
|
||
<p className="mx-auto mt-3 max-w-xl text-gray-500">
|
||
输入商品名称或条码,检索客观、可溯源的商品资料。人人可查,人人可贡献。
|
||
</p>
|
||
|
||
<form onSubmit={(e) => run(undefined, e)} className="mx-auto mt-7 flex max-w-2xl gap-2">
|
||
<div className="flex flex-1 items-center gap-2 rounded-xl border border-gray-200 bg-white px-3.5 shadow-sm transition focus-within:border-brand-400 focus-within:ring-4 focus-within:ring-brand-500/10">
|
||
<Search className="h-5 w-5 shrink-0 text-gray-400" />
|
||
<input
|
||
autoFocus
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
placeholder="例如:可乐、Nutella、5449000000996"
|
||
className="flex-1 bg-transparent py-3.5 outline-none"
|
||
/>
|
||
</div>
|
||
<button type="submit" disabled={loading} className="btn-primary px-7">
|
||
{loading ? "检索中…" : "检索"}
|
||
</button>
|
||
</form>
|
||
|
||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||
<span className="text-xs text-gray-400">试试:</span>
|
||
{EXAMPLES.map((ex) => (
|
||
<button key={ex} type="button" className="chip" onClick={() => run(ex)}>
|
||
<ScanBarcode className="w-3 h-3" /> {ex}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{error && (
|
||
<div className="mx-auto mt-6 max-w-2xl rounded-xl bg-red-50 px-4 py-2.5 text-sm text-red-700">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{searched ? (
|
||
<div className="mt-8">
|
||
<div className="mb-3 text-sm text-gray-500">
|
||
共 <span className="font-semibold text-gray-700">{total}</span> 条结果
|
||
{q ? `(关键词:${q})` : ""}
|
||
</div>
|
||
{items.length === 0 ? (
|
||
<div className="card p-10 text-center text-gray-500">
|
||
<p>没有找到相关商品。</p>
|
||
<button
|
||
onClick={onContribute}
|
||
className="mt-3 inline-flex items-center gap-1.5 font-medium text-brand-700 hover:underline"
|
||
>
|
||
<PlusCircle className="w-4 h-4" /> 你知道这个商品?去贡献档案
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<ul className="card divide-y divide-gray-100 overflow-hidden">
|
||
{items.map((p) => (
|
||
<li key={p.id}>
|
||
<button
|
||
onClick={() => onOpen(p.id)}
|
||
className="group flex w-full items-center justify-between gap-4 px-4 py-3.5 text-left transition hover:bg-brand-50/50"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium text-gray-800 group-hover:text-brand-700">
|
||
{p.name}
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-gray-500">
|
||
{p.brand || "未知品牌"}
|
||
{p.gtin ? ` · ${p.gtin}` : ""}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-3">
|
||
<span className="text-right text-xs text-gray-400">
|
||
<span className="block">{p.category_path || ""}</span>
|
||
{p.country_of_origin ? (
|
||
<span className="block">产地:{p.country_of_origin}</span>
|
||
) : null}
|
||
</span>
|
||
<ArrowRight className="h-4 w-4 text-gray-300 transition group-hover:translate-x-0.5 group-hover:text-brand-500" />
|
||
</div>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="mt-8 grid gap-4 sm:grid-cols-3">
|
||
{FEATURES.map(({ icon: Icon, title, desc }) => (
|
||
<div key={title} className="card p-5 transition hover:shadow-card-hover">
|
||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||
<Icon className="h-5 w-5" />
|
||
</span>
|
||
<h3 className="mt-3 font-semibold text-gray-800">{title}</h3>
|
||
<p className="mt-1 text-sm leading-relaxed text-gray-500">{desc}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|