优化开放 API 文档界面
This commit is contained in:
+319
-126
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Alert, Card, Collapse, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Alert, Card, Descriptions, Table, Tag, Typography } from 'antd'
|
||||
import EndpointDoc, { CommonResponseDoc } from '../openapi/EndpointDoc'
|
||||
import {
|
||||
commonResponseFields,
|
||||
@@ -8,26 +9,15 @@ import {
|
||||
orderStatusTable,
|
||||
paymentStatusTable,
|
||||
} from '../openapi/endpoints'
|
||||
import { endpointSections, type EndpointSpec } from '../openapi/types'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
const { Paragraph, Text } = Typography
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin.replace(':5173', ':8080')
|
||||
: 'http://localhost:8080'
|
||||
|
||||
const preStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 6,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.6,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}
|
||||
|
||||
const deliveryModes = [
|
||||
{
|
||||
mode: 'Web 发货页',
|
||||
@@ -42,58 +32,278 @@ const deliveryModes = [
|
||||
},
|
||||
]
|
||||
|
||||
export default function OpenApiDocs() {
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginTop: 0 }}>
|
||||
开放接口
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
面向商户系统调用的开放 API。凭证在「商户中心 / API 客户端」创建,
|
||||
采用 <Text code>X-App-Key</Text> + HMAC-SHA256 签名鉴权。
|
||||
发货链路同时支持「返回签名链接」和「返回结构化数据」两种模式。
|
||||
<Text code>/api/open/v1</Text> 仍是上游回调链路,和本页的商户 API 分开。
|
||||
</Paragraph>
|
||||
// ---------- TOC 目录构建 ----------
|
||||
interface TocLeaf {
|
||||
id: string
|
||||
label: string
|
||||
level: 3 // 接口子小节(接口地址/请求方式/...)
|
||||
}
|
||||
interface TocItem {
|
||||
id: string
|
||||
label: string
|
||||
children?: TocLeaf[]
|
||||
}
|
||||
interface TocGroup {
|
||||
title: string
|
||||
items: TocItem[]
|
||||
}
|
||||
|
||||
<Tabs
|
||||
tabPosition="left"
|
||||
style={{ minHeight: 680 }}
|
||||
items={[
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
children: <OverviewTab />,
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
label: '鉴权与签名',
|
||||
children: <AuthTab />,
|
||||
},
|
||||
{
|
||||
key: 'endpoints',
|
||||
label: '接口列表',
|
||||
children: <EndpointsTab />,
|
||||
},
|
||||
{
|
||||
key: 'delivery',
|
||||
label: '发货模式',
|
||||
children: <DeliveryModeTab />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态说明',
|
||||
children: <StatusTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
function buildToc(): TocGroup[] {
|
||||
return [
|
||||
{
|
||||
title: '开始',
|
||||
items: [
|
||||
{ id: 'overview', label: '概述' },
|
||||
{ id: 'auth', label: '鉴权与签名' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '接口',
|
||||
items: endpoints.map((ep) => ({
|
||||
id: `ep-${ep.key}`,
|
||||
label: ep.title,
|
||||
children: endpointSections.map((s) => ({
|
||||
id: `ep-${ep.key}-${s.id}`,
|
||||
label: s.label,
|
||||
level: 3 as const,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
{
|
||||
title: '附录',
|
||||
items: [{ id: 'status', label: '状态说明' }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const toc = buildToc()
|
||||
|
||||
const pageItems = toc.flatMap((group) => group.items)
|
||||
const pageIds = new Set(pageItems.map((item) => item.id))
|
||||
|
||||
function findPageId(id: string): string {
|
||||
if (pageIds.has(id)) return id
|
||||
for (const item of pageItems) {
|
||||
if (item.children?.some((child) => child.id === id)) return item.id
|
||||
}
|
||||
return 'overview'
|
||||
}
|
||||
|
||||
function getInitialHash() {
|
||||
if (typeof window === 'undefined') return 'overview'
|
||||
return decodeURIComponent(window.location.hash.replace(/^#/, '')) || 'overview'
|
||||
}
|
||||
|
||||
// ---------- 锚点跳转 ----------
|
||||
function scrollToId(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
const root = document.getElementById('docScroll')
|
||||
if (!root) return
|
||||
const top = root.scrollTop + el.getBoundingClientRect().top - root.getBoundingClientRect().top - 22
|
||||
root.scrollTo({ top, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
export default function OpenApiDocs() {
|
||||
const initialHash = useMemo(() => getInitialHash(), [])
|
||||
const [activePageId, setActivePageId] = useState(() => findPageId(initialHash))
|
||||
const [activeAnchorId, setActiveAnchorId] = useState(initialHash)
|
||||
const activeEndpoint = useMemo(
|
||||
() => endpoints.find((spec) => `ep-${spec.key}` === activePageId),
|
||||
[activePageId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const syncHash = () => {
|
||||
const nextHash = getInitialHash()
|
||||
setActivePageId(findPageId(nextHash))
|
||||
setActiveAnchorId(nextHash)
|
||||
}
|
||||
window.addEventListener('hashchange', syncHash)
|
||||
return () => window.removeEventListener('hashchange', syncHash)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.getElementById('docScroll')
|
||||
if (!root) return
|
||||
|
||||
if (activeAnchorId !== activePageId) {
|
||||
requestAnimationFrame(() => scrollToId(activeAnchorId))
|
||||
return
|
||||
}
|
||||
root.scrollTo({ top: 0 })
|
||||
}, [activeAnchorId, activePageId])
|
||||
|
||||
const activeIndex = useMemo(
|
||||
() => pageItems.findIndex((item) => item.id === activePageId),
|
||||
[activePageId],
|
||||
)
|
||||
|
||||
const handleNav = useCallback((id: string) => {
|
||||
const pageId = findPageId(id)
|
||||
setActivePageId(pageId)
|
||||
setActiveAnchorId(id)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${id}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStep = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
const next = pageItems[activeIndex + direction]
|
||||
if (next) handleNav(next.id)
|
||||
},
|
||||
[activeIndex, handleNav],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="api-docs">
|
||||
{/* 左侧目录 */}
|
||||
<aside className="api-docs__toc">
|
||||
<div className="api-docs__toc-tabs">
|
||||
<button
|
||||
className={`api-docs__toc-tab${activePageId !== 'auth' ? ' api-docs__toc-tab--active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => handleNav('overview')}
|
||||
>
|
||||
开放 API
|
||||
</button>
|
||||
<button
|
||||
className={`api-docs__toc-tab${activePageId === 'auth' ? ' api-docs__toc-tab--active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => handleNav('auth')}
|
||||
>
|
||||
API 密钥
|
||||
</button>
|
||||
</div>
|
||||
<nav className="api-docs__nav">
|
||||
{toc.map((group) => (
|
||||
<div className="api-docs__nav-group" key={group.title}>
|
||||
<div className="api-docs__nav-heading">{group.title}</div>
|
||||
{group.items.map((item) => {
|
||||
const isActive = activePageId === item.id
|
||||
return (
|
||||
<div className="api-docs__nav-row" key={item.id}>
|
||||
<a
|
||||
className={`api-docs__nav-item${isActive ? ' api-docs__nav-item--active' : ''}`}
|
||||
onClick={() => handleNav(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{item.children?.length && isActive ? (
|
||||
<div className="api-docs__nav-sub">
|
||||
{item.children.map((c) => (
|
||||
<a
|
||||
key={c.id}
|
||||
className={`api-docs__nav-sub-item${activeAnchorId === c.id ? ' api-docs__nav-sub-item--active' : ''}`}
|
||||
onClick={() => handleNav(c.id)}
|
||||
>
|
||||
{c.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 右侧文档 */}
|
||||
<div id="docScroll" className="api-docs__content">
|
||||
<div className="api-docs__inner">
|
||||
{activePageId === 'overview' ? <OverviewSection /> : null}
|
||||
{activePageId === 'auth' ? <AuthSection /> : null}
|
||||
{activeEndpoint ? <EndpointSection spec={activeEndpoint} /> : null}
|
||||
{activePageId === 'status' ? <StatusSection /> : null}
|
||||
<PageStepper
|
||||
currentIndex={activeIndex}
|
||||
items={pageItems}
|
||||
onStep={handleStep}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewTab() {
|
||||
function PageStepper({
|
||||
currentIndex,
|
||||
items,
|
||||
onStep,
|
||||
}: {
|
||||
currentIndex: number
|
||||
items: TocItem[]
|
||||
onStep: (direction: -1 | 1) => void
|
||||
}) {
|
||||
const prev = items[currentIndex - 1]
|
||||
const next = items[currentIndex + 1]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div className="api-docs__pager">
|
||||
<button
|
||||
className="api-docs__pager-btn"
|
||||
type="button"
|
||||
disabled={!prev}
|
||||
onClick={() => onStep(-1)}
|
||||
>
|
||||
<span>上一篇</span>
|
||||
<strong>{prev?.label ?? '无'}</strong>
|
||||
</button>
|
||||
<button
|
||||
className="api-docs__pager-btn api-docs__pager-btn--next"
|
||||
type="button"
|
||||
disabled={!next}
|
||||
onClick={() => onStep(1)}
|
||||
>
|
||||
<span>下一篇</span>
|
||||
<strong>{next?.label ?? '无'}</strong>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- 各文档小节 ----------
|
||||
function SectionWrap({
|
||||
id,
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
desc?: ReactNode
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="api-docs__section">
|
||||
<h2 id={id} className="api-docs__h2">
|
||||
{title}
|
||||
</h2>
|
||||
{desc ? <Paragraph className="api-docs__summary" type="secondary">{desc}</Paragraph> : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewSection() {
|
||||
return (
|
||||
<SectionWrap
|
||||
id="overview"
|
||||
title="概述"
|
||||
desc={
|
||||
<>
|
||||
面向商户系统调用的开放 API。凭证在「商户中心 / API 客户端」创建,
|
||||
采用 <Text code>X-App-Key</Text> + HMAC-SHA256 签名鉴权。
|
||||
发货链路同时支持「返回签名链接」和「返回结构化数据」两种模式。
|
||||
<Text code>/api/open/v1</Text> 仍是上游回调链路,和本页的商户 API 分开。
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
className="api-docs__alert"
|
||||
type="info"
|
||||
showIcon
|
||||
message="一句话上手"
|
||||
@@ -106,8 +316,9 @@ function OverviewTab() {
|
||||
</ol>
|
||||
}
|
||||
/>
|
||||
<Card size="small" title="发货链路概览">
|
||||
<Card className="api-docs__card" size="small" title="发货链路概览">
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="mode"
|
||||
@@ -118,14 +329,12 @@ function OverviewTab() {
|
||||
{
|
||||
title: '相关接口',
|
||||
dataIndex: 'interfaces',
|
||||
render: (v: string) => (
|
||||
<pre style={preStyle}>{v}</pre>
|
||||
),
|
||||
render: (v: string) => <pre className="api-docs__pre">{v}</pre>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card size="small" title="环境信息">
|
||||
<Card className="api-docs__card" size="small" title="环境信息">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="Base URL">
|
||||
<Text code>{baseUrl}</Text>
|
||||
@@ -140,42 +349,19 @@ function OverviewTab() {
|
||||
<Descriptions.Item label="请求体">POST 请求使用 <Text code>application/json</Text></Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<CommonResponseDoc fields={commonResponseFields} errors={errorCodes} />
|
||||
</Space>
|
||||
<div className="api-docs__common">
|
||||
<CommonResponseDoc fields={commonResponseFields} errors={errorCodes} />
|
||||
</div>
|
||||
</SectionWrap>
|
||||
)
|
||||
}
|
||||
|
||||
function DeliveryModeTab() {
|
||||
function AuthSection() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="推荐用法">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="只想给用户一个链接">
|
||||
用 <Text code>delivery-link</Text>,后端直接返回签名后的 Web 发货页地址,前端只负责跳转或复制。
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="想自建发货页面">
|
||||
用 <Text code>delivery</Text> 获取订单数据,再按需调用 <Text code>bind</Text> 和 <Text code>submit</Text>。
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="取消作废">
|
||||
取消作废属于商户后台操作,恢复后会重新签发新有效期和新签名。
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="两种模式共用同一套底层履约逻辑"
|
||||
description="不管是 Web 发货页还是商户自建页面,最终都会走同一套订单校验、绑定和提交逻辑,只是前端形态不同。"
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthTab() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="鉴权头(每次请求必带)">
|
||||
<SectionWrap id="auth" title="鉴权与签名">
|
||||
<Card className="api-docs__card" size="small" title="鉴权头(每次请求必带)">
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="header"
|
||||
@@ -187,13 +373,18 @@ function AuthTab() {
|
||||
]}
|
||||
columns={[
|
||||
{ title: 'Header', dataIndex: 'header', width: 160, render: (v) => <Text code>{v}</Text> },
|
||||
{ title: '必填', dataIndex: 'required', width: 80, render: (v) => v ? <Tag color="red">是</Tag> : <Tag>否</Tag> },
|
||||
{
|
||||
title: '必填',
|
||||
dataIndex: 'required',
|
||||
width: 80,
|
||||
render: (v) => v ? <span className="api-docs__required">是</span> : <Text type="secondary">否</Text>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名算法">
|
||||
<Card className="api-docs__card" size="small" title="签名算法">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="参与参数">
|
||||
<Text code>app_key</Text>、<Text code>body_sha256</Text>、<Text code>method</Text>、<Text code>nonce</Text>、<Text code>path</Text>、<Text code>timestamp</Text>
|
||||
@@ -214,19 +405,21 @@ function AuthTab() {
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名示例(GET,body 为空)">
|
||||
<pre style={preStyle}>{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}</pre>
|
||||
<Card className="api-docs__card" size="small" title="签名示例(GET,body 为空)">
|
||||
<pre className="api-docs__pre">{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}</pre>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名示例(POST,body 非空)">
|
||||
<pre style={preStyle}>{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
|
||||
<Card className="api-docs__card" size="small" title="签名示例(POST,body 非空)">
|
||||
<pre className="api-docs__pre">{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
|
||||
app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&method=POST&nonce=a1b2c3d4e5f67890&path=/api/client/v1/orders×tamp=1721450000`}</pre>
|
||||
</Card>
|
||||
|
||||
<Alert
|
||||
className="api-docs__alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message="签名注意事项"
|
||||
style={{ marginTop: 16 }}
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>POST 签名用的 <Text code>body</Text> 必须与实际发送的 body <b>字节级一致</b>,不要签名后再改空格或字段顺序。</li>
|
||||
@@ -236,36 +429,33 @@ app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&met
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</SectionWrap>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointsTab() {
|
||||
function EndpointSection({ spec }: { spec: EndpointSpec }) {
|
||||
return (
|
||||
<Collapse
|
||||
accordion
|
||||
items={endpoints.map((spec) => ({
|
||||
key: spec.key,
|
||||
label: (
|
||||
<Space size="small">
|
||||
<Tag color={spec.method === 'GET' ? 'blue' : 'green'} style={{ margin: 0, fontWeight: 700 }}>
|
||||
{spec.method}
|
||||
</Tag>
|
||||
<Text code style={{ fontSize: 13 }}>{spec.path}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{spec.summary}</Text>
|
||||
</Space>
|
||||
),
|
||||
children: <EndpointDoc spec={spec} />,
|
||||
}))}
|
||||
/>
|
||||
<div className="api-docs__section">
|
||||
<div className="api-docs__endpoint-head">
|
||||
<h2 id={`ep-${spec.key}`} className="api-docs__h2">
|
||||
{spec.title}
|
||||
</h2>
|
||||
<div className="api-docs__endpoint-meta">
|
||||
<Tag color={spec.method === 'GET' ? 'blue' : 'geekblue'}>{spec.method}</Tag>
|
||||
<Text code>{spec.scope}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<EndpointDoc spec={spec} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusTab() {
|
||||
function StatusSection() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="履约状态 fulfillment_status 与 can_fulfill">
|
||||
<SectionWrap id="status" title="状态说明">
|
||||
<Card className="api-docs__card" size="small" title="履约状态 fulfillment_status 与 can_fulfill">
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="name"
|
||||
@@ -283,8 +473,9 @@ function StatusTab() {
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card size="small" title="支付状态 payment_status">
|
||||
<Card className="api-docs__card" size="small" title="支付状态 payment_status">
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="name"
|
||||
@@ -296,11 +487,13 @@ function StatusTab() {
|
||||
/>
|
||||
</Card>
|
||||
<Alert
|
||||
className="api-docs__alert"
|
||||
type="info"
|
||||
showIcon
|
||||
message="推荐调用流程"
|
||||
style={{ marginTop: 16 }}
|
||||
description={
|
||||
<pre style={{ ...preStyle, background: 'transparent', padding: 0 }}>{`拿到 sku(商品列表)
|
||||
<pre className="api-docs__pre api-docs__pre--plain">{`拿到 sku(商品列表)
|
||||
↓
|
||||
POST /orders 下单(幂等,Idempotency-Key = client_order_no)
|
||||
↓
|
||||
@@ -313,6 +506,6 @@ POST /orders 下单(幂等,Idempotency-Key = client_order_no)
|
||||
GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`}</pre>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</SectionWrap>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user