新增 API 调试工具页面,支持填写 AppKey/Secret 测试开放接口
- 仅开发模式下管理员可见,菜单入口通过 import.meta.env.DEV 控制 - 浏览器端 Web Crypto API 计算 HMAC-SHA256 签名,与后端 BuildOpenV1Sign 一致 - 接口选择采用卡片式 Radio 列表,路径/查询/请求体参数动态生成 - AppKey/Secret 自动持久化到 localStorage,支持一键清除 - 展示签名详情(签名串/X-Sign)和完整响应
This commit is contained in:
@@ -7,6 +7,7 @@ import Login from './pages/Login'
|
|||||||
import Register from './pages/Register'
|
import Register from './pages/Register'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import OpenApiDocs from './pages/OpenApiDocs'
|
import OpenApiDocs from './pages/OpenApiDocs'
|
||||||
|
import ApiDebugger from './pages/ApiDebugger'
|
||||||
import MerchantCenter from './pages/MerchantCenter'
|
import MerchantCenter from './pages/MerchantCenter'
|
||||||
import PlatformMerchants from './pages/PlatformMerchants'
|
import PlatformMerchants from './pages/PlatformMerchants'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
@@ -53,6 +54,14 @@ function AppRoutes() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="api-debug"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<ApiDebugger />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
ShopOutlined,
|
ShopOutlined,
|
||||||
|
BugOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useAuth } from '../store/auth'
|
import { useAuth } from '../store/auth'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
@@ -42,6 +43,11 @@ export default function MainLayout() {
|
|||||||
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
|
{ key: '/platform-merchants', icon: <ShopOutlined />, label: '商户管理' },
|
||||||
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
|
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
|
||||||
)
|
)
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
items.push(
|
||||||
|
{ key: '/api-debug', icon: <BugOutlined />, label: 'API 调试' },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}, [isAdmin])
|
}, [isAdmin])
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Radio,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import { ClearOutlined, SendOutlined } from '@ant-design/icons'
|
||||||
|
import { endpoints } from '../openapi/endpoints'
|
||||||
|
|
||||||
|
const STORAGE_KEY_APP = 'api_debug_app_key'
|
||||||
|
const STORAGE_KEY_SECRET = 'api_debug_secret'
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
const methodColor: Record<string, string> = {
|
||||||
|
GET: 'blue',
|
||||||
|
POST: 'green',
|
||||||
|
PUT: 'orange',
|
||||||
|
PATCH: 'gold',
|
||||||
|
DELETE: 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256Hex(secret: string, data: string): Promise<string> {
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign'],
|
||||||
|
)
|
||||||
|
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(data))
|
||||||
|
return Array.from(new Uint8Array(sig))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(data: Uint8Array): Promise<string> {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', data as BufferSource)
|
||||||
|
return Array.from(new Uint8Array(hash))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignString(
|
||||||
|
appKey: string,
|
||||||
|
bodyHash: string,
|
||||||
|
method: string,
|
||||||
|
nonce: string,
|
||||||
|
path: string,
|
||||||
|
timestamp: string,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
`app_key=${appKey}`,
|
||||||
|
`body_sha256=${bodyHash}`,
|
||||||
|
`method=${method.toUpperCase()}`,
|
||||||
|
`nonce=${nonce}`,
|
||||||
|
`path=${path}`,
|
||||||
|
`timestamp=${timestamp}`,
|
||||||
|
].join('&')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiDebugger() {
|
||||||
|
const [appKey, setAppKey] = useState(() => localStorage.getItem(STORAGE_KEY_APP) ?? '')
|
||||||
|
const [secret, setSecret] = useState(() => localStorage.getItem(STORAGE_KEY_SECRET) ?? '')
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string>(endpoints[0]?.key ?? '')
|
||||||
|
const [pathValues, setPathValues] = useState<Record<string, string>>({})
|
||||||
|
const [queryValues, setQueryValues] = useState<Record<string, string>>({})
|
||||||
|
const [bodyText, setBodyText] = useState('')
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleAppKeyChange = (val: string) => {
|
||||||
|
setAppKey(val)
|
||||||
|
localStorage.setItem(STORAGE_KEY_APP, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSecretChange = (val: string) => {
|
||||||
|
setSecret(val)
|
||||||
|
localStorage.setItem(STORAGE_KEY_SECRET, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForget = () => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_APP)
|
||||||
|
localStorage.removeItem(STORAGE_KEY_SECRET)
|
||||||
|
setAppKey('')
|
||||||
|
setSecret('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [response, setResponse] = useState<{
|
||||||
|
status: number
|
||||||
|
headers: Record<string, string>
|
||||||
|
body: string
|
||||||
|
elapsed: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const [signInfo, setSignInfo] = useState<{
|
||||||
|
timestamp: string
|
||||||
|
nonce: string
|
||||||
|
signString: string
|
||||||
|
signature: string
|
||||||
|
path: string
|
||||||
|
query: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const endpoint = endpoints.find((e) => e.key === selectedKey)
|
||||||
|
const isCreateOrder = selectedKey === 'create-order'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (endpoint?.requestExample) {
|
||||||
|
try {
|
||||||
|
JSON.parse(endpoint.requestExample)
|
||||||
|
setBodyText(endpoint.requestExample)
|
||||||
|
} catch {
|
||||||
|
setBodyText('')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBodyText('')
|
||||||
|
}
|
||||||
|
setPathValues({})
|
||||||
|
setQueryValues({})
|
||||||
|
setIdempotencyKey('')
|
||||||
|
setResponse(null)
|
||||||
|
setSignInfo(null)
|
||||||
|
}, [selectedKey, endpoint])
|
||||||
|
|
||||||
|
const buildPath = useCallback(() => {
|
||||||
|
if (!endpoint) return ''
|
||||||
|
let p = endpoint.path
|
||||||
|
for (const key of Object.keys(pathValues)) {
|
||||||
|
if (pathValues[key]) {
|
||||||
|
p = p.replace(`{${key}}`, pathValues[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}, [endpoint, pathValues])
|
||||||
|
|
||||||
|
const buildQuery = useCallback(() => {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
for (const key of Object.keys(queryValues)) {
|
||||||
|
if (queryValues[key]) p.append(key, queryValues[key])
|
||||||
|
}
|
||||||
|
const qs = p.toString()
|
||||||
|
return qs ? `?${qs}` : ''
|
||||||
|
}, [queryValues])
|
||||||
|
|
||||||
|
const readyPath = useMemo(() => buildPath(), [buildPath])
|
||||||
|
const readyQuery = useMemo(() => buildQuery(), [buildQuery])
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!appKey.trim() || !secret.trim()) {
|
||||||
|
message.error('请先填写 AppKey 和 Secret')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!endpoint) return
|
||||||
|
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const bodyBytes = enc.encode(bodyText)
|
||||||
|
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||||
|
const nonce = crypto.randomUUID().replace(/-/g, '').substring(0, 16)
|
||||||
|
const bodyHash = await sha256Hex(bodyBytes)
|
||||||
|
const method = endpoint.method
|
||||||
|
const plainPath = readyPath
|
||||||
|
const signString = buildSignString(appKey.trim(), bodyHash, method, nonce, plainPath, timestamp)
|
||||||
|
const signature = await hmacSha256Hex(secret.trim(), signString)
|
||||||
|
|
||||||
|
setSignInfo({
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
signString,
|
||||||
|
signature,
|
||||||
|
path: plainPath,
|
||||||
|
query: readyQuery,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchHeaders: Record<string, string> = {
|
||||||
|
'X-App-Key': appKey.trim(),
|
||||||
|
'X-Timestamp': timestamp,
|
||||||
|
'X-Nonce': nonce,
|
||||||
|
'X-Sign': signature,
|
||||||
|
}
|
||||||
|
if (method !== 'GET' && bodyText) {
|
||||||
|
fetchHeaders['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
if (isCreateOrder && idempotencyKey.trim()) {
|
||||||
|
fetchHeaders['Idempotency-Key'] = idempotencyKey.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const start = performance.now()
|
||||||
|
try {
|
||||||
|
const res = await fetch(plainPath + readyQuery, {
|
||||||
|
method,
|
||||||
|
headers: fetchHeaders,
|
||||||
|
body: method === 'GET' ? undefined : bodyText,
|
||||||
|
})
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
const resBody = await res.text()
|
||||||
|
const resHeaders: Record<string, string> = {}
|
||||||
|
res.headers.forEach((v, k) => {
|
||||||
|
resHeaders[k] = v
|
||||||
|
})
|
||||||
|
setResponse({ status: res.status, headers: resHeaders, body: resBody, elapsed })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
setResponse({
|
||||||
|
status: 0,
|
||||||
|
headers: {},
|
||||||
|
body: `请求失败: ${msg}`,
|
||||||
|
elapsed: Math.round(performance.now() - start),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatJSON = (raw: string) => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
API 调试工具
|
||||||
|
</Title>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
填写商户 API 客户端的 AppKey 与 Secret,选择接口并发送请求。签名在浏览器端使用 Web Crypto API 计算。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{!import.meta.env.DEV && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="当前为生产构建,调试工具仅开发模式可用。"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="凭证"
|
||||||
|
style={{ marginBottom: 20 }}
|
||||||
|
extra={
|
||||||
|
<Button danger onClick={handleForget} disabled={!appKey && !secret}>
|
||||||
|
忘记凭证
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space wrap size="middle">
|
||||||
|
<Input
|
||||||
|
placeholder="AppKey"
|
||||||
|
value={appKey}
|
||||||
|
onChange={(e) => handleAppKeyChange(e.target.value)}
|
||||||
|
style={{ width: 320 }}
|
||||||
|
allowClear
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="Secret"
|
||||||
|
value={secret}
|
||||||
|
onChange={(e) => handleSecretChange(e.target.value)}
|
||||||
|
style={{ width: 400 }}
|
||||||
|
allowClear
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="选择接口"
|
||||||
|
style={{ marginBottom: 20 }}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
loading={loading}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!appKey.trim() || !secret.trim()}
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
发送请求
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Radio.Group
|
||||||
|
value={selectedKey}
|
||||||
|
onChange={(e) => setSelectedKey(e.target.value)}
|
||||||
|
style={{ width: '100%', display: 'block' }}
|
||||||
|
>
|
||||||
|
{endpoints.map((ep) => (
|
||||||
|
<div
|
||||||
|
key={ep.key}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
marginBottom: 8,
|
||||||
|
border: selectedKey === ep.key ? '1px solid #1677ff' : '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: selectedKey === ep.key ? '#f0f5ff' : '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectedKey(ep.key)}
|
||||||
|
>
|
||||||
|
<Radio value={ep.key} style={{ marginRight: 12 }} />
|
||||||
|
<Tag color={methodColor[ep.method]} style={{ fontWeight: 700, fontSize: 13 }}>
|
||||||
|
{ep.method}
|
||||||
|
</Tag>
|
||||||
|
<Text code style={{ fontSize: 14, marginRight: 12 }}>{ep.path}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>{ep.summary}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Radio.Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{endpoint && (
|
||||||
|
<Card title="请求参数" style={{ marginBottom: 20 }}>
|
||||||
|
<Form layout="vertical">
|
||||||
|
{endpoint.pathParams?.map((p) => (
|
||||||
|
<Form.Item key={p.name} label={<Text code style={{ fontSize: 14 }}>{`{${p.name}}`}</Text>} help={p.desc}>
|
||||||
|
<Input
|
||||||
|
placeholder={p.example ?? p.desc}
|
||||||
|
value={pathValues[p.name] ?? ''}
|
||||||
|
onChange={(e) => setPathValues((prev) => ({ ...prev, [p.name]: e.target.value }))}
|
||||||
|
style={{ maxWidth: 480 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{endpoint.queryParams?.map((p) => (
|
||||||
|
<Form.Item key={p.name} label={<Text code style={{ fontSize: 14 }}>{p.name}</Text>} help={p.desc}>
|
||||||
|
<Input
|
||||||
|
placeholder={p.example ?? p.desc}
|
||||||
|
value={queryValues[p.name] ?? ''}
|
||||||
|
onChange={(e) => setQueryValues((prev) => ({ ...prev, [p.name]: e.target.value }))}
|
||||||
|
style={{ maxWidth: 240 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{endpoint.method !== 'GET' && (
|
||||||
|
<Form.Item
|
||||||
|
label={
|
||||||
|
<Space>
|
||||||
|
<Text strong style={{ fontSize: 14 }}>Request Body (JSON)</Text>
|
||||||
|
<Button size="small" icon={<ClearOutlined />} onClick={() => setBodyText('')}>清空</Button>
|
||||||
|
{endpoint.requestExample && (
|
||||||
|
<Button size="small" onClick={() => setBodyText(endpoint.requestExample!)}>填入示例</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={10}
|
||||||
|
value={bodyText}
|
||||||
|
onChange={(e) => setBodyText(e.target.value)}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: 13, lineHeight: 1.5 }}
|
||||||
|
placeholder={endpoint.requestExample ?? '{}'}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreateOrder && (
|
||||||
|
<Form.Item label={<Text code style={{ fontSize: 14 }}>Idempotency-Key</Text>} help="须与 client_order_no 一致">
|
||||||
|
<Input
|
||||||
|
value={idempotencyKey}
|
||||||
|
onChange={(e) => setIdempotencyKey(e.target.value)}
|
||||||
|
style={{ maxWidth: 480 }}
|
||||||
|
placeholder="会从 Request Body 自动取 client_order_no"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message={
|
||||||
|
<span style={{ fontSize: 14 }}>
|
||||||
|
<Tag color={methodColor[endpoint.method]} style={{ fontWeight: 700 }}>{endpoint.method}</Tag>
|
||||||
|
<Text code style={{ fontSize: 13 }}>{readyPath + readyQuery}</Text>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{signInfo && (
|
||||||
|
<Card title="签名详情" style={{ marginBottom: 20 }}>
|
||||||
|
<Descriptions column={1} bordered>
|
||||||
|
<Descriptions.Item label="X-Timestamp">{signInfo.timestamp}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="X-Nonce">{signInfo.nonce}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="X-App-Key">{appKey.trim()}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="签名串">
|
||||||
|
<pre style={{ margin: 0, fontSize: 13, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{signInfo.signString}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="X-Sign">
|
||||||
|
<Text code copyable style={{ fontSize: 13 }}>{signInfo.signature}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<Card
|
||||||
|
title="响应"
|
||||||
|
extra={
|
||||||
|
<Space size="middle">
|
||||||
|
<Tag color={response.status >= 200 && response.status < 300 ? 'green' : 'red'}>
|
||||||
|
{response.status || 'ERR'}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary">{response.elapsed}ms</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, margin: '8px 0 6px' }}>Response Headers</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 10,
|
||||||
|
background: '#f6f8fa',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(response.headers)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join('\n') || '(无)'}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500, margin: '16px 0 6px' }}>Response Body</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 14,
|
||||||
|
background: '#f6f8fa',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxHeight: 600,
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatJSON(response.body)}
|
||||||
|
</pre>
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user