优化开放 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
+734
View File
@@ -15,3 +15,737 @@ body,
a {
color: #1677ff;
}
/* ============ 后台主布局与侧边栏 ============ */
.app-shell {
min-height: 100vh;
}
.app-main {
min-width: 0;
background: #f5f8fc;
}
.app-header {
height: 50px !important;
padding: 0 24px !important;
line-height: 50px !important;
background: #fff !important;
border-bottom: 1px solid #edf1f7;
display: flex;
align-items: center;
justify-content: flex-end;
}
.app-content {
min-width: 0;
margin: 16px;
}
.app-content__body {
min-height: calc(100vh - 50px - 32px);
padding: 20px;
background: #fff;
border: 1px solid #edf1f7;
border-radius: 8px;
}
.app-main--flush .app-content {
margin: 0;
}
.app-main--flush .app-content__body {
height: calc(100vh - 50px);
min-height: 0;
padding: 0;
border: 0;
border-radius: 0;
background: #fff;
overflow: hidden;
}
.app-sidebar {
position: relative;
background: #f4f8fc !important;
border-right: 1px solid #e3eaf2;
box-shadow: inset -1px 0 0 rgba(225, 233, 242, 0.8);
}
.app-sidebar .ant-layout-sider-children {
display: flex;
flex-direction: column;
min-height: 100%;
}
.app-sidebar__collapse {
position: absolute;
top: 42px;
right: -18px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid #e8eef6;
border-radius: 999px;
background: #fff;
color: #9aa4b2;
box-shadow: 0 8px 18px rgba(42, 71, 105, 0.12);
cursor: pointer;
transition: color 0.16s ease, box-shadow 0.16s ease;
}
.app-sidebar__collapse:hover {
color: #1677ff;
box-shadow: 0 10px 22px rgba(22, 119, 255, 0.16);
}
.app-sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
height: 50px;
padding: 0 18px;
color: #1f2329;
font-size: 17px;
font-weight: 800;
}
.app-sidebar__brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, #1677ff 0%, #39c5ff 100%);
color: #fff;
font-size: 15px;
font-weight: 800;
}
.app-sidebar__brand-text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.app-sidebar__nav {
flex: 1;
min-height: 0;
padding: 8px 14px 24px;
overflow-y: auto;
}
.app-sidebar__group {
margin-bottom: 13px;
}
.app-sidebar__item,
.app-sidebar__child {
width: 100%;
border: 0;
background: transparent;
color: #555f6d;
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background 0.16s ease, color 0.16s ease;
}
.app-sidebar__item {
display: flex;
align-items: center;
min-height: 42px;
padding: 8px 12px;
border-radius: 6px;
font-size: 16px;
font-weight: 650;
}
.app-sidebar__item:hover {
color: #1677ff;
background: #edf5ff;
}
.app-sidebar__item--active {
color: #1677ff;
}
.app-sidebar__item--disabled {
color: #9aa4b2;
cursor: default;
}
.app-sidebar__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 26px;
margin-right: 10px;
font-size: 18px;
}
.app-sidebar__label {
min-width: 0;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.app-sidebar__arrow {
margin-left: 8px;
color: currentColor;
font-size: 12px;
transform: rotate(0deg);
transition: transform 0.16s ease;
}
.app-sidebar__arrow--open {
transform: rotate(180deg);
}
.app-sidebar__children {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 6px;
padding-left: 44px;
}
.app-sidebar__child {
min-height: 38px;
padding: 8px 14px;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
}
.app-sidebar__child:hover:not(:disabled) {
color: #1677ff;
background: #eef6ff;
}
.app-sidebar__child--active {
color: #1677ff;
background: #dcecff;
}
.app-sidebar__child--disabled {
color: #5f6875;
cursor: default;
}
.app-sidebar__child:disabled {
cursor: default;
opacity: 1;
}
.app-sidebar--collapsed .app-sidebar__brand {
justify-content: center;
padding: 0;
}
.app-sidebar--collapsed .app-sidebar__nav {
padding: 8px 10px 24px;
}
.app-sidebar--collapsed .app-sidebar__group {
margin-bottom: 8px;
}
.app-sidebar--collapsed .app-sidebar__item {
justify-content: center;
min-height: 44px;
padding: 8px 0;
}
.app-sidebar--collapsed .app-sidebar__icon {
margin-right: 0;
}
/* ============ 开放 API 对接文档 ============ */
.api-docs {
display: flex;
align-items: stretch;
width: 100%;
height: 100%;
min-height: 0;
background: #fff;
border-radius: 0;
overflow: hidden;
}
/* 左侧目录 */
.api-docs__toc {
flex: 0 0 220px;
width: 220px;
height: 100%;
overflow-y: auto;
border-right: 1px solid #e7edf5;
background: #f8fbff;
padding: 16px 12px 20px;
}
.api-docs__toc-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 16px;
}
.api-docs__toc-tab {
height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: #4e5969;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
}
.api-docs__toc-tab:hover,
.api-docs__toc-tab--active {
color: #1677ff;
background: #edf5ff;
}
.api-docs__nav {
display: flex;
flex-direction: column;
gap: 14px;
}
.api-docs__nav-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.api-docs__nav-heading {
padding: 0 10px 6px;
font-size: 12px;
color: #9aa4b2;
}
.api-docs__nav-row {
min-width: 0;
}
.api-docs__nav-item,
.api-docs__nav-sub-item {
display: block;
min-height: 32px;
padding: 7px 12px;
font-size: 13px;
color: #4e5969;
cursor: pointer;
border-radius: 5px;
transition: all 0.15s;
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.api-docs__nav-item:hover,
.api-docs__nav-sub-item:hover {
color: #1677ff;
background: #f2f7ff;
}
.api-docs__nav-item--active {
color: #1677ff;
background: #edf5ff;
font-weight: 600;
}
.api-docs__nav-sub {
display: flex;
flex-direction: column;
margin: 2px 0 8px 12px;
padding-left: 12px;
border-left: 1px solid #e6edf6;
}
.api-docs__nav-sub-item {
min-height: 28px;
padding: 5px 10px;
font-size: 12.5px;
color: #86909c;
}
.api-docs__nav-sub-item--active {
color: #1677ff;
font-weight: 600;
background: #f4f8ff;
}
/* 右侧文档 */
.api-docs__content {
flex: 1 1 auto;
min-width: 0;
height: 100%;
overflow-y: auto;
scroll-behavior: smooth;
background: #fff;
padding: 0;
}
.api-docs__inner {
width: min(1120px, calc(100% - 48px));
max-width: 1120px;
min-height: 100%;
margin: 0 auto 0 0;
padding: 28px 0 64px 32px;
background: #fff;
border: 0;
border-radius: 0;
box-shadow: none;
}
.api-docs__section {
padding-top: 0;
margin-top: 0;
border-top: none;
}
.api-docs__section:first-child {
padding-top: 0;
margin-top: 0;
border-top: none;
}
.api-docs__h2 {
font-size: 20px;
font-weight: 700;
color: #1d2129;
margin: 0 0 12px;
scroll-margin-top: 24px;
}
.api-docs__summary {
max-width: 1080px;
margin: 0 0 16px !important;
color: #4e5969 !important;
line-height: 1.8;
}
.api-docs__endpoint-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 4px;
}
.api-docs__endpoint-meta {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.api-docs__endpoint {
color: #1f2329;
}
.api-docs__notice {
margin: 0 0 2px;
padding: 13px 16px;
border-left: 4px solid #1890ff;
border-radius: 4px;
background: #f1f7ff;
color: #4e5969;
line-height: 1.75;
}
.api-docs__notice-label {
color: #1f2329;
font-weight: 700;
}
/* EndpointDoc 子小节标题 */
.doc-section-title {
font-size: 16px;
font-weight: 700;
color: #1d2129;
margin: 22px 0 12px;
scroll-margin-top: 24px;
}
.doc-subtitle {
font-size: 14px;
font-weight: 600;
color: #4e5969;
margin: 10px 0 10px;
}
.api-docs__param-group {
margin-bottom: 18px;
}
.api-docs__card {
margin-top: 16px;
border-color: #eef1f5 !important;
border-radius: 6px !important;
box-shadow: none !important;
}
.api-docs__card .ant-card-head {
min-height: 42px;
border-bottom-color: #eef1f5;
}
.api-docs__card .ant-card-head-title {
font-size: 14px;
font-weight: 700;
color: #1d2129;
}
.api-docs__common {
margin-top: 18px;
}
.api-docs__pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #eef1f5;
}
.api-docs__pager-btn {
min-height: 70px;
padding: 12px 16px;
border: 1px solid #e7edf5;
border-radius: 6px;
background: #fbfdff;
color: #4e5969;
text-align: left;
cursor: pointer;
transition: all 0.16s ease;
}
.api-docs__pager-btn:hover:not(:disabled) {
border-color: #91caff;
background: #f2f8ff;
}
.api-docs__pager-btn:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.api-docs__pager-btn span {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #86909c;
}
.api-docs__pager-btn strong {
display: block;
color: #1d2129;
font-size: 14px;
}
.api-docs__pager-btn--next {
text-align: right;
}
.api-docs__alert {
border-radius: 4px !important;
}
.api-docs__table {
border-top: 1px solid #edf1f7;
}
.api-docs__table .ant-table {
color: #1f2329;
}
.api-docs__table .ant-table-thead > tr > th {
background: #fafcff !important;
color: #1d2129 !important;
font-weight: 700 !important;
border-bottom: 1px solid #edf1f7 !important;
border-right: 1px solid #f1f4f8 !important;
}
.api-docs__table .ant-table-tbody > tr > td {
padding-top: 14px !important;
padding-bottom: 14px !important;
border-bottom: 1px solid #edf1f7 !important;
border-right: 1px solid #f3f6fa !important;
}
.api-docs__table .ant-table-thead > tr > th:last-child,
.api-docs__table .ant-table-tbody > tr > td:last-child {
border-right: 0 !important;
}
.api-docs__table .ant-table-tbody > tr:hover > td {
background: #fbfdff !important;
}
.api-docs__field {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
color: #2454a6 !important;
background: transparent !important;
border: 0 !important;
padding: 0;
font-weight: 700;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
}
.api-docs__type {
color: #1f2329;
font-size: 13px;
font-weight: 500;
}
.api-docs__inline-example {
font-size: 12px;
color: #2454a6 !important;
background: transparent !important;
border: 0 !important;
padding: 0;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-weight: 600;
}
.api-docs__required {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 22px;
color: #cf1322;
background: #fff1f0;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
}
.api-docs__pre {
margin: 0;
padding: 12px 14px;
background: #f7f9fc;
border: 1px solid #e7edf5;
border-radius: 4px;
font-size: 12.5px;
line-height: 1.7;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
color: #1f2329;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.api-docs__pre--plain {
background: transparent;
border: 0;
padding: 0;
}
.api-docs__code,
.api-docs__endpoint-line {
margin: 0;
padding: 17px 18px;
background: #f7f9fc;
border: 1px solid #e7edf5;
border-radius: 4px;
color: #1f2329;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.75;
overflow: auto;
white-space: pre;
}
.api-docs__endpoint-line {
min-height: 56px;
display: flex;
align-items: center;
gap: 8px;
}
.api-docs__method {
font-weight: 800;
}
.api-docs__path {
color: #202632;
font-weight: 700;
}
/* JSON 语法高亮配色 */
.json-key {
color: #2454a6;
font-weight: 600;
}
.json-string {
color: #0f8a5f;
}
.json-number {
color: #b86b00;
}
.json-boolean {
color: #1677ff;
}
.json-null {
color: #86909c;
}
@media (max-width: 1024px) {
.api-docs {
flex-direction: column;
}
.api-docs__toc {
position: relative;
flex: none;
width: 100%;
max-height: 320px;
border-right: 0;
border-bottom: 1px solid #e7edf5;
}
.api-docs__content {
width: 100%;
height: auto;
min-height: calc(100vh - 50px - 320px);
}
.api-docs__inner {
padding: 22px 18px 64px;
}
.api-docs__endpoint-head {
align-items: flex-start;
flex-direction: column;
}
.api-docs__pager {
grid-template-columns: 1fr;
}
.api-docs__pager-btn--next {
text-align: left;
}
}
+214 -69
View File
@@ -1,52 +1,180 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import {
Layout,
Menu,
theme,
Dropdown,
Space,
Typography,
Avatar,
} from 'antd'
import {
DashboardOutlined,
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
ApiOutlined,
AppstoreOutlined,
DashboardOutlined,
DownOutlined,
LeftOutlined,
LogoutOutlined,
OrderedListOutlined,
RightOutlined,
ShopOutlined,
BugOutlined,
TeamOutlined,
UserOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { useAuth } from '../store/auth'
import type { MenuProps } from 'antd'
const { Header, Sider, Content } = Layout
interface SidebarChild {
key: string
label: string
path?: string
disabled?: boolean
}
interface SidebarSection {
key: string
label: string
icon: ReactNode
path?: string
disabled?: boolean
children?: SidebarChild[]
}
const adminSections: SidebarSection[] = [
{
key: 'dashboard',
label: '工作台',
icon: <DashboardOutlined />,
path: '/',
},
{
key: 'products',
label: '商品管理',
icon: <AppstoreOutlined />,
children: [{ key: 'platform-products', label: '平台授权商品', disabled: true }],
},
{
key: 'shops',
label: '店铺管理',
icon: <ShopOutlined />,
children: [
{ key: 'shop-list', label: '店铺列表', path: '/platform-merchants' },
{ key: 'shop-products', label: '店铺商品绑定', disabled: true },
],
},
{
key: 'orders',
label: '订单管理',
icon: <OrderedListOutlined />,
children: [
{ key: 'auto-orders', label: '自动发货订单', disabled: true },
{ key: 'manual-verify', label: '手动核销/反核销', disabled: true },
{ key: 'after-sales', label: '售后订单', disabled: true },
{ key: 'manual-orders', label: '手动处理订单', disabled: true },
],
},
{
key: 'staff',
label: '员工管理',
icon: <TeamOutlined />,
children: [
{ key: 'staff-list', label: '员工列表', disabled: true },
{ key: 'positions', label: '岗位列表', disabled: true },
{ key: 'operation-logs', label: '操作日志', disabled: true },
],
},
{
key: 'funds',
label: '资金',
icon: <WalletOutlined />,
children: [
{ key: 'point-details', label: '积分明细', disabled: true },
{ key: 'recharge-requests', label: '充值申请', disabled: true },
],
},
{
key: 'open-api',
label: '开放 API',
icon: <ApiOutlined />,
children: [
{ key: 'api-keys', label: 'API 密钥', path: '/merchant-center' },
{ key: 'api-docs', label: '对接文档', path: '/open-api' },
{ key: 'api-debug', label: '调用调试', path: '/api-debug' },
],
},
]
const merchantSections: SidebarSection[] = [
{
key: 'dashboard',
label: '工作台',
icon: <DashboardOutlined />,
path: '/',
},
{
key: 'open-api',
label: '开放 API',
icon: <ApiOutlined />,
children: [{ key: 'api-keys', label: 'API 密钥', path: '/merchant-center' }],
},
]
function getSelectedKey(pathname: string) {
if (pathname === '/') return 'dashboard'
if (pathname.startsWith('/merchant-center')) return 'api-keys'
if (pathname.startsWith('/open-api')) return 'api-docs'
if (pathname.startsWith('/api-debug')) return 'api-debug'
if (pathname.startsWith('/platform-merchants')) return 'shop-list'
return 'dashboard'
}
function getInitialOpenKeys(sections: SidebarSection[], selectedKey: string) {
return sections
.filter((section) => section.children?.some((child) => child.key === selectedKey))
.map((section) => section.key)
}
export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false)
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
const isDocsPage = location.pathname.startsWith('/open-api')
const menuItems: MenuProps['items'] = useMemo(() => {
const items: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/merchant-center', icon: <ShopOutlined />, label: '商户中心' },
]
if (isAdmin) {
items.push(
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
{ key: '/api-debug', icon: <BugOutlined />, label: 'API 调试' },
)
const sections = useMemo(() => (isAdmin ? adminSections : merchantSections), [isAdmin])
const selectedKey = getSelectedKey(location.pathname)
const [openKeys, setOpenKeys] = useState<string[]>(() => getInitialOpenKeys(sections, selectedKey))
useEffect(() => {
const nextOpenKeys = getInitialOpenKeys(sections, selectedKey)
setOpenKeys((current) => Array.from(new Set([...current, ...nextOpenKeys])))
}, [sections, selectedKey])
const toggleSection = (section: SidebarSection) => {
if (section.disabled) return
if (section.path) {
navigate(section.path)
return
}
return items
}, [isAdmin])
if (collapsed) {
const firstEnabledChild = section.children?.find((child) => child.path && !child.disabled)
if (firstEnabledChild?.path) navigate(firstEnabledChild.path)
return
}
setOpenKeys((current) =>
current.includes(section.key)
? current.filter((key) => key !== section.key)
: [...current, section.key],
)
}
const handleChildClick = (child: SidebarChild) => {
if (!child.path || child.disabled) return
navigate(child.path)
}
const userMenu: MenuProps['items'] = [
{
@@ -61,46 +189,70 @@ export default function MainLayout() {
]
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed} theme="dark">
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 700,
fontSize: collapsed ? 14 : 16,
letterSpacing: 1,
}}
<Layout className="app-shell">
<Sider
className={`app-sidebar${collapsed ? ' app-sidebar--collapsed' : ''}`}
trigger={null}
collapsible
collapsed={collapsed}
width={230}
collapsedWidth={74}
>
<button
className="app-sidebar__collapse"
type="button"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
>
{collapsed ? '供货' : '皮肤供货平台'}
{collapsed ? <RightOutlined /> : <LeftOutlined />}
</button>
<div className="app-sidebar__brand">
<span className="app-sidebar__brand-mark">S</span>
{!collapsed ? <span className="app-sidebar__brand-text">Skin Sales</span> : null}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname === '/' ? '/' : `/${location.pathname.split('/')[1]}`]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
<nav className="app-sidebar__nav">
{sections.map((section) => {
const isOpen = openKeys.includes(section.key)
const isSelected = selectedKey === section.key
const hasSelectedChild = section.children?.some((child) => child.key === selectedKey)
return (
<div className="app-sidebar__group" key={section.key}>
<button
className={`app-sidebar__item${isSelected || hasSelectedChild ? ' app-sidebar__item--active' : ''}${section.disabled ? ' app-sidebar__item--disabled' : ''}`}
type="button"
onClick={() => toggleSection(section)}
title={collapsed ? section.label : undefined}
>
<span className="app-sidebar__icon">{section.icon}</span>
{!collapsed ? <span className="app-sidebar__label">{section.label}</span> : null}
{!collapsed && section.children?.length ? (
<DownOutlined className={`app-sidebar__arrow${isOpen ? ' app-sidebar__arrow--open' : ''}`} />
) : null}
</button>
{!collapsed && section.children?.length && isOpen ? (
<div className="app-sidebar__children">
{section.children.map((child) => (
<button
className={`app-sidebar__child${selectedKey === child.key ? ' app-sidebar__child--active' : ''}${child.disabled ? ' app-sidebar__child--disabled' : ''}`}
key={child.key}
type="button"
onClick={() => handleChildClick(child)}
disabled={child.disabled}
>
{child.label}
</button>
))}
</div>
) : null}
</div>
)
})}
</nav>
</Sider>
<Layout>
<Layout className={`app-main${isDocsPage ? ' app-main--flush' : ''}`}>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
className="app-header"
>
<span
style={{ fontSize: 18, cursor: 'pointer' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
<Dropdown menu={{ items: userMenu }}>
<Space style={{ cursor: 'pointer' }}>
<Avatar size="small" icon={<UserOutlined />} />
@@ -111,15 +263,8 @@ export default function MainLayout() {
</Space>
</Dropdown>
</Header>
<Content style={{ margin: 24 }}>
<div
style={{
padding: 24,
minHeight: 360,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Content className="app-content">
<div className="app-content__body">
<Outlet />
</div>
</Content>
+228 -97
View File
@@ -1,7 +1,8 @@
import type { CSSProperties } from 'react'
import { Alert, Card, Descriptions, Space, Table, Tag, Typography } from 'antd'
import type { ReactNode } from 'react'
import { Space, Table, Tag, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { EndpointSpec, ParamSpec } from './types'
import { errorCodes } from './endpoints'
const { Text, Paragraph } = Typography
@@ -13,119 +14,235 @@ const methodColor: Record<string, string> = {
DELETE: 'red',
}
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 methodText: Record<string, string> = {
GET: '#1677ff',
POST: '#389e0d',
PUT: '#d46b08',
PATCH: '#d48806',
DELETE: '#cf1322',
}
function FieldName({ children }: { children: string }) {
return <span className="api-docs__field">{children}</span>
}
function InlineExample({ children }: { children: string }) {
return <span className="api-docs__inline-example">{children}</span>
}
function RequiredMark({ required }: { required?: boolean }) {
return required ? <span className="api-docs__required"></span> : <Text type="secondary"></Text>
}
// 轻量 JSON 语法高亮:把 JSON 字符串渲染成带颜色 span 的 HTML。
function highlightJson(json: string): string {
const esc = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return esc.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+\.?\d*([eE][+-]?\d+)?)/g,
(match) => {
let cls = 'json-number'
if (/^"/.test(match)) {
cls = /:$/.test(match) ? 'json-key' : 'json-string'
} else if (/true|false/.test(match)) {
cls = 'json-boolean'
} else if (/null/.test(match)) {
cls = 'json-null'
}
return `<span class="${cls}">${match}</span>`
},
)
}
function JsonBlock({ code }: { code: string }) {
return <pre className="api-docs__code" dangerouslySetInnerHTML={{ __html: highlightJson(code) }} />
}
function MethodPathBlock({ method, path }: { method: string; path: string }) {
return (
<pre className="api-docs__endpoint-line">
<span className="api-docs__method" style={{ color: methodText[method] }}>{method}</span>
{' '}
<span className="api-docs__path">{path}</span>
</pre>
)
}
// 请求参数表格:参数名 | 类型 | 必填 | 说明(示例并入说明列)
const paramColumns: ColumnsType<ParamSpec> = [
{
title: '字段',
title: '参数名',
dataIndex: 'name',
width: 200,
render: (v: string) => <Text code style={{ whiteSpace: 'nowrap' }}>{v}</Text>,
},
{
title: '必填',
dataIndex: 'required',
width: 70,
render: (v?: boolean) =>
v ? <Tag color="red"></Tag> : <Tag></Tag>,
width: 260,
render: (v: string) => <FieldName>{v}</FieldName>,
},
{
title: '类型',
dataIndex: 'type',
width: 180,
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v}</Text>,
width: 140,
render: (v: string) => <Text className="api-docs__type">{v}</Text>,
},
{ title: '说明', dataIndex: 'desc' },
{
title: '示例',
dataIndex: 'example',
width: 160,
render: (v?: string) =>
v ? <Text code style={{ fontSize: 12 }}>{v}</Text> : null,
title: '必填',
dataIndex: 'required',
width: 72,
render: (v?: boolean) => <RequiredMark required={v} />,
},
{
title: '说明',
render: (_: unknown, record: ParamSpec) => (
<span>
{record.desc}
{record.example ? (
<>
{' '}
<Text type="secondary" style={{ fontSize: 12 }}></Text>{' '}
<InlineExample>{record.example}</InlineExample>
</>
) : null}
</span>
),
},
]
// 响应字段表格:字段 | 类型 | 说明(无必填列)
const responseColumns: ColumnsType<ParamSpec> = [
{
title: '字段',
dataIndex: 'name',
width: 280,
render: (v: string) => <FieldName>{v}</FieldName>,
},
{
title: '类型',
dataIndex: 'type',
width: 140,
render: (v: string) => <Text className="api-docs__type">{v}</Text>,
},
{ title: '说明', dataIndex: 'desc' },
]
function SectionTitle({ id, children }: { id: string; children: ReactNode }) {
return (
<h4 id={id} className="doc-section-title">
{children}
</h4>
)
}
function SubTitle({ children }: { children: ReactNode }) {
return <h5 className="doc-subtitle">{children}</h5>
}
function ParamTable({ title, params }: { title: string; params: ParamSpec[] }) {
return (
<Card size="small" title={title} style={{ width: '100%' }}>
<div className="api-docs__param-group">
<SubTitle>{title}</SubTitle>
<Table
className="api-docs__table"
size="small"
pagination={false}
rowKey="name"
dataSource={params}
columns={paramColumns}
/>
</Card>
</div>
)
}
function CodeBlock({ title, code }: { title: string; code: string }) {
return (
<Card size="small" title={title} style={{ width: '100%' }}>
<pre style={preStyle}>{code}</pre>
</Card>
)
}
/** 通用接口渲染:方法+路径+权限 → 参数表 → 请求/响应示例 → 字段说明 → 注意事项。 */
/** 通用接口渲染:摘要 → 接口地址 → 请求方式 → 请求参数 → 响应参数 → 状态码 → 响应示例 → 注意事项。 */
export default function EndpointDoc({ spec }: { spec: EndpointSpec }) {
const prefix = `ep-${spec.key}`
const hasParams =
!!spec.pathParams?.length || !!spec.queryParams?.length || !!spec.bodyParams?.length
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small">
<Space wrap size="middle" align="center">
<Tag color={methodColor[spec.method]} style={{ margin: 0, fontSize: 13, fontWeight: 700 }}>
{spec.method}
</Tag>
<Text code copyable style={{ fontSize: 14 }}>
{spec.path}
</Text>
<Tag color="purple">{spec.scope}</Tag>
</Space>
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
{spec.summary}
</Paragraph>
</Card>
{spec.pathParams?.length ? <ParamTable title="路径参数" params={spec.pathParams} /> : null}
{spec.queryParams?.length ? <ParamTable title="查询参数" params={spec.queryParams} /> : null}
{spec.bodyParams?.length ? <ParamTable title="请求体参数" params={spec.bodyParams} /> : null}
{spec.requestExample ? <CodeBlock title="请求示例" code={spec.requestExample} /> : null}
<CodeBlock title="响应示例" code={spec.responseExample} />
{spec.responseFields?.length ? (
<ParamTable title="响应字段说明" params={spec.responseFields} />
) : null}
<Space className="api-docs__endpoint" direction="vertical" size={20} style={{ width: '100%' }}>
<Paragraph className="api-docs__summary" type="secondary">
{spec.summary}
</Paragraph>
{spec.notes?.length ? (
<Alert
type="warning"
showIcon
message="注意事项"
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
{spec.notes.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
}
/>
<div className="api-docs__notice">
<span className="api-docs__notice-label"></span>
{spec.notes.join('')}
</div>
) : null}
<section>
<SectionTitle id={`${prefix}-address`}></SectionTitle>
<MethodPathBlock method={spec.method} path={spec.path} />
</section>
<section>
<SectionTitle id={`${prefix}-method`}></SectionTitle>
<Space size="middle" align="center">
<Text code style={{ color: methodText[spec.method], fontWeight: 700 }}>
{spec.method}
</Text>
<Tag color={methodColor[spec.method]} style={{ margin: 0 }}>
{spec.scope}
</Tag>
</Space>
</section>
<section>
<SectionTitle id={`${prefix}-params`}></SectionTitle>
{spec.pathParams?.length ? <ParamTable title="路径参数" params={spec.pathParams} /> : null}
{spec.queryParams?.length ? <ParamTable title="查询参数" params={spec.queryParams} /> : null}
{spec.bodyParams?.length ? <ParamTable title="请求体参数" params={spec.bodyParams} /> : null}
{!hasParams ? <Text type="secondary"></Text> : null}
{spec.requestExample ? (
<>
<SubTitle></SubTitle>
<JsonBlock code={spec.requestExample} />
</>
) : null}
</section>
<section>
<SectionTitle id={`${prefix}-response`}></SectionTitle>
{spec.responseFields?.length ? (
<Table
className="api-docs__table"
size="small"
pagination={false}
rowKey="name"
dataSource={spec.responseFields}
columns={responseColumns}
/>
) : (
<Text type="secondary"></Text>
)}
</section>
<section>
<SectionTitle id={`${prefix}-status`}></SectionTitle>
<Table
className="api-docs__table"
size="small"
pagination={false}
rowKey="name"
dataSource={errorCodes}
columns={[
{
title: 'HTTP 状态码',
dataIndex: 'name',
width: 120,
render: (v) => <Text code>{v}</Text>,
},
{ title: '含义', dataIndex: 'desc' },
]}
/>
</section>
<section>
<SectionTitle id={`${prefix}-example`}></SectionTitle>
<JsonBlock code={spec.responseExample} />
</section>
</Space>
)
}
/** 通用响应说明卡片,供页面顶部展示统一响应格式与错误码。 */
/** 通用响应说明,供「概述」展示统一响应格式与错误码。 */
export function CommonResponseDoc({
fields,
errors,
@@ -134,31 +251,45 @@ export function CommonResponseDoc({
errors: ParamSpec[]
}) {
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="统一响应格式">
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="结构">
<Text code>{`{ "code": 0, "message": "ok", "data": {} }`}</Text>
</Descriptions.Item>
{fields.map((f) => (
<Descriptions.Item key={f.name} label={<Text code>{f.name}</Text>}>
{f.desc}
</Descriptions.Item>
))}
</Descriptions>
</Card>
<Card size="small" title="错误码">
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<section>
<SectionTitle id="common-response"></SectionTitle>
<JsonBlock
code={`{
"code": 0,
"message": "ok",
"data": {}
}`}
/>
<Table
className="api-docs__table"
size="small"
pagination={false}
rowKey="name"
dataSource={fields}
columns={responseColumns}
style={{ marginTop: 14 }}
/>
</section>
<section>
<SectionTitle id="error-codes"></SectionTitle>
<Table
className="api-docs__table"
size="small"
pagination={false}
rowKey="name"
dataSource={errors}
columns={[
{ title: 'HTTP/code', dataIndex: 'name', width: 120, render: (v) => <Text code>{v}</Text> },
{
title: 'HTTP/code',
dataIndex: 'name',
width: 120,
render: (v) => <Text code>{v}</Text>,
},
{ title: '含义', dataIndex: 'desc' },
]}
/>
</Card>
</section>
</Space>
)
}
+9
View File
@@ -152,6 +152,7 @@ export const endpoints: EndpointSpec[] = [
key: 'list-products',
method: 'GET',
path: '/api/client/v1/products',
title: '商品列表',
summary: '查询商户已授权且上架的商品列表',
scope: 'products:read',
queryParams: [
@@ -196,6 +197,7 @@ export const endpoints: EndpointSpec[] = [
key: 'create-order',
method: 'POST',
path: '/api/client/v1/orders',
title: '创建订单',
summary: '幂等创建订单并扣款(成功返回 201,重复请求返回 200 且 idempotent=true',
scope: 'orders:write',
bodyParams: [
@@ -240,6 +242,7 @@ export const endpoints: EndpointSpec[] = [
key: 'query-order',
method: 'GET',
path: '/api/client/v1/orders/{order_no}',
title: '查询订单',
summary: '查询订单状态与详情',
scope: 'orders:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -250,6 +253,7 @@ export const endpoints: EndpointSpec[] = [
key: 'cancel-order',
method: 'POST',
path: '/api/client/v1/orders/{order_no}/cancel',
title: '取消订单',
summary: '取消未履约订单并退款(仅 pending/failed 可取消)',
scope: 'orders:write',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -266,6 +270,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-link',
method: 'GET',
path: '/api/client/v1/orders/{order_no}/delivery-link',
title: '获取发货链接',
summary: '获取签名发货链接,适合直接跳转平台 Web 发货页',
scope: 'orders:read + fulfillment:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -286,6 +291,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-order',
method: 'GET',
path: '/api/client/v1/orders/{order_no}/delivery',
title: '查询发货数据',
summary: '查询发货页结构化数据,适合商户自建发货界面',
scope: 'orders:read + fulfillment:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -316,6 +322,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-bind',
method: 'POST',
path: '/api/client/v1/orders/{order_no}/delivery/bind',
title: '发起绑定',
summary: '发起绑定,返回绑定跳转链接与二维码地址',
scope: 'orders:write',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -340,6 +347,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-submit',
method: 'POST',
path: '/api/client/v1/orders/{order_no}/delivery/submit',
title: '提交发货',
summary: '提交发货到上游并进入履约中状态',
scope: 'orders:write',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -369,6 +377,7 @@ export const endpoints: EndpointSpec[] = [
key: 'get-wallet',
method: 'GET',
path: '/api/client/v1/wallet',
title: '余额查询',
summary: '查询商户钱包余额',
scope: 'wallet:read',
responseExample: `{
+12
View File
@@ -2,6 +2,16 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
/** 每个接口下固定的子小节,供目录(TOC)构建与锚点跳转使用。 */
export const endpointSections = [
{ id: 'address', label: '接口地址' },
{ id: 'method', label: '请求方式' },
{ id: 'params', label: '请求参数' },
{ id: 'response', label: '响应参数' },
{ id: 'status', label: '状态码' },
{ id: 'example', label: '响应示例' },
] as const
/** 单个接口的完整描述。 */
export interface EndpointSpec {
/** 唯一 key,用于折叠面板与锚点 */
@@ -10,6 +20,8 @@ export interface EndpointSpec {
method: HttpMethod
/** 接口路径,路径参数用 {name} 表示 */
path: string
/** 中文接口名,用于目录与标题展示 */
title: string
/** 一句话说明 */
summary: string
/** 调用所需权限 scope */
+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>
)
}