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 { useState } from "react";
|
||||||
import { Boxes, Search, PlusCircle } from "lucide-react";
|
import { Boxes, Search, PlusCircle, Code2 } from "lucide-react";
|
||||||
import Home from "./components/Home";
|
import Home from "./components/Home";
|
||||||
import ProductView from "./components/ProductView";
|
import ProductView from "./components/ProductView";
|
||||||
import Contribute from "./components/Contribute";
|
import Contribute from "./components/Contribute";
|
||||||
|
import ApiDocs from "./components/ApiDocs";
|
||||||
|
|
||||||
type View =
|
type View =
|
||||||
| { name: "home" }
|
| { name: "home" }
|
||||||
| { name: "product"; id: string }
|
| { name: "product"; id: string }
|
||||||
| { name: "contribute" };
|
| { name: "contribute" }
|
||||||
|
| { name: "api" };
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState<View>({ name: "home" });
|
const [view, setView] = useState<View>({ name: "home" });
|
||||||
@@ -41,6 +43,14 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<PlusCircle className="w-4 h-4" /> 贡献档案
|
<PlusCircle className="w-4 h-4" /> 贡献档案
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -50,6 +60,7 @@ export default function App() {
|
|||||||
<Home
|
<Home
|
||||||
onOpen={(id) => setView({ name: "product", id })}
|
onOpen={(id) => setView({ name: "product", id })}
|
||||||
onContribute={() => setView({ name: "contribute" })}
|
onContribute={() => setView({ name: "contribute" })}
|
||||||
|
onApi={() => setView({ name: "api" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view.name === "product" && (
|
{view.name === "product" && (
|
||||||
@@ -58,12 +69,16 @@ export default function App() {
|
|||||||
{view.name === "contribute" && (
|
{view.name === "contribute" && (
|
||||||
<Contribute onDone={() => setView({ name: "home" })} />
|
<Contribute onDone={() => setView({ name: "home" })} />
|
||||||
)}
|
)}
|
||||||
|
{view.name === "api" && <ApiDocs />}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t bg-white">
|
<footer className="border-t bg-white">
|
||||||
<div className="max-w-5xl mx-auto px-4 py-4 text-xs text-gray-400 leading-relaxed">
|
<div className="max-w-5xl mx-auto px-4 py-4 text-xs text-gray-400 leading-relaxed">
|
||||||
OpenGoods 是公益性「商品事实库」,仅收录客观商品信息(条码、品牌、品类、营养、官方建议零售价快照等),不含任何购买/交易功能。
|
OpenGoods 是公益性「商品事实库」,仅收录客观商品信息(条码、品牌、品类、营养、官方建议零售价快照等),不含任何购买/交易功能。
|
||||||
公众投稿须经人工审核后方可收纳。
|
公众投稿须经人工审核后方可收纳。
|
||||||
|
<button onClick={() => setView({ name: "api" })} className="ml-1 text-emerald-600 hover:underline">
|
||||||
|
API 调用说明
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</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 { useState } from "react";
|
||||||
import { Search, PlusCircle } from "lucide-react";
|
import { Search, PlusCircle, Code2 } from "lucide-react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { ProductSummary } from "../types";
|
import type { ProductSummary } from "../types";
|
||||||
|
|
||||||
export default function Home({
|
export default function Home({
|
||||||
onOpen,
|
onOpen,
|
||||||
onContribute,
|
onContribute,
|
||||||
|
onApi,
|
||||||
}: {
|
}: {
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
onContribute: () => void;
|
onContribute: () => void;
|
||||||
|
onApi: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [items, setItems] = useState<ProductSummary[]>([]);
|
const [items, setItems] = useState<ProductSummary[]>([]);
|
||||||
@@ -59,6 +61,12 @@ export default function Home({
|
|||||||
{loading ? "检索中…" : "检索"}
|
{loading ? "检索中…" : "检索"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
Reference in New Issue
Block a user