优化开放 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 { a {
color: #1677ff; 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;
}
}
+215 -70
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 { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { import {
Layout, Layout,
Menu,
theme,
Dropdown, Dropdown,
Space, Space,
Typography, Typography,
Avatar, Avatar,
} from 'antd' } from 'antd'
import { import {
DashboardOutlined,
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
ApiOutlined, ApiOutlined,
AppstoreOutlined,
DashboardOutlined,
DownOutlined,
LeftOutlined,
LogoutOutlined,
OrderedListOutlined,
RightOutlined,
ShopOutlined, ShopOutlined,
BugOutlined, TeamOutlined,
UserOutlined,
WalletOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useAuth } from '../store/auth' import { useAuth } from '../store/auth'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
const { Header, Sider, Content } = Layout 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() { export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const { user, logout, isAdmin } = useAuth() const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { const isDocsPage = location.pathname.startsWith('/open-api')
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
const menuItems: MenuProps['items'] = useMemo(() => { const sections = useMemo(() => (isAdmin ? adminSections : merchantSections), [isAdmin])
const items: MenuProps['items'] = [ const selectedKey = getSelectedKey(location.pathname)
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' }, const [openKeys, setOpenKeys] = useState<string[]>(() => getInitialOpenKeys(sections, selectedKey))
{ key: '/merchant-center', icon: <ShopOutlined />, label: '商户中心' },
] useEffect(() => {
if (isAdmin) { const nextOpenKeys = getInitialOpenKeys(sections, selectedKey)
items.push( setOpenKeys((current) => Array.from(new Set([...current, ...nextOpenKeys])))
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' }, }, [sections, selectedKey])
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
{ key: '/api-debug', icon: <BugOutlined />, label: 'API 调试' }, const toggleSection = (section: SidebarSection) => {
if (section.disabled) return
if (section.path) {
navigate(section.path)
return
}
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],
) )
} }
return items
}, [isAdmin]) const handleChildClick = (child: SidebarChild) => {
if (!child.path || child.disabled) return
navigate(child.path)
}
const userMenu: MenuProps['items'] = [ const userMenu: MenuProps['items'] = [
{ {
@@ -61,46 +189,70 @@ export default function MainLayout() {
] ]
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout className="app-shell">
<Sider trigger={null} collapsible collapsed={collapsed} theme="dark"> <Sider
<div className={`app-sidebar${collapsed ? ' app-sidebar--collapsed' : ''}`}
style={{ trigger={null}
height: 64, collapsible
display: 'flex', collapsed={collapsed}
alignItems: 'center', width={230}
justifyContent: 'center', collapsedWidth={74}
color: '#fff',
fontWeight: 700,
fontSize: collapsed ? 14 : 16,
letterSpacing: 1,
}}
> >
{collapsed ? '供货' : '皮肤供货平台'} <button
</div> className="app-sidebar__collapse"
<Menu type="button"
theme="dark"
mode="inline"
selectedKeys={[location.pathname === '/' ? '/' : `/${location.pathname.split('/')[1]}`]}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span
style={{ fontSize: 18, cursor: 'pointer' }}
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
aria-label={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>
<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 className={`app-main${isDocsPage ? ' app-main--flush' : ''}`}>
<Header
className="app-header"
> >
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</span>
<Dropdown menu={{ items: userMenu }}> <Dropdown menu={{ items: userMenu }}>
<Space style={{ cursor: 'pointer' }}> <Space style={{ cursor: 'pointer' }}>
<Avatar size="small" icon={<UserOutlined />} /> <Avatar size="small" icon={<UserOutlined />} />
@@ -111,15 +263,8 @@ export default function MainLayout() {
</Space> </Space>
</Dropdown> </Dropdown>
</Header> </Header>
<Content style={{ margin: 24 }}> <Content className="app-content">
<div <div className="app-content__body">
style={{
padding: 24,
minHeight: 360,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Outlet /> <Outlet />
</div> </div>
</Content> </Content>
+221 -90
View File
@@ -1,7 +1,8 @@
import type { CSSProperties } from 'react' import type { ReactNode } from 'react'
import { Alert, Card, Descriptions, Space, Table, Tag, Typography } from 'antd' import { Space, Table, Tag, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import type { EndpointSpec, ParamSpec } from './types' import type { EndpointSpec, ParamSpec } from './types'
import { errorCodes } from './endpoints'
const { Text, Paragraph } = Typography const { Text, Paragraph } = Typography
@@ -13,119 +14,235 @@ const methodColor: Record<string, string> = {
DELETE: 'red', DELETE: 'red',
} }
const preStyle: CSSProperties = { const methodText: Record<string, string> = {
margin: 0, GET: '#1677ff',
padding: 12, POST: '#389e0d',
background: '#f6f8fa', PUT: '#d46b08',
borderRadius: 6, PATCH: '#d48806',
fontSize: 12.5, DELETE: '#cf1322',
lineHeight: 1.6,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
} }
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> = [ const paramColumns: ColumnsType<ParamSpec> = [
{ {
title: '字段', title: '参数名',
dataIndex: 'name', dataIndex: 'name',
width: 200, width: 260,
render: (v: string) => <Text code style={{ whiteSpace: 'nowrap' }}>{v}</Text>, render: (v: string) => <FieldName>{v}</FieldName>,
},
{
title: '必填',
dataIndex: 'required',
width: 70,
render: (v?: boolean) =>
v ? <Tag color="red"></Tag> : <Tag></Tag>,
}, },
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
width: 180, width: 140,
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v}</Text>, render: (v: string) => <Text className="api-docs__type">{v}</Text>,
}, },
{ title: '说明', dataIndex: 'desc' },
{ {
title: '示例', title: '必填',
dataIndex: 'example', dataIndex: 'required',
width: 160, width: 72,
render: (v?: string) => render: (v?: boolean) => <RequiredMark required={v} />,
v ? <Text code style={{ fontSize: 12 }}>{v}</Text> : null, },
{
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[] }) { function ParamTable({ title, params }: { title: string; params: ParamSpec[] }) {
return ( return (
<Card size="small" title={title} style={{ width: '100%' }}> <div className="api-docs__param-group">
<SubTitle>{title}</SubTitle>
<Table <Table
className="api-docs__table"
size="small" size="small"
pagination={false} pagination={false}
rowKey="name" rowKey="name"
dataSource={params} dataSource={params}
columns={paramColumns} 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 }) { export default function EndpointDoc({ spec }: { spec: EndpointSpec }) {
const prefix = `ep-${spec.key}`
const hasParams =
!!spec.pathParams?.length || !!spec.queryParams?.length || !!spec.bodyParams?.length
return ( return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space className="api-docs__endpoint" direction="vertical" size={20} style={{ width: '100%' }}>
<Card size="small"> <Paragraph className="api-docs__summary" type="secondary">
<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} {spec.summary}
</Paragraph> </Paragraph>
</Card> {spec.notes?.length ? (
<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.pathParams?.length ? <ParamTable title="路径参数" params={spec.pathParams} /> : null}
{spec.queryParams?.length ? <ParamTable title="查询参数" params={spec.queryParams} /> : null} {spec.queryParams?.length ? <ParamTable title="查询参数" params={spec.queryParams} /> : null}
{spec.bodyParams?.length ? <ParamTable title="请求体参数" params={spec.bodyParams} /> : 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>
{spec.requestExample ? <CodeBlock title="请求示例" code={spec.requestExample} /> : null} <section>
<CodeBlock title="响应示例" code={spec.responseExample} /> <SectionTitle id={`${prefix}-response`}></SectionTitle>
{spec.responseFields?.length ? ( {spec.responseFields?.length ? (
<ParamTable title="响应字段说明" params={spec.responseFields} /> <Table
) : null} className="api-docs__table"
size="small"
{spec.notes?.length ? ( pagination={false}
<Alert rowKey="name"
type="warning" dataSource={spec.responseFields}
showIcon columns={responseColumns}
message="注意事项"
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
{spec.notes.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
}
/> />
) : null} ) : (
<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> </Space>
) )
} }
/** 通用响应说明卡片,供页面顶部展示统一响应格式与错误码。 */ /** 通用响应说明,供「概述」展示统一响应格式与错误码。 */
export function CommonResponseDoc({ export function CommonResponseDoc({
fields, fields,
errors, errors,
@@ -134,31 +251,45 @@ export function CommonResponseDoc({
errors: ParamSpec[] errors: ParamSpec[]
}) { }) {
return ( return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size={24} style={{ width: '100%' }}>
<Card size="small" title="统一响应格式"> <section>
<Descriptions size="small" column={1} bordered> <SectionTitle id="common-response"></SectionTitle>
<Descriptions.Item label="结构"> <JsonBlock
<Text code>{`{ "code": 0, "message": "ok", "data": {} }`}</Text> code={`{
</Descriptions.Item> "code": 0,
{fields.map((f) => ( "message": "ok",
<Descriptions.Item key={f.name} label={<Text code>{f.name}</Text>}> "data": {}
{f.desc} }`}
</Descriptions.Item> />
))}
</Descriptions>
</Card>
<Card size="small" title="错误码">
<Table <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" size="small"
pagination={false} pagination={false}
rowKey="name" rowKey="name"
dataSource={errors} dataSource={errors}
columns={[ 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' }, { title: '含义', dataIndex: 'desc' },
]} ]}
/> />
</Card> </section>
</Space> </Space>
) )
} }
+9
View File
@@ -152,6 +152,7 @@ export const endpoints: EndpointSpec[] = [
key: 'list-products', key: 'list-products',
method: 'GET', method: 'GET',
path: '/api/client/v1/products', path: '/api/client/v1/products',
title: '商品列表',
summary: '查询商户已授权且上架的商品列表', summary: '查询商户已授权且上架的商品列表',
scope: 'products:read', scope: 'products:read',
queryParams: [ queryParams: [
@@ -196,6 +197,7 @@ export const endpoints: EndpointSpec[] = [
key: 'create-order', key: 'create-order',
method: 'POST', method: 'POST',
path: '/api/client/v1/orders', path: '/api/client/v1/orders',
title: '创建订单',
summary: '幂等创建订单并扣款(成功返回 201,重复请求返回 200 且 idempotent=true', summary: '幂等创建订单并扣款(成功返回 201,重复请求返回 200 且 idempotent=true',
scope: 'orders:write', scope: 'orders:write',
bodyParams: [ bodyParams: [
@@ -240,6 +242,7 @@ export const endpoints: EndpointSpec[] = [
key: 'query-order', key: 'query-order',
method: 'GET', method: 'GET',
path: '/api/client/v1/orders/{order_no}', path: '/api/client/v1/orders/{order_no}',
title: '查询订单',
summary: '查询订单状态与详情', summary: '查询订单状态与详情',
scope: 'orders:read', scope: 'orders:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -250,6 +253,7 @@ export const endpoints: EndpointSpec[] = [
key: 'cancel-order', key: 'cancel-order',
method: 'POST', method: 'POST',
path: '/api/client/v1/orders/{order_no}/cancel', path: '/api/client/v1/orders/{order_no}/cancel',
title: '取消订单',
summary: '取消未履约订单并退款(仅 pending/failed 可取消)', summary: '取消未履约订单并退款(仅 pending/failed 可取消)',
scope: 'orders:write', scope: 'orders:write',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -266,6 +270,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-link', key: 'delivery-link',
method: 'GET', method: 'GET',
path: '/api/client/v1/orders/{order_no}/delivery-link', path: '/api/client/v1/orders/{order_no}/delivery-link',
title: '获取发货链接',
summary: '获取签名发货链接,适合直接跳转平台 Web 发货页', summary: '获取签名发货链接,适合直接跳转平台 Web 发货页',
scope: 'orders:read + fulfillment:read', scope: 'orders:read + fulfillment:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -286,6 +291,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-order', key: 'delivery-order',
method: 'GET', method: 'GET',
path: '/api/client/v1/orders/{order_no}/delivery', path: '/api/client/v1/orders/{order_no}/delivery',
title: '查询发货数据',
summary: '查询发货页结构化数据,适合商户自建发货界面', summary: '查询发货页结构化数据,适合商户自建发货界面',
scope: 'orders:read + fulfillment:read', scope: 'orders:read + fulfillment:read',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -316,6 +322,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-bind', key: 'delivery-bind',
method: 'POST', method: 'POST',
path: '/api/client/v1/orders/{order_no}/delivery/bind', path: '/api/client/v1/orders/{order_no}/delivery/bind',
title: '发起绑定',
summary: '发起绑定,返回绑定跳转链接与二维码地址', summary: '发起绑定,返回绑定跳转链接与二维码地址',
scope: 'orders:write', scope: 'orders:write',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -340,6 +347,7 @@ export const endpoints: EndpointSpec[] = [
key: 'delivery-submit', key: 'delivery-submit',
method: 'POST', method: 'POST',
path: '/api/client/v1/orders/{order_no}/delivery/submit', path: '/api/client/v1/orders/{order_no}/delivery/submit',
title: '提交发货',
summary: '提交发货到上游并进入履约中状态', summary: '提交发货到上游并进入履约中状态',
scope: 'orders:write', scope: 'orders:write',
pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }], pathParams: [{ name: 'order_no', type: 'string', required: true, desc: '平台订单号', example: 'FO20260730000123' }],
@@ -369,6 +377,7 @@ export const endpoints: EndpointSpec[] = [
key: 'get-wallet', key: 'get-wallet',
method: 'GET', method: 'GET',
path: '/api/client/v1/wallet', path: '/api/client/v1/wallet',
title: '余额查询',
summary: '查询商户钱包余额', summary: '查询商户钱包余额',
scope: 'wallet:read', scope: 'wallet:read',
responseExample: `{ responseExample: `{
+12
View File
@@ -2,6 +2,16 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 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 { export interface EndpointSpec {
/** 唯一 key,用于折叠面板与锚点 */ /** 唯一 key,用于折叠面板与锚点 */
@@ -10,6 +20,8 @@ export interface EndpointSpec {
method: HttpMethod method: HttpMethod
/** 接口路径,路径参数用 {name} 表示 */ /** 接口路径,路径参数用 {name} 表示 */
path: string path: string
/** 中文接口名,用于目录与标题展示 */
title: string
/** 一句话说明 */ /** 一句话说明 */
summary: string summary: string
/** 调用所需权限 scope */ /** 调用所需权限 scope */
+310 -117
View File
@@ -1,5 +1,6 @@
import type { CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Alert, Card, Collapse, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd' import type { ReactNode } from 'react'
import { Alert, Card, Descriptions, Table, Tag, Typography } from 'antd'
import EndpointDoc, { CommonResponseDoc } from '../openapi/EndpointDoc' import EndpointDoc, { CommonResponseDoc } from '../openapi/EndpointDoc'
import { import {
commonResponseFields, commonResponseFields,
@@ -8,26 +9,15 @@ import {
orderStatusTable, orderStatusTable,
paymentStatusTable, paymentStatusTable,
} from '../openapi/endpoints' } from '../openapi/endpoints'
import { endpointSections, type EndpointSpec } from '../openapi/types'
const { Title, Paragraph, Text } = Typography const { Paragraph, Text } = Typography
const baseUrl = const baseUrl =
typeof window !== 'undefined' typeof window !== 'undefined'
? window.location.origin.replace(':5173', ':8080') ? window.location.origin.replace(':5173', ':8080')
: 'http://localhost: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 = [ const deliveryModes = [
{ {
mode: 'Web 发货页', mode: 'Web 发货页',
@@ -42,58 +32,278 @@ const deliveryModes = [
}, },
] ]
export default function OpenApiDocs() { // ---------- TOC 目录构建 ----------
return ( interface TocLeaf {
<div> id: string
<Title level={4} style={{ marginTop: 0 }}> label: string
level: 3 // 接口子小节(接口地址/请求方式/...)
</Title> }
<Paragraph type="secondary"> interface TocItem {
API / API id: string
<Text code>X-App-Key</Text> + HMAC-SHA256 label: string
children?: TocLeaf[]
<Text code>/api/open/v1</Text> API }
</Paragraph> interface TocGroup {
title: string
items: TocItem[]
}
<Tabs function buildToc(): TocGroup[] {
tabPosition="left" return [
style={{ minHeight: 680 }}
items={[
{ {
key: 'overview', title: '开始',
label: '概览', items: [
children: <OverviewTab />, { id: 'overview', label: '概述' },
{ id: 'auth', label: '鉴权与签名' },
],
}, },
{ {
key: 'auth', title: '接口',
label: '鉴权与签名', items: endpoints.map((ep) => ({
children: <AuthTab />, id: `ep-${ep.key}`,
label: ep.title,
children: endpointSections.map((s) => ({
id: `ep-${ep.key}-${s.id}`,
label: s.label,
level: 3 as const,
})),
})),
}, },
{ {
key: 'endpoints', title: '附录',
label: '接口列表', items: [{ id: 'status', label: '状态说明' }],
children: <EndpointsTab />,
}, },
{ ]
key: 'delivery', }
label: '发货模式',
children: <DeliveryModeTab />, 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],
key: 'status', )
label: '状态说明',
children: <StatusTab />, 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>
</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 ( 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 <Alert
className="api-docs__alert"
type="info" type="info"
showIcon showIcon
message="一句话上手" message="一句话上手"
@@ -106,8 +316,9 @@ function OverviewTab() {
</ol> </ol>
} }
/> />
<Card size="small" title="发货链路概览"> <Card className="api-docs__card" size="small" title="发货链路概览">
<Table <Table
className="api-docs__table"
size="small" size="small"
pagination={false} pagination={false}
rowKey="mode" rowKey="mode"
@@ -118,14 +329,12 @@ function OverviewTab() {
{ {
title: '相关接口', title: '相关接口',
dataIndex: 'interfaces', dataIndex: 'interfaces',
render: (v: string) => ( render: (v: string) => <pre className="api-docs__pre">{v}</pre>,
<pre style={preStyle}>{v}</pre>
),
}, },
]} ]}
/> />
</Card> </Card>
<Card size="small" title="环境信息"> <Card className="api-docs__card" size="small" title="环境信息">
<Descriptions size="small" column={1} bordered> <Descriptions size="small" column={1} bordered>
<Descriptions.Item label="Base URL"> <Descriptions.Item label="Base URL">
<Text code>{baseUrl}</Text> <Text code>{baseUrl}</Text>
@@ -140,42 +349,19 @@ function OverviewTab() {
<Descriptions.Item label="请求体">POST 使 <Text code>application/json</Text></Descriptions.Item> <Descriptions.Item label="请求体">POST 使 <Text code>application/json</Text></Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
<div className="api-docs__common">
<CommonResponseDoc fields={commonResponseFields} errors={errorCodes} /> <CommonResponseDoc fields={commonResponseFields} errors={errorCodes} />
</Space> </div>
</SectionWrap>
) )
} }
function DeliveryModeTab() { function AuthSection() {
return ( return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <SectionWrap id="auth" title="鉴权与签名">
<Card size="small" title="推荐用法"> <Card className="api-docs__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="鉴权头(每次请求必带)">
<Table <Table
className="api-docs__table"
size="small" size="small"
pagination={false} pagination={false}
rowKey="header" rowKey="header"
@@ -187,13 +373,18 @@ function AuthTab() {
]} ]}
columns={[ columns={[
{ title: 'Header', dataIndex: 'header', width: 160, render: (v) => <Text code>{v}</Text> }, { 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' }, { title: '说明', dataIndex: 'desc' },
]} ]}
/> />
</Card> </Card>
<Card size="small" title="签名算法"> <Card className="api-docs__card" size="small" title="签名算法">
<Descriptions size="small" column={1} bordered> <Descriptions size="small" column={1} bordered>
<Descriptions.Item label="参与参数"> <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> <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> </Descriptions>
</Card> </Card>
<Card size="small" title="签名示例(GETbody 为空)"> <Card className="api-docs__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> <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>
<Card size="small" title="签名示例(POSTbody 非空)"> <Card className="api-docs__card" size="small" title="签名示例(POSTbody 非空)">
<pre style={preStyle}>{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"} <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> app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&method=POST&nonce=a1b2c3d4e5f67890&path=/api/client/v1/orders&timestamp=1721450000`}</pre>
</Card> </Card>
<Alert <Alert
className="api-docs__alert"
type="warning" type="warning"
showIcon showIcon
message="签名注意事项" message="签名注意事项"
style={{ marginTop: 16 }}
description={ description={
<ul style={{ margin: 0, paddingLeft: 20 }}> <ul style={{ margin: 0, paddingLeft: 20 }}>
<li>POST <Text code>body</Text> body <b></b></li> <li>POST <Text code>body</Text> body <b></b></li>
@@ -236,36 +429,33 @@ app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&met
</ul> </ul>
} }
/> />
</Space> </SectionWrap>
) )
} }
function EndpointsTab() { function EndpointSection({ spec }: { spec: EndpointSpec }) {
return ( return (
<Collapse <div className="api-docs__section">
accordion <div className="api-docs__endpoint-head">
items={endpoints.map((spec) => ({ <h2 id={`ep-${spec.key}`} className="api-docs__h2">
key: spec.key, {spec.title}
label: ( </h2>
<Space size="small"> <div className="api-docs__endpoint-meta">
<Tag color={spec.method === 'GET' ? 'blue' : 'green'} style={{ margin: 0, fontWeight: 700 }}> <Tag color={spec.method === 'GET' ? 'blue' : 'geekblue'}>{spec.method}</Tag>
{spec.method} <Text code>{spec.scope}</Text>
</Tag> </div>
<Text code style={{ fontSize: 13 }}>{spec.path}</Text> </div>
<Text type="secondary" style={{ fontSize: 12 }}>{spec.summary}</Text> <EndpointDoc spec={spec} />
</Space> </div>
),
children: <EndpointDoc spec={spec} />,
}))}
/>
) )
} }
function StatusTab() { function StatusSection() {
return ( return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <SectionWrap id="status" title="状态说明">
<Card size="small" title="履约状态 fulfillment_status 与 can_fulfill"> <Card className="api-docs__card" size="small" title="履约状态 fulfillment_status 与 can_fulfill">
<Table <Table
className="api-docs__table"
size="small" size="small"
pagination={false} pagination={false}
rowKey="name" rowKey="name"
@@ -283,8 +473,9 @@ function StatusTab() {
]} ]}
/> />
</Card> </Card>
<Card size="small" title="支付状态 payment_status"> <Card className="api-docs__card" size="small" title="支付状态 payment_status">
<Table <Table
className="api-docs__table"
size="small" size="small"
pagination={false} pagination={false}
rowKey="name" rowKey="name"
@@ -296,11 +487,13 @@ function StatusTab() {
/> />
</Card> </Card>
<Alert <Alert
className="api-docs__alert"
type="info" type="info"
showIcon showIcon
message="推荐调用流程" message="推荐调用流程"
style={{ marginTop: 16 }}
description={ 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 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> GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`}</pre>
} }
/> />
</Space> </SectionWrap>
) )
} }