feat: 增加客户 OpenAPI 文档页,支持轨迹与文件说明
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:
zzh 2026-10-05 18:55:07 +08:00
parent aca25c649a
commit 1026d909e9
16 changed files with 1762 additions and 55 deletions

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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', [

View File

@ -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',

View File

@ -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}须为有效数字',

View File

@ -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',

View File

@ -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',

View File

@ -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;

View 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>

View 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>

View 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: '创建时间' },
];

View 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>

View 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}`;
}

View File

@ -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(

View File

@ -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',