feat: 增加客户 OpenAPI 文档页,支持轨迹与文件说明
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m37s
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m37s
菜单走 SPA 回退;附件增加 POD;调试页去掉接口调试 tab。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
aca25c649a
commit
1026d909e9
@ -106,7 +106,7 @@ server {
|
||||
}
|
||||
|
||||
# ============ API 前端路由(必须先于 /api/ 代理)============
|
||||
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest)(/|$) {
|
||||
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest|openApi)(/|$) {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ server {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
|
||||
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest)(/|$) {
|
||||
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest|openApi)(/|$) {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ server {
|
||||
try_files $uri @spa;
|
||||
}
|
||||
|
||||
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest)(/|$) {
|
||||
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest|openApi)(/|$) {
|
||||
try_files $uri @spa;
|
||||
}
|
||||
|
||||
|
||||
@ -279,6 +279,7 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record<string, string> = {
|
||||
'System.BatchEmail': 'System.BatchEmail',
|
||||
'Api.ApiTest': 'Api.ApiTest',
|
||||
'Api.Test.Query': 'Api.ApiTest',
|
||||
'Api.OpenApi.Query': 'Api.OpenApi',
|
||||
'Channel.LabelDesign': 'Channel.LabelDesign',
|
||||
'Order.BatchJobs.Query': 'Order.BatchJobs',
|
||||
'Order.WaybillManage.Query': 'Order.WaybillManage',
|
||||
@ -420,6 +421,12 @@ const CORE_GROUPS: PageButtonGroup[] = [
|
||||
{ code: 'Copy', name: '复制' },
|
||||
{ code: 'Format', name: '格式化' },
|
||||
]),
|
||||
B('Api.OpenApi', [
|
||||
{ code: 'Api.OpenApi.Query', name: '查询' },
|
||||
{ code: 'SendDebug', name: '发送调试' },
|
||||
{ code: 'ImportCurl', name: '导入cURL' },
|
||||
{ code: 'Copy', name: '复制' },
|
||||
]),
|
||||
|
||||
// ── 轨迹 ──
|
||||
B('Track.Order', [
|
||||
|
||||
@ -1112,6 +1112,121 @@ export default {
|
||||
'menu.api.apiDebugger': 'API Debugger',
|
||||
'menu.api.apiLike': 'API LIKE',
|
||||
'menu.api.apiTest': 'API Test',
|
||||
'menu.api.openApi': 'OpenAPI',
|
||||
'api.openApi.tab.docs': 'Reference',
|
||||
'api.openApi.tab.debug': 'Try it',
|
||||
'api.openApi.navTitle': 'On this page',
|
||||
'api.openApi.nav.overview': 'Overview',
|
||||
'api.openApi.nav.auth': 'Authentication',
|
||||
'api.openApi.nav.errors': 'Errors',
|
||||
'api.openApi.nav.parcel': 'Create parcel',
|
||||
'api.openApi.nav.pickup': 'Pickup push fee',
|
||||
'api.openApi.nav.track': 'Query track',
|
||||
'api.openApi.nav.files': 'Get files',
|
||||
'api.openApi.title': 'OpenAPI Reference',
|
||||
'api.openApi.lead':
|
||||
'Machine-to-machine APIs for customer systems. Read authentication first, then call resources. Code samples sit on the right, Stripe / Twilio style.',
|
||||
'api.openApi.baseUrl': 'Base URL',
|
||||
'api.openApi.contentType': 'Content-Type',
|
||||
'api.openApi.copy': 'Copy',
|
||||
'api.openApi.copyCurl': 'Copy cURL',
|
||||
'api.openApi.copySuccess': 'Copied',
|
||||
'api.openApi.copyFailed': 'Copy failed',
|
||||
'api.openApi.tryIt': 'Try in debugger',
|
||||
'api.openApi.overviewBody':
|
||||
'OpenAPI calls go through the Admin gateway under /openapi. JSON fields are camelCase. Customer order APIs do not use login JWT; they use AppKey plus HMAC.',
|
||||
'api.openApi.overviewPoint1':
|
||||
'Credentials: generate AppKey / AppSecret in customer management. The full secret is shown only on create or reset — store it offline.',
|
||||
'api.openApi.overviewPoint2':
|
||||
'Signing: SHA-256 the raw HTTP body bytes, build stringToSign with appKey and timestamp, then HMAC-SHA256 with AppSecret. The secret is never sent.',
|
||||
'api.openApi.overviewPoint3':
|
||||
'Clock: timestamp is Unix seconds. Drift beyond 300 seconds against server UTC returns “request expired”. Use NTP.',
|
||||
'api.openApi.overviewPoint4':
|
||||
'All calls are POST + JSON. Treat the envelope code (string) as the business status; HTTP is usually 200. Do not send a login JWT.',
|
||||
'api.openApi.overviewPoint5':
|
||||
'clientNo is the create-parcel idempotency key: the same customer resubmitting the same number gets the original order (code=200), not a new one.',
|
||||
'api.openApi.compatTitle': 'Compatibility',
|
||||
'api.openApi.compatBody':
|
||||
'We only add fields. Existing field meanings and success code values stay stable. Ignore unknown fields. Read by name, not by order.',
|
||||
'api.openApi.col.method': 'Method',
|
||||
'api.openApi.col.path': 'Path',
|
||||
'api.openApi.col.auth': 'Auth',
|
||||
'api.openApi.authHmac': 'Header HMAC (appKey / timestamp / sign)',
|
||||
'api.openApi.authUpstream': 'Not customer HMAC; authenticated by itfx then forwarded',
|
||||
'api.openApi.signedEndpointNote':
|
||||
'Required headers: appKey, timestamp, sign. Sign the raw body bytes of this request.',
|
||||
'api.openApi.trackLookupNote':
|
||||
'If several numbers are sent, clientNo wins. The order must belong to the credential customer, or you get “order not found”. Tracking is queried by service number; missing serviceNo fails.',
|
||||
'api.openApi.signPitfall1':
|
||||
'UTF-8 without BOM. The separators in stringToSign are LF (\\n), not Windows \\r\\n.',
|
||||
'api.openApi.signPitfall2':
|
||||
'Header names are appKey, timestamp, sign (not Authorization). timestamp is Unix seconds, not milliseconds.',
|
||||
'api.openApi.signPitfall3':
|
||||
'Pretty-print, key reorder, or HttpClient rewriting Content changes bytes and yields invalid signature. Sign the exact string you send.',
|
||||
'api.openApi.errMsgTitle': 'Common msg values (match the string)',
|
||||
'api.openApi.msgInvalidCred':
|
||||
'Missing headers, unknown/disabled AppKey, or abnormal customer. Same wording on purpose.',
|
||||
'api.openApi.msgInvalidSign': 'HMAC mismatch: body bytes differ from what was signed, or wrong Secret.',
|
||||
'api.openApi.msgExpired': 'timestamp is not an integer, or skew exceeds ±300 seconds.',
|
||||
'api.openApi.msgBody': 'Empty body or JSON that cannot be deserialized.',
|
||||
'api.openApi.msgOrderMissing': 'Number not found, or the order is not owned by this AppKey’s customer.',
|
||||
'api.openApi.msgLookup': 'Track/files need at least one locator field.',
|
||||
'api.openApi.msgFileType': 'Unknown type, or a type customers cannot query (e.g. downstreamLabel).',
|
||||
'api.openApi.authTitle': 'Authentication',
|
||||
'api.openApi.authIntro':
|
||||
'Applies to create parcel, query track, and get files. A three-header scheme similar to AWS Signature / Alipay Open Platform: identity in headers, secret used only locally.',
|
||||
'api.openApi.headersTitle': 'Headers',
|
||||
'api.openApi.col.header': 'Header',
|
||||
'api.openApi.col.field': 'Field',
|
||||
'api.openApi.col.type': 'Type',
|
||||
'api.openApi.col.required': 'Required',
|
||||
'api.openApi.col.desc': 'Description',
|
||||
'api.openApi.col.code': 'code',
|
||||
'api.openApi.col.msg': 'msg',
|
||||
'api.openApi.yes': 'Yes',
|
||||
'api.openApi.no': 'No',
|
||||
'api.openApi.headerAppKey': 'Customer OpenAPI AppKey',
|
||||
'api.openApi.headerTimestamp': 'Unix seconds (UTC). ±300s skew allowed',
|
||||
'api.openApi.headerSign': 'Lowercase hex. HMAC-SHA256(appSecret, stringToSign)',
|
||||
'api.openApi.signAlgoTitle': 'Signature algorithm',
|
||||
'api.openApi.signStep1': 'Take the exact HTTP body string you will send (UTF-8). Do not minify then re-space.',
|
||||
'api.openApi.signStep2': 'bodyHash = lowercase hex of SHA-256(rawBody). Empty body hashes the empty string.',
|
||||
'api.openApi.signStep3':
|
||||
'stringToSign = appKey + LF + timestamp + LF + bodyHash (LF is \\n). sign = lowercase hex HMAC-SHA256(appSecret, stringToSign).',
|
||||
'api.openApi.signWarn':
|
||||
'Sign the body that is actually sent. Changing indentation, key order, or encoding after JSON.stringify invalidates the signature.',
|
||||
'api.openApi.playgroundTitle': 'Signature playground',
|
||||
'api.openApi.playgroundTip':
|
||||
'AppSecret is used only in the browser. Copy the computed sign into the cURL sample on the right.',
|
||||
'api.openApi.appKey': 'AppKey',
|
||||
'api.openApi.appSecret': 'AppSecret',
|
||||
'api.openApi.timestamp': 'timestamp',
|
||||
'api.openApi.rawBody': 'rawBody (signed as-is)',
|
||||
'api.openApi.appKeyPh': 'Customer AppKey',
|
||||
'api.openApi.appSecretPh': 'Local only',
|
||||
'api.openApi.refreshTs': 'Refresh',
|
||||
'api.openApi.errorsTitle': 'Errors',
|
||||
'api.openApi.errorsIntro':
|
||||
'Business results live in the JSON envelope; HTTP status is usually still 200. Read code first, then msg / data.',
|
||||
'api.openApi.err200': 'Success; data is the business object',
|
||||
'api.openApi.err400':
|
||||
'Auth or input failure: invalid credential, invalid signature, expired request, empty body, malformed JSON, etc.',
|
||||
'api.openApi.err500': 'Unclassified server error',
|
||||
'api.openApi.parcelSummary':
|
||||
'Create a self-ship parcel. Customer Id comes from the signed credential. clientNo is required and is the idempotency key: the same customer resubmitting the same clientNo gets the existing order. inboundMethod is always selfShip.',
|
||||
'api.openApi.requestBody': 'Request Body',
|
||||
'api.openApi.responseBody': 'Response data',
|
||||
'api.openApi.envelopeHint': 'Envelope is { code, msg, data }. Table below is data.',
|
||||
'api.openApi.pickupSummary':
|
||||
'Push pickup weight/size and compute receivable/payable for the current product. For carriers / itfx upstream.',
|
||||
'api.openApi.pickupAuthNote':
|
||||
'This endpoint does not check customer AppKey. Authentication happens on the itfx upstream before OMS.',
|
||||
'api.openApi.trackSummary':
|
||||
'Query tracking for the authenticated customer. Provide at least one of clientNo / serviceNo / trackNo. Events are looked up in the track center by service number. events is [] when none exist.',
|
||||
'api.openApi.filesSummary':
|
||||
'Return download URLs for one attachment type on that order. Only orders owned by the credential customer. Downstream labels are not available.',
|
||||
'api.openApi.fileTypeEnum': 'fileType values',
|
||||
'api.openApi.sampleHint': 'Replace keys and numbers with your environment. Request / Response are JSON; cURL and SDKs follow the current endpoint.',
|
||||
|
||||
// API - Call Statistics
|
||||
'api.callStatistics.name.app': 'Application Name',
|
||||
@ -2009,6 +2124,7 @@ export default {
|
||||
'order.myOrders.drawer.attachments.types.downstreamLabel': 'Downstream label',
|
||||
'order.myOrders.drawer.attachments.types.lastMileLabel': 'Last-mile label',
|
||||
'order.myOrders.drawer.attachments.types.infraredImage': 'Infrared image',
|
||||
'order.myOrders.drawer.attachments.types.pod': 'POD',
|
||||
'order.myOrders.drawer.attachments.source.upload': 'Upload',
|
||||
'order.myOrders.drawer.attachments.source.systemGenerate': 'System generated',
|
||||
'order.myOrders.drawer.attachments.source.downstream': 'Downstream',
|
||||
@ -2355,6 +2471,7 @@ export default {
|
||||
'order.priceCheck.table.askAiCanShip': 'Can this ship?',
|
||||
'order.priceCheck.table.askAiCheaper': 'Anything cheaper?',
|
||||
'order.priceCheck.table.empty': 'No matching quotes',
|
||||
'order.priceCheck.table.emptyWithFailures': '{count} misses — see reasons on the right',
|
||||
'order.priceCheck.table.emptyHint': 'Try adding postal code, dimensions, or cargo/warehouse filters; contact support if needed.',
|
||||
|
||||
'order.priceCheck.tag.priceOptimal': 'Best price',
|
||||
@ -2398,6 +2515,17 @@ export default {
|
||||
'order.priceCheck.tradeTerms.ddp': 'DDP (duties included)',
|
||||
'order.priceCheck.tradeTerms.ddu': 'DDU (duties excluded)',
|
||||
'order.priceCheck.guide.failureTitle': 'No quotes found',
|
||||
'order.priceCheck.guide.failureSummary': '{productCount} products, {itemCount} misses, sorted by product',
|
||||
'order.priceCheck.guide.failureUnknownProduct': 'Unspecified product',
|
||||
'order.priceCheck.guide.failureCargo': 'Cargo: {name}',
|
||||
'order.priceCheck.guide.failureWarehouse': 'Warehouse: {name}',
|
||||
'order.priceCheck.guide.failureStepLabel': 'Step: {step}',
|
||||
'order.priceCheck.guide.failureStep.product': 'Product',
|
||||
'order.priceCheck.guide.failureStep.snapshot': 'Price snapshot',
|
||||
'order.priceCheck.guide.failureStep.blacklist': 'Blacklist',
|
||||
'order.priceCheck.guide.failureStep.weight': 'Weight',
|
||||
'order.priceCheck.guide.failureStep.zone': 'Price zone',
|
||||
'order.priceCheck.guide.failureStep.price': 'Sell price',
|
||||
'order.priceCheck.ai.title': 'AI Route Pick',
|
||||
'order.priceCheck.ai.desc': 'Enter cargo name — we check customer guides for can-ship and best route',
|
||||
'order.priceCheck.ai.descShort': 'Pick a route by cargo name',
|
||||
@ -5353,6 +5481,7 @@ export default {
|
||||
'channel.area.matchMode.prefix': 'Prefix',
|
||||
'channel.area.matchMode.exact': 'Exact',
|
||||
'channel.area.matchMode.range': 'Range',
|
||||
'channel.area.matchMode.all': 'Any',
|
||||
'channel.area.form.placeholder.keyword': 'Enter keyword',
|
||||
'channel.area.form.placeholder.areaName': 'Enter area name',
|
||||
'channel.area.form.placeholder.input': 'Please enter',
|
||||
@ -5517,8 +5646,8 @@ export default {
|
||||
'channel.weightRounding.section.basic': 'Basic info',
|
||||
'channel.weightRounding.section.rule': 'Rounding range',
|
||||
'channel.weightRounding.tip.roundingName': 'Rounding scheme name shown in the left list',
|
||||
'channel.weightRounding.tip.startValue': 'Inclusive lower bound; must be less than end value',
|
||||
'channel.weightRounding.tip.endValue': 'Upper bound; must be greater than start value',
|
||||
'channel.weightRounding.tip.startValue': 'Exclusive lower bound (left-open); must be less than end value. Sharing the next range’s start is allowed',
|
||||
'channel.weightRounding.tip.endValue': 'Inclusive upper bound; must be greater than start value. Interval is (start, end]',
|
||||
'channel.weightRounding.tip.public': 'Round-up step in kg, e.g. 0.5 rounds up per 0.5 kg',
|
||||
'channel.weightRounding.modal.addRounding': 'Add rounding',
|
||||
'channel.weightRounding.modal.editRounding': 'Edit rounding',
|
||||
@ -5541,9 +5670,11 @@ export default {
|
||||
'channel.weightRounding.tip.emptySelectDesc': 'Select or create a rounding scheme on the left, then manage weight intervals on the right.',
|
||||
'channel.weightRounding.tip.addRoundingGroup': 'Rounding schemes define how billed weight is rounded, e.g. 0.5 kg or 1 kg steps.',
|
||||
'channel.weightRounding.tip.editRoundingGroup': 'Renaming a scheme does not change existing interval rules.',
|
||||
'channel.weightRounding.tip.addRule': 'Start value must be less than end value. Rounding value is the step size (e.g. 0.5 rounds up per 0.5 kg).',
|
||||
'channel.weightRounding.tip.editRule': 'Changes affect rounding results. Ensure intervals do not overlap and values are correct.',
|
||||
'channel.weightRounding.tip.addRule': 'Ranges are left-open right-closed (start, end]. 0–10 and 10–25 must touch; 10 kg uses the first range. Rounding value is the step size (e.g. 0.5 rounds up per 0.5 kg).',
|
||||
'channel.weightRounding.tip.editRule': 'Changes affect rounding. Use (start, end]; adjacent ranges must touch. Crossing or leaving a gap is not allowed.',
|
||||
'channel.weightRounding.form.rules.rangeInvalid': 'Start value must be less than end value',
|
||||
'channel.weightRounding.form.rules.rangeOverlap': 'Ranges cannot overlap: ({current}] crosses existing ({existed}]',
|
||||
'channel.weightRounding.form.rules.rangeGap': 'Ranges must touch end-to-start: gap between {left} and {right}',
|
||||
'channel.weightRounding.form.rules.publicPositive': 'Rounding value must be a number greater than 0',
|
||||
'channel.weightRounding.form.rules.numberRequired': 'Please enter {field}',
|
||||
'channel.weightRounding.form.rules.numberInvalid': '{field} must be a valid number',
|
||||
|
||||
@ -1104,6 +1104,121 @@ export default {
|
||||
'menu.api.apiDebugger': 'API调试',
|
||||
'menu.api.apiLike': 'API LIKE',
|
||||
'menu.api.apiTest': 'API测试',
|
||||
'menu.api.openApi': 'OpenAPI',
|
||||
'api.openApi.tab.docs': '接口文档',
|
||||
'api.openApi.tab.debug': '接口调试',
|
||||
'api.openApi.navTitle': '目录',
|
||||
'api.openApi.nav.overview': '概述',
|
||||
'api.openApi.nav.auth': '鉴权与签名',
|
||||
'api.openApi.nav.errors': '错误与响应',
|
||||
'api.openApi.nav.parcel': '创建包裹',
|
||||
'api.openApi.nav.pickup': '揽收推费',
|
||||
'api.openApi.nav.track': '查询轨迹',
|
||||
'api.openApi.nav.files': '获取文件',
|
||||
'api.openApi.title': 'OpenAPI 参考',
|
||||
'api.openApi.lead':
|
||||
'面向客户系统的机机接口。风格对齐 Stripe / Twilio:先读鉴权,再按资源调用;右侧为可复制的语言示例。',
|
||||
'api.openApi.baseUrl': 'Base URL',
|
||||
'api.openApi.contentType': 'Content-Type',
|
||||
'api.openApi.copy': '复制',
|
||||
'api.openApi.copyCurl': '复制 cURL',
|
||||
'api.openApi.copySuccess': '已复制',
|
||||
'api.openApi.copyFailed': '复制失败',
|
||||
'api.openApi.tryIt': '在调试器中试用',
|
||||
'api.openApi.overviewBody':
|
||||
'所有 OpenAPI 均走 Admin 网关,路径以 /openapi 开头。JSON 字段为 camelCase。客户下单类接口不使用登录 JWT,而是用 AppKey + HMAC 签名。',
|
||||
'api.openApi.overviewPoint1':
|
||||
'凭证:客户管理中生成 AppKey / AppSecret;Secret 只在生成或重置时完整展示一次,请离线保存。',
|
||||
'api.openApi.overviewPoint2':
|
||||
'签名:对「原始 HTTP Body 字节」做 SHA-256,再与 appKey、timestamp 组成 stringToSign,用 AppSecret 做 HMAC-SHA256。Secret 永不出现在请求里。',
|
||||
'api.openApi.overviewPoint3':
|
||||
'时钟:timestamp 为 Unix 秒,与服务器 UTC 相差超过 300 秒会返回「请求已过期」。请用 NTP 校时。',
|
||||
'api.openApi.overviewPoint4':
|
||||
'全部为 POST + JSON。业务成败看信封 code(字符串),HTTP 多为 200。不要用登录 JWT。',
|
||||
'api.openApi.overviewPoint5':
|
||||
'clientNo 是创建包裹的幂等键:同一客户再次提交相同单号,返回原单(code=200),不会新建。',
|
||||
'api.openApi.compatTitle': '兼容约定',
|
||||
'api.openApi.compatBody':
|
||||
'本接口只增加字段,不修改已有字段含义,也不把成功的 code 改成别的值。新增字段请忽略;客户端应按字段名取值,不要依赖字段顺序。',
|
||||
'api.openApi.col.method': 'Method',
|
||||
'api.openApi.col.path': 'Path',
|
||||
'api.openApi.col.auth': '鉴权',
|
||||
'api.openApi.authHmac': 'Header HMAC(appKey / timestamp / sign)',
|
||||
'api.openApi.authUpstream': '非客户 HMAC;由 itfx 上游鉴权后转发',
|
||||
'api.openApi.signedEndpointNote':
|
||||
'Header 必填 appKey、timestamp、sign。sign 对「本请求即将发出的 Body 原始字节」计算。',
|
||||
'api.openApi.trackLookupNote':
|
||||
'同时传多个单号时优先 clientNo。查到的订单必须属于当前凭证客户,否则「订单不存在」。轨迹中心按服务单号查询,无服务单号会失败。',
|
||||
'api.openApi.signPitfall1':
|
||||
'UTF-8 无 BOM;stringToSign 中间是 LF(\\n),不是 Windows 的 \\r\\n。',
|
||||
'api.openApi.signPitfall2':
|
||||
'Header 名固定为 appKey、timestamp、sign(不要用 Authorization)。timestamp 是十进制 Unix 秒,不是毫秒。',
|
||||
'api.openApi.signPitfall3':
|
||||
'美化 JSON、调字段顺序、HttpClient 自动改 Content 都会改字节导致「签名无效」。先签再发,发的就是签的那串。',
|
||||
'api.openApi.errMsgTitle': '常见 msg(请按字符串匹配)',
|
||||
'api.openApi.msgInvalidCred':
|
||||
'缺 Header、AppKey 不存在/停用、客户状态异常。统一文案,不要依赖细分。',
|
||||
'api.openApi.msgInvalidSign': 'HMAC 对不上:Body 字节与签名时不一致,或 Secret 错误。',
|
||||
'api.openApi.msgExpired': 'timestamp 无法解析,或与服务器相差超过 ±300 秒。',
|
||||
'api.openApi.msgBody': '空 Body 或 JSON 无法反序列化。',
|
||||
'api.openApi.msgOrderMissing': '单号不存在,或不属于当前 AppKey 对应客户。',
|
||||
'api.openApi.msgLookup': '轨迹/文件查询至少要有一个定位字段。',
|
||||
'api.openApi.msgFileType': '未知类型,或客户不可查询(如 downstreamLabel)。',
|
||||
'api.openApi.authTitle': '鉴权与签名',
|
||||
'api.openApi.authIntro':
|
||||
'适用接口:创建包裹、查询轨迹、获取文件。请求头三元组与 AWS Signature / 支付宝开放平台同类:身份在 Header,密钥只参与本地计算。',
|
||||
'api.openApi.headersTitle': '请求头',
|
||||
'api.openApi.col.header': 'Header',
|
||||
'api.openApi.col.field': '字段',
|
||||
'api.openApi.col.type': '类型',
|
||||
'api.openApi.col.required': '必填',
|
||||
'api.openApi.col.desc': '说明',
|
||||
'api.openApi.col.code': 'code',
|
||||
'api.openApi.col.msg': 'msg',
|
||||
'api.openApi.yes': '是',
|
||||
'api.openApi.no': '否',
|
||||
'api.openApi.headerAppKey': '客户开放接口 AppKey',
|
||||
'api.openApi.headerTimestamp': 'Unix 秒(UTC)。允许 ±300 秒时钟偏差',
|
||||
'api.openApi.headerSign': '小写 hex。HMAC-SHA256(appSecret, stringToSign)',
|
||||
'api.openApi.signAlgoTitle': '签名算法',
|
||||
'api.openApi.signStep1': '取将要发送的 HTTP Body 原始字符串 rawBody(UTF-8)。不要先 minify 再改空格。',
|
||||
'api.openApi.signStep2': 'bodyHash = SHA-256(rawBody) 的小写十六进制。空 Body 对空字符串哈希。',
|
||||
'api.openApi.signStep3':
|
||||
'stringToSign = appKey + LF + timestamp + LF + bodyHash(LF 即 \\n)。sign = HMAC-SHA256(appSecret, stringToSign) 小写 hex。',
|
||||
'api.openApi.signWarn':
|
||||
'必须对「最终发出的 Body」签名。JSON.stringify 后再改缩进、字段顺序或编码,都会导致签名无效。',
|
||||
'api.openApi.playgroundTitle': '签名计算器',
|
||||
'api.openApi.playgroundTip':
|
||||
'AppSecret 只在浏览器本地计算,不会上传。算出的 sign 可复制到右侧 cURL。',
|
||||
'api.openApi.appKey': 'AppKey',
|
||||
'api.openApi.appSecret': 'AppSecret',
|
||||
'api.openApi.timestamp': 'timestamp',
|
||||
'api.openApi.rawBody': 'rawBody(参与签名)',
|
||||
'api.openApi.appKeyPh': '客户 AppKey',
|
||||
'api.openApi.appSecretPh': '仅本地计算',
|
||||
'api.openApi.refreshTs': '刷新',
|
||||
'api.openApi.errorsTitle': '错误与响应',
|
||||
'api.openApi.errorsIntro':
|
||||
'业务结果放在 JSON 信封里,HTTP 状态通常仍为 200。请先读 code,再读 msg / data。',
|
||||
'api.openApi.err200': '调用成功,data 为业务对象',
|
||||
'api.openApi.err400':
|
||||
'鉴权或参数失败:凭证无效、签名无效、请求已过期、请求体不能为空、请求体格式错误等',
|
||||
'api.openApi.err500': '未分类系统异常',
|
||||
'api.openApi.parcelSummary':
|
||||
'创建自发货订单包裹。客户 Id 由签名凭证解析。clientNo 必填且为幂等键:相同客户重复提交同一 clientNo 时返回已存在订单。开放接口固定 inboundMethod = selfShip。',
|
||||
'api.openApi.requestBody': 'Request Body',
|
||||
'api.openApi.responseBody': 'Response data',
|
||||
'api.openApi.envelopeHint': '外层为 { code, msg, data },下表为 data 字段。',
|
||||
'api.openApi.pickupSummary':
|
||||
'推送揽收件重尺并按当前产品计算应收应付。供承运 / itfx 上游转发。',
|
||||
'api.openApi.pickupAuthNote':
|
||||
'本接口不校验客户 AppKey。鉴权在 itfx 上游完成后再转发到 OMS。',
|
||||
'api.openApi.trackSummary':
|
||||
'查询本客户订单轨迹。clientNo / serviceNo / trackNo 至少填一项;轨迹按服务单号向轨迹中心查询。无节点时 events 为空数组。',
|
||||
'api.openApi.filesSummary':
|
||||
'按附件类型返回该订单文件下载地址。仅能查询当前凭证所属客户的订单。不提供下游面单。',
|
||||
'api.openApi.fileTypeEnum': 'fileType 枚举',
|
||||
'api.openApi.sampleHint': '示例中的密钥与单号请换成你的环境数据。Request / Response 为 JSON;cURL 与 SDK 会跟当前接口切换。',
|
||||
|
||||
// API - 调用统计
|
||||
'api.callStatistics.name.app': '应用名称',
|
||||
@ -1895,6 +2010,7 @@ export default {
|
||||
'order.myOrders.drawer.attachments.types.downstreamLabel': '下游面单',
|
||||
'order.myOrders.drawer.attachments.types.lastMileLabel': '末端面单',
|
||||
'order.myOrders.drawer.attachments.types.infraredImage': '红外图片',
|
||||
'order.myOrders.drawer.attachments.types.pod': 'POD',
|
||||
'order.myOrders.drawer.attachments.source.upload': '上传',
|
||||
'order.myOrders.drawer.attachments.source.systemGenerate': '系统生成',
|
||||
'order.myOrders.drawer.attachments.source.downstream': '下游回传',
|
||||
@ -2241,6 +2357,7 @@ export default {
|
||||
'order.priceCheck.table.askAiCanShip': '这个能不能发?',
|
||||
'order.priceCheck.table.askAiCheaper': '有没有更便宜?',
|
||||
'order.priceCheck.table.empty': '暂无匹配报价',
|
||||
'order.priceCheck.table.emptyWithFailures': '共 {count} 条未命中,详见右侧原因',
|
||||
'order.priceCheck.table.emptyHint': '可尝试填写邮编、尺寸或选择货物属性/仓库后重试;也可联系客服协助查价。',
|
||||
|
||||
'order.priceCheck.tag.priceOptimal': '价格最优',
|
||||
@ -2285,6 +2402,17 @@ export default {
|
||||
'order.priceCheck.guide.close': '关闭说明',
|
||||
'order.priceCheck.guide.idle': '点选左侧报价卡,查看对客说明与限制',
|
||||
'order.priceCheck.guide.failureTitle': '查价未出结果',
|
||||
'order.priceCheck.guide.failureSummary': '共 {productCount} 个产品、{itemCount} 条未命中,按产品排序',
|
||||
'order.priceCheck.guide.failureUnknownProduct': '未指定产品',
|
||||
'order.priceCheck.guide.failureCargo': '货物:{name}',
|
||||
'order.priceCheck.guide.failureWarehouse': '仓库:{name}',
|
||||
'order.priceCheck.guide.failureStepLabel': '步骤:{step}',
|
||||
'order.priceCheck.guide.failureStep.product': '产品',
|
||||
'order.priceCheck.guide.failureStep.snapshot': '价格快照',
|
||||
'order.priceCheck.guide.failureStep.blacklist': '黑名单',
|
||||
'order.priceCheck.guide.failureStep.weight': '计重',
|
||||
'order.priceCheck.guide.failureStep.zone': '价格分区',
|
||||
'order.priceCheck.guide.failureStep.price': '卖价',
|
||||
'order.priceCheck.ai.title': 'AI 荐线',
|
||||
'order.priceCheck.ai.desc': '输入品名,对照对客说明判断能否寄、哪条更合适',
|
||||
'order.priceCheck.ai.descShort': '输入品名,对照对客说明荐线',
|
||||
@ -5390,6 +5518,7 @@ export default {
|
||||
'channel.area.matchMode.prefix': '前缀',
|
||||
'channel.area.matchMode.exact': '精确',
|
||||
'channel.area.matchMode.range': '区间',
|
||||
'channel.area.matchMode.all': '不限',
|
||||
'channel.area.form.placeholder.keyword': '请输入关键字',
|
||||
'channel.area.form.placeholder.areaName': '请输入分区名称',
|
||||
'channel.area.form.placeholder.input': '请输入',
|
||||
@ -5569,8 +5698,8 @@ export default {
|
||||
'channel.weightRounding.section.basic': '基础信息',
|
||||
'channel.weightRounding.section.rule': '进位区间',
|
||||
'channel.weightRounding.tip.roundingName': '进位制名称,用于左侧列表区分不同取整方案',
|
||||
'channel.weightRounding.tip.startValue': '重量区间下限(含),须小于截止值',
|
||||
'channel.weightRounding.tip.endValue': '重量区间上限,须大于起始值',
|
||||
'channel.weightRounding.tip.startValue': '重量区间下限(不含),须小于截止值;与下一段起始值相同不算重叠',
|
||||
'channel.weightRounding.tip.endValue': '重量区间上限(含),须大于起始值;左开右闭 (起始, 截止]',
|
||||
'channel.weightRounding.tip.public': '向上取整步长(kg),例如 0.5 表示按 0.5kg 进位',
|
||||
'channel.weightRounding.modal.addRounding': '新增进位制',
|
||||
'channel.weightRounding.modal.editRounding': '修改进位制',
|
||||
@ -5593,9 +5722,11 @@ export default {
|
||||
'channel.weightRounding.tip.emptySelectDesc': '在左侧选择或新建进位制,选中后可在右侧维护各重量区间的进位规则。',
|
||||
'channel.weightRounding.tip.addRoundingGroup': '进位制用于定义计费重量的取整方式,例如「0.5kg 进位」或「1kg 进位」。',
|
||||
'channel.weightRounding.tip.editRoundingGroup': '修改进位制名称不会影响已有区间配置。',
|
||||
'channel.weightRounding.tip.addRule': '起始值须小于截止值;进位值表示向上取整的步长,例如 0.5 表示按 0.5kg 向上进位。',
|
||||
'channel.weightRounding.tip.editRule': '修改后将影响计费重量进位结果,请确认区间不重叠且数值准确。',
|
||||
'channel.weightRounding.tip.addRule': '区间为左开右闭 (起始, 截止]:例如 0~10 与 10~25 必须首尾相接,10kg 走前一段。进位值表示向上取整步长,例如 0.5 表示按 0.5kg 向上进位。',
|
||||
'channel.weightRounding.tip.editRule': '修改后将影响计费重量进位结果。请按左开右闭配置,相邻区间必须端点相接,不能交叉或留空档。',
|
||||
'channel.weightRounding.form.rules.rangeInvalid': '起始值须小于截止值',
|
||||
'channel.weightRounding.form.rules.rangeOverlap': '起始区间不能重叠:({current}] 与已有 ({existed}] 交叉',
|
||||
'channel.weightRounding.form.rules.rangeGap': '起始区间必须首尾相接:{left} 与 {right} 之间有缺口',
|
||||
'channel.weightRounding.form.rules.publicPositive': '进位值须为大于 0 的数字',
|
||||
'channel.weightRounding.form.rules.numberRequired': '请填写{field}',
|
||||
'channel.weightRounding.form.rules.numberInvalid': '{field}须为有效数字',
|
||||
|
||||
@ -126,6 +126,18 @@ const SYSTEM: AppRouteRecordRaw = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
path: 'openApi',
|
||||
name: 'OpenApiDocs',
|
||||
component: () => import('@/views/api/openApi/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.api.openApi',
|
||||
requiresAuth: true,
|
||||
permission: 'Api.OpenApi.Query',
|
||||
order: 8,
|
||||
icon: 'icon-book',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'apiDebugger',
|
||||
name: 'ApiDebugger',
|
||||
|
||||
@ -75,6 +75,7 @@ export const MENU_NAME_TO_LOCALE_KEY: Record<string, string> = {
|
||||
API调试: 'menu.api.apiDebugger',
|
||||
'API LIKE': 'menu.api.apiLike',
|
||||
API测试: 'menu.api.apiTest',
|
||||
OpenAPI: 'menu.api.openApi',
|
||||
系统: 'menu.system',
|
||||
组织管理: 'menu.system.organization',
|
||||
用户管理: 'menu.system.user',
|
||||
|
||||
@ -1,33 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import ApiTest from '@/components/ApiTest/index.vue';
|
||||
import MarkdownViewer from '@/components/MarkdownViewer/index.vue';
|
||||
import guideContent from './guide.md?raw';
|
||||
|
||||
defineOptions({
|
||||
name: 'ApiDebugger',
|
||||
});
|
||||
|
||||
const pageTab = ref<'debug' | 'guide'>('guide');
|
||||
const pageTabItems = [
|
||||
{ key: 'guide', label: '新手指引' },
|
||||
{ key: 'debug', label: '接口调试' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="api-debugger-page">
|
||||
<div class="api-debugger-card">
|
||||
<div class="page-tabs-header">
|
||||
<SlidingTabs v-model="pageTab" :items="pageTabItems" aria-label="API debugger tabs" />
|
||||
</div>
|
||||
<div class="page-tabs-body">
|
||||
<div v-show="pageTab === 'guide'" class="guide-pane">
|
||||
<MarkdownViewer :content="guideContent" />
|
||||
</div>
|
||||
<div v-show="pageTab === 'debug'" class="debug-pane">
|
||||
<ApiTest :enable-defaults="false" />
|
||||
</div>
|
||||
<div class="guide-pane">
|
||||
<MarkdownViewer :content="guideContent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -54,31 +38,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-tabs-header {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
}
|
||||
|
||||
.page-tabs-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debug-pane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.api-test-container) {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-pane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
67
src/views/api/openApi/FieldTable.vue
Normal file
67
src/views/api/openApi/FieldTable.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { FieldRow } from './catalog';
|
||||
|
||||
defineOptions({ name: 'OpenApiFieldTable' });
|
||||
|
||||
defineProps<{
|
||||
rows: FieldRow[];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="spec-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('api.openApi.col.field') }}</th>
|
||||
<th>{{ t('api.openApi.col.type') }}</th>
|
||||
<th>{{ t('api.openApi.col.required') }}</th>
|
||||
<th>{{ t('api.openApi.col.desc') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.name">
|
||||
<td><code>{{ row.name }}</code></td>
|
||||
<td>{{ row.type }}</td>
|
||||
<td>{{ row.required ? t('api.openApi.yes') : t('api.openApi.no') }}</td>
|
||||
<td>{{ row.desc }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.spec-table {
|
||||
width: 100%;
|
||||
margin: 8px 0 16px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--color-fill-2);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr:nth-child(even) td {
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 1px 4px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
959
src/views/api/openApi/OpenApiDocs.vue
Normal file
959
src/views/api/openApi/OpenApiDocs.vue
Normal file
@ -0,0 +1,959 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { resolveDirectAdminApiUrl } from '@/utils/adminApi';
|
||||
import SlidingTabs from '@/components/SlidingTabs/index.vue';
|
||||
import FieldTable from './FieldTable.vue';
|
||||
import {
|
||||
ITEM_FIELDS,
|
||||
PARCEL_FIELDS,
|
||||
PARCEL_OUTPUT_FIELDS,
|
||||
RECEIVER_FIELDS,
|
||||
SAMPLE_PARCEL_BODY,
|
||||
SAMPLE_PARCEL_RESPONSE,
|
||||
SAMPLE_TRACK_BODY,
|
||||
SAMPLE_TRACK_RESPONSE,
|
||||
SAMPLE_FILES_BODY,
|
||||
SAMPLE_FILES_RESPONSE,
|
||||
SAMPLE_ERROR_RESPONSE,
|
||||
SENDER_FIELDS,
|
||||
ORDER_LOOKUP_FIELDS,
|
||||
TRACK_EVENT_FIELDS,
|
||||
FILE_TYPE_FIELDS,
|
||||
FILE_TYPE_ENUM_FIELDS,
|
||||
FILE_ITEM_FIELDS,
|
||||
} from './catalog';
|
||||
import { buildSignedCurl, computeOpenApiSign, unixTimestampSeconds } from './sign';
|
||||
|
||||
defineOptions({ name: 'OpenApiDocs' });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const baseUrl = computed(() => resolveDirectAdminApiUrl());
|
||||
const parcelPath = '/openapi/parcel/create';
|
||||
const trackPath = '/openapi/track/query';
|
||||
const filesPath = '/openapi/files/get';
|
||||
const parcelUrl = computed(() => `${baseUrl.value}${parcelPath}`);
|
||||
const trackUrl = computed(() => `${baseUrl.value}${trackPath}`);
|
||||
const filesUrl = computed(() => `${baseUrl.value}${filesPath}`);
|
||||
|
||||
const navItems = [
|
||||
{ id: 'overview', labelKey: 'api.openApi.nav.overview' },
|
||||
{ id: 'auth', labelKey: 'api.openApi.nav.auth' },
|
||||
{ id: 'errors', labelKey: 'api.openApi.nav.errors' },
|
||||
{ id: 'parcel', labelKey: 'api.openApi.nav.parcel' },
|
||||
{ id: 'track', labelKey: 'api.openApi.nav.track' },
|
||||
{ id: 'files', labelKey: 'api.openApi.nav.files' },
|
||||
];
|
||||
|
||||
const activeSection = ref('overview');
|
||||
const langTab = ref('request');
|
||||
const langItems = [
|
||||
{ key: 'request', label: 'Request' },
|
||||
{ key: 'response', label: 'Response' },
|
||||
{ key: 'curl', label: 'cURL' },
|
||||
{ key: 'python', label: 'Python' },
|
||||
{ key: 'csharp', label: 'C#' },
|
||||
{ key: 'java', label: 'Java' },
|
||||
];
|
||||
|
||||
const appKey = ref('');
|
||||
const appSecret = ref('');
|
||||
const timestamp = ref(unixTimestampSeconds());
|
||||
const rawBody = ref(SAMPLE_PARCEL_BODY);
|
||||
const bodyHash = ref('');
|
||||
const stringToSign = ref('');
|
||||
const sign = ref('');
|
||||
const signError = ref('');
|
||||
const scrollRoot = ref<HTMLElement | null>(null);
|
||||
const showCodePane = computed(
|
||||
() =>
|
||||
activeSection.value === 'auth' ||
|
||||
activeSection.value === 'parcel' ||
|
||||
activeSection.value === 'track' ||
|
||||
activeSection.value === 'files',
|
||||
);
|
||||
|
||||
function selectPage(id: string) {
|
||||
activeSection.value = id;
|
||||
nextTick(() => {
|
||||
if (scrollRoot.value) scrollRoot.value.scrollTop = 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSign() {
|
||||
signError.value = '';
|
||||
timestamp.value = unixTimestampSeconds();
|
||||
if (!appKey.value.trim() || !appSecret.value.trim()) {
|
||||
bodyHash.value = '';
|
||||
stringToSign.value = '';
|
||||
sign.value = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await computeOpenApiSign(
|
||||
appKey.value.trim(),
|
||||
timestamp.value,
|
||||
rawBody.value,
|
||||
appSecret.value,
|
||||
);
|
||||
bodyHash.value = result.bodyHash;
|
||||
stringToSign.value = result.stringToSign;
|
||||
sign.value = result.sign;
|
||||
} catch (e) {
|
||||
signError.value = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
watch([appKey, appSecret, rawBody], () => {
|
||||
void refreshSign();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void refreshSign();
|
||||
});
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
Message.success(t('api.openApi.copySuccess'));
|
||||
} catch {
|
||||
Message.error(t('api.openApi.copyFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function parcelHeaders(signed: boolean): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
appKey: signed && appKey.value.trim() ? appKey.value.trim() : 'YOUR_APP_KEY',
|
||||
timestamp: signed && timestamp.value ? timestamp.value : '1710000000',
|
||||
sign: signed && sign.value ? sign.value : 'YOUR_SIGN_HEX',
|
||||
};
|
||||
}
|
||||
|
||||
function parcelCurl(signed: boolean) {
|
||||
return buildSignedCurl({
|
||||
url: parcelUrl.value,
|
||||
method: 'POST',
|
||||
headers: parcelHeaders(signed),
|
||||
body: sampleBody.value,
|
||||
});
|
||||
}
|
||||
|
||||
function signedQueryCurl(url: string, body: string, signed: boolean) {
|
||||
return buildSignedCurl({
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: parcelHeaders(signed),
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
function canSign() {
|
||||
return Boolean(appKey.value.trim() && appSecret.value.trim() && sign.value);
|
||||
}
|
||||
|
||||
const sampleBody = computed(() => {
|
||||
if (activeSection.value === 'track') return SAMPLE_TRACK_BODY;
|
||||
if (activeSection.value === 'files') return SAMPLE_FILES_BODY;
|
||||
return rawBody.value || SAMPLE_PARCEL_BODY;
|
||||
});
|
||||
|
||||
const sampleUrl = computed(() => {
|
||||
if (activeSection.value === 'track') return trackUrl.value;
|
||||
if (activeSection.value === 'files') return filesUrl.value;
|
||||
return parcelUrl.value;
|
||||
});
|
||||
|
||||
const pythonSample = computed(
|
||||
() => `import hashlib, hmac, time, urllib.request
|
||||
|
||||
app_key = "YOUR_APP_KEY"
|
||||
app_secret = "YOUR_APP_SECRET"
|
||||
timestamp = str(int(time.time()))
|
||||
# 必须与 HTTP Body 字节完全一致(含空格/换行)。不要 dumps 后再改格式。
|
||||
raw_body = """${sampleBody.value}"""
|
||||
body_hash = hashlib.sha256(raw_body.encode("utf-8")).hexdigest()
|
||||
string_to_sign = f"{app_key}\\n{timestamp}\\n{body_hash}"
|
||||
sign = hmac.new(
|
||||
app_secret.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
req = urllib.request.Request(
|
||||
"${sampleUrl.value}",
|
||||
data=raw_body.encode("utf-8"),
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"appKey": app_key,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
print(resp.read().decode())`,
|
||||
);
|
||||
|
||||
const csharpSample = computed(
|
||||
() => `using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
static string Sha256Hex(string text) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
|
||||
|
||||
static string HmacSha256Hex(string secret, string content) =>
|
||||
Convert.ToHexString(
|
||||
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(content)))
|
||||
.ToLowerInvariant();
|
||||
|
||||
var appKey = "YOUR_APP_KEY";
|
||||
var appSecret = "YOUR_APP_SECRET";
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
var rawBody = """
|
||||
${sampleBody.value}
|
||||
""";
|
||||
var sign = HmacSha256Hex(appSecret, $"{appKey}\\n{timestamp}\\n{Sha256Hex(rawBody)}");
|
||||
|
||||
using var client = new HttpClient();
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "${sampleUrl.value}");
|
||||
req.Content = new StringContent(rawBody, Encoding.UTF8, "application/json");
|
||||
req.Headers.TryAddWithoutValidation("appKey", appKey);
|
||||
req.Headers.TryAddWithoutValidation("timestamp", timestamp);
|
||||
req.Headers.TryAddWithoutValidation("sign", sign);
|
||||
var res = await client.SendAsync(req);
|
||||
Console.WriteLine(await res.Content.ReadAsStringAsync());`,
|
||||
);
|
||||
|
||||
const javaSample = computed(
|
||||
() => `import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
|
||||
static String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
var appKey = "YOUR_APP_KEY";
|
||||
var appSecret = "YOUR_APP_SECRET";
|
||||
var timestamp = String.valueOf(Instant.now().getEpochSecond());
|
||||
var rawBody = """
|
||||
${sampleBody.value}
|
||||
""";
|
||||
var bodyHash = toHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(rawBody.getBytes(StandardCharsets.UTF_8)));
|
||||
var stringToSign = appKey + "\\n" + timestamp + "\\n" + bodyHash;
|
||||
var mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
var sign = toHex(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
var req = HttpRequest.newBuilder(URI.create("${sampleUrl.value}"))
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.header("appKey", appKey)
|
||||
.header("timestamp", timestamp)
|
||||
.header("sign", sign)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(rawBody, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
|
||||
System.out.println(res.body());`,
|
||||
);
|
||||
|
||||
const jsonRequestSample = computed(() => sampleBody.value);
|
||||
|
||||
const jsonResponseSample = computed(() => {
|
||||
if (activeSection.value === 'auth') return SAMPLE_ERROR_RESPONSE;
|
||||
if (activeSection.value === 'track') return SAMPLE_TRACK_RESPONSE;
|
||||
if (activeSection.value === 'files') return SAMPLE_FILES_RESPONSE;
|
||||
return SAMPLE_PARCEL_RESPONSE;
|
||||
});
|
||||
|
||||
const currentCurl = computed(() => {
|
||||
if (activeSection.value === 'track') {
|
||||
return signedQueryCurl(trackUrl.value, SAMPLE_TRACK_BODY, canSign());
|
||||
}
|
||||
if (activeSection.value === 'files') {
|
||||
return signedQueryCurl(filesUrl.value, SAMPLE_FILES_BODY, canSign());
|
||||
}
|
||||
return parcelCurl(canSign());
|
||||
});
|
||||
|
||||
const currentSample = computed(() => {
|
||||
if (langTab.value === 'request') return jsonRequestSample.value;
|
||||
if (langTab.value === 'response') return jsonResponseSample.value;
|
||||
if (langTab.value === 'curl') return currentCurl.value;
|
||||
if (langTab.value === 'python') return pythonSample.value;
|
||||
if (langTab.value === 'csharp') return csharpSample.value;
|
||||
return javaSample.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="openapi-docs">
|
||||
<aside class="docs-nav">
|
||||
<div class="docs-nav-title">{{ t('api.openApi.navTitle') }}</div>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="docs-nav-item"
|
||||
:class="{ active: activeSection === item.id }"
|
||||
@click="selectPage(item.id)"
|
||||
>
|
||||
{{ t(item.labelKey) }}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div ref="scrollRoot" class="docs-scroll">
|
||||
<div class="docs-grid" :class="{ 'no-code': !showCodePane }">
|
||||
<article class="docs-article">
|
||||
<section v-if="activeSection === 'overview'" class="docs-section">
|
||||
<h1>{{ t('api.openApi.title') }}</h1>
|
||||
<p class="lead">{{ t('api.openApi.lead') }}</p>
|
||||
<div class="env-row">
|
||||
<div class="env-card">
|
||||
<div class="env-label">{{ t('api.openApi.baseUrl') }}</div>
|
||||
<code>{{ baseUrl }}</code>
|
||||
<a-button size="mini" @click="copyText(baseUrl)">{{ t('api.openApi.copy') }}</a-button>
|
||||
</div>
|
||||
<div class="env-card">
|
||||
<div class="env-label">{{ t('api.openApi.contentType') }}</div>
|
||||
<code>application/json; charset=utf-8</code>
|
||||
</div>
|
||||
</div>
|
||||
<p>{{ t('api.openApi.overviewBody') }}</p>
|
||||
<ul>
|
||||
<li>{{ t('api.openApi.overviewPoint1') }}</li>
|
||||
<li>{{ t('api.openApi.overviewPoint2') }}</li>
|
||||
<li>{{ t('api.openApi.overviewPoint3') }}</li>
|
||||
<li>{{ t('api.openApi.overviewPoint4') }}</li>
|
||||
<li>{{ t('api.openApi.overviewPoint5') }}</li>
|
||||
</ul>
|
||||
<h3>{{ t('api.openApi.endpointTable') }}</h3>
|
||||
<table class="spec-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('api.openApi.col.method') }}</th>
|
||||
<th>{{ t('api.openApi.col.path') }}</th>
|
||||
<th>{{ t('api.openApi.col.auth') }}</th>
|
||||
<th>{{ t('api.openApi.col.desc') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>POST</code></td>
|
||||
<td><code>{{ parcelPath }}</code></td>
|
||||
<td>{{ t('api.openApi.authHmac') }}</td>
|
||||
<td>{{ t('api.openApi.nav.parcel') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST</code></td>
|
||||
<td><code>{{ trackPath }}</code></td>
|
||||
<td>{{ t('api.openApi.authHmac') }}</td>
|
||||
<td>{{ t('api.openApi.nav.track') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>POST</code></td>
|
||||
<td><code>{{ filesPath }}</code></td>
|
||||
<td>{{ t('api.openApi.authHmac') }}</td>
|
||||
<td>{{ t('api.openApi.nav.files') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>{{ t('api.openApi.compatTitle') }}</h3>
|
||||
<p>{{ t('api.openApi.compatBody') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="activeSection === 'auth'" class="docs-section">
|
||||
<h2>{{ t('api.openApi.authTitle') }}</h2>
|
||||
<p>{{ t('api.openApi.authIntro') }}</p>
|
||||
<h3>{{ t('api.openApi.headersTitle') }}</h3>
|
||||
<table class="spec-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('api.openApi.col.header') }}</th>
|
||||
<th>{{ t('api.openApi.col.type') }}</th>
|
||||
<th>{{ t('api.openApi.col.required') }}</th>
|
||||
<th>{{ t('api.openApi.col.desc') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>appKey</code></td>
|
||||
<td>string</td>
|
||||
<td>{{ t('api.openApi.yes') }}</td>
|
||||
<td>{{ t('api.openApi.headerAppKey') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>timestamp</code></td>
|
||||
<td>string</td>
|
||||
<td>{{ t('api.openApi.yes') }}</td>
|
||||
<td>{{ t('api.openApi.headerTimestamp') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>sign</code></td>
|
||||
<td>string</td>
|
||||
<td>{{ t('api.openApi.yes') }}</td>
|
||||
<td>{{ t('api.openApi.headerSign') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>{{ t('api.openApi.signAlgoTitle') }}</h3>
|
||||
<ol>
|
||||
<li>{{ t('api.openApi.signStep1') }}</li>
|
||||
<li>{{ t('api.openApi.signStep2') }}</li>
|
||||
<li>{{ t('api.openApi.signStep3') }}</li>
|
||||
</ol>
|
||||
<pre class="algo-block">bodyHash = SHA256_HEX(rawBody) // UTF-8;空 Body 对空字符串哈希
|
||||
stringToSign = appKey + "\\n" + timestamp + "\\n" + bodyHash
|
||||
sign = HMAC_SHA256_HEX(appSecret, stringToSign) // 小写 hex</pre>
|
||||
<a-alert type="warning">{{ t('api.openApi.signWarn') }}</a-alert>
|
||||
<ul class="pitfall-list">
|
||||
<li>{{ t('api.openApi.signPitfall1') }}</li>
|
||||
<li>{{ t('api.openApi.signPitfall2') }}</li>
|
||||
<li>{{ t('api.openApi.signPitfall3') }}</li>
|
||||
</ul>
|
||||
|
||||
<h3>{{ t('api.openApi.playgroundTitle') }}</h3>
|
||||
<p class="muted">{{ t('api.openApi.playgroundTip') }}</p>
|
||||
<a-form :model="{}" layout="vertical" class="sign-form">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item :label="t('api.openApi.appKey')">
|
||||
<a-input v-model="appKey" :placeholder="t('api.openApi.appKeyPh')" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item :label="t('api.openApi.appSecret')">
|
||||
<a-input-password v-model="appSecret" :placeholder="t('api.openApi.appSecretPh')" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item :label="t('api.openApi.timestamp')">
|
||||
<a-input v-model="timestamp" readonly>
|
||||
<template #append>
|
||||
<a-button size="mini" @click="refreshSign">{{ t('api.openApi.refreshTs') }}</a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item :label="t('api.openApi.rawBody')">
|
||||
<a-textarea v-model="rawBody" :auto-size="{ minRows: 10, maxRows: 18 }" class="mono" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<div v-if="signError" class="sign-error">{{ signError }}</div>
|
||||
<div class="sign-out">
|
||||
<div>
|
||||
<span class="k">bodyHash</span>
|
||||
<code>{{ bodyHash || '—' }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">stringToSign</span>
|
||||
<pre>{{ stringToSign || '—' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span class="k">sign</span>
|
||||
<code>{{ sign || '—' }}</code>
|
||||
<a-button size="mini" :disabled="!sign" @click="copyText(sign)">{{ t('api.openApi.copy') }}</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeSection === 'errors'" class="docs-section">
|
||||
<h2>{{ t('api.openApi.errorsTitle') }}</h2>
|
||||
<p>{{ t('api.openApi.errorsIntro') }}</p>
|
||||
<pre>{ "code": "200", "msg": "success", "data": { ... } }</pre>
|
||||
<table class="spec-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('api.openApi.col.code') }}</th>
|
||||
<th>{{ t('api.openApi.col.msg') }}</th>
|
||||
<th>{{ t('api.openApi.col.desc') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>200</code></td>
|
||||
<td>success</td>
|
||||
<td>{{ t('api.openApi.err200') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>400</code></td>
|
||||
<td>凭证无效 / 签名无效 / 请求已过期 / …</td>
|
||||
<td>{{ t('api.openApi.err400') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>500</code></td>
|
||||
<td>系统异常</td>
|
||||
<td>{{ t('api.openApi.err500') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>{{ t('api.openApi.errMsgTitle') }}</h3>
|
||||
<table class="spec-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('api.openApi.col.msg') }}</th>
|
||||
<th>{{ t('api.openApi.col.desc') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>凭证无效</code></td>
|
||||
<td>{{ t('api.openApi.msgInvalidCred') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>签名无效</code></td>
|
||||
<td>{{ t('api.openApi.msgInvalidSign') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>请求已过期</code></td>
|
||||
<td>{{ t('api.openApi.msgExpired') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>请求体不能为空</code> / <code>请求体格式错误</code></td>
|
||||
<td>{{ t('api.openApi.msgBody') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>订单不存在</code></td>
|
||||
<td>{{ t('api.openApi.msgOrderMissing') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>请传入 clientNo、serviceNo 或 trackNo</code></td>
|
||||
<td>{{ t('api.openApi.msgLookup') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>不支持的附件类型</code></td>
|
||||
<td>{{ t('api.openApi.msgFileType') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="activeSection === 'parcel'" class="docs-section">
|
||||
<div class="op-head">
|
||||
<span class="method post">POST</span>
|
||||
<code class="path">{{ parcelPath }}</code>
|
||||
</div>
|
||||
<p>{{ t('api.openApi.parcelSummary') }}</p>
|
||||
<p class="muted">{{ t('api.openApi.signedEndpointNote') }}</p>
|
||||
<h3>{{ t('api.openApi.requestBody') }}</h3>
|
||||
<FieldTable :rows="PARCEL_FIELDS" />
|
||||
<h4>receiver</h4>
|
||||
<FieldTable :rows="RECEIVER_FIELDS" />
|
||||
<h4>sender</h4>
|
||||
<FieldTable :rows="SENDER_FIELDS" />
|
||||
<h4>items[]</h4>
|
||||
<FieldTable :rows="ITEM_FIELDS" />
|
||||
<h3>{{ t('api.openApi.responseBody') }}</h3>
|
||||
<p>{{ t('api.openApi.envelopeHint') }}</p>
|
||||
<FieldTable :rows="PARCEL_OUTPUT_FIELDS" />
|
||||
</section>
|
||||
|
||||
<section v-if="activeSection === 'track'" class="docs-section">
|
||||
<div class="op-head">
|
||||
<span class="method post">POST</span>
|
||||
<code class="path">{{ trackPath }}</code>
|
||||
</div>
|
||||
<p>{{ t('api.openApi.trackSummary') }}</p>
|
||||
<p class="muted">{{ t('api.openApi.signedEndpointNote') }}</p>
|
||||
<p class="muted">{{ t('api.openApi.trackLookupNote') }}</p>
|
||||
<h3>{{ t('api.openApi.requestBody') }}</h3>
|
||||
<FieldTable :rows="ORDER_LOOKUP_FIELDS" />
|
||||
<h3>{{ t('api.openApi.responseBody') }}</h3>
|
||||
<p>{{ t('api.openApi.envelopeHint') }}</p>
|
||||
<FieldTable :rows="TRACK_EVENT_FIELDS" />
|
||||
</section>
|
||||
|
||||
<section v-if="activeSection === 'files'" class="docs-section">
|
||||
<div class="op-head">
|
||||
<span class="method post">POST</span>
|
||||
<code class="path">{{ filesPath }}</code>
|
||||
</div>
|
||||
<p>{{ t('api.openApi.filesSummary') }}</p>
|
||||
<p class="muted">{{ t('api.openApi.signedEndpointNote') }}</p>
|
||||
<h3>{{ t('api.openApi.requestBody') }}</h3>
|
||||
<FieldTable :rows="ORDER_LOOKUP_FIELDS" />
|
||||
<FieldTable :rows="FILE_TYPE_FIELDS" />
|
||||
<h4>{{ t('api.openApi.fileTypeEnum') }}</h4>
|
||||
<FieldTable :rows="FILE_TYPE_ENUM_FIELDS" />
|
||||
<h3>{{ t('api.openApi.responseBody') }}</h3>
|
||||
<p>{{ t('api.openApi.envelopeHint') }}</p>
|
||||
<FieldTable :rows="FILE_ITEM_FIELDS" />
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside v-if="showCodePane" class="docs-code">
|
||||
<div class="code-sticky">
|
||||
<div class="code-head">
|
||||
<SlidingTabs
|
||||
v-model="langTab"
|
||||
:items="langItems"
|
||||
size="mini"
|
||||
aria-label="SDK samples"
|
||||
/>
|
||||
<a-button size="mini" @click="copyText(currentSample)">{{ t('api.openApi.copy') }}</a-button>
|
||||
</div>
|
||||
<pre class="code-body">{{ currentSample }}</pre>
|
||||
<p class="code-hint">{{ t('api.openApi.sampleHint') }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.openapi-docs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--app-card-bg);
|
||||
}
|
||||
|
||||
.docs-nav {
|
||||
flex-shrink: 0;
|
||||
width: 200px;
|
||||
padding: 16px 8px 24px 16px;
|
||||
border-right: 1px solid var(--color-border-2);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.docs-nav-title {
|
||||
margin-bottom: 8px;
|
||||
padding: 0 8px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.docs-nav-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-2);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-fill-2);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--app-primary-color) 12%, transparent);
|
||||
color: var(--app-primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
gap: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.docs-article {
|
||||
padding: 20px 24px 48px;
|
||||
color: var(--color-text-1);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.docs-grid.no-code {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.docs-section {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 8px 0 12px;
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid var(--app-primary-color);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 20px 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--color-text-2);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.env-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.env-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 280px;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
|
||||
.env-label {
|
||||
width: 100%;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.spec-table) {
|
||||
width: 100%;
|
||||
margin: 8px 0 16px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--color-fill-2);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tr:nth-child(even) td {
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 1px 4px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.algo-block,
|
||||
.docs-article pre {
|
||||
margin: 12px 0;
|
||||
padding: 12px 14px;
|
||||
overflow: auto;
|
||||
background: #f0f3f4;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-1);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sign-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mono :deep(textarea) {
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sign-error {
|
||||
margin-bottom: 8px;
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
|
||||
.sign-out {
|
||||
margin: 8px 0 16px;
|
||||
padding: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
> div {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.k {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-1);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.op-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.method {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
&.post {
|
||||
background: #e8ffea;
|
||||
color: #00b42a;
|
||||
}
|
||||
}
|
||||
|
||||
.path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-code {
|
||||
border-left: 1px solid var(--color-border-2);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.code-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.code-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
:deep(.t-tabs) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.code-body {
|
||||
margin: 0;
|
||||
max-height: calc(100vh - 220px);
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #f0f3f4;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-1);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.code-hint {
|
||||
margin: 8px 0 0;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pitfall-list {
|
||||
margin: 8px 0 16px;
|
||||
padding-left: 18px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.docs-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.docs-code {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
}
|
||||
|
||||
.code-sticky {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
329
src/views/api/openApi/catalog.ts
Normal file
329
src/views/api/openApi/catalog.ts
Normal file
@ -0,0 +1,329 @@
|
||||
export interface FieldRow {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const PARCEL_FIELDS: FieldRow[] = [
|
||||
{ name: 'clientNo', type: 'string', required: true, desc: '客户单号,幂等键。同一客户重复提交相同 clientNo 返回已存在订单,不新建' },
|
||||
{ name: 'productCode', type: 'string', required: true, desc: '销售产品编码(运输方式)' },
|
||||
{ name: 'channelCode', type: 'string', required: false, desc: '渠道编码' },
|
||||
{ name: 'trackNo', type: 'string', required: false, desc: '跟踪号;通常由下游回写,可不传' },
|
||||
{ name: 'serviceNo', type: 'string', required: false, desc: '服务单号;不传则系统生成' },
|
||||
{ name: 'cargoAttribute', type: 'string', required: false, desc: '货物属性:general / electronics / sensitive' },
|
||||
{ name: 'exportCustomsMode', type: 'string', required: false, desc: '出口报关方式(字典 export_customs_mode)' },
|
||||
{ name: 'exportQualificationId', type: 'int', required: false, desc: '出口资质备案 Id(9610 汇总申报)' },
|
||||
{ name: 'platformCode', type: 'string', required: false, desc: '电商平台(9610 汇总申报)' },
|
||||
{ name: 'warehouseId', type: 'int', required: false, desc: '仓库主键' },
|
||||
{ name: 'length', type: 'number', required: false, desc: '长 (cm)' },
|
||||
{ name: 'width', type: 'number', required: false, desc: '宽 (cm)' },
|
||||
{ name: 'height', type: 'number', required: false, desc: '高 (cm)' },
|
||||
{ name: 'actualWeight', type: 'number', required: true, desc: '实重 (kg)' },
|
||||
{ name: 'volumetricWeight', type: 'number', required: false, desc: '体积重 (kg)' },
|
||||
{ name: 'chargeableWeight', type: 'number', required: false, desc: '计费重 (kg)' },
|
||||
{ name: 'cubic', type: 'number', required: false, desc: '体积 (cm³)' },
|
||||
{ name: 'quantity', type: 'int', required: false, desc: '包裹件数' },
|
||||
{ name: 'isCod', type: 'boolean', required: false, desc: '是否 COD,默认 false' },
|
||||
{ name: 'codCurrency', type: 'string', required: false, desc: 'COD 币种' },
|
||||
{ name: 'codSum', type: 'number', required: false, desc: 'COD 金额' },
|
||||
{ name: 'declaredCurrency', type: 'string', required: false, desc: '申报币种(字典 currency)' },
|
||||
{ name: 'declaredSum', type: 'number', required: false, desc: '申报总金额;不填可由明细汇总' },
|
||||
{ name: 'additionalServices', type: 'string[]', required: false, desc: '附加服务字典 code 列表' },
|
||||
{ name: 'isRemote', type: 'byte', required: false, desc: '是否偏远(可选预传)' },
|
||||
{ name: 'couponInstanceId', type: 'int', required: false, desc: '优惠券实例 Id' },
|
||||
{ name: 'saveAsDraft', type: 'boolean', required: false, desc: '仅保存草稿,不预报计费' },
|
||||
{ name: 'receiver', type: 'object', required: true, desc: '收件人' },
|
||||
{ name: 'sender', type: 'object', required: false, desc: '寄件人' },
|
||||
{ name: 'items', type: 'object[]', required: true, desc: '申报明细' },
|
||||
];
|
||||
|
||||
export const RECEIVER_FIELDS: FieldRow[] = [
|
||||
{ name: 'recipientsName', type: 'string', required: true, desc: '收件人姓名' },
|
||||
{ name: 'companyName', type: 'string', required: false, desc: '公司名称' },
|
||||
{ name: 'phone', type: 'string', required: true, desc: '手机号' },
|
||||
{ name: 'telePhone', type: 'string', required: false, desc: '固定电话' },
|
||||
{ name: 'email', type: 'string', required: false, desc: '邮箱' },
|
||||
{ name: 'countryCode', type: 'string', required: true, desc: '国家/地区二字码(ISO,如 US);兼容 t_country.Id 数字字符串' },
|
||||
{ name: 'province', type: 'string', required: false, desc: '省/州' },
|
||||
{ name: 'city', type: 'string', required: false, desc: '城市' },
|
||||
{ name: 'streetNumber', type: 'string', required: false, desc: '门牌号' },
|
||||
{ name: 'streetAddress1', type: 'string', required: true, desc: '地址第一行' },
|
||||
{ name: 'streetAddress2', type: 'string', required: false, desc: '地址第二行' },
|
||||
{ name: 'streetAddress3', type: 'string', required: false, desc: '地址第三行' },
|
||||
{ name: 'zipCode', type: 'string', required: false, desc: '邮编' },
|
||||
{ name: 'taxType', type: 'int', required: false, desc: '税号类型' },
|
||||
{ name: 'taxNo', type: 'string', required: false, desc: '税号' },
|
||||
{ name: 'certificateType', type: 'int', required: false, desc: '证件类型' },
|
||||
{ name: 'certificateNo', type: 'string', required: false, desc: '证件号码' },
|
||||
{ name: 'eori', type: 'string', required: false, desc: 'EORI' },
|
||||
{ name: 'vat', type: 'string', required: false, desc: 'VAT' },
|
||||
];
|
||||
|
||||
export const SENDER_FIELDS: FieldRow[] = [
|
||||
{ name: 'senderName', type: 'string', required: false, desc: '寄件人姓名' },
|
||||
{ name: 'company', type: 'string', required: false, desc: '公司' },
|
||||
{ name: 'phone', type: 'string', required: false, desc: '手机号' },
|
||||
{ name: 'telePhone', type: 'string', required: false, desc: '固定电话' },
|
||||
{ name: 'email', type: 'string', required: false, desc: '邮箱' },
|
||||
{ name: 'countryCode', type: 'string', required: false, desc: '国家/地区二字码' },
|
||||
{ name: 'province', type: 'string', required: false, desc: '省份' },
|
||||
{ name: 'city', type: 'string', required: false, desc: '城市' },
|
||||
{ name: 'zipCode', type: 'string', required: false, desc: '邮编' },
|
||||
{ name: 'streetAddress1', type: 'string', required: false, desc: '地址第一行' },
|
||||
{ name: 'streetAddress2', type: 'string', required: false, desc: '地址第二行' },
|
||||
{ name: 'streetAddress3', type: 'string', required: false, desc: '地址第三行' },
|
||||
{ name: 'houseNumber', type: 'string', required: false, desc: '门牌号' },
|
||||
];
|
||||
|
||||
export const ITEM_FIELDS: FieldRow[] = [
|
||||
{ name: 'cnName', type: 'string', required: true, desc: '中文品名' },
|
||||
{ name: 'enName', type: 'string', required: true, desc: '英文品名' },
|
||||
{ name: 'sku', type: 'string', required: false, desc: 'SKU' },
|
||||
{ name: 'unit', type: 'string', required: false, desc: '单位' },
|
||||
{ name: 'unitPrice', type: 'number', required: true, desc: '单价' },
|
||||
{ name: 'unitWeight', type: 'number', required: false, desc: '单件重量 (kg)' },
|
||||
{ name: 'quantity', type: 'int', required: true, desc: '数量' },
|
||||
{ name: 'amount', type: 'number', required: false, desc: '金额' },
|
||||
{ name: 'weight', type: 'number', required: false, desc: '重量合计' },
|
||||
{ name: 'imHsCode', type: 'string', required: false, desc: '进口 HS' },
|
||||
{ name: 'exHsCode', type: 'string', required: false, desc: '出口 HS' },
|
||||
{ name: 'declareElement', type: 'string', required: false, desc: '出口申报要素' },
|
||||
{ name: 'skuImage', type: 'string', required: false, desc: 'SKU 图片 URL' },
|
||||
{ name: 'category', type: 'string', required: false, desc: '品类' },
|
||||
{ name: 'brand', type: 'string', required: false, desc: '品牌' },
|
||||
{ name: 'brandType', type: 'string', required: false, desc: '品牌类型' },
|
||||
{ name: 'isMagnetic', type: 'boolean', required: false, desc: '是否带磁' },
|
||||
{ name: 'distribution', type: 'string', required: false, desc: '配货方式' },
|
||||
{ name: 'material', type: 'string', required: false, desc: '材质' },
|
||||
{ name: 'enMaterial', type: 'string', required: false, desc: '英文材质' },
|
||||
{ name: 'purpose', type: 'string', required: false, desc: '用途' },
|
||||
{ name: 'attributes', type: 'string', required: false, desc: '属性集合' },
|
||||
{ name: 'originCountry', type: 'string', required: false, desc: '原产国' },
|
||||
{ name: 'salesLink', type: 'string', required: false, desc: '销售链接' },
|
||||
{ name: 'model', type: 'string', required: false, desc: '型号' },
|
||||
{ name: 'specifications', type: 'string', required: false, desc: '规格' },
|
||||
{ name: 'comment', type: 'string', required: false, desc: '备注' },
|
||||
];
|
||||
|
||||
export const PARCEL_OUTPUT_FIELDS: FieldRow[] = [
|
||||
{ name: 'orderId', type: 'int', required: false, desc: '订单主键' },
|
||||
{ name: 'clientNo', type: 'string', required: false, desc: '客户单号' },
|
||||
{ name: 'serviceNo', type: 'string', required: false, desc: '服务单号' },
|
||||
{ name: 'trackNo', type: 'string', required: false, desc: '跟踪号' },
|
||||
{ name: 'labelUrl', type: 'string', required: false, desc: '面单地址' },
|
||||
{ name: 'consigneeCountry', type: 'string', required: false, desc: '收件国家/地区二字码' },
|
||||
{ name: 'consigneeCountryName', type: 'string', required: false, desc: '收件国家/地区中文名' },
|
||||
{ name: 'pickupNo', type: 'string', required: false, desc: '同步揽收单号(上门揽收成功时)' },
|
||||
{ name: 'pickupOrderId', type: 'int', required: false, desc: '同步揽收单 Id' },
|
||||
{ name: 'carrierWaybillNo', type: 'string', required: false, desc: '国内承运运单号' },
|
||||
{ name: 'pickupCreateError', type: 'string', required: false, desc: '揽收单创建失败原因(运单已成功时仅提示)' },
|
||||
];
|
||||
|
||||
export const PICKUP_FEE_FIELDS: FieldRow[] = [
|
||||
{ name: 'carrierWaybillNo', type: 'string', required: true, desc: '承运运单号' },
|
||||
{ name: 'totalActualWeight', type: 'number', required: true, desc: '实重合计' },
|
||||
{ name: 'carrierFreightWeight', type: 'number', required: false, desc: '承运计费重(对照)' },
|
||||
{ name: 'carrierQuotedFreightAmount', type: 'number', required: false, desc: '承运报价运费合计(对照)' },
|
||||
{ name: 'carrierQuotedReceivableAmount', type: 'number', required: false, desc: '承运报文应收(对照)' },
|
||||
{ name: 'valueInfoList', type: 'object[]', required: false, desc: '承运附加费明细' },
|
||||
{ name: 'valueInfoList[].feeCode', type: 'string', required: false, desc: '费用编码' },
|
||||
{ name: 'valueInfoList[].feeName', type: 'string', required: false, desc: '费用名称' },
|
||||
{ name: 'valueInfoList[].feeAmount', type: 'number', required: false, desc: '费用金额' },
|
||||
{ name: 'packageSizes', type: 'object[]', required: false, desc: '尺寸明细' },
|
||||
{ name: 'packageSizes[].length', type: 'number', required: false, desc: '长' },
|
||||
{ name: 'packageSizes[].width', type: 'number', required: false, desc: '宽' },
|
||||
{ name: 'packageSizes[].height', type: 'number', required: false, desc: '高' },
|
||||
{ name: 'packageSizes[].count', type: 'int', required: false, desc: '件数' },
|
||||
];
|
||||
|
||||
export const PICKUP_FEE_OUTPUT_FIELDS: FieldRow[] = [
|
||||
{ name: 'pickupNo', type: 'string', required: false, desc: '揽收单号' },
|
||||
{ name: 'totalActualWeight', type: 'number', required: false, desc: '实重合计' },
|
||||
{ name: 'chargeableWeight', type: 'number', required: false, desc: '计费重' },
|
||||
{ name: 'volumetricWeight', type: 'number', required: false, desc: '体积重' },
|
||||
{ name: 'receivableAmount', type: 'number', required: false, desc: '应收金额' },
|
||||
{ name: 'carrierPayableAmount', type: 'number', required: false, desc: '承运应付' },
|
||||
{ name: 'carrierQuotedFreightAmount', type: 'number', required: false, desc: '承运报价运费' },
|
||||
];
|
||||
|
||||
export const SAMPLE_PARCEL_BODY = `{
|
||||
"clientNo": "CLI-20261005-001",
|
||||
"productCode": "YOUR_PRODUCT_CODE",
|
||||
"actualWeight": 0.5,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"declaredCurrency": "USD",
|
||||
"quantity": 1,
|
||||
"saveAsDraft": false,
|
||||
"receiver": {
|
||||
"recipientsName": "John Smith",
|
||||
"phone": "14155550123",
|
||||
"countryCode": "US",
|
||||
"province": "CA",
|
||||
"city": "Los Angeles",
|
||||
"streetAddress1": "123 Main St",
|
||||
"zipCode": "90001"
|
||||
},
|
||||
"sender": {
|
||||
"senderName": "Zhang San",
|
||||
"phone": "13800138000",
|
||||
"countryCode": "CN",
|
||||
"province": "Guangdong",
|
||||
"city": "Shenzhen",
|
||||
"streetAddress1": "Nanshan District"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"cnName": "T恤",
|
||||
"enName": "T-Shirt",
|
||||
"quantity": 1,
|
||||
"unitPrice": 12.5,
|
||||
"unitWeight": 0.2
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
export const SAMPLE_PICKUP_FEE_BODY = `{
|
||||
"carrierWaybillNo": "SF1234567890",
|
||||
"totalActualWeight": 1.25,
|
||||
"carrierFreightWeight": 1.5,
|
||||
"carrierQuotedFreightAmount": 18.5,
|
||||
"packageSizes": [
|
||||
{ "length": 30, "width": 20, "height": 15, "count": 1 }
|
||||
]
|
||||
}`;
|
||||
|
||||
export const SAMPLE_TRACK_BODY = `{
|
||||
"clientNo": "CLI-20261005-001",
|
||||
"serviceNo": "IDE2607281746002926"
|
||||
}`;
|
||||
|
||||
export const SAMPLE_FILES_BODY = `{
|
||||
"clientNo": "CLI-20261005-001",
|
||||
"serviceNo": "IDE2607281746002926",
|
||||
"fileType": "systemLabel"
|
||||
}`;
|
||||
|
||||
export const SAMPLE_PARCEL_RESPONSE = `{
|
||||
"code": "200",
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"orderId": 10001,
|
||||
"clientNo": "CLI-20261005-001",
|
||||
"serviceNo": "IDE2607281746002926",
|
||||
"trackNo": "",
|
||||
"labelUrl": "https://files.example.com/labels/10001.pdf",
|
||||
"consigneeCountry": "US",
|
||||
"consigneeCountryName": "美国",
|
||||
"pickupNo": null,
|
||||
"pickupOrderId": null,
|
||||
"carrierWaybillNo": null,
|
||||
"pickupCreateError": null
|
||||
}
|
||||
}`;
|
||||
|
||||
export const SAMPLE_PICKUP_FEE_RESPONSE = `{
|
||||
"code": "200",
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"pickupNo": "PK20261005001",
|
||||
"totalActualWeight": 1.25,
|
||||
"chargeableWeight": 1.5,
|
||||
"volumetricWeight": 0.9,
|
||||
"receivableAmount": 22.8,
|
||||
"carrierPayableAmount": 18.5,
|
||||
"carrierQuotedFreightAmount": 18.5
|
||||
}
|
||||
}`;
|
||||
|
||||
export const SAMPLE_TRACK_RESPONSE = `{
|
||||
"code": "200",
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"orderId": 10001,
|
||||
"clientNo": "CLI-20261005-001",
|
||||
"serviceNo": "IDE2607281746002926",
|
||||
"trackNo": "1Z999AA10123456784",
|
||||
"events": [
|
||||
{
|
||||
"trackCode": "IT",
|
||||
"status": "In Transit",
|
||||
"content": "包裹已离开分拨中心",
|
||||
"enContent": "Departed from sorting facility",
|
||||
"location": "Shenzhen",
|
||||
"time": "2026-10-05T10:20:00",
|
||||
"files": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}`;
|
||||
|
||||
export const SAMPLE_FILES_RESPONSE = `{
|
||||
"code": "200",
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"orderId": 10001,
|
||||
"clientNo": "CLI-20261005-001",
|
||||
"serviceNo": "IDE2607281746002926",
|
||||
"fileType": "systemLabel",
|
||||
"files": [
|
||||
{
|
||||
"id": 501,
|
||||
"fileName": "label-10001.pdf",
|
||||
"fileUrl": "https://files.example.com/order-files/10001/systemLabel/label.pdf",
|
||||
"contentType": "application/pdf",
|
||||
"fileSize": 128400,
|
||||
"source": "systemGenerate",
|
||||
"createTime": "2026-10-05T09:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`;
|
||||
|
||||
export const SAMPLE_ERROR_RESPONSE = `{
|
||||
"code": "400",
|
||||
"msg": "签名无效",
|
||||
"data": null
|
||||
}`;
|
||||
|
||||
export const ORDER_LOOKUP_FIELDS: FieldRow[] = [
|
||||
{ name: 'clientNo', type: 'string', required: false, desc: '客户单号。与 serviceNo / trackNo 至少填一项;同时传时优先按 clientNo 查' },
|
||||
{ name: 'serviceNo', type: 'string', required: false, desc: '服务单号(OMS 运单号,轨迹系统按此查询)' },
|
||||
{ name: 'trackNo', type: 'string', required: false, desc: '跟踪号' },
|
||||
];
|
||||
|
||||
export const TRACK_EVENT_FIELDS: FieldRow[] = [
|
||||
{ name: 'events[].trackCode', type: 'string', required: false, desc: '轨迹编码' },
|
||||
{ name: 'events[].status', type: 'string', required: false, desc: '轨迹状态文案' },
|
||||
{ name: 'events[].content', type: 'string', required: false, desc: '中文轨迹内容' },
|
||||
{ name: 'events[].enContent', type: 'string', required: false, desc: '英文轨迹内容' },
|
||||
{ name: 'events[].location', type: 'string', required: false, desc: '发生地点' },
|
||||
{ name: 'events[].time', type: 'datetime', required: false, desc: '发生时间' },
|
||||
{ name: 'events[].files', type: 'object[]', required: false, desc: '轨迹节点附件' },
|
||||
{ name: 'events[].files[].fileName', type: 'string', required: false, desc: '附件文件名' },
|
||||
{ name: 'events[].files[].url', type: 'string', required: false, desc: '附件 URL' },
|
||||
];
|
||||
|
||||
export const FILE_TYPE_FIELDS: FieldRow[] = [
|
||||
{ name: 'fileType', type: 'string', required: true, desc: '附件类型,见下表' },
|
||||
];
|
||||
|
||||
export const FILE_TYPE_ENUM_FIELDS: FieldRow[] = [
|
||||
{ name: 'tradeProof', type: 'enum', required: false, desc: '交易证明' },
|
||||
{ name: 'systemLabel', type: 'enum', required: false, desc: '系统面单(通常 1 份)' },
|
||||
{ name: 'lastMileLabel', type: 'enum', required: false, desc: '尾程面单' },
|
||||
{ name: 'infraredImage', type: 'enum', required: false, desc: '红外图片' },
|
||||
{ name: 'pod', type: 'enum', required: false, desc: 'POD(签收证明)' },
|
||||
];
|
||||
|
||||
export const FILE_ITEM_FIELDS: FieldRow[] = [
|
||||
{ name: 'id', type: 'int', required: false, desc: '附件 Id' },
|
||||
{ name: 'fileName', type: 'string', required: false, desc: '文件名' },
|
||||
{ name: 'fileUrl', type: 'string', required: false, desc: '可下载 URL' },
|
||||
{ name: 'contentType', type: 'string', required: false, desc: 'MIME' },
|
||||
{ name: 'fileSize', type: 'long', required: false, desc: '字节数' },
|
||||
{ name: 'source', type: 'string', required: false, desc: 'upload / systemGenerate / downstream / migrate' },
|
||||
{ name: 'createTime', type: 'datetime', required: false, desc: '创建时间' },
|
||||
];
|
||||
42
src/views/api/openApi/index.vue
Normal file
42
src/views/api/openApi/index.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import OpenApiDocs from './OpenApiDocs.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'OpenApiDocPage',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="openapi-page-container">
|
||||
<div class="openapi-card">
|
||||
<OpenApiDocs />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.openapi-page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 16px 16px 24px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
background: var(--app-page-bg, #f0f3f4);
|
||||
}
|
||||
|
||||
.openapi-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
61
src/views/api/openApi/sign.ts
Normal file
61
src/views/api/openApi/sign.ts
Normal file
@ -0,0 +1,61 @@
|
||||
function toHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function sha256Hex(text: string): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return toHex(digest);
|
||||
}
|
||||
|
||||
async function hmacSha256Hex(secret: string, content: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(content));
|
||||
return toHex(signature);
|
||||
}
|
||||
|
||||
/** 与后端 CustomerOpenApiAuthHelper.ComputeSign 一致。 */
|
||||
export async function computeOpenApiSign(
|
||||
appKey: string,
|
||||
timestamp: string,
|
||||
rawBody: string,
|
||||
appSecret: string,
|
||||
): Promise<{ bodyHash: string; stringToSign: string; sign: string }> {
|
||||
const bodyHash = await sha256Hex(rawBody ?? '');
|
||||
const stringToSign = `${appKey}\n${timestamp}\n${bodyHash}`;
|
||||
const sign = await hmacSha256Hex(appSecret, stringToSign);
|
||||
return { bodyHash, stringToSign, sign };
|
||||
}
|
||||
|
||||
export function unixTimestampSeconds(): string {
|
||||
return String(Math.floor(Date.now() / 1000));
|
||||
}
|
||||
|
||||
export function escapeCurlSingleQuotes(value: string): string {
|
||||
return value.replace(/'/g, `'\\''`);
|
||||
}
|
||||
|
||||
export function buildSignedCurl(params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}): string {
|
||||
const headerFlags = Object.entries(params.headers)
|
||||
.map(([k, v]) => ` -H '${escapeCurlSingleQuotes(`${k}: ${v}`)}'`)
|
||||
.join(' \\\n');
|
||||
const body = params.body ?? '';
|
||||
const bodyFlag = body.length
|
||||
? ` \\\n -d '${escapeCurlSingleQuotes(body)}'`
|
||||
: '';
|
||||
return `curl -X ${params.method} '${escapeCurlSingleQuotes(params.url)}' \\\n${headerFlags}${bodyFlag}`;
|
||||
}
|
||||
@ -126,6 +126,12 @@ const FILE_TYPE_DEFS = [
|
||||
maxCount: 20,
|
||||
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
|
||||
},
|
||||
{
|
||||
type: 'pod',
|
||||
labelKey: 'order.myOrders.drawer.attachments.types.pod',
|
||||
maxCount: 20,
|
||||
accept: '.jpg,.jpeg,.png,.webp,.gif,.bmp,.pdf',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@ -431,7 +431,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
// API(itfx):{apiOrigin}/itfx
|
||||
// 使用正则表达式匹配,排除前端路由路径
|
||||
'^/api/(?!upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface|downstreamInterface/newPort|appList|appList/createApplication|requestLog|callStatistics|apiDebugger|trackApi)': {
|
||||
'^/api/(?!upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface|downstreamInterface/newPort|appList|appList/createApplication|requestLog|callStatistics|apiDebugger|apiTest|openApi|trackApi)': {
|
||||
target: `${apiOrigin}/itfx`,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
@ -557,6 +557,8 @@ export default defineConfig(({ mode }) => {
|
||||
'/api/requestLog',
|
||||
'/api/callStatistics',
|
||||
'/api/apiDebugger',
|
||||
'/api/apiTest',
|
||||
'/api/openApi',
|
||||
// Channel 相关前端路由
|
||||
'/channel/salesProducts',
|
||||
'/channel/serviceChannels',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user