feat(public): 首页新增 API 调用说明页(只读端点/参数/示例/免责声明)
CI / Python (ingestion) (pull_request) Successful in 6s
CI / Go (api) (pull_request) Failing after 17s
CI / Migrations (postgres) (pull_request) Failing after 20s

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
oyaegeli98668
2026-06-20 05:26:53 +00:00
parent c9a4404052
commit 2d95052bd9
3 changed files with 305 additions and 3 deletions
+17 -2
View File
@@ -1,13 +1,15 @@
import { useState } from "react";
import { Boxes, Search, PlusCircle } from "lucide-react";
import { Boxes, Search, PlusCircle, Code2 } from "lucide-react";
import Home from "./components/Home";
import ProductView from "./components/ProductView";
import Contribute from "./components/Contribute";
import ApiDocs from "./components/ApiDocs";
type View =
| { name: "home" }
| { name: "product"; id: string }
| { name: "contribute" };
| { name: "contribute" }
| { name: "api" };
export default function App() {
const [view, setView] = useState<View>({ name: "home" });
@@ -41,6 +43,14 @@ export default function App() {
>
<PlusCircle className="w-4 h-4" />
</button>
<button
className={`px-3 py-1.5 rounded-md flex items-center gap-1.5 ${
view.name === "api" ? "bg-emerald-50 text-emerald-700" : "text-gray-600 hover:bg-gray-100"
}`}
onClick={() => setView({ name: "api" })}
>
<Code2 className="w-4 h-4" /> API
</button>
</nav>
</div>
</header>
@@ -50,6 +60,7 @@ export default function App() {
<Home
onOpen={(id) => setView({ name: "product", id })}
onContribute={() => setView({ name: "contribute" })}
onApi={() => setView({ name: "api" })}
/>
)}
{view.name === "product" && (
@@ -58,12 +69,16 @@ export default function App() {
{view.name === "contribute" && (
<Contribute onDone={() => setView({ name: "home" })} />
)}
{view.name === "api" && <ApiDocs />}
</main>
<footer className="border-t bg-white">
<div className="max-w-5xl mx-auto px-4 py-4 text-xs text-gray-400 leading-relaxed">
OpenGoods /
稿
<button onClick={() => setView({ name: "api" })} className="ml-1 text-emerald-600 hover:underline">
API
</button>
</div>
</footer>
</div>
+279
View File
@@ -0,0 +1,279 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
const ORIGIN = typeof window !== "undefined" ? window.location.origin : "https://goods.tangshasha.com";
const BASE = `${ORIGIN}/api/v1`;
function CopyBtn({ text }: { text: string }) {
const [done, setDone] = useState(false);
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
/* clipboard unavailable */
}
}}
className="text-gray-400 hover:text-gray-600"
title="复制"
>
{done ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
</button>
);
}
function Code({ children }: { children: string }) {
return (
<div className="relative group">
<pre className="bg-gray-900 text-gray-100 text-xs rounded-md p-3 overflow-x-auto whitespace-pre">
{children}
</pre>
<div className="absolute top-2 right-2 opacity-70 group-hover:opacity-100">
<CopyBtn text={children} />
</div>
</div>
);
}
function Method({ m }: { m: string }) {
const color = m === "GET" ? "bg-sky-100 text-sky-700" : "bg-emerald-100 text-emerald-700";
return <span className={`text-xs font-mono font-semibold rounded px-1.5 py-0.5 ${color}`}>{m}</span>;
}
type Param = { name: string; required?: boolean; desc: string };
function Endpoint({
method,
path,
title,
desc,
params,
example,
response,
}: {
method: string;
path: string;
title: string;
desc: string;
params?: Param[];
example: string;
response: string;
}) {
return (
<div className="bg-white border rounded-lg p-5">
<div className="flex items-center gap-2 flex-wrap">
<Method m={method} />
<code className="text-sm text-gray-800 font-mono break-all">{path}</code>
<span className="ml-auto" />
<CopyBtn text={`${ORIGIN}${path}`} />
</div>
<div className="mt-2 font-medium text-gray-800">{title}</div>
<p className="text-sm text-gray-500 mt-0.5">{desc}</p>
{params && params.length > 0 && (
<table className="mt-3 w-full text-sm">
<thead className="text-gray-400 text-left">
<tr>
<th className="font-medium pr-4 pb-1"></th>
<th className="font-medium pr-4 pb-1"></th>
<th className="font-medium pb-1"></th>
</tr>
</thead>
<tbody className="align-top">
{params.map((p) => (
<tr key={p.name}>
<td className="pr-4 py-0.5 font-mono text-gray-700">{p.name}</td>
<td className="pr-4 py-0.5 text-gray-500">{p.required ? "是" : "否"}</td>
<td className="py-0.5 text-gray-600">{p.desc}</td>
</tr>
))}
</tbody>
</table>
)}
<div className="mt-3 text-xs text-gray-400 mb-1"></div>
<Code>{example}</Code>
<div className="mt-3 text-xs text-gray-400 mb-1"></div>
<Code>{response}</Code>
</div>
);
}
export default function ApiDocs() {
return (
<div className="space-y-5">
<div className="bg-white border rounded-lg p-5">
<h1 className="text-2xl font-bold text-gray-800">API </h1>
<p className="mt-2 text-gray-600 text-sm leading-relaxed">
OpenGoods <strong></strong> REST API
/Nutri-Score
JSONUTF-8/
</p>
<div className="mt-3 text-sm text-gray-700">
<div>
<code className="font-mono bg-gray-100 rounded px-1.5 py-0.5">{BASE}</code>
</div>
<ul className="mt-2 list-disc pl-5 text-gray-600 space-y-1">
<li> API Key / Token GET </li>
<li>
<code className="font-mono">page</code> 1
<code className="font-mono">size</code> 20 100
</li>
<li>
<code className="font-mono">404</code>
<code className="font-mono">{` {"error":{"code","message","request_id"}}`}</code>
</li>
<li> OpenGoods </li>
</ul>
</div>
</div>
<Endpoint
method="GET"
path="/healthz"
title="健康检查"
desc="服务存活探针。"
example={`curl ${ORIGIN}/healthz`}
response={`{ "status": "ok" }`}
/>
<Endpoint
method="GET"
path="/api/v1/products/barcode/{gtin}"
title="按条码查询商品"
desc="按 GTIN(条码)精确查询单个商品档案。"
params={[{ name: "gtin", required: true, desc: "商品条码(路径参数),如 5449000000996" }]}
example={`curl ${BASE}/products/barcode/5449000000996`}
response={`{
"id": "…",
"gtin": "5449000000996",
"name": "可口可乐 经典原味",
"brand": "Coca-Cola",
"category_path": "food.beverages.carbonated",
"net_content_value": 33,
"net_content_unit": "cl",
"country_of_origin": "Algeria",
"quality_score": 0.81
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/search"
title="搜索商品"
desc="按名称模糊搜索,可按品类过滤,支持分页。"
params={[
{ name: "q", desc: "关键词(名称/条码),留空返回全部" },
{ name: "category", desc: "品类编码过滤,如 food.beverages" },
{ name: "page", desc: "页码,默认 1" },
{ name: "size", desc: "每页条数,默认 20,最大 100" },
]}
example={`curl "${BASE}/products/search?q=nutella&page=1&size=20"`}
response={`{
"items": [
{ "id": "…", "gtin": "3017624010701",
"name": "Nutella", "brand": "Ferrero",
"category_path": "food.snacks.chocolate" }
],
"page": 1, "size": 20, "total": 1
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/{id}"
title="商品详情"
desc="按商品 UUID 获取完整档案(含配料、营养、添加剂、图片、MSRP 等)。"
params={[{ name: "id", required: true, desc: "商品 UUID(路径参数)" }]}
example={`curl ${BASE}/products/{id}`}
response={`{
"id": "…", "name": "…", "brand": "…",
"ingredients_text": "…",
"nutriments": { "energy_kcal": 42, "sugars_g": 10.6 },
"nutrition_basis": "per_100g",
"nutri_score": "E",
"images": [], "msrp": [],
"quality_score": 0.81
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/{id}/nutriments"
title="商品营养成分"
desc="仅返回该商品的营养字段。"
params={[{ name: "id", required: true, desc: "商品 UUID(路径参数)" }]}
example={`curl ${BASE}/products/{id}/nutriments`}
response={`{
"basis": "per_100g",
"values": { "energy_kcal": 42, "sugars_g": 10.6, "salt_g": 0 }
}`}
/>
<Endpoint
method="GET"
path="/api/v1/products/{id}/msrp"
title="厂商建议零售价快照"
desc="官方建议零售价历史快照,仅供参考,不含任何购买入口。"
params={[{ name: "id", required: true, desc: "商品 UUID(路径参数)" }]}
example={`curl ${BASE}/products/{id}/msrp`}
response={`{
"items": [
{ "amount": 3.5, "currency": "CNY", "region": "CN", "effective_date": "2025-01-01" }
],
"disclaimer": "厂商建议零售价历史快照,仅供参考,不构成购买建议…"
}`}
/>
<Endpoint
method="GET"
path="/api/v1/brands"
title="品牌列表"
desc="分页列出全部品牌。"
params={[
{ name: "page", desc: "页码,默认 1" },
{ name: "size", desc: "每页条数,默认 20,最大 100" },
]}
example={`curl "${BASE}/brands?page=1&size=20"`}
response={`{
"items": [ { "id": "…", "name": "Ferrero" } ],
"page": 1, "size": 20, "total": 4
}`}
/>
<Endpoint
method="GET"
path="/api/v1/categories"
title="品类树"
desc="返回完整品类层级(编码 + 名称)。"
example={`curl ${BASE}/categories`}
response={`{
"items": [
{ "id": "…", "code": "food", "name": "食品饮料", "parent_id": null }
]
}`}
/>
<Endpoint
method="GET"
path="/api/v1/sources/{id}"
title="数据来源"
desc="按 ID 查询单个数据来源(含许可、信任权重)。"
params={[{ name: "id", required: true, desc: "来源 UUID(路径参数)" }]}
example={`curl ${BASE}/sources/{id}`}
response={`{
"id": "…", "name": "openfoodfacts",
"license": "ODbL", "trust_weight": 0.7
}`}
/>
<div className="text-xs text-gray-400 leading-relaxed">
/
OpenFoodFacts ODbL
</div>
</div>
);
}
+9 -1
View File
@@ -1,14 +1,16 @@
import { useState } from "react";
import { Search, PlusCircle } from "lucide-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 [items, setItems] = useState<ProductSummary[]>([]);
@@ -59,6 +61,12 @@ export default function Home({
{loading ? "检索中…" : "检索"}
</button>
</form>
<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 && (