优化开放 API 文档界面

This commit is contained in:
yml2213
2026-07-31 14:16:29 +08:00
parent 949a0eb8f7
commit 371481126e
6 changed files with 1516 additions and 292 deletions
+319 -126
View File
@@ -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="签名示例(GETbody 为空)">
<pre style={preStyle}>{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products&timestamp=1721450000`}</pre>
<Card className="api-docs__card" size="small" title="签名示例(GETbody 为空)">
<pre className="api-docs__pre">{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products&timestamp=1721450000`}</pre>
</Card>
<Card size="small" title="签名示例(POSTbody 非空)">
<pre style={preStyle}>{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
<Card className="api-docs__card" size="small" title="签名示例(POSTbody 非空)">
<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&timestamp=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>
)
}