feat(public): 首页新增 API 调用说明页(只读端点/参数/示例/免责声明)
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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、
|
||||
厂商建议零售价快照等)。返回均为 JSON(UTF-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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user