diff --git a/frontend/src/index.css b/frontend/src/index.css index dbeabde..576f876 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 594f022..0dfc340 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -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: , + path: '/', + }, + { + key: 'products', + label: '商品管理', + icon: , + children: [{ key: 'platform-products', label: '平台授权商品', disabled: true }], + }, + { + key: 'shops', + label: '店铺管理', + icon: , + children: [ + { key: 'shop-list', label: '店铺列表', path: '/platform-merchants' }, + { key: 'shop-products', label: '店铺商品绑定', disabled: true }, + ], + }, + { + key: 'orders', + label: '订单管理', + icon: , + 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: , + children: [ + { key: 'staff-list', label: '员工列表', disabled: true }, + { key: 'positions', label: '岗位列表', disabled: true }, + { key: 'operation-logs', label: '操作日志', disabled: true }, + ], + }, + { + key: 'funds', + label: '资金', + icon: , + children: [ + { key: 'point-details', label: '积分明细', disabled: true }, + { key: 'recharge-requests', label: '充值申请', disabled: true }, + ], + }, + { + key: 'open-api', + label: '开放 API', + icon: , + 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: , + path: '/', + }, + { + key: 'open-api', + label: '开放 API', + icon: , + 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: , label: '数据概览' }, - { key: '/merchant-center', icon: , label: '商户中心' }, - ] - if (isAdmin) { - items.push( - { key: '/platform-merchants', icon: , label: '商户管理' }, - { key: '/open-api', icon: , label: '开放接口' }, - { key: '/api-debug', icon: , label: 'API 调试' }, - ) + const sections = useMemo(() => (isAdmin ? adminSections : merchantSections), [isAdmin]) + const selectedKey = getSelectedKey(location.pathname) + const [openKeys, setOpenKeys] = useState(() => 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 ( - - -
+ + +
+ S + {!collapsed ? Skin Sales : null}
- navigate(key)} - /> + - +
- setCollapsed(!collapsed)} - > - {collapsed ? : } - } /> @@ -111,15 +263,8 @@ export default function MainLayout() {
- -
+ +
diff --git a/frontend/src/openapi/EndpointDoc.tsx b/frontend/src/openapi/EndpointDoc.tsx index b43d962..4a538dc 100644 --- a/frontend/src/openapi/EndpointDoc.tsx +++ b/frontend/src/openapi/EndpointDoc.tsx @@ -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 = { 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 = { + GET: '#1677ff', + POST: '#389e0d', + PUT: '#d46b08', + PATCH: '#d48806', + DELETE: '#cf1322', } +function FieldName({ children }: { children: string }) { + return {children} +} + +function InlineExample({ children }: { children: string }) { + return {children} +} + +function RequiredMark({ required }: { required?: boolean }) { + return required ? : +} + +// 轻量 JSON 语法高亮:把 JSON 字符串渲染成带颜色 span 的 HTML。 +function highlightJson(json: string): string { + const esc = json.replace(/&/g, '&').replace(//g, '>') + 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 `${match}` + }, + ) +} + +function JsonBlock({ code }: { code: string }) { + return
+}
+
+function MethodPathBlock({ method, path }: { method: string; path: string }) {
+  return (
+    
+      {method}
+      {' '}
+      {path}
+    
+ ) +} + +// 请求参数表格:参数名 | 类型 | 必填 | 说明(示例并入说明列) const paramColumns: ColumnsType = [ { - title: '字段', + title: '参数名', dataIndex: 'name', - width: 200, - render: (v: string) => {v}, - }, - { - title: '必填', - dataIndex: 'required', - width: 70, - render: (v?: boolean) => - v ? : , + width: 260, + render: (v: string) => {v}, }, { title: '类型', dataIndex: 'type', - width: 180, - render: (v: string) => {v}, + width: 140, + render: (v: string) => {v}, }, - { title: '说明', dataIndex: 'desc' }, { - title: '示例', - dataIndex: 'example', - width: 160, - render: (v?: string) => - v ? {v} : null, + title: '必填', + dataIndex: 'required', + width: 72, + render: (v?: boolean) => , + }, + { + title: '说明', + render: (_: unknown, record: ParamSpec) => ( + + {record.desc} + {record.example ? ( + <> + {' '} + 示例{' '} + {record.example} + + ) : null} + + ), }, ] +// 响应字段表格:字段 | 类型 | 说明(无必填列) +const responseColumns: ColumnsType = [ + { + title: '字段', + dataIndex: 'name', + width: 280, + render: (v: string) => {v}, + }, + { + title: '类型', + dataIndex: 'type', + width: 140, + render: (v: string) => {v}, + }, + { title: '说明', dataIndex: 'desc' }, +] + +function SectionTitle({ id, children }: { id: string; children: ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function SubTitle({ children }: { children: ReactNode }) { + return
{children}
+} + function ParamTable({ title, params }: { title: string; params: ParamSpec[] }) { return ( - +
+ {title} - + ) } -function CodeBlock({ title, code }: { title: string; code: string }) { - return ( - -
{code}
-
- ) -} - -/** 通用接口渲染:方法+路径+权限 → 参数表 → 请求/响应示例 → 字段说明 → 注意事项。 */ +/** 通用接口渲染:摘要 → 接口地址 → 请求方式 → 请求参数 → 响应参数 → 状态码 → 响应示例 → 注意事项。 */ export default function EndpointDoc({ spec }: { spec: EndpointSpec }) { + const prefix = `ep-${spec.key}` + const hasParams = + !!spec.pathParams?.length || !!spec.queryParams?.length || !!spec.bodyParams?.length return ( - - - - - {spec.method} - - - {spec.path} - - {spec.scope} - - - {spec.summary} - - - - {spec.pathParams?.length ? : null} - {spec.queryParams?.length ? : null} - {spec.bodyParams?.length ? : null} - - {spec.requestExample ? : null} - - - {spec.responseFields?.length ? ( - - ) : null} - + + + {spec.summary} + {spec.notes?.length ? ( - - {spec.notes.map((n, i) => ( -
  • {n}
  • - ))} - - } - /> +
    + 限制: + {spec.notes.join(';')} +
    ) : null} + +
    + 接口地址 + +
    + +
    + 请求方式 + + + {spec.method} + + + {spec.scope} + + +
    + +
    + 请求参数 + {spec.pathParams?.length ? : null} + {spec.queryParams?.length ? : null} + {spec.bodyParams?.length ? : null} + {!hasParams ? 该接口无请求参数。 : null} + {spec.requestExample ? ( + <> + 请求示例 + + + ) : null} +
    + +
    + 响应参数 + {spec.responseFields?.length ? ( +
    + ) : ( + 详见下方响应示例。 + )} + + +
    + 状态码 +
    {v}, + }, + { title: '含义', dataIndex: 'desc' }, + ]} + /> + + +
    + 响应示例 + +
    + ) } -/** 通用响应说明卡片,供页面顶部展示统一响应格式与错误码。 */ +/** 通用响应说明,供「概述」展示统一响应格式与错误码。 */ export function CommonResponseDoc({ fields, errors, @@ -134,31 +251,45 @@ export function CommonResponseDoc({ errors: ParamSpec[] }) { return ( - - - - - {`{ "code": 0, "message": "ok", "data": {} }`} - - {fields.map((f) => ( - {f.name}}> - {f.desc} - - ))} - - - + +
    + 统一响应格式 +
    + +
    + 错误码 +
    {v} }, + { + title: 'HTTP/code', + dataIndex: 'name', + width: 120, + render: (v) => {v}, + }, { title: '含义', dataIndex: 'desc' }, ]} /> - + ) } diff --git a/frontend/src/openapi/endpoints.ts b/frontend/src/openapi/endpoints.ts index e70b21c..abc3679 100644 --- a/frontend/src/openapi/endpoints.ts +++ b/frontend/src/openapi/endpoints.ts @@ -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: `{ diff --git a/frontend/src/openapi/types.ts b/frontend/src/openapi/types.ts index 73cfd10..655658a 100644 --- a/frontend/src/openapi/types.ts +++ b/frontend/src/openapi/types.ts @@ -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 */ diff --git a/frontend/src/pages/OpenApiDocs.tsx b/frontend/src/pages/OpenApiDocs.tsx index 2d77487..81d1925 100644 --- a/frontend/src/pages/OpenApiDocs.tsx +++ b/frontend/src/pages/OpenApiDocs.tsx @@ -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 ( -
    - - 开放接口 - - - 面向商户系统调用的开放 API。凭证在「商户中心 / API 客户端」创建, - 采用 X-App-Key + HMAC-SHA256 签名鉴权。 - 发货链路同时支持「返回签名链接」和「返回结构化数据」两种模式。 - /api/open/v1 仍是上游回调链路,和本页的商户 API 分开。 - +// ---------- TOC 目录构建 ---------- +interface TocLeaf { + id: string + label: string + level: 3 // 接口子小节(接口地址/请求方式/...) +} +interface TocItem { + id: string + label: string + children?: TocLeaf[] +} +interface TocGroup { + title: string + items: TocItem[] +} - , - }, - { - key: 'auth', - label: '鉴权与签名', - children: , - }, - { - key: 'endpoints', - label: '接口列表', - children: , - }, - { - key: 'delivery', - label: '发货模式', - children: , - }, - { - key: 'status', - label: '状态说明', - children: , - }, - ]} - /> +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 ( +
    + {/* 左侧目录 */} + + + {/* 右侧文档 */} +
    +
    + {activePageId === 'overview' ? : null} + {activePageId === 'auth' ? : null} + {activeEndpoint ? : null} + {activePageId === 'status' ? : null} + +
    +
    ) } -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 ( - +
    + + +
    + ) +} + +// ---------- 各文档小节 ---------- +function SectionWrap({ + id, + title, + desc, + children, +}: { + id: string + title: string + desc?: ReactNode + children?: ReactNode +}) { + return ( +
    +

    + {title} +

    + {desc ? {desc} : null} + {children} +
    + ) +} + +function OverviewSection() { + return ( + + 面向商户系统调用的开放 API。凭证在「商户中心 / API 客户端」创建, + 采用 X-App-Key + HMAC-SHA256 签名鉴权。 + 发货链路同时支持「返回签名链接」和「返回结构化数据」两种模式。 + /api/open/v1 仍是上游回调链路,和本页的商户 API 分开。 + + } + > } /> - +
    ( -
    {v}
    - ), + render: (v: string) =>
    {v}
    , }, ]} /> - + {baseUrl} @@ -140,42 +349,19 @@ function OverviewTab() { POST 请求使用 application/json - - +
    + +
    + ) } -function DeliveryModeTab() { +function AuthSection() { return ( - - - - - 用 delivery-link,后端直接返回签名后的 Web 发货页地址,前端只负责跳转或复制。 - - - 用 delivery 获取订单数据,再按需调用 bindsubmit。 - - - 取消作废属于商户后台操作,恢复后会重新签发新有效期和新签名。 - - - - - - ) -} - -function AuthTab() { - return ( - - + +
    {v} }, - { title: '必填', dataIndex: 'required', width: 80, render: (v) => v ? : }, + { + title: '必填', + dataIndex: 'required', + width: 80, + render: (v) => v ? : , + }, { title: '说明', dataIndex: 'desc' }, ]} /> - + app_keybody_sha256methodnoncepathtimestamp @@ -214,19 +405,21 @@ function AuthTab() { - -
    {`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}
    + +
    {`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}
    - -
    {`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
    +      
    +        
    {`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
     app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&method=POST&nonce=a1b2c3d4e5f67890&path=/api/client/v1/orders×tamp=1721450000`}
  • POST 签名用的 body 必须与实际发送的 body 字节级一致,不要签名后再改空格或字段顺序。
  • @@ -236,36 +429,33 @@ app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&met } /> - + ) } -function EndpointsTab() { +function EndpointSection({ spec }: { spec: EndpointSpec }) { return ( - ({ - key: spec.key, - label: ( - - - {spec.method} - - {spec.path} - {spec.summary} - - ), - children: , - }))} - /> +
    +
    +

    + {spec.title} +

    +
    + {spec.method} + {spec.scope} +
    +
    + +
    ) } -function StatusTab() { +function StatusSection() { return ( - - + +
    - +
    {`拿到 sku(商品列表) +
    {`拿到 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 回调`}
    } /> - + ) }