From df4b7eba8e5bbe41c62c9271a774b0d622a3c3e0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 10:44:14 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- README.md | 4 +-- docker-compose.test.yml | 8 ++---- nginx/default.conf.template | 14 ++++++++- src/api/crossTabRefresh.test.mjs | 35 +++++++++++++++++++++-- src/api/crossTabRefresh.ts | 21 +++++++++++++- src/api/http.ts | 8 +++++- src/deployment/nginxConfig.test.mjs | 9 ++++++ src/modules/auth/views/AuthViews.test.mjs | 2 ++ 9 files changed, 89 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index bfb8669..6144c14 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ VITE_API_BASE_URL=/api VITE_OVERSEAS_API_BASE_URL=/overseas-api OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300 -API_PROXY_TARGET=http://8.163.30.128:9000 +API_PROXY_TARGET=http://127.0.0.1:9000 VITE_ADMIN_WEB_ORIGIN=http://localhost:5173 diff --git a/README.md b/README.md index 39edc79..b2a4b55 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ pnpm test 依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。 -一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到测试环境 `http://8.163.30.128:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 +一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。 @@ -47,4 +47,4 @@ pnpm test 头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。 -联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://8.163.30.128:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。2026-07-31 测试 Gateway 登录和真实角色列表读取正常;Warehouse 服务已注册,但仓库和往来单位业务查询仍返回 `S0500`,头程草稿创建和相关浏览器流程需待后端修复后继续验收。 +联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d9e8b3c..53790ad 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: Dockerfile args: - VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-http://8.163.30.128:9001}" + VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-}" NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" image: client-web-test:local @@ -12,8 +12,6 @@ services: restart: unless-stopped ports: - "${HOST_PORT:-9002}:80" - extra_hosts: - - "host.docker.internal:host-gateway" environment: - # Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。 - API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" + API_UPSTREAM: "http://test-api-cn.buggoglobal.com" + OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" diff --git a/nginx/default.conf.template b/nginx/default.conf.template index bd49fde..605ebfd 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -16,7 +16,19 @@ server { client_max_body_size 12m; proxy_pass ${API_UPSTREAM}; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 海外业务使用独立 Gateway;浏览器前缀只在此处改写一次为 /api。 + location /overseas-api/ { + client_max_body_size 12m; + rewrite ^/overseas-api/(.*)$ /api/$1 break; + proxy_pass ${OVERSEAS_API_UPSTREAM}; + proxy_http_version 1.1; + proxy_set_header Host $proxy_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/src/api/crossTabRefresh.test.mjs b/src/api/crossTabRefresh.test.mjs index 44ddd28..200c354 100644 --- a/src/api/crossTabRefresh.test.mjs +++ b/src/api/crossTabRefresh.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { coordinateCrossTabRefresh } from './crossTabRefresh.ts' +import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh.ts' test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => { let currentRefreshToken = 'refresh-old' @@ -10,7 +10,7 @@ test('reuses tokens rotated by another browser tab while waiting for the refresh configurable: true, value: { locks: { - async request(_name, callback) { + async request(_name, _options, callback) { currentRefreshToken = 'refresh-new' return callback() }, @@ -41,7 +41,7 @@ test('refreshes normally when this tab owns the current refresh token', async () const originalNavigator = globalThis.navigator Object.defineProperty(globalThis, 'navigator', { configurable: true, - value: { locks: { request: async (_name, callback) => callback() } }, + value: { locks: { request: async (_name, _options, callback) => callback() } }, }) try { @@ -76,3 +76,32 @@ test('does not wait for rotation after the shared session was removed', async () Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) } }) + +test('aborts a refresh lock that cannot be acquired', async () => { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request(_name, options) { + return new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true }) + }) + }, + }, + }, + }) + + try { + await assert.rejects(coordinateCrossTabRefresh({ + lockName: 'client-refresh', + lockTimeoutMs: 5, + observedRefreshToken: 'refresh-old', + currentRefreshToken: () => 'refresh-old', + reuseCurrentSession: async () => 'unexpected', + refresh: async () => 'unexpected', + }), CrossTabRefreshTimeoutError) + } finally { + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator }) + } +}) diff --git a/src/api/crossTabRefresh.ts b/src/api/crossTabRefresh.ts index 6f429ec..11252f2 100644 --- a/src/api/crossTabRefresh.ts +++ b/src/api/crossTabRefresh.ts @@ -1,11 +1,21 @@ export interface CrossTabRefreshOptions { lockName: string + lockTimeoutMs?: number observedRefreshToken: string currentRefreshToken: () => string reuseCurrentSession: () => Promise refresh: (refreshToken: string) => Promise } +export class CrossTabRefreshTimeoutError extends Error { + constructor() { + super('登录状态刷新等待超时,请重新登录') + this.name = 'CrossTabRefreshTimeoutError' + } +} + +const DEFAULT_LOCK_TIMEOUT_MS = 20_000 + export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptions): Promise { const run = async () => { const currentRefreshToken = options.currentRefreshToken() @@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh(options: CrossTabRefreshOptio throw error } } - return navigator.locks.request(options.lockName, run) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) + try { + return await navigator.locks.request(options.lockName, { signal: controller.signal }, run) + } catch (error) { + if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError() + throw error + } finally { + clearTimeout(timeout) + } } diff --git a/src/api/http.ts b/src/api/http.ts index 50b2f63..c3df4fb 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -3,7 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' -import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh' +import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', @@ -78,6 +78,12 @@ export async function refreshAuthenticatedSession() { return tokens.accessToken }, }) + .catch((error) => { + if (error instanceof CrossTabRefreshTimeoutError) { + throw new ApiError(error.message, 'A0401', 401) + } + throw error + }) .finally(() => { refreshPromise = null }) diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 64ba5e7..809f9b8 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8') +const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8') const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8') const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8') @@ -10,6 +11,14 @@ test('allows qualification uploads with multipart overhead through the API proxy assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 12m;/) }) +test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => { + assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/) + assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/) + assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/) + assert.match(config, /proxy_set_header Host \$proxy_host;/) + assert.doesNotMatch(config, /\/api\/api/) +}) + test('runs each deployment on its target node without controller-local SSH files', () => { assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-dev'\) \? 'java-dev' : 'java-test'/) assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 88bcd41..9939122 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -87,6 +87,8 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /return await transport\.request\(request\)/) assert.match(httpSource, /envelope\.code !== '200'/) assert.match(httpSource, /redirectToLoginAfterSessionExpiry/) + assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError/) + assert.match(httpSource, /new ApiError\(error\.message, 'A0401', 401\)/) assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/) assert.match(authStoreSource, /decodeMerchantToken/) assert.doesNotMatch(authStoreSource, /mock-client:/) From 458c8d5961424b0fa63594b86c50052f0ed8611a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 10:44:59 +0800 Subject: [PATCH 02/15] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/http.ts | 6 +++++- src/modules/auth/views/AuthViews.test.mjs | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/api/http.ts b/src/api/http.ts index c3df4fb..9206f64 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -80,7 +80,11 @@ export async function refreshAuthenticatedSession() { }) .catch((error) => { if (error instanceof CrossTabRefreshTimeoutError) { - throw new ApiError(error.message, 'A0401', 401) + const expiredError = new ApiError(error.message, 'A0401', 401) + clearSessionTokens() + window.dispatchEvent(new CustomEvent('cc-client-session-expired')) + redirectToLoginAfterSessionExpiry() + throw expiredError } throw error }) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 9939122..f635cd4 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -88,7 +88,8 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(httpSource, /envelope\.code !== '200'/) assert.match(httpSource, /redirectToLoginAfterSessionExpiry/) assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError/) - assert.match(httpSource, /new ApiError\(error\.message, 'A0401', 401\)/) + assert.match(httpSource, /const expiredError = new ApiError\(error\.message, 'A0401', 401\)/) + assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/) assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/) assert.match(authStoreSource, /decodeMerchantToken/) assert.doesNotMatch(authStoreSource, /mock-client:/) From 22f0528593e79b534e9227497ccd1bbdf65e6c6d Mon Sep 17 00:00:00 2001 From: wujinliang <2318309077@qq.com> Date: Thu, 27 Aug 2026 21:49:18 +0800 Subject: [PATCH 03/15] 1 --- .idea/.gitignore | 8 ++++++++ .idea/client-web.iml | 9 +++++++++ .idea/misc.xml | 6 ++++++ .idea/modules.xml | 8 ++++++++ .idea/vcs.xml | 6 ++++++ docker-compose.test.yml | 2 +- src/deployment/nginxConfig.test.mjs | 1 + src/modules/first-leg/api/firstLeg.ts | 1 + src/modules/first-leg/store/firstLegOrders.test.mjs | 3 ++- src/modules/first-leg/store/firstLegOrders.ts | 2 +- src/modules/notifications/views/NotificationsView.vue | 11 +++++++++++ .../tenant-management/api/merchantIam.test.mjs | 9 +++++++++ src/modules/tenant-management/contracts.ts | 9 +++++++++ src/modules/tenant-management/views/MembersView.vue | 2 +- 14 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/client-web.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/client-web.iml b/.idea/client-web.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/client-web.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..07115cd --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e8c26cd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 53790ad..c3d40ea 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: Dockerfile args: - VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-}" + VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-https://test-admin.buggoglobal.com}" NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" image: client-web-test:local diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index 809f9b8..c8f4703 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -14,6 +14,7 @@ test('allows qualification uploads with multipart overhead through the API proxy test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => { assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/) assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/) + assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/) assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/) assert.match(config, /proxy_set_header Host \$proxy_host;/) assert.doesNotMatch(config, /\/api\/api/) diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts index fac2bbb..f11a035 100644 --- a/src/modules/first-leg/api/firstLeg.ts +++ b/src/modules/first-leg/api/firstLeg.ts @@ -238,6 +238,7 @@ export interface MerchantFirstLegOrderRow { inboundCode: string | null channelName: string | null destinationType: ApiDestinationType | null + pickupMethod: ApiPickupMethod | null customerName: string | null receivingCompanyName: string | null specificationModel: string | null diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 17fc525..db004c8 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -12,13 +12,14 @@ test('maps the first-leg aggregate list fields without loading detail rows', () assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/) assert.match(apiSource, /apiRequest>/) assert.match(storeSource, /function mapFirstLegOrderRow/) - for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) { + for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) { assert.match(storeSource, new RegExp(`row\\.${field}`)) } assert.match(storeSource, /row\.skuNos/) assert.match(storeSource, /row\.cargoes/) assert.match(storeSource, /chargeableWeightKg: null/) assert.match(storeSource, /chargeableVolumeCbm: null/) + assert.match(apiSource, /pickupMethod: ApiPickupMethod \\| null/) assert.match(storeSource, /orderResult\.value\.records\.map\(mapFirstLegOrderRow\)/) assert.match(storeSource, /orderResult\.value\.records\.map\(\(item\) => mapUnifiedOrderRow\(item, warehouses\.value\)\)/) }) diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index e6e56de..8fd10d9 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -398,7 +398,7 @@ function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder { inboundId: row.inboundId == null ? '' : String(row.inboundId), inboundCode: row.inboundCode ?? '', }, createdAt: row.createdAt, updatedAt: row.createdAt, tradeTerm: '', destinationCountryCode: '', destinationWarehouseId: '', - destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '', + destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: row.pickupMethod ?? 'SELF_EXPRESS', channelCode: '', channelName: row.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', customsInspection: false, destinationType: row.destinationType ?? 'PRIVATE', amazonWarehouseCode: '', shipmentId: row.shipmentId ?? row.internationalOrderNo ?? '', requestedPickupAt: '', receivingTime: '', submittedAt: '', completedAt: '', diff --git a/src/modules/notifications/views/NotificationsView.vue b/src/modules/notifications/views/NotificationsView.vue index 55eada1..bf73626 100644 --- a/src/modules/notifications/views/NotificationsView.vue +++ b/src/modules/notifications/views/NotificationsView.vue @@ -44,4 +44,15 @@ onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()]) diff --git a/src/modules/tenant-management/api/merchantIam.test.mjs b/src/modules/tenant-management/api/merchantIam.test.mjs index ab11e9c..c23acc3 100644 --- a/src/modules/tenant-management/api/merchantIam.test.mjs +++ b/src/modules/tenant-management/api/merchantIam.test.mjs @@ -107,3 +107,12 @@ test('keeps client baseline permissions selected when server resources do not de assert.match(permissionsViewSource, /:disabled="isRequiredPermission\(item\.code\)"/) assert.doesNotMatch(permissionsViewSource, /selectedCodes\.value\.unshift\('account:profile:view'\)/) }) + +test('exposes all message-center permissions in the enterprise role editor', () => { + for (const permission of ['merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all']) { + assert.match(contractsSource, new RegExp(`code: '${permission}'`)) + assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`)) + } + assert.match(contractsSource, /key: 'notifications'/) + assert.match(storeSource, /const mappedMerchantPermissionCodes = new Set\(Object\.values\(FRONTEND_TO_MERCHANT_PERMISSION_CODES\)\.flat\(\)\)/) +}) diff --git a/src/modules/tenant-management/contracts.ts b/src/modules/tenant-management/contracts.ts index 52a0831..b146221 100644 --- a/src/modules/tenant-management/contracts.ts +++ b/src/modules/tenant-management/contracts.ts @@ -25,6 +25,15 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [ label: '业务总览', items: [{ code: 'dashboard:view', label: '查看总览', description: '所有企业成员默认可用,可查看企业统计和最近订单' }], }, + { + key: 'notifications', + label: '消息中心', + items: [ + { code: 'merchant:notification:view', label: '查看我的消息', description: '查看本企业和当前成员可见的站内消息' }, + { code: 'merchant:notification:read', label: '标记消息已读', description: '将已查看的站内消息标记为已读' }, + { code: 'merchant:notification:read-all', label: '全部标记已读', description: '将当前成员的全部站内消息标记为已读' }, + ], + }, { key: 'finance', label: '财务账单', diff --git a/src/modules/tenant-management/views/MembersView.vue b/src/modules/tenant-management/views/MembersView.vue index b49c681..4dc0154 100644 --- a/src/modules/tenant-management/views/MembersView.vue +++ b/src/modules/tenant-management/views/MembersView.vue @@ -200,7 +200,7 @@ async function resetPassword(member: TenantMember) {
- +
请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。
From f28b7f178b0cc048008648ff2388e8e2e920ad54 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 16:54:16 +0800 Subject: [PATCH 04/15] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/http.ts | 24 +- src/api/types.ts | 1 + src/modules/first-leg/api/customs.test.mjs | 34 ++ src/modules/first-leg/api/customs.ts | 217 ++++++++++- .../components/CustomsDocumentsDialog.vue | 341 ++++++++++++++++++ .../views/FirstLegOrdersView.test.mjs | 32 +- .../first-leg/views/FirstLegOrdersView.vue | 90 +++-- 7 files changed, 679 insertions(+), 60 deletions(-) create mode 100644 src/modules/first-leg/api/customs.test.mjs create mode 100644 src/modules/first-leg/components/CustomsDocumentsDialog.vue diff --git a/src/api/http.ts b/src/api/http.ts index 9206f64..ed8a391 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -18,7 +18,7 @@ interface RetryableRequest extends InternalAxiosRequestConfig { function unwrap(envelope: ApiEnvelope) { if (envelope.code !== '200') { const fieldErrors = asFieldErrors(envelope.data) - throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId) + throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId) } return envelope.data } @@ -36,6 +36,7 @@ function mapError(error: unknown) { error.response?.status, fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, ) } if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR') @@ -142,6 +143,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) { const response = await transport.request({ ...config, responseType: 'blob' }) return response.data } catch (error) { + await throwBlobApiError(error) throw mapError(error) } } @@ -151,8 +153,28 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) { const response = await transport.request({ ...config, responseType: 'blob' }) return { blob: response.data, headers: response.headers } } catch (error) { + await throwBlobApiError(error) throw mapError(error) } } +async function throwBlobApiError(error: unknown) { + if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return + try { + const body = JSON.parse(await error.response.data.text()) as unknown + if (!isApiEnvelope(body)) return + const fieldErrors = asFieldErrors(body.data) + throw new ApiError( + apiErrorMessage(body.message, fieldErrors), + body.code, + error.response.status, + fieldErrors, + typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, + ) + } catch (parsedError) { + if (parsedError instanceof ApiError) throw parsedError + } +} + export { ApiError } diff --git a/src/api/types.ts b/src/api/types.ts index f8f6518..e262136 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -18,6 +18,7 @@ export class ApiError extends Error { readonly status?: number, readonly fieldErrors?: ApiFieldErrors, readonly traceId?: string | null, + readonly requestId?: string | null, ) { super(message) this.name = 'ApiError' diff --git a/src/modules/first-leg/api/customs.test.mjs b/src/modules/first-leg/api/customs.test.mjs new file mode 100644 index 0000000..1178ea2 --- /dev/null +++ b/src/modules/first-leg/api/customs.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./customs.ts', import.meta.url), 'utf8') +const httpTypes = readFileSync(new URL('../../../api/types.ts', import.meta.url), 'utf8') +const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8') + +test('maps only the frozen merchant container-customs contract', () => { + assert.match(source, /type MerchantCustomsStatus = 'WAITING_UPLOAD' \| 'DOCUMENT_UPLOADED' \| 'DECLARING' \| 'CUSTOMS_COMPLETED'/) + assert.match(source, /inboundId: string/) + assert.match(source, /orderId: string/) + assert.match(source, /documentId: string/) + assert.match(source, /loadingPlanId: string/) + assert.match(source, /url: `\/merchant\/v1\/customs\/orders\/\$\{encodedId\(orderId\)\}`/) + assert.doesNotMatch(source, /customs\/cases|MerchantCustomsCaseDetail|getMerchantCustomsByOrder/) +}) + +test('sends multi-inbound scope, optimistic versions and idempotency for every write', () => { + assert.match(source, /params\.append\('inboundIds', id\)/) + assert.match(source, /expectedVersion: document\.source\.version/) + assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/) + assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4) + assert.match(source, /data\.append\('file', file\)/) + assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/) +}) + +test('preserves request and trace identifiers on API errors', () => { + assert.match(httpTypes, /readonly requestId\?: string \| null/) + assert.match(httpSource, /envelope\.traceId, envelope\.requestId/) + assert.match(source, /interface ApiErrorMeta/) + assert.match(source, /requestId: error\.requestId \?\? null/) + assert.match(source, /traceId: error\.traceId \?\? null/) +}) diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index bf89f41..ea26674 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -1,31 +1,210 @@ -import { ApiError, apiRequest } from '@/api/http' +import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http' +import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg' -export interface MerchantCustomsReceipt { - id: number | string - receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string - receiptVersion: number | null - fileName: string | null - uploadedAt: string | null +export type MerchantCustomsStatus = 'WAITING_UPLOAD' | 'DOCUMENT_UPLOADED' | 'DECLARING' | 'CUSTOMS_COMPLETED' +export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED' + +export interface MerchantCustomsFile { + fileId: string + fileName: string + fileHash: string + contentType: string | null + sizeBytes: number + version: number + uploadedAt: string } -export interface MerchantCustomsCaseDetail { - customsCase: { - id: number | string - customsNo: string | null - orderId: number | string - status: string | null - updatedAt: string | null - } - receipts: MerchantCustomsReceipt[] +export interface MerchantCustomsInbound { + inboundId: string + orderId: string + orderNo: string + inboundCode: string + tenantId: string + customerName: string + taxMode: 'TAX_REFUND' | 'BUY_DECLARATION' + covered: boolean } -export function getMerchantCustomsByOrder(orderId: number | string) { - return apiRequest({ +export interface MerchantCustomsDocument { + documentId: string + tenantId: string + source: MerchantCustomsFile + inboundIds: string[] + inboundCodes: string[] + frozen: boolean + completion: MerchantCustomsFile | null +} + +export interface MerchantCustomsContainer { + aggregateId: string + loadingPlanId: string + containerBookingId: string + planNo?: string + cabinetNo?: string + containerNo: string | null + status: ContainerCustomsStatus + merchantStatus?: MerchantCustomsStatus | null + version: number + taxRefundCount: number + coveredTaxRefundCount: number + inbounds: MerchantCustomsInbound[] + documents: MerchantCustomsDocument[] + startedAt?: string | null + completedAt?: string | null +} + +export interface ApiErrorMeta { + code: string + requestId: string | null + traceId: string | null +} + +export interface MerchantCustomsDownload { + blob: Blob + fileName: string +} + +export const MERCHANT_CUSTOMS_STATUS_LABELS: Record = { + WAITING_UPLOAD: '待上传资料', + DOCUMENT_UPLOADED: '资料已上传', + DECLARING: '报关中', + CUSTOMS_COMPLETED: '报关完成', +} + +const MAX_CUSTOMS_FILE_SIZE = 50 * 1024 * 1024 + +function encodedId(value: number | string) { + return encodeURIComponent(String(value)) +} + +function inboundQuery(inboundIds: Array) { + const params = new URLSearchParams() + inboundIds.map(String).forEach((id) => params.append('inboundIds', id)) + return params.toString() +} + +function fileBody(file: File) { + const data = new FormData() + data.append('file', file) + return data +} + +export function validateMerchantCustomsFile(file: File) { + if (file.size > MAX_CUSTOMS_FILE_SIZE) throw new Error('单个文件不能超过 50MB') +} + +export function getMerchantCustomsForOrder(orderId: number | string) { + return apiRequest({ method: 'GET', - url: `/merchant/v1/customs/orders/${orderId}`, + url: `/merchant/v1/customs/orders/${encodedId(orderId)}`, }) } +export function uploadMerchantCustomsDocument(planId: number | string, inboundIds: Array, file: File) { + validateMerchantCustomsFile(file) + return apiRequest({ + method: 'POST', + url: `/merchant/v1/customs/loading-plans/${encodedId(planId)}/documents/upload?${inboundQuery(inboundIds)}`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-upload-${planId}`) }, + data: fileBody(file), + timeout: 120_000, + }) +} + +export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: Array, file: File) { + validateMerchantCustomsFile(file) + const params = new URLSearchParams(inboundQuery(inboundIds)) + params.set('expectedVersion', String(document.source.version)) + return apiRequest({ + method: 'POST', + url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/upload?${params.toString()}`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-replace-${document.documentId}`) }, + data: fileBody(file), + timeout: 120_000, + }) +} + +export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: Array) { + return apiRequest({ + method: 'PUT', + url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) }, + data: { inboundIds: inboundIds.map(String), expectedVersion: document.source.version }, + }) +} + +export function deleteMerchantCustomsDocument(document: MerchantCustomsDocument) { + return apiRequest({ + method: 'DELETE', + url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}`, + headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-delete-${document.documentId}`) }, + params: { expectedVersion: document.source.version }, + }) +} + +function contentDispositionFileName(value: unknown, fallback: string) { + if (typeof value !== 'string') return fallback + const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) { + try { return decodeURIComponent(encoded) } catch { return fallback } + } + return value.match(/filename="?([^";]+)"?/i)?.[1] || fallback +} + +async function requireBinaryDownload(blob: Blob) { + if (!blob.type.toLowerCase().includes('json')) return + try { + const body = JSON.parse(await blob.text()) as { code?: unknown; message?: unknown; requestId?: unknown; traceId?: unknown } + if (typeof body.message === 'string') { + throw new ApiError( + body.message, + typeof body.code === 'string' ? body.code : 'DOWNLOAD_FAILED', + undefined, + undefined, + typeof body.traceId === 'string' ? body.traceId : null, + typeof body.requestId === 'string' ? body.requestId : null, + ) + } + } catch (error) { + if (error instanceof ApiError) throw error + } +} + +async function download(url: string, fallbackName: string): Promise { + const response = await apiBlobResponseRequest({ method: 'GET', url }) + await requireBinaryDownload(response.blob) + return { + blob: response.blob, + fileName: contentDispositionFileName(response.headers['content-disposition'], fallbackName), + } +} + +export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) { + return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName) +} + +export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) { + return download( + `/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`, + document.completion?.fileName || `customs-completion-${document.documentId}`, + ) +} + +export function merchantCustomsErrorMeta(error: unknown): ApiErrorMeta | null { + if (!(error instanceof ApiError)) return null + return { code: error.code, requestId: error.requestId ?? null, traceId: error.traceId ?? null } +} + +export function merchantCustomsErrorMessage(error: unknown, fallback: string) { + if (!(error instanceof ApiError)) return error instanceof Error ? error.message : fallback + const identifiers = [ + error.code ? `错误码 ${error.code}` : '', + error.requestId ? `请求号 ${error.requestId}` : '', + error.traceId ? `追踪号 ${error.traceId}` : '', + ].filter(Boolean) + return `${error.message || fallback}${identifiers.length ? `(${identifiers.join(',')})` : ''}` +} + export function isMerchantCustomsUnavailable(error: unknown) { return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') } diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue new file mode 100644 index 0000000..83648a0 --- /dev/null +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 0070544..a74f1bd 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -6,6 +6,8 @@ const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url) const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8') const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8') const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8') +const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8') +const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8') test('routes the confirmed first-leg path to the implemented order view', () => { assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/) @@ -198,7 +200,7 @@ test('shows one unified progress line and C02 business summaries without detail assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/) assert.match(progressSource, /国内装柜进度及后续履约同步/) assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/) - assert.match(source, /报关回执/) + assert.doesNotMatch(source, /

报关回执<\/h3>/) assert.match(source, /船期摘要/) assert.match(source, /柜次进度/) assert.doesNotMatch(source, /summary\.sealNo \|\|/) @@ -492,6 +494,34 @@ test('copies reactive order drafts into plain request data', () => { assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/) }) +test('opens customer customs documents only for server-confirmed loaded tax-refund orders', () => { + assert.match(source, /order\.status !== 'LOADED'/) + assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/) + assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/) + assert.match(source, /v-if="eligibleCustomsDetail\(row as FirstLegOrder\)"[\s\S]*?报关资料/) + assert.match(source, / { + for (const path of [ + '/merchant/v1/customs/orders/', + '/merchant/v1/customs/loading-plans/', + '/merchant/v1/customs/documents/', + '/inbounds', + '/completion/download', + ]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/'))) + assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/) + assert.match(customsApiSource, /inboundIds: inboundIds\.map\(String\)/) + assert.match(customsApiSource, /requestId: error\.requestId/) + assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/) + assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/) + assert.match(customsDialogSource, /资料版本历史/) + assert.match(customsDialogSource, /尚未上传退税报关资料/) + assert.match(customsDialogSource, /退税报关资料加载失败/) + assert.doesNotMatch(customsDialogSource, /accept=/) +}) + test('uses the global inbound-code policy to require old-system codes during parallel running', () => { assert.match(source, /getMerchantInboundCodePolicy\(\)/) assert.match(source, /async function validateEditorForm\(\)[\s\S]*?const policy = await getMerchantInboundCodePolicy\(\)/) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 8f64b5a..e6ba9d1 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -2,7 +2,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus' -import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue' +import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, FileText, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue' import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' @@ -10,8 +10,9 @@ import { ApiError } from '@/api/http' import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue' import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue' import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue' +import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue' import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling' -import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs' +import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs' import { estimateMerchantFirstLegPrice, downloadMerchantOrderCartonLabelsBatch, @@ -95,7 +96,7 @@ const cargoTemplateColumns = [ const cargoNumberKeys = new Set(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm']) const fixedOrderColumns: DataTableColumn[] = [ { key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true }, - { key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, + { key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, { key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' }, ] const selectableOrderColumns: DataTableColumn[] = [ @@ -188,9 +189,10 @@ const inboundBillingLoading = ref(false) const inboundBillingUnavailable = ref(false) const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布') const inboundBillingError = ref('') -const customsDetail = ref(null) -const customsLoading = ref(false) -const customsUnavailable = ref(false) +const customsByOrder = reactive>({}) +const customsEligibilityPending = new Set() +const customsDialogOpen = ref(false) +const customsDialogOrderId = ref('') const supportingDataLoading = ref(false) const supportingDataErrors = ref([]) const destinationWarehousesLoading = ref(false) @@ -461,6 +463,41 @@ const pagedOrders = computed(() => { return filteredOrders.value.slice(start, start + tablePageSize.value) }) +function eligibleCustomsDetail(order: FirstLegOrder) { + const value = customsByOrder[order.id] + if (order.status !== 'LOADED' || !value?.merchantStatus) return null + return value.inbounds.some((item) => item.orderId === order.id && item.taxMode === 'TAX_REFUND') ? value : null +} + +async function loadCustomsEligibility(order: FirstLegOrder) { + if (order.status !== 'LOADED' || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return + customsEligibilityPending.add(order.id) + try { + customsByOrder[order.id] = await getMerchantCustomsForOrder(order.id) + } catch (error) { + customsByOrder[order.id] = null + if (!isMerchantCustomsUnavailable(error)) { + // Keep the operation hidden when eligibility cannot be proven by the customer-scoped server response. + } + } finally { + customsEligibilityPending.delete(order.id) + } +} + +watch(() => pagedOrders.value.map((order) => `${order.id}:${order.status}`).join('|'), () => { + pagedOrders.value.forEach((order) => void loadCustomsEligibility(order)) +}, { immediate: true }) + +function openCustomsDocuments(order: FirstLegOrder) { + if (!eligibleCustomsDetail(order)) return + customsDialogOrderId.value = order.id + customsDialogOpen.value = true +} + +function updateCustomsDetail(value: MerchantCustomsContainer) { + if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value +} + const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined) const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null) const pagedDetailCargoItems = computed(() => { @@ -486,7 +523,6 @@ const progressSummaryItems = computed(() => { { label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' }, { label: '拣货进度', value: pickingProgress }, { label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' }, - { label: '报关回执', value: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' }, { label: '船期摘要', value: transportReference || '待更新' }, { label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') }, { label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') }, @@ -598,9 +634,6 @@ watch(isCreatePage, (isCreate, wasCreate) => { watch(detailOpen, (open) => { if (!open) { resetInboundBillingState() - customsDetail.value = null - customsLoading.value = false - customsUnavailable.value = false if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items) } }) @@ -1073,7 +1106,6 @@ async function openDetail(order: FirstLegOrder) { detailLoading.value = true detailError.value = '' void loadInboundBilling(order) - void loadCustoms(order) try { const detailedOrder = await store.loadDetail(order.id) // Load previews from the persisted file IDs after the store has replaced the detail row. @@ -1138,21 +1170,6 @@ async function loadInboundBilling(order: FirstLegOrder) { } } -async function loadCustoms(order: FirstLegOrder) { - customsDetail.value = null - customsUnavailable.value = false - if (order.status === 'DRAFT') return - customsLoading.value = true - try { - customsDetail.value = await getMerchantCustomsByOrder(order.id) - } catch (error) { - if (isMerchantCustomsUnavailable(error)) customsUnavailable.value = true - else ElMessage.warning(error instanceof Error ? error.message : '报关回执加载失败') - } finally { - customsLoading.value = false - } -} - async function retryInboundBilling() { const order = detailId.value ? store.getById(detailId.value) : undefined if (order) await loadInboundBilling(order) @@ -1632,6 +1649,7 @@ async function searchOrders(model: QueryModel) {
详情 编辑 + 报关资料
@@ -2226,9 +2241,6 @@ async function searchOrders(model: QueryModel) { .cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; } .cabinet-progress-card dt { color: var(--muted); font-size: 10px; } .cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; } -.customs-receipt-list { display: grid; gap: 8px; } -.customs-receipt-list article { display: flex; flex-wrap: wrap; gap: 10px; padding: 11px 13px; border: 1px solid var(--border); color: var(--muted); font-size: 11px; } -.customs-receipt-list strong { color: var(--navy); } .express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); } .express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; } .express-information span { color: var(--muted); font-size: 11px; } From 6a445bbb8fc00b4041a5bb1f9900ef777d442036 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 17:10:17 +0800 Subject: [PATCH 05/15] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 02257ae..830237b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ | 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` | | 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` | | 头程下单、订单、入仓码/SKU 查询、箱唛与履约进度 | `src/modules/first-leg/`;列表与查询从 `views/FirstLegOrdersView.vue`、`store/firstLegOrders.ts`、`api/unifiedOrders.ts` 进入,箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/`、`firstleg/`、`warehouse/`;稳定箱码与打印契约先读 Backend `firstleg/AGENTS.md` | +| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、收款、未收余额与客户确认 | `src/modules/finance/`;确认入口 `views/FinanceBillsView.vue`、`api/finance.ts` | Backend `finance/`;只能查看登录态客户的已出账账单。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认以登录成员、`expectedVersion` 和幂等键提交,成功后记录确认人姓名快照与时间 | | 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 | From a10ee73ba9165efe9baf17b68848f1e83a7edd36 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 17:27:12 +0800 Subject: [PATCH 06/15] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E9=80=80=E7=A8=8E=E6=8A=A5=E5=85=B3=E9=95=BFID?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/first-leg/api/customs.test.mjs | 2 ++ src/modules/first-leg/api/customs.ts | 18 +++++++++--------- .../views/FirstLegOrdersView.test.mjs | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/modules/first-leg/api/customs.test.mjs b/src/modules/first-leg/api/customs.test.mjs index 1178ea2..2e780ee 100644 --- a/src/modules/first-leg/api/customs.test.mjs +++ b/src/modules/first-leg/api/customs.test.mjs @@ -12,6 +12,8 @@ test('maps only the frozen merchant container-customs contract', () => { assert.match(source, /orderId: string/) assert.match(source, /documentId: string/) assert.match(source, /loadingPlanId: string/) + assert.match(source, /getMerchantCustomsForOrder\(orderId: string\)/) + assert.doesNotMatch(source, /number \| string/) assert.match(source, /url: `\/merchant\/v1\/customs\/orders\/\$\{encodedId\(orderId\)\}`/) assert.doesNotMatch(source, /customs\/cases|MerchantCustomsCaseDetail|getMerchantCustomsByOrder/) }) diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts index ea26674..6e7d15f 100644 --- a/src/modules/first-leg/api/customs.ts +++ b/src/modules/first-leg/api/customs.ts @@ -73,13 +73,13 @@ export const MERCHANT_CUSTOMS_STATUS_LABELS: Record) { +function inboundQuery(inboundIds: string[]) { const params = new URLSearchParams() - inboundIds.map(String).forEach((id) => params.append('inboundIds', id)) + inboundIds.forEach((id) => params.append('inboundIds', id)) return params.toString() } @@ -93,14 +93,14 @@ export function validateMerchantCustomsFile(file: File) { if (file.size > MAX_CUSTOMS_FILE_SIZE) throw new Error('单个文件不能超过 50MB') } -export function getMerchantCustomsForOrder(orderId: number | string) { +export function getMerchantCustomsForOrder(orderId: string) { return apiRequest({ method: 'GET', url: `/merchant/v1/customs/orders/${encodedId(orderId)}`, }) } -export function uploadMerchantCustomsDocument(planId: number | string, inboundIds: Array, file: File) { +export function uploadMerchantCustomsDocument(planId: string, inboundIds: string[], file: File) { validateMerchantCustomsFile(file) return apiRequest({ method: 'POST', @@ -111,7 +111,7 @@ export function uploadMerchantCustomsDocument(planId: number | string, inboundId }) } -export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: Array, file: File) { +export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: string[], file: File) { validateMerchantCustomsFile(file) const params = new URLSearchParams(inboundQuery(inboundIds)) params.set('expectedVersion', String(document.source.version)) @@ -124,12 +124,12 @@ export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument }) } -export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: Array) { +export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: string[]) { return apiRequest({ method: 'PUT', url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`, headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) }, - data: { inboundIds: inboundIds.map(String), expectedVersion: document.source.version }, + data: { inboundIds, expectedVersion: document.source.version }, }) } diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index a74f1bd..b395768 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -512,7 +512,7 @@ test('supports the frozen customs document workflow and customer-safe downloads' '/completion/download', ]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/'))) assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/) - assert.match(customsApiSource, /inboundIds: inboundIds\.map\(String\)/) + assert.match(customsApiSource, /data: \{ inboundIds, expectedVersion: document\.source\.version \}/) assert.match(customsApiSource, /requestId: error\.requestId/) assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/) assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/) From 033091ab590729c608c9e0eea795bac63032c675 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 22:05:40 +0800 Subject: [PATCH 07/15] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E7=AA=84=E5=B1=8F=E9=80=80=E7=A8=8E=E8=B5=84=E6=96=99?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/first-leg/views/FirstLegOrdersView.test.mjs | 1 + src/modules/first-leg/views/FirstLegOrdersView.vue | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index b395768..f758ac6 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -499,6 +499,7 @@ test('opens customer customs documents only for server-confirmed loaded tax-refu assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/) assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/) assert.match(source, /v-if="eligibleCustomsDetail\(row as FirstLegOrder\)"[\s\S]*?报关资料/) + assert.match(source, /class="mobile-order-card"[\s\S]*?v-if="eligibleCustomsDetail\(order\)"[\s\S]*?openCustomsDocuments\(order\)[\s\S]*?报关资料/) assert.match(source, /入仓码 {{ order.businessSummary.inboundCode }} {{ order.referenceNo || '无 Reference ID' }}
目的地
{{ order.destination || '待更新' }}
目的仓类型
{{ destinationTypeLabels[order.destinationType] }}
收货仓
{{ order.receivingWarehouseName || '待更新' }}
货物摘要
{{ getOrderCargoSummary(order) }}
运输方式
{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '待更新' }}
交货方式
{{ pickupMethodLabels[order.pickupMethod] }}
预约提货时间
{{ order.pickupMethod === 'PICKUP' && order.requestedPickupAt ? formatDateTime(order.requestedPickupAt) : '-' }}
柜号 / 柜次
{{ order.businessSummary.cabinetNo || order.businessSummary.containerNo }}
船名 / 航次
{{ [order.businessSummary.vesselName, order.businessSummary.voyageNo].filter(Boolean).join(' · ') }}
预计船期
{{ formatDateTime(order.businessSummary.sailingAt) }}
-
更新于 {{ formatDateTime(order.updatedAt) }}
详情编辑
+
+ 更新于 {{ formatDateTime(order.updatedAt) }} +
+ 详情 + 编辑 + 报关资料 +
+
From ee5c0493d0f2d5945420fb31ceb1dfd82ad5d9a8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 28 Aug 2026 22:12:09 +0800 Subject: [PATCH 08/15] =?UTF-8?q?=E6=8F=90=E5=8D=87=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AFAPI=E4=B8=8A=E4=BC=A0=E8=AF=B7=E6=B1=82=E4=BD=93?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/default.conf.template | 4 ++-- src/deployment/nginxConfig.test.mjs | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/nginx/default.conf.template b/nginx/default.conf.template index 605ebfd..2c714ab 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -13,7 +13,7 @@ server { # 前端统一请求 /api,保持原始路径转发给 Gateway。 location /api/ { - client_max_body_size 12m; + client_max_body_size 55m; proxy_pass ${API_UPSTREAM}; proxy_http_version 1.1; proxy_set_header Host $proxy_host; @@ -24,7 +24,7 @@ server { # 海外业务使用独立 Gateway;浏览器前缀只在此处改写一次为 /api。 location /overseas-api/ { - client_max_body_size 12m; + client_max_body_size 55m; rewrite ^/overseas-api/(.*)$ /api/$1 break; proxy_pass ${OVERSEAS_API_UPSTREAM}; proxy_http_version 1.1; diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index c8f4703..c32b26a 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -7,8 +7,10 @@ const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8') const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8') -test('allows qualification uploads with multipart overhead through the API proxy', () => { - assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 12m;/) +test('allows 50 MiB uploads with multipart overhead through both API proxies', () => { + assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 55m;/) + assert.match(config, /location \/overseas-api\/ \{[\s\S]*?client_max_body_size 55m;/) + assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2) }) test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => { From 2cbc362795a2f0a56c07ed388f90c69df4092ba3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 29 Aug 2026 10:58:00 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=85=A5=E5=BA=93=E8=B4=B9=E7=94=A8=E6=9C=AA=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/first-leg/views/FirstLegOrdersView.test.mjs | 1 + src/modules/first-leg/views/FirstLegOrdersView.vue | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index f758ac6..b6760bf 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -452,6 +452,7 @@ test('loads merchant inbound billing as an isolated read-only detail section', ( assert.match(source, / Date: Sat, 29 Aug 2026 11:01:15 +0800 Subject: [PATCH 10/15] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/first-leg/components/CustomsDocumentsDialog.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index 83648a0..fe048e4 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -262,7 +262,7 @@ function formatSize(bytes: number) { {{ item.inboundCode }} - {{ item.orderNo }} + From 545a0f23ed51db6e622d1dfeff97ca73f2ae422d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 29 Aug 2026 11:45:57 +0800 Subject: [PATCH 11/15] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E8=B4=B9=E7=94=A8=E6=8D=A2=E7=AE=97=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=A9=BA=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/InboundBillingPanel.test.mjs | 2 +- .../first-leg/components/InboundBillingPanel.vue | 11 ++++++----- src/modules/first-leg/types/inboundBilling.ts | 6 +++--- .../first-leg/utils/inboundBilling.test.mjs | 9 ++++++++- src/modules/first-leg/utils/inboundBilling.ts | 15 +++++++++++++-- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index 98edded..083ba22 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -19,7 +19,7 @@ test('shows the confirmed merchant inbound and fee fields without write actions' assert.match(source, new RegExp(label)) } assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) - assert.match(source, /getRoundingModeLabel\(props\.data\.latestFee\.roundingMode\)/) + assert.match(source, /formatConversionPrecision\(/) assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/) assert.doesNotMatch(source, /internalNotes|statusHistory|files/) assert.match(source, /latestFee\.ruleCode/) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index 0b15be3..ec715c1 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -2,12 +2,12 @@ import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue' import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling' import { + formatConversionPrecision, formatInboundDateTime, formatInboundMeasure, formatInboundMoney, formatInboundNumber, getChargeModeLabel, - getRoundingModeLabel, getTaxModeLabel, } from '@/modules/first-leg/utils/inboundBilling' @@ -109,10 +109,11 @@ defineEmits<{
计费方式
{{ getChargeModeLabel(props.data.latestFee.chargeMode) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}
换算与精度
-
- 系数 {{ formatInboundNumber(props.data.latestFee.conversionFactor) }} · - 保留 {{ props.data.latestFee.roundingScale }} 位 {{ getRoundingModeLabel(props.data.latestFee.roundingMode) }} -
+
{{ formatConversionPrecision( + props.data.latestFee.conversionFactor, + props.data.latestFee.roundingScale, + props.data.latestFee.roundingMode, + ) }}
实际重量
{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}
实际体积
{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}
diff --git a/src/modules/first-leg/types/inboundBilling.ts b/src/modules/first-leg/types/inboundBilling.ts index e700078..68ffe25 100644 --- a/src/modules/first-leg/types/inboundBilling.ts +++ b/src/modules/first-leg/types/inboundBilling.ts @@ -79,9 +79,9 @@ export interface MerchantInboundFeeSnapshot { destinationCode: string taxMode: string chargeMode: 'KG' | 'CBM' - conversionFactor: number - roundingScale: number - roundingMode: string + conversionFactor: number | null + roundingScale: number | null + roundingMode: string | null actualWeightKg: number actualVolumeCbm: number convertedQuantity: number diff --git a/src/modules/first-leg/utils/inboundBilling.test.mjs b/src/modules/first-leg/utils/inboundBilling.test.mjs index bb093f5..fec95b4 100644 --- a/src/modules/first-leg/utils/inboundBilling.test.mjs +++ b/src/modules/first-leg/utils/inboundBilling.test.mjs @@ -16,5 +16,12 @@ test('translates backend rounding modes into customer-facing calculation labels' assert.match(source, /export function getRoundingModeLabel/) assert.match(source, /CEILING: '向上取整'/) assert.match(source, /HALF_UP: '四舍五入'/) - assert.match(source, /return labels\[value\] \|\| value \|\| '-'/) + assert.match(source, /return value \? labels\[value\] \|\| value : '-'/) +}) + +test('renders conversion metadata as one complete label or a single empty marker', () => { + assert.match(source, /export function formatConversionPrecision/) + assert.match(source, /roundingScale === null \|\| roundingScale === undefined/) + assert.match(source, /\|\| !roundingMode\) return '-'/) + assert.match(source, /`系数 \$\{formatInboundNumber\(conversionFactor\)\} · 保留 \$\{roundingScale\} 位 \$\{getRoundingModeLabel\(roundingMode\)\}`/) }) diff --git a/src/modules/first-leg/utils/inboundBilling.ts b/src/modules/first-leg/utils/inboundBilling.ts index 842cd58..600fcad 100644 --- a/src/modules/first-leg/utils/inboundBilling.ts +++ b/src/modules/first-leg/utils/inboundBilling.ts @@ -46,7 +46,7 @@ export function getTaxModeLabel(value: string) { return value || '-' } -export function getRoundingModeLabel(value: string) { +export function getRoundingModeLabel(value: string | null | undefined) { const labels: Record = { UP: '向上舍入', DOWN: '向下舍入', @@ -56,5 +56,16 @@ export function getRoundingModeLabel(value: string) { HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入', } - return labels[value] || value || '-' + return value ? labels[value] || value : '-' +} + +export function formatConversionPrecision( + conversionFactor: number | null | undefined, + roundingScale: number | null | undefined, + roundingMode: string | null | undefined, +) { + if (conversionFactor === null || conversionFactor === undefined + || roundingScale === null || roundingScale === undefined + || !roundingMode) return '-' + return `系数 ${formatInboundNumber(conversionFactor)} · 保留 ${roundingScale} 位 ${getRoundingModeLabel(roundingMode)}` } From da92b75ae19a6f248c896560605b29460039124e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 30 Aug 2026 17:35:52 +0800 Subject: [PATCH 12/15] feat(client): show inbound business relationships --- .../api/inboundCodeAssociations.test.mjs | 19 +++ .../first-leg/api/inboundCodeAssociations.ts | 22 ++++ .../components/CustomsDocumentsDialog.vue | 4 +- .../InboundCodeAssociationDialog.test.mjs | 32 +++++ .../InboundCodeAssociationDialog.vue | 115 ++++++++++++++++++ .../views/FirstLegOrdersView.test.mjs | 17 ++- .../first-leg/views/FirstLegOrdersView.vue | 55 ++++++--- .../first-leg/views/QuickOrderView.test.mjs | 5 +- .../first-leg/views/QuickOrderView.vue | 8 +- .../views/businessIdentifiers.test.mjs | 27 ++++ .../views/WarehouseServiceView.vue | 14 +-- 11 files changed, 288 insertions(+), 30 deletions(-) create mode 100644 src/modules/first-leg/api/inboundCodeAssociations.test.mjs create mode 100644 src/modules/first-leg/api/inboundCodeAssociations.ts create mode 100644 src/modules/first-leg/components/InboundCodeAssociationDialog.test.mjs create mode 100644 src/modules/first-leg/components/InboundCodeAssociationDialog.vue create mode 100644 src/modules/first-leg/views/businessIdentifiers.test.mjs diff --git a/src/modules/first-leg/api/inboundCodeAssociations.test.mjs b/src/modules/first-leg/api/inboundCodeAssociations.test.mjs new file mode 100644 index 0000000..f2b4173 --- /dev/null +++ b/src/modules/first-leg/api/inboundCodeAssociations.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./inboundCodeAssociations.ts', import.meta.url), 'utf8') + +test('queries the customer-scoped inbound-code association without browser-selected customer scope', () => { + assert.match(source, /method: 'GET'/) + assert.match(source, /`\/merchant\/v1\/orders\/first-leg\/inbound-code-associations\/\$\{encodeURIComponent\(inboundCode\.trim\(\)\)\}`/) + assert.doesNotMatch(source, /params:[\s\S]*customerId/) +}) + +test('keeps internal association identifiers as strings without making them display keys', () => { + for (const field of ['orderId', 'customerId']) assert.match(source, new RegExp(`${field}: string`)) + assert.match(source, /destinationWarehouseId: string \| null/) + for (const field of ['inboundCode', 'orderNo', 'customerName', 'destinationWarehouseCode', 'destinationWarehouseName']) { + assert.match(source, new RegExp(`${field}: string`)) + } +}) diff --git a/src/modules/first-leg/api/inboundCodeAssociations.ts b/src/modules/first-leg/api/inboundCodeAssociations.ts new file mode 100644 index 0000000..48b9392 --- /dev/null +++ b/src/modules/first-leg/api/inboundCodeAssociations.ts @@ -0,0 +1,22 @@ +import { apiRequest } from '@/api/http' + +export interface MerchantInboundCodeAssociation { + inboundCode: string + orderNo: string + orderStatus: string + orderId: string + customerId: string + customerName: string + destinationType: string + destinationCountryCode: string | null + destinationWarehouseId: string | null + destinationWarehouseCode: string | null + destinationWarehouseName: string | null +} + +export function getMerchantInboundCodeAssociation(inboundCode: string) { + return apiRequest({ + method: 'GET', + url: `/merchant/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode.trim())}`, + }) +} diff --git a/src/modules/first-leg/components/CustomsDocumentsDialog.vue b/src/modules/first-leg/components/CustomsDocumentsDialog.vue index fe048e4..0a975dd 100644 --- a/src/modules/first-leg/components/CustomsDocumentsDialog.vue +++ b/src/modules/first-leg/components/CustomsDocumentsDialog.vue @@ -240,7 +240,7 @@ function formatSize(bytes: number) {
柜次 - {{ detail.containerNo || detail.cabinetNo || detail.planNo || detail.loadingPlanId }} + {{ detail.containerNo || detail.cabinetNo || detail.planNo || '—' }}
资料覆盖 @@ -281,7 +281,7 @@ function formatSize(bytes: number) {
{{ item.source.fileName }} 版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }} - 关联入仓码:{{ item.inboundCodes.join('、') || '-' }} + 关联入仓码:{{ item.inboundCodes.join('、') || '—' }}
diff --git a/src/modules/first-leg/components/InboundCodeAssociationDialog.test.mjs b/src/modules/first-leg/components/InboundCodeAssociationDialog.test.mjs new file mode 100644 index 0000000..18f0218 --- /dev/null +++ b/src/modules/first-leg/components/InboundCodeAssociationDialog.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./InboundCodeAssociationDialog.vue', import.meta.url), 'utf8') + +test('loads only the customer-scoped server association and fails closed', () => { + assert.match(source, /getMerchantInboundCodeAssociation\(inboundCode\)/) + assert.match(source, /association\.value = null/) + assert.match(source, /关联摘要加载失败/) + assert.match(source, /@click="loadAssociation"/) + assert.doesNotMatch(source, /orderId\)|customerId\)|destinationWarehouseId\)/) +}) + +test('renders business identifiers and names without internal long identifiers', () => { + for (const label of ['入仓码', '订单号', '订单状态', '客户名称', '目的国家', '目的仓']) { + assert.match(source, new RegExp(label)) + } + assert.match(source, /association\.inboundCode/) + assert.match(source, /association\.orderNo/) + assert.match(source, /association\.customerName/) + assert.match(source, /destinationWarehouseCode/) + assert.match(source, /destinationWarehouseName/) + assert.doesNotMatch(source, /association\.(?:orderId|customerId|destinationWarehouseId)/) + assert.match(source, /\|\| '—'/) +}) + +test('uses a bounded centered summary dialog suitable for the desktop detail flow', () => { + assert.match(source, /]*width="620px"/) + assert.match(source, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/) + assert.match(source, /min-height: 190px/) +}) diff --git a/src/modules/first-leg/components/InboundCodeAssociationDialog.vue b/src/modules/first-leg/components/InboundCodeAssociationDialog.vue new file mode 100644 index 0000000..12fe916 --- /dev/null +++ b/src/modules/first-leg/components/InboundCodeAssociationDialog.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index b6760bf..e54cd6d 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -223,9 +223,10 @@ test('uses only fields present on C02 rows before loading full detail', () => { test('shows and searches the inbound code after a draft is submitted', () => { assert.match(source, /label: '入仓码 \/ SKU'/) assert.match(source, /order\.businessSummary\.inboundCode/) - assert.match(source, / - + From e22c671de41b696f31f1d12422e0bbb8a7df5d43 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 30 Aug 2026 18:26:37 +0800 Subject: [PATCH 13/15] test(client): align contracts with current behavior --- .../views/FirstLegOrdersView.test.mjs | 25 +++++++++++-------- src/styles/navigationStyles.test.mjs | 6 ++--- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index e54cd6d..6aeeda4 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -34,7 +34,7 @@ test('exposes real list, draft, submit, and detail workflow entries without canc assert.match(source, /function submitOrder\(\)/) assert.match(source, /async function openDetail\(order: FirstLegOrder\)/) assert.match(source, /Promise\.allSettled\(\[/) - assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\)/) + assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\{ includeDrafts: true \}\)/) assert.match(source, /Promise\.allSettled\(\[\s*orderPageRequest,\s*companyRequest/) assert.match(source, /tenantStore\.loadCompanyContext\(\)/) assert.match(source, /await store\.loadDetail\(order\.id\)/) @@ -58,7 +58,7 @@ test('defers approval checks to the server when the member cannot read the compa assert.match(source, /const canViewCompanyProfile = computed/) assert.match(source, /const companySubmissionBlocked = computed\(\(\) => companyAuditLoaded\.value/) assert.match(source, /const knownCompanyAuditStatus = companyAuditLoaded\.value \? tenantStore\.company\.auditStatus : undefined/) - assert.match(source, /store\.submit\([^\n]*knownCompanyAuditStatus\)/) + assert.match(source, /store\.submitSaved\(savedOrder\.id, knownCompanyAuditStatus\)/) }) test('renders dynamic cargo lines and a responsive editor dialog', () => { @@ -91,7 +91,7 @@ test('uses the shared query toolbar and data table without metric cards', () => assert.match(source, /async function searchOrders\(model: QueryModel\)/) assert.match(source, /await store\.load\(\{/) assert.match(source, / { + const queryToolbarSource = source.slice(source.indexOf('') + ''.length) assert.match(source, /class="order-table-toolbar"/) assert.match(source, /订单列表<\/strong>共 \{\{ filteredOrders\.length \}\} 条<\/span>/) assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/) - assert.doesNotMatch(source, //) + assert.match(queryToolbarSource, /