优化开放 API 文档界面
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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'] = [
|
||||
{
|
||||
@@ -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}
|
||||
>
|
||||
{collapsed ? '供货' : '皮肤供货平台'}
|
||||
</div>
|
||||
<Menu
|
||||
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' }}
|
||||
<button
|
||||
className="app-sidebar__collapse"
|
||||
type="button"
|
||||
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 }}>
|
||||
<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>
|
||||
|
||||
@@ -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, '&').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 `<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' }}>
|
||||
<Space className="api-docs__endpoint" direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Paragraph className="api-docs__summary" type="secondary">
|
||||
{spec.summary}
|
||||
</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.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>
|
||||
|
||||
{spec.requestExample ? <CodeBlock title="请求示例" code={spec.requestExample} /> : null}
|
||||
<CodeBlock title="响应示例" code={spec.responseExample} />
|
||||
|
||||
<section>
|
||||
<SectionTitle id={`${prefix}-response`}>响应参数</SectionTitle>
|
||||
{spec.responseFields?.length ? (
|
||||
<ParamTable title="响应字段说明" params={spec.responseFields} />
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
}
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="name"
|
||||
dataSource={spec.responseFields}
|
||||
columns={responseColumns}
|
||||
/>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 通用响应说明卡片,供页面顶部展示统一响应格式与错误码。 */
|
||||
/** 通用响应说明,供「概述」展示统一响应格式与错误码。 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: `{
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+310
-117
@@ -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={[
|
||||
function buildToc(): TocGroup[] {
|
||||
return [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
children: <OverviewTab />,
|
||||
title: '开始',
|
||||
items: [
|
||||
{ id: 'overview', label: '概述' },
|
||||
{ id: 'auth', label: '鉴权与签名' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
label: '鉴权与签名',
|
||||
children: <AuthTab />,
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'endpoints',
|
||||
label: '接口列表',
|
||||
children: <EndpointsTab />,
|
||||
title: '附录',
|
||||
items: [{ id: 'status', label: '状态说明' }],
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态说明',
|
||||
children: <StatusTab />,
|
||||
},
|
||||
]}
|
||||
[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>
|
||||
<div className="api-docs__common">
|
||||
<CommonResponseDoc fields={commonResponseFields} errors={errorCodes} />
|
||||
</Space>
|
||||
</div>
|
||||
</SectionWrap>
|
||||
)
|
||||
}
|
||||
|
||||
function DeliveryModeTab() {
|
||||
function AuthSection() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="推荐用法">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="只想给用户一个链接">
|
||||
用 <Text code>delivery-link</Text>,后端直接返回签名后的 Web 发货页地址,前端只负责跳转或复制。
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="想自建发货页面">
|
||||
用 <Text code>delivery</Text> 获取订单数据,再按需调用 <Text code>bind</Text> 和 <Text code>submit</Text>。
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="取消作废">
|
||||
取消作废属于商户后台操作,恢复后会重新签发新有效期和新签名。
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="两种模式共用同一套底层履约逻辑"
|
||||
description="不管是 Web 发货页还是商户自建页面,最终都会走同一套订单校验、绑定和提交逻辑,只是前端形态不同。"
|
||||
/>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthTab() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="鉴权头(每次请求必带)">
|
||||
<SectionWrap id="auth" title="鉴权与签名">
|
||||
<Card className="api-docs__card" size="small" title="鉴权头(每次请求必带)">
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="header"
|
||||
@@ -187,13 +373,18 @@ function AuthTab() {
|
||||
]}
|
||||
columns={[
|
||||
{ title: 'Header', dataIndex: 'header', width: 160, render: (v) => <Text code>{v}</Text> },
|
||||
{ title: '必填', dataIndex: 'required', width: 80, render: (v) => v ? <Tag color="red">是</Tag> : <Tag>否</Tag> },
|
||||
{
|
||||
title: '必填',
|
||||
dataIndex: 'required',
|
||||
width: 80,
|
||||
render: (v) => v ? <span className="api-docs__required">是</span> : <Text type="secondary">否</Text>,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'desc' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名算法">
|
||||
<Card className="api-docs__card" size="small" title="签名算法">
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="参与参数">
|
||||
<Text code>app_key</Text>、<Text code>body_sha256</Text>、<Text code>method</Text>、<Text code>nonce</Text>、<Text code>path</Text>、<Text code>timestamp</Text>
|
||||
@@ -214,19 +405,21 @@ function AuthTab() {
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名示例(GET,body 为空)">
|
||||
<pre style={preStyle}>{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}</pre>
|
||||
<Card className="api-docs__card" size="small" title="签名示例(GET,body 为空)">
|
||||
<pre className="api-docs__pre">{`app_key=ak_xxx&body_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855&method=GET&nonce=a1b2c3d4e5f67890&path=/api/client/v1/products×tamp=1721450000`}</pre>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="签名示例(POST,body 非空)">
|
||||
<pre style={preStyle}>{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
|
||||
<Card className="api-docs__card" size="small" title="签名示例(POST,body 非空)">
|
||||
<pre className="api-docs__pre">{`# body = {"client_order_no":"shop-10001","sku":"suit_pink_sheep"}
|
||||
app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&method=POST&nonce=a1b2c3d4e5f67890&path=/api/client/v1/orders×tamp=1721450000`}</pre>
|
||||
</Card>
|
||||
|
||||
<Alert
|
||||
className="api-docs__alert"
|
||||
type="warning"
|
||||
showIcon
|
||||
message="签名注意事项"
|
||||
style={{ marginTop: 16 }}
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>POST 签名用的 <Text code>body</Text> 必须与实际发送的 body <b>字节级一致</b>,不要签名后再改空格或字段顺序。</li>
|
||||
@@ -236,36 +429,33 @@ app_key=ak_xxx&body_sha256=<实际请求 body 字节的 SHA256 十六进制>&met
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</SectionWrap>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointsTab() {
|
||||
function EndpointSection({ spec }: { spec: EndpointSpec }) {
|
||||
return (
|
||||
<Collapse
|
||||
accordion
|
||||
items={endpoints.map((spec) => ({
|
||||
key: spec.key,
|
||||
label: (
|
||||
<Space size="small">
|
||||
<Tag color={spec.method === 'GET' ? 'blue' : 'green'} style={{ margin: 0, fontWeight: 700 }}>
|
||||
{spec.method}
|
||||
</Tag>
|
||||
<Text code style={{ fontSize: 13 }}>{spec.path}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{spec.summary}</Text>
|
||||
</Space>
|
||||
),
|
||||
children: <EndpointDoc spec={spec} />,
|
||||
}))}
|
||||
/>
|
||||
<div className="api-docs__section">
|
||||
<div className="api-docs__endpoint-head">
|
||||
<h2 id={`ep-${spec.key}`} className="api-docs__h2">
|
||||
{spec.title}
|
||||
</h2>
|
||||
<div className="api-docs__endpoint-meta">
|
||||
<Tag color={spec.method === 'GET' ? 'blue' : 'geekblue'}>{spec.method}</Tag>
|
||||
<Text code>{spec.scope}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<EndpointDoc spec={spec} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusTab() {
|
||||
function StatusSection() {
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" title="履约状态 fulfillment_status 与 can_fulfill">
|
||||
<SectionWrap id="status" title="状态说明">
|
||||
<Card className="api-docs__card" size="small" title="履约状态 fulfillment_status 与 can_fulfill">
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="name"
|
||||
@@ -283,8 +473,9 @@ function StatusTab() {
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card size="small" title="支付状态 payment_status">
|
||||
<Card className="api-docs__card" size="small" title="支付状态 payment_status">
|
||||
<Table
|
||||
className="api-docs__table"
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="name"
|
||||
@@ -296,11 +487,13 @@ function StatusTab() {
|
||||
/>
|
||||
</Card>
|
||||
<Alert
|
||||
className="api-docs__alert"
|
||||
type="info"
|
||||
showIcon
|
||||
message="推荐调用流程"
|
||||
style={{ marginTop: 16 }}
|
||||
description={
|
||||
<pre style={{ ...preStyle, background: 'transparent', padding: 0 }}>{`拿到 sku(商品列表)
|
||||
<pre className="api-docs__pre api-docs__pre--plain">{`拿到 sku(商品列表)
|
||||
↓
|
||||
POST /orders 下单(幂等,Idempotency-Key = client_order_no)
|
||||
↓
|
||||
@@ -313,6 +506,6 @@ POST /orders 下单(幂等,Idempotency-Key = client_order_no)
|
||||
GET /orders/{order_no} 轮询,或接收 order.fulfillment.updated 回调`}</pre>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</SectionWrap>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user