69a0149bbe
Search: - migration 0009: trigram GIN index on brand.name + btree on country_of_origin - SearchProducts: typo-tolerant word_similarity matching (>=0.42) on top of ILIKE substring + barcode; new brand/country filters; rank by similarity * (0.5 + quality_score). Response gains country_of_origin, quality_score and per-result relevance score. - public search UI: brand/country filter inputs; show country in results Docs: - serve embedded OpenAPI 3 spec at GET /api/v1/openapi.json (not rate limited) - ApiDocs page: auth + rate-limit section, updated search params/response - docs/api.md developer guide Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
import { useState } from "react";
|
||
import { Search, PlusCircle, Code2 } from "lucide-react";
|
||
import { api } from "../api";
|
||
import type { ProductSummary } from "../types";
|
||
|
||
export default function Home({
|
||
onOpen,
|
||
onContribute,
|
||
onApi,
|
||
}: {
|
||
onOpen: (id: string) => void;
|
||
onContribute: () => void;
|
||
onApi: () => void;
|
||
}) {
|
||
const [q, setQ] = useState("");
|
||
const [brand, setBrand] = useState("");
|
||
const [country, setCountry] = 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(e?: React.FormEvent) {
|
||
e?.preventDefault();
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const res = await api.search(q.trim(), 1, 30, {
|
||
brand: brand.trim() || undefined,
|
||
country: country.trim() || undefined,
|
||
});
|
||
setItems(res.items);
|
||
setTotal(res.total);
|
||
setSearched(true);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "搜索失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="text-center py-10">
|
||
<h1 className="text-3xl font-bold text-gray-800">天工商品档案公共仓</h1>
|
||
<p className="mt-2 text-gray-500">
|
||
输入商品名称或条码,检索客观、可溯源的商品资料。人人可查,人人可贡献。
|
||
</p>
|
||
<form onSubmit={run} className="mt-6 max-w-2xl mx-auto flex gap-2">
|
||
<div className="flex-1 flex items-center gap-2 bg-white border rounded-lg px-3 shadow-sm focus-within:ring-2 focus-within:ring-emerald-400">
|
||
<Search className="w-5 h-5 text-gray-400" />
|
||
<input
|
||
autoFocus
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
placeholder="例如:可乐、Nutella、5449000000996"
|
||
className="flex-1 py-3 outline-none bg-transparent"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="px-6 rounded-lg bg-emerald-600 text-white font-medium hover:bg-emerald-700 disabled:opacity-60"
|
||
>
|
||
{loading ? "检索中…" : "检索"}
|
||
</button>
|
||
</form>
|
||
<div className="mt-3 max-w-2xl mx-auto flex flex-wrap items-center justify-center gap-2 text-sm">
|
||
<input
|
||
value={brand}
|
||
onChange={(e) => setBrand(e.target.value)}
|
||
placeholder="按品牌筛选(如 Ferrero)"
|
||
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
|
||
/>
|
||
<input
|
||
value={country}
|
||
onChange={(e) => setCountry(e.target.value)}
|
||
placeholder="按产地筛选(如 China)"
|
||
className="flex-1 min-w-[14rem] bg-white border rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-emerald-300"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={onApi}
|
||
className="mt-4 inline-flex items-center gap-1.5 text-sm text-emerald-700 hover:underline"
|
||
>
|
||
<Code2 className="w-4 h-4" /> 开发者?查看 API 调用说明,免鉴权接入商品档案
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="max-w-2xl mx-auto bg-red-50 text-red-700 text-sm rounded-md px-4 py-2">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{searched && (
|
||
<div className="mt-2">
|
||
<div className="text-sm text-gray-500 mb-2">
|
||
共 {total} 条结果{q ? `(关键词:${q})` : ""}
|
||
</div>
|
||
{items.length === 0 ? (
|
||
<div className="bg-white border rounded-lg p-8 text-center text-gray-500">
|
||
<p>没有找到相关商品。</p>
|
||
<button
|
||
onClick={onContribute}
|
||
className="mt-3 inline-flex items-center gap-1.5 text-emerald-700 hover:underline"
|
||
>
|
||
<PlusCircle className="w-4 h-4" /> 你知道这个商品?去贡献档案
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<ul className="bg-white border rounded-lg divide-y">
|
||
{items.map((p) => (
|
||
<li key={p.id}>
|
||
<button
|
||
onClick={() => onOpen(p.id)}
|
||
className="w-full text-left px-4 py-3 hover:bg-gray-50 flex items-center justify-between gap-4"
|
||
>
|
||
<div>
|
||
<div className="font-medium text-gray-800">{p.name}</div>
|
||
<div className="text-xs text-gray-500 mt-0.5">
|
||
{p.brand || "未知品牌"}
|
||
{p.gtin ? ` · ${p.gtin}` : ""}
|
||
</div>
|
||
</div>
|
||
<span className="text-xs text-gray-400 text-right">
|
||
<span className="block">{p.category_path || ""}</span>
|
||
{p.country_of_origin ? (
|
||
<span className="block text-gray-400">产地:{p.country_of_origin}</span>
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|