# Conflicts:
#	src/modules/overseas-warehouse/api/warehouseServices.ts
#	src/modules/overseas-warehouse/views/WarehouseServiceView.vue
This commit is contained in:
2026-09-06 15:59:14 +08:00
60 changed files with 2109 additions and 200 deletions
+13 -10
View File
@@ -3,7 +3,7 @@
## 项目定位
- 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
- 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。
@@ -43,22 +43,25 @@
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
| 登录、注册与会话 | `src/modules/auth/` | Backend `auth/`、`iam/` |
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` |
| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常旋转的国内/海外 Token 会话统一持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token |
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 |
| 工作台与系统入口 | `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` |
| 头程下单、订单、入仓码业务关系、箱唛与履约进度 | `src/modules/first-leg/`;列表从 `views/FirstLegOrdersView.vue` 进入,点击入仓码由 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` 查询订单、客户和目的仓关联摘要;箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/` 的客户隔离 `InboundCodeAssociationService`、`order-v1.yaml`,以及 `firstleg/`、`warehouse/`;不得用内部 ID 补齐缺失业务编号 |
| 头程下单、中文批量导入、入仓码关系、箱唛、履约进度与入库费用确认 | `src/modules/first-leg/`;列表与批量入口 `views/FirstLegOrdersView.vue`,模板/导入 API `api/firstLeg.ts`,入仓详情 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts`,独立计费重弹框 `components/InboundBillingPanel.vue`、`api/inboundBilling.ts` | 中文 XLSX 模板按当前客户生成地址、全部有效仓库和渠道下拉,最多 50 行,只创建草稿并支持部分/原子导入;导入结果只显示 Excel 行号,不展示内部 `clientRowId`。所有订单行均可进入计费重弹框,尚未推送时展示明确空态;只有具备编辑权限且服务端返回当前 `SENT` 费用时才可二次确认并提交 `expectedFeeVersion` 与幂等键,成功后版本锁定、平台不得再修改。Backend `order/`、`firstleg/`、`warehouse/`,不得用内部 ID 补齐缺失业务编号 |
| 装柜完成后的退税报关资料 | `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` 的发布状态与阶段范围,不自动扩展实现 |
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
| 客户资金账户与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际、冻结、可用余额和不可变流水;充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 |
| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID |
| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;Backend `lastleg-service/` 的 `relabel/`、`transfer/`、`discard/`,请求统一经 `src/api/overseasHttp.ts` 与 `gateway-overseas/` |
## 租户、认证与数据安全
- 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。
- 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。
- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;国内请求使用国内 Access Token,海外请求统一由 `src/api/overseasHttp.ts` 读取短时海外 Token。海外 Token 缺失或 401 时复用同一刷新编排并最多重放一次,页面不要复制令牌逻辑或持久化密码、敏感资质内容。
- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `contactPhone`,个人资料或成员管理中的登录手机号由 IAM/Auth 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。
- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;国内请求使用国内 Access Token,海外请求统一由 `src/api/overseasHttp.ts` 读取短时海外 Token。启动时 Access Token 已过期但 Refresh Token 仍存在,必须先尝试恢复会话;并发或跨标签页 401 共用一次刷新编排,每个原请求最多重放一次,只有明确 401/会话失效才清理 Token 并保留原路径跳转登录页。登录页“记住账号”只保存登录 ID,不改变 Access/Refresh Token 的持久化与续期策略;页面不要复制令牌逻辑或持久化密码、敏感资质内容。
- 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。
- 头程订单统一使用当前正式读写契约;不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时按根无兼容规则同步删除生产分支、类型、文案、Mock 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
@@ -95,17 +98,17 @@ pnpm build
## 全量验收脚本
- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。
- Client 全量验收唯一入口为 `../../baiyue-firstleg/scripts/client-test-env-acceptance.mjs`;入库测量与计费专项场景才单独执行 `client-phase2-test-env-acceptance.mjs`,不替代完整入口。
- 入口通过测试环境 HTTP 验证注册/审核、客户隔离、订单、入库计费、文件、财务和权限链路,会创建或修改测试数据,必须获得当前对话明确授权。
- `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Client 服务已完成全量验收。
- HTTP 验收通过后,由人工使用开发验收账号逐页检查真实接口调用、客户可见字段、原型、权限、错误态、空态和 `1920×1080` 桌面布局,并将截图发送给 AI 分析;未收到人工截图时标记为待人工确认,不追加 AI 截图或窄屏浏览器验收。
## Client 全量验收快速索引
当前按客户侧全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
当前按客户侧全量业务闭环验收。业务域文档用于核对功能来源和验收证据,不限制本次验收的模块范围;按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
| 验收域 | Client 范围与代码入口 | 验收首读 |
|---|---|---|---|
|---|---|---|
| 客户基础与订单 | 登录/注册、企业资料与资质、成员/角色权限、工作台和订单 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;按客户可见契约核对权限、状态、幂等和错误处理 |
| 履约、海外仓与财务 | 入库计费、柜次进度、船期、海外仓贴标/转运/废弃/退货换标、消息和客户账单 | `src/modules/first-leg/`、`overseas-warehouse/`、`finance/`、`notifications/`;按服务端权限核对客户可见字段、客户隔离和只读/写入边界 |
+3 -3
View File
@@ -35,16 +35,16 @@ pnpm test
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经海外 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
登录表单在开发和生产环境均默认留空;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。
登录表单在开发和生产环境默认留空;用户主动选择“记住账号”后只回填登录账号、不保存密码,也不改变 Access/Refresh Token 的持久化和刷新策略;URL 显式传入的登录用户名仍优先。源码和构建产物不得包含联调账号、默认密码或开发提示文案。
“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除分别调用 `PUT /merchant/v1/iam/roles/{roleId}`、`PUT /merchant/v1/iam/roles/{roleId}/status` 和 `DELETE /merchant/v1/iam/roles/{roleId}`,内置角色及仍有关联成员的角色继续受保护。
企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照、法人身份证为必填资质,货代资质证书和银行开户许可证为选填资质,均使用文件服务分别上传,接受 PDF、JPG、PNG 且不超过 5MB;两项必填资质齐全后可显式提交审核,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地订单与客户验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
+14
View File
@@ -11,6 +11,19 @@ server {
return 200 "ok\n";
}
# SPA 入口每次重新校验,避免发布后浏览器继续运行旧资源清单。
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
# Vite 产物文件名带内容哈希,可安全长期缓存。
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# 前端统一请求 /api,保持原始路径转发给 Gateway。
location /api/ {
client_max_body_size 55m;
@@ -36,6 +49,7 @@ server {
# 支持 Vue Router history 路由直接刷新。
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
try_files $uri $uri/ /index.html;
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240" role="img" aria-labelledby="title desc">
<title id="title">开发环境示例收款二维码</title>
<desc id="desc">仅用于开发流程验收,不包含真实收款信息</desc>
<rect width="240" height="240" fill="#fff"/>
<g fill="#111827">
<path d="M16 16h64v64H16zm10 10v44h44V26zm10 10h24v24H36zM160 16h64v64h-64zm10 10v44h44V26zm10 10h24v24h-24zM16 160h64v64H16zm10 10v44h44v-44zm10 10h24v24H36z"/>
<path d="M96 16h12v12H96zm24 0h12v24h-12zm16 8h12v12h-12zM88 44h20v12H88zm28 8h12v20h-12zm20-8h12v28h-12zM88 84h12v20H88zm24 0h28v12h-28zm36 4h12v20h-12zm20 0h20v12h-20zm28 0h28v12h-28zM16 96h20v12H16zm32-8h12v28H48zm20 8h20v12H68zm96 16h20v12h-20zm32-8h28v12h-28zM16 124h12v20H16zm20-8h20v12H36zm32 4h20v20H68zm92 16h12v16h-12zm20-4h20v12h-20zm28 4h16v20h-16zM92 160h20v12H92zm28-8h12v28h-12zm16 8h20v12h-20zm28 0h20v20h-20zm32 4h28v12h-28zM88 188h12v20H88zm20-4h20v12h-20zm28 4h16v20h-16zm28 0h12v12h-12zm20 0h20v12h-20zm28 4h12v28h-12zM96 216h24v8H96zm36-4h28v12h-28zm48 4h20v8h-20z"/>
</g>
<rect x="82" y="100" width="76" height="42" rx="5" fill="#fff" stroke="#111827" stroke-width="3"/>
<text x="120" y="119" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" font-weight="700" fill="#111827">开发示例</text>
<text x="120" y="135" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#b42318">不可付款</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+5 -5
View File
@@ -19,12 +19,12 @@
- 当前开发阶段按目标模型直接修改;破坏性修改必须同步更新当前应用全部消费者、测试和接口映射,不保留旧模型兼容分支。
- 本包没有独立质量脚本;变更后运行父应用声明的类型检查、测试或构建命令。
## 一期/二期/三期契约索引
## 订单与客户/入库测量与计费/国内货柜履约契约索引
- 一期共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `src/modules/` 全部消费者。
- 二期仅承接客户可见的到仓与计费结果。`WAIT_SPLIT + APPROVED + CONFIRMED` 为当前终点;内部测量明细、备注和文件权限不得通过共享类型绕过。
- 三期仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Client 模块复用时才新增共享导出;先读 `../../../docs/三期/三期开工门禁.md`。只建客户可见的柜次、进度、授权回执和船期类型,不通过 shared 暴露照片、锁码或后台内部资料。
- 订单与客户共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `src/modules/` 全部消费者。
- 入库测量与计费仅承接客户可见的到仓与计费结果。`WAIT_SPLIT + APPROVED + CONFIRMED` 为当前终点;内部测量明细、备注和文件权限不得通过共享类型绕过。
- 国内货柜履约仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Client 模块复用时才新增共享导出;先读 `../../../docs/国内货柜履约/国内货柜履约开工门禁.md`。只建客户可见的柜次、进度、授权回执和船期类型,不通过 shared 暴露照片、锁码或后台内部资料。
定位规则:从 `src/index.ts` 的导出开始,反查模块 `api`、store 和 `contracts.ts` 消费者;字段含义不明确时回到服务端 DTO/OpenAPI,不在 shared 中扩展业务语义。
阶段文档入口(路径相对本文件所在目录):一期 `../../../docs/一期/README.md`;二期 `../../../docs/二期/README.md` 与 `../../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`;三期 `../../../docs/三期/README.md`、`../../../docs/三期/三期开发规划.md` 和 `../../../docs/三期/三期需求确认表.md`。范围与契约以阶段确认和设计文档为准,原型只作补充。
业务域文档入口(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/README.md`;入库测量与计费 `../../../docs/入库测量与计费/README.md` 与 `../../../docs/入库测量与计费/入库测量与计费需求优化版-v2-测量计费入库主流程.md`;国内货柜履约 `../../../docs/国内货柜履约/README.md`、`../../../docs/国内货柜履约/国内货柜履约开发规划.md` 和 `../../../docs/国内货柜履约/国内货柜履约需求确认表.md`。范围与契约以正式确认和设计文档为准,原型只作补充。
+31 -11
View File
@@ -1,32 +1,52 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useAuthStore } from '@/modules/auth/public'
let readyInterval: number | null = null
let expectedAdminOrigin = ''
let impersonationExchangePending = false
function configuredImpersonationOrigin() {
const queryOrigin = new URLSearchParams(window.location.search).get('impersonationOrigin')
if (queryOrigin) {
try { return new URL(queryOrigin).origin } catch { return '' }
}
return String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim() || window.location.origin
}
onMounted(() => {
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
const targetOrigin = configuredAdminOrigin || window.location.origin
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, targetOrigin)
expectedAdminOrigin = configuredImpersonationOrigin()
if (!window.opener || !expectedAdminOrigin) return
window.addEventListener('message', acceptImpersonationTicket)
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, expectedAdminOrigin)
announceReady()
readyInterval = window.setInterval(announceReady, 300)
window.setTimeout(() => {
if (readyInterval !== null) window.clearInterval(readyInterval)
readyInterval = null
}, 10000)
window.addEventListener('message', stopReadyAnnouncement)
})
function stopReadyAnnouncement(event: MessageEvent<unknown>) {
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
const message = event.data as { source?: string; type?: string } | null
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || readyInterval === null) return
window.clearInterval(readyInterval)
async function acceptImpersonationTicket(event: MessageEvent<unknown>) {
const message = event.data as { source?: string; type?: string; code?: string } | null
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code || impersonationExchangePending) return
impersonationExchangePending = true
if (readyInterval !== null) window.clearInterval(readyInterval)
readyInterval = null
const auth = useAuthStore()
try {
await auth.acceptImpersonationCode(message.code)
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-complete' }, expectedAdminOrigin)
window.location.replace('/')
} catch {
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-failed' }, expectedAdminOrigin)
window.location.replace('/login?impersonation=failed')
}
}
onBeforeUnmount(() => {
if (readyInterval !== null) window.clearInterval(readyInterval)
window.removeEventListener('message', stopReadyAnnouncement)
window.removeEventListener('message', acceptImpersonationTicket)
})
</script>
+7 -4
View File
@@ -46,6 +46,11 @@ function mapError(error: unknown) {
let refreshPromise: Promise<string> | null = null
let redirectingToLogin = false
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
function isPublicAuthRequest(url?: string) {
return publicAuthPaths.some((path) => url?.endsWith(path))
}
function redirectToLoginAfterSessionExpiry() {
if (redirectingToLogin || typeof window === 'undefined') return
@@ -98,7 +103,7 @@ export async function refreshAuthenticatedSession() {
transport.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}`
return config
})
@@ -106,9 +111,7 @@ transport.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const request = error.config as RetryableRequest | undefined
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path))
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) {
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
throw mapError(error)
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY,
REMEMBERED_LOGIN_ID_KEY,
clearSessionTokens,
getRememberedLoginId,
persistRememberedLoginId,
persistTokenPair,
} from './session.ts'
function storage() {
const values = new Map()
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
}
}
function installStorage() {
globalThis.localStorage = storage()
globalThis.sessionStorage = storage()
}
test('persists the normal rotating session independently of remembered account preference', () => {
installStorage()
persistTokenPair({
tokenType: 'Bearer',
accessToken: 'access-token',
expiresIn: 1440,
refreshToken: 'refresh-token',
refreshExpiresIn: 604800,
})
assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), 'access-token')
assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), 'refresh-token')
assert.equal(sessionStorage.getItem(ACCESS_TOKEN_KEY), null)
assert.equal(sessionStorage.getItem(REFRESH_TOKEN_KEY), null)
})
test('remembers only the login id and keeps it when session tokens are cleared', () => {
installStorage()
persistRememberedLoginId('merchant-user', true)
localStorage.setItem(ACCESS_TOKEN_KEY, 'access-token')
localStorage.setItem(REFRESH_TOKEN_KEY, 'refresh-token')
clearSessionTokens()
assert.equal(getRememberedLoginId(), 'merchant-user')
assert.equal(localStorage.getItem(REMEMBERED_LOGIN_ID_KEY), 'merchant-user')
assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), null)
assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), null)
persistRememberedLoginId('merchant-user', false)
assert.equal(getRememberedLoginId(), '')
})
+15 -6
View File
@@ -2,6 +2,7 @@ export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token'
export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token'
export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at'
export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id'
export interface TokenPair {
tokenType: string
@@ -33,17 +34,25 @@ export function getOverseasAccessToken() {
return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || ''
}
export function persistTokenPair(tokens: TokenPair, remember?: boolean) {
const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage
export function persistTokenPair(tokens: TokenPair) {
clearSessionTokens()
storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) {
storage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
storage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
}
}
export function getRememberedLoginId() {
return localStorage.getItem(REMEMBERED_LOGIN_ID_KEY) || ''
}
export function persistRememberedLoginId(loginId: string, remember: boolean) {
if (remember) localStorage.setItem(REMEMBERED_LOGIN_ID_KEY, loginId)
else localStorage.removeItem(REMEMBERED_LOGIN_ID_KEY)
}
export function persistTemporaryAccessToken(accessToken: string) {
clearSessionTokens()
sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
+1
View File
@@ -19,6 +19,7 @@ declare module 'vue' {
DataTable: typeof import('./components/common/DataTable.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+6
View File
@@ -14,6 +14,12 @@ test('allows 50 MiB uploads with multipart overhead through both API proxies', (
assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
})
test('does not cache the SPA entry while keeping hashed assets immutable', () => {
assert.match(config, /location = \/index\.html[\s\S]*no-store, no-cache, must-revalidate/)
assert.match(config, /location \/assets\/[\s\S]*max-age=31536000, immutable/)
assert.match(config, /location \/ \{[\s\S]*no-store, no-cache, must-revalidate[\s\S]*try_files \$uri \$uri\/ \/index\.html/)
})
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"/)
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { readdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const sourceRoot = path.dirname(fileURLToPath(import.meta.url))
function collectVueFiles(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(directory, entry.name)
return entry.isDirectory() ? collectVueFiles(target) : entry.name.endsWith('.vue') ? [target] : []
})
}
function lineNumber(source, offset) {
return source.slice(0, offset).split('\n').length
}
function hasLiteralAttribute(tag, name) {
return new RegExp(`\\s${name}(?:\\s|=|/?>)`).test(tag)
}
function isEditableNativeTextInput(tag) {
if (hasLiteralAttribute(tag, 'disabled') || hasLiteralAttribute(tag, 'readonly')) return false
const type = tag.match(/\stype\s*=\s*["']([^"']+)["']/)?.[1]?.toLowerCase() ?? 'text'
return ['text', 'search', 'email', 'tel', 'url', 'password'].includes(type)
}
test('all editable text inputs provide a user-facing placeholder', () => {
const violations = []
for (const file of collectVueFiles(sourceRoot)) {
const source = readFileSync(file, 'utf8')
const template = source.match(/<template(?:\s[^>]*)?>([\s\S]*)<\/template>/)?.[1] ?? ''
const withoutComments = template.replace(/<!--[\s\S]*?-->/g, '')
const attributeContent = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*`
const tagPattern = new RegExp(`<el-input(?:-number)?\\b${attributeContent}/?>|<(?:input|textarea)\\b${attributeContent}/?>`, 'g')
for (const match of withoutComments.matchAll(tagPattern)) {
const tag = match[0]
const isElementInput = tag.startsWith('<el-input')
const editable = isElementInput
? !hasLiteralAttribute(tag, 'disabled') && !hasLiteralAttribute(tag, 'readonly')
: isEditableNativeTextInput(tag)
if (editable && !/\s:?(?:placeholder)\s*=/.test(tag)) {
violations.push(`${path.relative(sourceRoot, file)}:${lineNumber(withoutComments, match.index ?? 0)} ${tag.replace(/\s+/g, ' ')}`)
}
}
}
assert.deepEqual(violations, [], `Editable text inputs without placeholders:\n${violations.join('\n')}`)
})
+21 -17
View File
@@ -2,7 +2,7 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { CurrentUser } from '@chongchong/shared'
import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
import { ApiError } from '@/api/http'
import { ApiError, refreshAuthenticatedSession } from '@/api/http'
import {
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
hasMappedMerchantPermission,
@@ -11,8 +11,8 @@ import {
getTenantProfile,
updateMerchantPersonalProfile,
} from '@/modules/tenant-management/public'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistRememberedLoginId, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
import { decodeMerchantToken, MerchantTokenExpiredError, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
export interface ClientAccountProfile {
username: string
@@ -103,8 +103,8 @@ export const useAuthStore = defineStore('client-auth', () => {
if (token.value) {
try {
applyToken(token.value)
} catch {
clearSession()
} catch (error) {
if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession()
}
}
@@ -133,8 +133,20 @@ export const useAuthStore = defineStore('client-auth', () => {
async function restoreSession() {
if (sessionRestored.value) return
if (!isAuthenticated.value) {
sessionRestored.value = true
return
if (!getRefreshToken()) {
sessionRestored.value = true
return
}
try {
applyToken(await refreshAuthenticatedSession())
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
return
}
}
try {
await loadSessionContext()
@@ -152,7 +164,8 @@ export const useAuthStore = defineStore('client-auth', () => {
async function login(loginId: string, password: string, remember = false) {
const tokens = await loginMerchant(loginId, password)
const decoded = decodeMerchantToken(tokens.accessToken)
persistTokenPair(tokens, remember)
persistTokenPair(tokens)
persistRememberedLoginId(loginId, remember)
token.value = tokens.accessToken
payload.value = decoded
user.value = currentUser(decoded)
@@ -223,15 +236,6 @@ export const useAuthStore = defineStore('client-auth', () => {
}
window.addEventListener('cc-client-session-expired', clearSession)
window.addEventListener('message', (event: MessageEvent<unknown>) => {
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
const message = event.data as { source?: string; type?: string; code?: string } | null
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code) return
void acceptImpersonationCode(message.code)
.then(() => window.location.replace('/'))
.catch(() => window.location.replace('/login?impersonation=failed'))
})
window.addEventListener('storage', (event) => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
})
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { decodeMerchantToken } from './jwt.ts'
import { decodeMerchantToken, MerchantTokenExpiredError } from './jwt.ts'
function tokenFor(payload) {
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
@@ -29,5 +29,5 @@ test('rejects malformed tokens', () => {
})
test('rejects expired tokens', () => {
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), /登录状态已过期/)
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), MerchantTokenExpiredError)
})
+10 -2
View File
@@ -9,6 +9,13 @@ export interface MerchantJwtPayload {
exp: number
}
export class MerchantTokenExpiredError extends Error {
constructor() {
super('登录状态已过期')
this.name = 'MerchantTokenExpiredError'
}
}
function decodeBase64Url(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
@@ -27,10 +34,11 @@ export function decodeMerchantToken(token: string): MerchantJwtPayload {
const payload = value as Partial<MerchantJwtPayload>
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
if (payload.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
if (payload.exp * 1000 <= Date.now()) throw new MerchantTokenExpiredError()
return payload as MerchantJwtPayload
} catch (error) {
if (error instanceof Error && ['账号不属于当前应用端', '登录状态已过期'].includes(error.message)) throw error
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
if (error instanceof MerchantTokenExpiredError) throw error
throw new Error('登录凭证无法解析,请重新登录')
}
}
+17 -3
View File
@@ -13,6 +13,8 @@ const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url)
const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8')
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8')
const appSource = readFileSync(new URL('../../../App.vue', import.meta.url), 'utf8')
const routerSource = readFileSync(new URL('../../../router/index.ts', import.meta.url), 'utf8')
const stylesSource = readFileSync(new URL('../../../styles/index.css', import.meta.url), 'utf8')
test('uses one prototype-inspired responsive shell for login and registration', () => {
@@ -42,10 +44,14 @@ test('matches the frozen username-or-mobile login contract', () => {
assert.match(loginSource, /忘记密码/)
})
test('persists both rotating tokens according to the remember choice', () => {
test('persists rotating tokens independently and only remembers the login id by choice', () => {
assert.match(sessionSource, /ACCESS_TOKEN_KEY/)
assert.match(sessionSource, /REFRESH_TOKEN_KEY/)
assert.match(sessionSource, /remember \? localStorage : sessionStorage/)
assert.match(sessionSource, /localStorage\.setItem\(ACCESS_TOKEN_KEY, tokens\.accessToken\)/)
assert.match(sessionSource, /localStorage\.setItem\(REFRESH_TOKEN_KEY, tokens\.refreshToken\)/)
assert.match(sessionSource, /persistRememberedLoginId/)
assert.match(loginSource, /getRememberedLoginId/)
assert.match(loginSource, /记住账号/)
assert.match(sessionSource, /clearSessionTokens/)
})
@@ -91,6 +97,7 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(httpSource, /refreshPromise/)
assert.match(httpSource, /\/auth\/refresh/)
assert.match(httpSource, /persistTokenPair\(tokens\)/)
assert.match(httpSource, /token && !isPublicAuthRequest\(config\.url\)/)
assert.match(httpSource, /request\._retriedAfterRefresh = true/)
assert.match(httpSource, /return await transport\.request\(request\)/)
assert.match(httpSource, /envelope\.code !== '200'/)
@@ -100,12 +107,19 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
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.match(authStoreSource, /MerchantTokenExpiredError && getRefreshToken\(\)/)
assert.match(authStoreSource, /applyToken\(await refreshAuthenticatedSession\(\)\)/)
assert.doesNotMatch(authStoreSource, /mock-client:/)
})
test('handles impersonation exchange as public authentication and exposes failures', () => {
assert.match(httpSource, /publicAuthPaths = \[[^\]]*'\/auth\/impersonations\/exchange'/)
assert.match(authStoreSource, /\/login\?impersonation=failed/)
assert.match(appSource, /impersonationOrigin/)
assert.match(appSource, /await auth\.acceptImpersonationCode\(message\.code\)/)
assert.match(appSource, /type: 'impersonation-complete'/)
assert.match(appSource, /type: 'impersonation-failed'/)
assert.doesNotMatch(authStoreSource, /message\.type !== 'impersonation-ticket'/)
assert.match(routerSource, /isImpersonationHandoff[\s\S]*!isImpersonationHandoff/)
assert.match(loginSource, /route\.query\.impersonation === 'failed'/)
assert.match(loginSource, /代登录失败或授权已失效,请返回管理后台重新发起/)
})
+5 -3
View File
@@ -5,6 +5,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
import { useAuthStore } from '@/modules/auth/store/auth'
import AuthShell from '@/modules/auth/components/AuthShell.vue'
import { getRememberedLoginId } from '@/api/session'
interface LoginForm {
account: string
@@ -19,12 +20,13 @@ const formRef = ref<FormInstance>()
const loading = ref(false)
const impersonationFailed = route.query.impersonation === 'failed'
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
const rememberedLoginId = getRememberedLoginId()
const form = reactive<LoginForm>({
account: typeof route.query.account === 'string'
? route.query.account
: '',
: rememberedLoginId,
password: '',
remember: false,
remember: Boolean(rememberedLoginId),
})
const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
const mainlandMobilePattern = /^1[3-9]\d{9}$/
@@ -113,7 +115,7 @@ function showResetHint() {
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
</el-form-item>
<div class="auth-form-options">
<el-checkbox v-model="form.remember">记住我</el-checkbox>
<el-checkbox v-model="form.remember">记住账号</el-checkbox>
<button type="button" @click="showResetHint">忘记密码?</button>
</div>
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
@@ -0,0 +1,165 @@
import { overseasApiRequest } from '@/api/overseasHttp'
export interface PageResult<T> {
records: T[]
page: number
size: number
total: number
pages: number
}
export type CrossBorderBusinessKind = 'procurement' | 'trucking' | 'looseboard'
export interface BusinessOrderQuery {
page: number
size: number
orderNo?: string
status?: string
routeKeyword?: string
productName?: string
purchaseCategory?: string
transportType?: string
transportMode?: string
requestedFrom?: string
requestedTo?: string
createdFrom?: string
createdTo?: string
}
export interface ProcurementSummary {
orderNo: string
purchaseCategory: string
productName: string
productSpecification: string | null
purchaseQuantity: number
budgetUnitPrice: number | null
currency: string | null
inboundQuantity: number
outboundQuantity: number
inventoryQuantity: number
status: string
version: number
createdAt: string
updatedAt: string
}
export interface ProcurementDetail {
summary: ProcurementSummary
productUrl: string | null
receivingAddress: string | null
contactName: string
contactPhone: string
domesticCarrier: string | null
domesticTrackingNo: string | null
overseasCarrier: string | null
overseasTrackingNo: string | null
estimatedArrivalAt: string | null
remark: string | null
reviewRemark: string | null
}
export interface ProcurementCreate {
customerName: string
purchaseCategory: string
productName: string
productSpecification?: string
productUrl?: string
purchaseQuantity: number
budgetUnitPrice?: number
currency?: string
receivingAddress?: string
contactName: string
contactPhone: string
remark?: string
idempotencyKey: string
}
export interface TransportSummary {
orderNo: string
transportType: string
transportMode?: string
palletRequired?: string
origin: string
destination: string
containerType: string | null
palletCount?: number
packageCount: number
totalWeightKg: number
totalVolumeCbm: number
budgetAmount: number | null
currency: string | null
contactName: string | null
contactInfo: string | null
requestedDate: string | null
status: string
version: number
createdAt: string
updatedAt: string
}
export interface TransportDetail {
summary: TransportSummary
originAddress: string | null
destinationAddress: string | null
remark: string | null
}
export interface TransportCreate {
transportType: string
transportMode?: string
palletRequired?: string
origin: string
originAddress?: string
destination: string
destinationAddress?: string
containerType?: string
palletCount?: number
packageCount: number
totalWeightKg: number
totalVolumeCbm: number
budgetAmount?: number
currency?: string
contactName?: string
contactInfo?: string
requestedDate?: string
remark?: string
idempotencyKey: string
}
export interface TransportUpdate extends Omit<TransportCreate, 'idempotencyKey'> {
version: number
}
const endpointByKind: Record<CrossBorderBusinessKind, string> = {
procurement: '/merchant/v1/last-leg/overseas-procurements',
trucking: '/merchant/v1/last-leg/us-truck-orders',
looseboard: '/merchant/v1/last-leg/local-looseboard-orders',
}
function normalizePage<T>(data: PageResult<T>): PageResult<T> {
return { ...data, page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
}
export function listBusinessOrders<T>(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) {
return overseasApiRequest<PageResult<T>>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage)
}
export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: string) {
return overseasApiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
}
export function createProcurement(payload: ProcurementCreate) {
return overseasApiRequest<ProcurementDetail>({ method: 'POST', url: endpointByKind.procurement, data: payload })
}
export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) {
return overseasApiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
}
export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) {
return overseasApiRequest<TransportDetail>({
method: kind === 'trucking' ? 'PATCH' : 'PUT',
url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`,
data: payload,
})
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8')
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8')
test('activates all three customer cross-border pages with frozen permissions', () => {
for (const [path, permission] of [
['cross-border/procurement', 'merchant:cross-border:procurement'],
['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking'],
['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping'],
]) {
assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`))
}
assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i)
})
test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => {
assert.match(api, /overseas-procurements/)
assert.match(api, /us-truck-orders/)
assert.match(api, /local-looseboard-orders/)
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
assert.match(view, /const stableSubmitKey = submitKey\.value \|\| idempotencyKey\(\)/)
assert.match(view, /submitKey\.value = stableSubmitKey/)
assert.match(view, /idempotencyKey: stableSubmitKey/g)
})
test('covers list detail create loading error empty pagination and actions-first contracts', () => {
assert.match(view, /mode\.value = 'detail'/)
assert.match(view, /mode\.value = 'list'/)
assert.match(view, /mode = 'create'/)
assert.match(view, /v-loading="loading"/)
assert.match(view, /type="error"/)
assert.match(view, /empty-text="暂无订单"/)
assert.match(view, /<el-pagination/)
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
})
@@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/modules/auth/public'
import {
createProcurement,
createTransport,
getBusinessOrder,
listBusinessOrders,
type CrossBorderBusinessKind,
type ProcurementDetail,
type ProcurementSummary,
type TransportDetail,
type TransportSummary,
} from '../api/businessOrders'
const props = defineProps<{ kind: CrossBorderBusinessKind }>()
const auth = useAuthStore()
type BusinessRow = ProcurementSummary | TransportSummary
type BusinessDetail = ProcurementDetail | TransportDetail
type PageMode = 'list' | 'create' | 'detail'
interface ColumnDefinition { key: string; label: string; width?: number }
interface BusinessForm {
purchaseCategory: string
productName: string
productSpecification: string
productUrl: string
purchaseQuantity: number
transportMode: string
palletRequired: string
transportType: string
origin: string
originAddress: string
destination: string
destinationAddress: string
containerType: string
palletCount: number
packageCount: number
totalWeightKg: number
totalVolumeCbm: number
budgetAmount?: number
currency: string
contactName: string
contactInfo: string
requestedDate: string
receivingAddress: string
remark: string
}
const configuration = computed(() => ({
procurement: {
title: '海外采购',
description: '查询并管理海外采购订单',
columns: [
{ key: 'orderNo', label: '订单号', width: 180 },
{ key: 'purchaseCategory', label: '采购品类', width: 130 },
{ key: 'productName', label: '商品名称', width: 160 },
{ key: 'productSpecification', label: '商品规格', width: 160 },
{ key: 'purchaseQuantity', label: '采购数量', width: 100 },
{ key: 'budgetUnitPrice', label: '预算单价', width: 110 },
{ key: 'status', label: '状态', width: 120 },
{ key: 'createdAt', label: '创建时间', width: 170 },
] satisfies ColumnDefinition[],
},
trucking: {
title: '美国本土卡',
description: '查询并管理美国本土卡订单',
columns: transportColumns(false),
},
looseboard: {
title: '美本土散板',
description: '查询并管理美本土散板订单',
columns: transportColumns(true),
},
})[props.kind])
function transportColumns(looseboard: boolean): ColumnDefinition[] {
return [
{ key: 'orderNo', label: '订单编号', width: 180 },
...(looseboard ? [{ key: 'transportMode', label: '运输方式', width: 120 }, { key: 'palletRequired', label: '是否打盘', width: 100 }] : []),
{ key: 'transportType', label: '运输类型', width: 130 },
{ key: 'origin', label: '起运地', width: 150 },
{ key: 'destination', label: '目的地', width: 150 },
{ key: 'containerType', label: '柜型', width: 100 },
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
{ key: 'budgetAmount', label: '预算总价', width: 110 },
{ key: 'requestedDate', label: '要求时间', width: 120 },
{ key: 'status', label: '状态', width: 130 },
]
}
const mode = ref<PageMode>('list')
const loading = ref(false)
const submitting = ref(false)
const submitKey = ref('')
const error = ref('')
const rows = ref<BusinessRow[]>([])
const detail = ref<BusinessDetail | null>(null)
const formRef = ref<FormInstance>()
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
const total = ref(0)
const form = reactive<BusinessForm>(emptyForm())
const rules: FormRules<BusinessForm> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
purchaseQuantity: [{ required: true, type: 'number', min: 1, message: '采购数量至少为 1', trigger: 'change' }],
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }],
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
contactInfo: [{ required: true, message: '请输入联系电话或联系方式', trigger: 'blur' }],
requestedDate: [{ required: true, message: '请选择要求时间', trigger: 'change' }],
}
const detailFields = computed(() => {
if (!detail.value) return []
const summary = detail.value.summary
if (props.kind === 'procurement') {
const current = detail.value as ProcurementDetail
return [
['订单号', summary.orderNo], ['采购品类', read(summary, 'purchaseCategory')],
['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')],
['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))],
['联系人', current.contactName], ['联系电话', current.contactPhone],
['收货地址', current.receivingAddress], ['商品链接', current.productUrl],
['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)],
['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)],
['状态', statusLabel(summary.status)], ['备注', current.remark],
]
}
const current = detail.value as TransportDetail
return [
['订单编号', summary.orderNo], ['运输方式', read(summary, 'transportMode')],
['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))],
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
['柜型', read(summary, 'containerType')], ['托盘数', read(summary, 'palletCount')],
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
['联系人', read(summary, 'contactName')], ['联系方式', read(summary, 'contactInfo')],
['要求时间', read(summary, 'requestedDate')], ['状态', statusLabel(summary.status)], ['备注', current.remark],
]
})
function emptyForm(): BusinessForm {
return {
purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1,
transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '',
destination: '', destinationAddress: '', containerType: '', palletCount: 1, packageCount: 0,
totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '',
receivingAddress: '', remark: '',
}
}
function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() }
function read(source: object, key: string) { return Reflect.get(source, key) as unknown }
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function joinText(...values: unknown[]) { const result = values.filter(Boolean).join(' / '); return result || '—' }
function money(amount: unknown, currency: unknown) { return amount === null || amount === undefined ? '—' : `${text(currency)} ${Number(amount).toFixed(2)}` }
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO' ? '否' : text(value) }
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
function statusLabel(value: unknown) {
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
}
function displayCell(candidate: unknown, key: string) {
const row = candidate as BusinessRow
const value = read(row, key)
if (key === 'status') return statusLabel(value)
if (key === 'transportType') return transportTypeLabel(value)
if (key === 'transportMode') return transportModeLabel(value)
if (key === 'palletRequired') return yesNo(value)
if (key === 'createdAt') return dateTime(value)
if (key === 'budgetUnitPrice' || key === 'budgetAmount') return money(value, read(row, 'currency'))
return text(value)
}
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
async function load() {
loading.value = true
error.value = ''
try {
const params = {
page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined,
routeKeyword: query.routeKeyword || undefined, productName: query.productName || undefined,
purchaseCategory: query.purchaseCategory || undefined,
}
const result = props.kind === 'procurement'
? await listBusinessOrders<ProcurementSummary>('procurement', params)
: await listBusinessOrders<TransportSummary>(props.kind, params)
rows.value = result.records
total.value = result.total
} catch (cause) {
error.value = errorMessage(cause)
rows.value = []
total.value = 0
} finally { loading.value = false }
}
function resetQuery() {
Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
void load()
}
async function openDetail(candidate: unknown) {
const row = candidate as BusinessRow
loading.value = true
error.value = ''
try {
detail.value = props.kind === 'procurement'
? await getBusinessOrder<ProcurementDetail>('procurement', row.orderNo)
: await getBusinessOrder<TransportDetail>(props.kind, row.orderNo)
mode.value = 'detail'
} catch (cause) { ElMessage.error(errorMessage(cause)) }
finally { loading.value = false }
}
async function submit() {
if (!await formRef.value?.validate().catch(() => false)) return
submitting.value = true
const stableSubmitKey = submitKey.value || idempotencyKey()
submitKey.value = stableSubmitKey
try {
if (props.kind === 'procurement') {
await createProcurement({
customerName: auth.user?.tenantName || '',
purchaseCategory: form.purchaseCategory, productName: form.productName,
productSpecification: form.productSpecification || undefined, productUrl: form.productUrl || undefined,
purchaseQuantity: form.purchaseQuantity, budgetUnitPrice: form.budgetAmount,
currency: form.currency || undefined, receivingAddress: form.receivingAddress || undefined,
contactName: form.contactName, contactPhone: form.contactInfo, remark: form.remark || undefined,
idempotencyKey: stableSubmitKey,
})
} else {
const base = {
transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined,
destination: form.destination, destinationAddress: form.destinationAddress || undefined,
containerType: form.containerType || undefined, packageCount: form.packageCount,
totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
budgetAmount: form.budgetAmount, currency: form.currency || undefined,
contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined,
requestedDate: form.requestedDate || undefined, remark: form.remark || undefined,
idempotencyKey: stableSubmitKey,
}
await createTransport(props.kind, props.kind === 'trucking'
? { ...base, palletCount: form.palletCount }
: { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired })
}
ElMessage.success('订单提交成功')
mode.value = 'list'
resetForm()
await load()
} catch (cause) { ElMessage.error(errorMessage(cause)) }
finally { submitting.value = false }
}
onMounted(() => void load())
</script>
<template>
<section v-loading="loading" class="business-orders" :aria-busy="loading">
<header class="page-head">
<div>
<h2>{{ mode === 'detail' ? `${configuration.title}订单详情` : mode === 'create' ? `${configuration.title}下单` : configuration.title }}</h2>
<p>{{ mode === 'list' ? configuration.description : mode === 'create' ? '请填写订单信息,带 * 项为必填项' : `订单号:${detail?.summary.orderNo || '—'}` }}</p>
</div>
<el-button v-if="mode !== 'list'" @click="mode = 'list'">返回列表</el-button>
<el-button v-else type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button>
</header>
<template v-if="mode === 'list'">
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false">
<template #default><el-button size="small" @click="load">重新加载</el-button></template>
</el-alert>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="query.page = 1; load()">
<el-form-item label="订单号"><el-input v-model.trim="query.orderNo" clearable placeholder="请输入订单号" /></el-form-item>
<el-form-item v-if="kind === 'procurement'" label="采购品类"><el-input v-model.trim="query.purchaseCategory" clearable placeholder="请输入采购品类" /></el-form-item>
<el-form-item v-if="kind === 'procurement'" label="商品名称"><el-input v-model.trim="query.productName" clearable placeholder="请输入商品名称" /></el-form-item>
<el-form-item v-else label="运输路线"><el-input v-model.trim="query.routeKeyword" clearable placeholder="起运地或目的地" /></el-form-item>
<el-form-item label="状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="rows" empty-text="暂无订单" border>
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
<el-table-column v-for="column in configuration.columns" :key="column.key" :label="column.label" :width="column.width" show-overflow-tooltip>
<template #default="scope">{{ displayCell(scope.row, column.key) }}</template>
</el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
</el-card>
</template>
<el-card v-else-if="mode === 'detail'" shadow="never">
<dl class="detail-grid">
<div v-for="field in detailFields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
</dl>
</el-card>
<el-card v-else shadow="never" class="form-card">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<template v-if="kind === 'procurement'">
<el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
<el-form-item label="商品名称" prop="productName"><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="请输入规格、型号等信息" /></el-form-item>
<el-form-item label="商品链接"><el-input v-model.trim="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item>
<el-form-item label="采购数量" prop="purchaseQuantity"><el-input-number v-model="form.purchaseQuantity" :min="1" placeholder="请输入数量" /></el-form-item>
<el-form-item label="收货地址"><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="请输入收货地址" /></el-form-item>
</template>
<template v-else>
<el-form-item v-if="kind === 'looseboard'" label="运输方式"><el-radio-group v-model="form.transportMode"><el-radio-button value="SHARED_TRUCK">拼车</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION">优先拼载</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="是否打盘"><el-radio-group v-model="form.palletRequired"><el-radio-button value="NO">否</el-radio-button><el-radio-button value="YES">是</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="运输类型"><el-select v-model="form.transportType"><el-option label="港口提柜" value="PORT_PICKUP" /><el-option label="仓到仓" value="WAREHOUSE_TO_WAREHOUSE" /><el-option label="仓到门" value="WAREHOUSE_TO_DOOR" /></el-select></el-form-item>
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="form.origin" maxlength="255" placeholder="请输入起运地" /></el-form-item>
<el-form-item label="起运地址"><el-input v-model.trim="form.originAddress" maxlength="500" placeholder="请输入详细起运地址" /></el-form-item>
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="请输入目的地" /></el-form-item>
<el-form-item label="目的地址"><el-input v-model.trim="form.destinationAddress" maxlength="500" placeholder="请输入详细目的地址" /></el-form-item>
<el-form-item label="柜型"><el-input v-model.trim="form.containerType" maxlength="32" placeholder="请输入柜型" /></el-form-item>
<el-form-item v-if="kind === 'trucking'" label="托盘数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" placeholder="请输入托盘数" /></el-form-item>
<el-form-item label="件数"><el-input-number v-model="form.packageCount" :min="0" placeholder="请输入件数" /></el-form-item>
<el-form-item label="总重量(kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" placeholder="请输入总重量" /></el-form-item>
<el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item>
<el-form-item label="要求时间" :prop="kind === 'trucking' ? 'requestedDate' : undefined"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
</template>
<el-form-item :label="kind === 'procurement' ? '预算单价' : '预算金额'"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
<el-form-item><el-button @click="mode = 'list'">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
</el-form>
</el-card>
</section>
</template>
<style scoped>
.business-orders { display: grid; gap: 18px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
.filter-card :deep(.el-input) { width: 190px; }
.pagination { justify-content: flex-end; margin-top: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }
.detail-grid div { min-width: 0; padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; line-height: 1.55; }
.form-card :deep(.el-form) { max-width: 860px; }
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
.form-card .currency { width: 110px; margin-left: 12px; }
</style>
+3 -3
View File
@@ -9,7 +9,7 @@ export const crossBorderModule = {
path: 'cross-border/procurement',
name: 'cross-border-procurement',
component: () => import('./views/ProcurementView.vue'),
release: 'prototype',
release: 'active',
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' },
navigation: { key: 'cross-border-procurement', icon: Package },
},
@@ -17,7 +17,7 @@ export const crossBorderModule = {
path: 'cross-border/domestic-trucking',
name: 'cross-border-domestic-trucking',
component: () => import('./views/DomesticTruckingView.vue'),
release: 'prototype',
release: 'active',
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' },
navigation: { key: 'cross-border-domestic-trucking', icon: Truck },
},
@@ -25,7 +25,7 @@ export const crossBorderModule = {
path: 'cross-border/pallet-shipping',
name: 'cross-border-pallet-shipping',
component: () => import('./views/PalletShippingView.vue'),
release: 'prototype',
release: 'active',
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' },
navigation: { key: 'cross-border-pallet-shipping', icon: Ship },
},
@@ -1,5 +1,5 @@
<script setup lang="ts">
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
</script>
<template><PrototypeFeatureView title="美国本土卡" description="创建美国本土卡车运输订单" /></template>
<template><BusinessOrdersView kind="trucking" /></template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
</script>
<template><PrototypeFeatureView title="美本土散板" description="创建美国本土散板运输订单" /></template>
<template><BusinessOrdersView kind="looseboard" /></template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
</script>
<template><PrototypeFeatureView title="海外采购" description="管理海外采购订单" /></template>
<template><BusinessOrdersView kind="procurement" /></template>
@@ -25,12 +25,15 @@ test('isolates recoverable dashboard failures without hiding successful sections
assert.doesNotMatch(source, /<template v-else>\s*<section class="quick-order-band">/)
})
test('matches the prototype business overview while keeping real order routing and data', () => {
test('matches the prototype business overview and routes each card to its real business page', () => {
assert.match(source, /选择业务,快速下单/)
assert.match(source, /海外采购/)
assert.match(source, /美国本土卡/)
assert.match(source, /美本土散板/)
assert.match(source, /void router\.push\('\/orders\/first-leg\/create'\)/)
assert.match(source, /route: '\/cross-border\/procurement'/)
assert.match(source, /route: '\/cross-border\/domestic-trucking'/)
assert.match(source, /route: '\/cross-border\/pallet-shipping'/)
assert.match(source, /auth\.hasPermission\(business\.permission\)/)
assert.match(source, /order\.status === 'DELIVERED'/)
assert.match(source, /currentMonthOrderCount/)
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
+12 -11
View File
@@ -40,7 +40,6 @@ const loadErrorItems = computed(() =>
})),
)
const canViewOrders = computed(() => auth.hasPermission('order:first-leg:list'))
const canCreateOrders = computed(() => auth.hasPermission('order:first-leg:create'))
const accountName = computed(() => {
if (!companyUnavailable.value && tenant.company.companyName) return tenant.company.companyName
return auth.user?.tenantName || auth.user?.name || '企业客户'
@@ -67,18 +66,24 @@ const businessCards = [
description: '采购代购 · 验货质检 · 集中发货',
icon: ShoppingCart,
tone: 'blue',
route: '/cross-border/procurement',
permission: 'merchant:cross-border:procurement',
},
{
title: '美国本土卡',
description: '港口提柜 · 仓到仓 · 仓到门',
icon: Truck,
tone: 'green',
route: '/cross-border/domestic-trucking',
permission: 'merchant:cross-border:domestic-trucking',
},
{
title: '美本土散板',
description: '拼车拼载 · 打盘服务 · 灵活派送',
icon: PanelsTopLeft,
tone: 'teal',
route: '/cross-border/pallet-shipping',
permission: 'merchant:cross-border:pallet-shipping',
},
] as const
@@ -111,16 +116,12 @@ async function loadDashboardData() {
}
}
function openBusinessOrder() {
if (canCreateOrders.value) {
void router.push('/orders/first-leg/create')
function openBusinessOrder(business: typeof businessCards[number]) {
if (auth.hasPermission(business.permission)) {
void router.push(business.route)
return
}
if (canViewOrders.value) {
void router.push('/orders/first-leg')
return
}
ElMessage.warning('当前账号暂无头程订单权限,请联系企业管理员。')
ElMessage.warning(`当前账号暂无${business.title}权限,请联系企业管理员。`)
}
function showSupportMessage(type: 'service' | 'faq' | 'quote') {
@@ -164,7 +165,7 @@ onMounted(() => void loadDashboardData())
type="button"
class="business-card"
:class="`business-card--${business.tone}`"
@click="openBusinessOrder"
@click="openBusinessOrder(business)"
>
<span class="business-card__icon" aria-hidden="true">
<component :is="business.icon" :size="29" :stroke-width="2.4" />
@@ -174,7 +175,7 @@ onMounted(() => void loadDashboardData())
<small>{{ business.description }}</small>
</span>
<span class="business-card__action">
{{ canCreateOrders ? '开始下单' : canViewOrders ? '查看订单' : '暂无权限' }}
{{ auth.hasPermission(business.permission) ? '开始下单' : '暂无权限' }}
<ArrowRight :size="18" aria-hidden="true" />
</span>
</button>
+106
View File
@@ -0,0 +1,106 @@
import { apiRequest } from '@/api/http'
export interface PageResult<T> {
records: T[]
total: number
page: number
size: number
pages: number
}
export interface FinanceAccount {
currencyCode: 'CNY'
actualBalance: string
frozenBalance: string
availableBalance: string
creditLimit: string
creditUsed: string
creditFrozen: string
availableCredit: string
version: number
updatedAt: string
}
export type AccountChangeType = 'RECHARGE' | 'DEDUCTION' | 'FREEZE' | 'UNFREEZE' | 'CONSUMPTION' | 'CREDIT_LIMIT'
export interface AccountLedgerEntry {
ledgerId: string
businessNo: string
changeType: AccountChangeType
amount: string
actualBalanceAfter: string
frozenBalanceAfter: string
availableBalanceAfter: string
creditLimitAfter: string
creditUsedAfter: string
availableCreditAfter: string
sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION' | 'CREDIT_LIMIT_ADJUSTMENT' | string
sourceNo: string
operatorName: string | null
remark: string | null
createdAt: string
}
export type RechargePaymentMethod = 'WECHAT' | 'ALIPAY'
export type RechargeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export interface RechargeRequest {
requestId: string
requestNo: string
amount: string
paymentMethod: RechargePaymentMethod
voucherFileId: string
voucherFileName: string
status: RechargeStatus
version: number
submittedByName: string | null
reviewedByName: string | null
reviewRemark: string | null
createdAt: string
reviewedAt: string | null
}
export interface RechargeChannel {
paymentMethod: RechargePaymentMethod
displayName: string
enabled: boolean
qrCodeUrl: string | null
}
export function getFinanceAccount() {
return apiRequest<FinanceAccount>({ method: 'GET', url: '/client/finance/v1/account' })
}
export function getAccountLedger(page: number, size: number) {
return apiRequest<PageResult<AccountLedgerEntry>>({
method: 'GET',
url: '/client/finance/v1/account/ledger',
params: { page, size },
})
}
export function getRechargeRequests(page: number, size: number) {
return apiRequest<PageResult<RechargeRequest>>({
method: 'GET',
url: '/client/finance/v1/recharge-requests',
params: { page, size },
})
}
export function getRechargeChannels() {
return apiRequest<RechargeChannel[]>({ method: 'GET', url: '/client/finance/v1/recharge-channels' })
}
export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, file: File,
idempotencyKey: string) {
const formData = new FormData()
formData.append('amount', amount)
formData.append('paymentMethod', paymentMethod)
formData.append('file', file, file.name)
return apiRequest<RechargeRequest>({
method: 'POST',
url: '/client/finance/v1/recharge-requests',
headers: { 'Idempotency-Key': idempotencyKey },
data: formData,
})
}
+2
View File
@@ -34,6 +34,7 @@ export interface FinanceBillLineWire {
sourceVersion: number
orderId: WireScalar | null
inboundId: WireScalar | null
inboundCode: string | null
feeItemCode: string
feeItemName: string
amount: WireScalar
@@ -89,6 +90,7 @@ function mapBillLine(line: FinanceBillLineWire): FinanceBillLine {
return {
id: text(line.id),
orderId: line.orderId == null ? null : text(line.orderId),
inboundCode: line.inboundCode?.trim() || null,
feeNo: `${line.sourceType}:${text(line.sourceId)}/v${line.sourceVersion}`,
feeName: line.feeItemName,
amount: text(line.amount),
+1
View File
@@ -39,6 +39,7 @@ export interface FinanceCustomerConfirmationResult {
export interface FinanceBillLine {
id: string
orderId: string | null
inboundCode: string | null
feeNo: string | null
feeName: string
amount: string
+9 -1
View File
@@ -1,10 +1,18 @@
import { ReceiptText } from '@lucide/vue'
import { CircleDollarSign, ReceiptText } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const financeModule = {
key: 'finance',
navigationGroup: { title: '财务结算', icon: ReceiptText },
routes: [
{
path: 'finance/account',
name: 'finance-account',
component: () => import('./views/FinanceAccountView.vue'),
release: 'active',
meta: { title: '资金账户', description: '查看本企业资金余额、流水与充值申请', permission: 'merchant:finance:account:view' },
navigation: { key: 'finance-account', title: '资金账户', icon: CircleDollarSign },
},
{
path: 'finance/bills',
name: 'finance-bills',
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const apiSource = readFileSync(new URL('../api/account.ts', import.meta.url), 'utf8')
const viewSource = readFileSync(new URL('./FinanceAccountView.vue', import.meta.url), 'utf8')
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const permissionsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
test('uses only authenticated client finance account endpoints without customer scope', () => {
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channels']) {
assert.match(apiSource, new RegExp(path.replaceAll('/', '\\/')))
}
assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
assert.match(apiSource, /formData\.append\('amount', amount\)/)
assert.match(apiSource, /formData\.append\('paymentMethod', paymentMethod\)/)
assert.match(apiSource, /formData\.append\('file', file, file\.name\)/)
assert.doesNotMatch(apiSource, /customerId|tenantId|mock|fallback/i)
})
test('activates the account route and maps read and recharge permissions independently', () => {
assert.match(routesSource, /path: 'finance\/account'[\s\S]*FinanceAccountView\.vue[\s\S]*release: 'active'[\s\S]*permission: 'merchant:finance:account:view'/)
assert.match(permissionsSource, /'merchant:finance:account:view': \['merchant:finance:account:view'\]/)
assert.match(permissionsSource, /'merchant:finance:recharge:create': \['merchant:finance:recharge:create'\]/)
assert.match(viewSource, /auth\.hasPermission\('merchant:finance:recharge:create'\)/)
assert.match(viewSource, /<el-button v-if="canRecharge"/)
})
test('renders server balances, ledger and recharge states without internal identifiers', () => {
for (const field of ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
for (const field of ['creditUsedAfter', 'availableCreditAfter']) assert.match(viewSource, new RegExp(`row\\.${field}`))
assert.match(viewSource, /实际可用资金与可用授信合计/)
assert.match(viewSource, /CREDIT_LIMIT: '授信调整'/)
assert.match(viewSource, /row-key="businessNo"/)
assert.match(viewSource, /row-key="requestNo"/)
assert.match(viewSource, /empty-text="暂无资金流水"/)
assert.match(viewSource, /empty-text="暂无充值申请"/)
assert.match(viewSource, /account-state--error/)
assert.match(viewSource, /v-loading="pageLoading"/)
assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
})
test('matches the frozen recharge workflow and preserves an idempotency key for failed retries', () => {
assert.match(viewSource, /title="账户充值" width="760px"/)
for (const amount of ['50.00', '100.00', '200.00', '500.00']) assert.match(viewSource, new RegExp(amount.replace('.', '\\.')))
assert.match(viewSource, /\['WECHAT', 'ALIPAY'\]/)
assert.match(viewSource, /selectedChannel\.value\?\.enabled && selectedChannel\.value\.qrCodeUrl/)
assert.match(viewSource, /accept="\.jpg,\.jpeg,\.png,image\/jpeg,image\/png"/)
assert.match(viewSource, /if \(!pendingIdempotencyKey\)[\s\S]*globalThis\.crypto\?\.randomUUID\?\.\(\)/)
assert.match(viewSource, /pendingIdempotencyKey = ''[\s\S]*rechargeVisible\.value = false/)
assert.match(viewSource, /:disabled="!canSubmit"/)
assert.match(viewSource, /await loadAccountData\(\)/)
assert.doesNotMatch(viewSource, /payment SDK|支付成功|模拟成功/)
})
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CircleDollarSign, RefreshCcw, Upload } from '@lucide/vue'
import { ElMessage, type UploadFile, type UploadFiles, type UploadInstance } from 'element-plus'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/modules/auth/public'
import {
createRechargeRequest,
getAccountLedger,
getFinanceAccount,
getRechargeChannels,
getRechargeRequests,
type AccountChangeType,
type AccountLedgerEntry,
type FinanceAccount,
type RechargeChannel,
type RechargePaymentMethod,
type RechargeRequest,
type RechargeStatus,
} from '../api/account'
const auth = useAuthStore()
const account = ref<FinanceAccount | null>(null)
const ledger = ref<AccountLedgerEntry[]>([])
const rechargeRequests = ref<RechargeRequest[]>([])
const channels = ref<RechargeChannel[]>([])
const pageLoading = ref(false)
const pageError = ref('')
const channelsError = ref('')
const ledgerPage = ref(1)
const ledgerTotal = ref(0)
const rechargePage = ref(1)
const rechargeTotal = ref(0)
const pageSize = 10
const rechargeVisible = ref(false)
const submitting = ref(false)
const submitError = ref('')
const voucherFile = ref<File | null>(null)
const voucherUpload = ref<UploadInstance>()
let pendingIdempotencyKey = ''
const form = reactive({ amount: '100.00', paymentMethod: 'WECHAT' as RechargePaymentMethod })
const quickAmounts = ['50.00', '100.00', '200.00', '500.00']
const canRecharge = computed(() => auth.hasPermission('merchant:finance:recharge:create'))
const selectedChannel = computed(() => channels.value.find((item) => item.paymentMethod === form.paymentMethod))
const channelReady = computed(() => Boolean(selectedChannel.value?.enabled && selectedChannel.value.qrCodeUrl))
const amountValid = computed(() => {
const value = Number(form.amount)
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
})
const canSubmit = computed(() => amountValid.value && channelReady.value && Boolean(voucherFile.value) && !submitting.value)
const changeLabels: Record<AccountChangeType, string> = {
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
}
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
const methodLabels: Record<RechargePaymentMethod, string> = { WECHAT: '微信支付', ALIPAY: '支付宝' }
function messageOf(error: unknown, fallback: string) {
return error instanceof ApiError ? error.message : fallback
}
function formatAmount(value: string | null | undefined) {
const number = Number(value ?? '0')
return Number.isFinite(number)
? number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '0.00'
}
function formatAccountAmount(value: string | null | undefined) {
return value == null ? '—' : formatAmount(value)
}
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
}
function statusType(status: RechargeStatus) {
return ({ PENDING: 'warning', APPROVED: 'success', REJECTED: 'danger' })[status] as 'warning' | 'success' | 'danger'
}
function amountClass(amount: string) {
return Number(amount) >= 0 ? 'amount-positive' : 'amount-negative'
}
async function loadAccountData() {
pageLoading.value = true
pageError.value = ''
channelsError.value = ''
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, pageSize), getRechargeRequests(rechargePage.value, pageSize)] as const
try {
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
account.value = accountResult
ledger.value = ledgerResult.records
ledgerTotal.value = ledgerResult.total
rechargeRequests.value = rechargeResult.records
rechargeTotal.value = rechargeResult.total
} catch (error) {
account.value = null
ledger.value = []
rechargeRequests.value = []
ledgerTotal.value = 0
rechargeTotal.value = 0
pageError.value = messageOf(error, '资金账户加载失败,请稍后重试')
} finally {
pageLoading.value = false
}
if (canRecharge.value) {
try {
channels.value = await getRechargeChannels()
} catch (error) {
channels.value = []
channelsError.value = messageOf(error, '充值渠道加载失败,请稍后重试')
}
}
}
async function changeLedgerPage(nextPage: number) {
ledgerPage.value = nextPage
try {
const result = await getAccountLedger(nextPage, pageSize)
ledger.value = result.records
ledgerTotal.value = result.total
} catch (error) {
ElMessage.error(messageOf(error, '资金流水加载失败,请重试'))
}
}
async function changeRechargePage(nextPage: number) {
rechargePage.value = nextPage
try {
const result = await getRechargeRequests(nextPage, pageSize)
rechargeRequests.value = result.records
rechargeTotal.value = result.total
} catch (error) {
ElMessage.error(messageOf(error, '充值申请加载失败,请重试'))
}
}
function openRecharge() {
submitError.value = ''
rechargeVisible.value = true
}
function chooseAmount(amount: string) {
form.amount = amount
}
function handleVoucherChange(uploadFile: UploadFile, _uploadFiles: UploadFiles) {
const file = uploadFile.raw
if (!file) return
const extensionAllowed = /\.(?:jpe?g|png)$/i.test(file.name)
const mimeAllowed = ['image/jpeg', 'image/png'].includes(file.type)
if (!extensionAllowed || !mimeAllowed) {
voucherFile.value = null
ElMessage.error('付款截图仅支持 jpg、jpeg 或 png 图片')
return
}
voucherFile.value = file
}
function handleVoucherRemove() {
voucherFile.value = null
}
function normalizedAmount() {
return Number(form.amount).toFixed(2)
}
async function submitRecharge() {
if (submitting.value) return
submitError.value = ''
if (!amountValid.value) {
submitError.value = '请输入大于 0 且最多两位小数的充值金额'
return
}
if (!channelReady.value) {
submitError.value = `${methodLabels[form.paymentMethod]}收款二维码尚未配置,暂不能提交充值申请`
return
}
if (!voucherFile.value) {
submitError.value = '请上传付款截图'
return
}
if (!pendingIdempotencyKey) {
pendingIdempotencyKey = globalThis.crypto?.randomUUID?.() ?? `recharge-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
submitting.value = true
try {
await createRechargeRequest(normalizedAmount(), form.paymentMethod, voucherFile.value, pendingIdempotencyKey)
pendingIdempotencyKey = ''
rechargeVisible.value = false
voucherFile.value = null
voucherUpload.value?.clearFiles()
ElMessage.success('充值申请已提交,等待平台审核')
await loadAccountData()
} catch (error) {
submitError.value = messageOf(error, '充值申请提交失败,请使用当前内容重试')
} finally {
submitting.value = false
}
}
watch([() => form.amount, () => form.paymentMethod, voucherFile], () => {
if (!submitting.value) pendingIdempotencyKey = ''
submitError.value = ''
})
onMounted(() => { void loadAccountData() })
</script>
<template>
<div v-loading="pageLoading" class="finance-account-page" :aria-busy="pageLoading">
<div class="account-heading">
<div><h2>资金账户</h2><p>查看本企业 CNY 资金、授信额度、账户流水和充值申请状态。</p></div>
<el-button v-if="canRecharge" type="primary" @click="openRecharge"><CircleDollarSign :size="17" />账户充值</el-button>
</div>
<div v-if="pageError" class="account-state account-state--error" role="alert">
<p>{{ pageError }}</p><el-button @click="loadAccountData"><RefreshCcw :size="15" />重新加载</el-button>
</div>
<template v-else>
<section class="balance-grid" aria-label="账户余额">
<article><span>实际余额</span><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>平台配置的授信上限</small></article>
<article><span>已用授信</span><strong>¥ {{ formatAccountAmount(account?.creditUsed) }}</strong><small>已结算使用的授信</small></article>
<article><span>可用授信</span><strong>¥ {{ formatAccountAmount(account?.availableCredit) }}</strong><small>扣除已用和冻结后的授信</small></article>
</section>
<section class="account-card">
<div class="section-heading"><div><h3>账户流水</h3><p>每次资金变动均生成唯一业务单号。</p></div></div>
<el-table :data="ledger" row-key="businessNo" empty-text="暂无资金流水">
<el-table-column prop="businessNo" label="业务单号" min-width="190" />
<el-table-column label="变动类型" width="110"><template #default="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template></el-table-column>
<el-table-column label="变动金额" width="145" align="right"><template #default="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template></el-table-column>
<el-table-column label="实际余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template></el-table-column>
<el-table-column label="冻结金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template></el-table-column>
<el-table-column label="可用余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template></el-table-column>
<el-table-column label="已用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.creditUsedAfter) }}</template></el-table-column>
<el-table-column label="可用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableCreditAfter) }}</template></el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="130"><template #default="{ row }">{{ row.operatorName || '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
<el-table-column label="发生时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
</section>
<section class="account-card">
<div class="section-heading"><div><h3>充值申请</h3><p>付款后上传截图,平台审核通过后资金入账。</p></div></div>
<el-table :data="rechargeRequests" row-key="requestNo" empty-text="暂无充值申请">
<el-table-column prop="requestNo" label="充值申请单号" min-width="190" />
<el-table-column label="金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column>
<el-table-column label="付款方式" width="120"><template #default="{ row }">{{ methodLabels[row.paymentMethod as RechargePaymentMethod] }}</template></el-table-column>
<el-table-column prop="voucherFileName" label="付款截图" min-width="170" />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template></el-table-column>
<el-table-column prop="submittedByName" label="提交人" min-width="120"><template #default="{ row }">{{ row.submittedByName || '—' }}</template></el-table-column>
<el-table-column prop="reviewRemark" label="审核备注" min-width="180"><template #default="{ row }">{{ row.reviewRemark || '—' }}</template></el-table-column>
<el-table-column label="提交时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="审核时间" width="170"><template #default="{ row }">{{ formatDateTime(row.reviewedAt) }}</template></el-table-column>
</el-table>
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
</section>
</template>
<el-dialog v-model="rechargeVisible" title="账户充值" width="760px" :close-on-click-modal="false" :close-on-press-escape="!submitting" :show-close="!submitting" class="recharge-dialog">
<div class="recharge-form">
<label class="field-label" for="recharge-amount">充值金额</label>
<el-input id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
<div class="quick-amounts" aria-label="快捷充值金额">
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount) }}</button>
</div>
<div class="payment-methods" aria-label="付款方式">
<button v-for="method in (['WECHAT', 'ALIPAY'] as RechargePaymentMethod[])" :key="method" type="button" :class="[`payment-${method.toLowerCase()}`, { active: form.paymentMethod === method }]" @click="form.paymentMethod = method"><span>{{ method === 'WECHAT' ? '微' : '支' }}</span>{{ methodLabels[method] }}</button>
</div>
<div v-if="channelsError" class="channel-warning" role="alert">{{ channelsError }}</div>
<div v-else-if="channelReady" class="qr-panel">
<img :src="selectedChannel?.qrCodeUrl || ''" :alt="`${methodLabels[form.paymentMethod]}收款二维码`">
<p>请使用{{ methodLabels[form.paymentMethod] }}扫码完成付款</p>
</div>
<div v-else class="channel-warning" role="status">{{ methodLabels[form.paymentMethod] }}收款二维码尚未配置,暂不能提交充值申请。</div>
<div class="voucher-upload">
<span class="field-label">付款截图</span>
<el-upload ref="voucherUpload" :auto-upload="false" :limit="1" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :on-change="handleVoucherChange" :on-remove="handleVoucherRemove">
<el-button><Upload :size="16" />选择付款截图</el-button>
<template #tip><div class="el-upload__tip">仅支持 jpg、jpeg、png 图片;提交后进入平台审核。</div></template>
</el-upload>
</div>
<p v-if="submitError" class="submit-error" role="alert">{{ submitError }}</p>
</div>
<template #footer><div class="dialog-actions"><el-button :disabled="submitting" @click="rechargeVisible = false">取消</el-button><el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="submitRecharge">确认充值</el-button></div></template>
</el-dialog>
</div>
</template>
<style scoped>
.finance-account-page { display: flex; min-height: 420px; flex-direction: column; gap: 16px; }
.account-heading, .section-heading, .pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.account-heading h2, .section-heading h3 { margin: 0; color: var(--navy); }.account-heading h2 { font-size: 22px; }.section-heading h3 { font-size: 16px; }
.account-heading p, .section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }.account-heading .el-button { display: inline-flex; align-items: center; gap: 6px; }
.balance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.balance-grid article { display: flex; min-height: 132px; flex-direction: column; padding: 22px 24px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
.balance-grid span { color: var(--muted); font-size: 13px; }.balance-grid strong { margin-top: 14px; color: var(--navy); font-size: 28px; line-height: 1; }.balance-grid small { margin-top: auto; color: #8a98a8; }.balance-grid .balance-available { border-color: #b9e1db; background: #f3faf8; }.balance-grid .balance-available strong { color: #058b78; }
.account-card { min-width: 0; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }.section-heading { margin-bottom: 14px; }
.pagination-row { padding-top: 14px; color: var(--muted); font-size: 12px; }.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
.account-state { display: flex; min-height: 240px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border-radius: 8px; }.account-state--error { border: 1px solid #f5c2bd; background: #fff8f7; color: #b42318; }.account-state p { margin: 0; }
.recharge-form { display: flex; flex-direction: column; gap: 16px; }.field-label { color: #4e6075; font-size: 14px; font-weight: 650; }
.quick-amounts, .payment-methods { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.quick-amounts button, .payment-methods button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active, .payment-methods button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
.payment-methods { grid-template-columns: repeat(2, 1fr); }.payment-methods button { display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 17px; font-weight: 650; }.payment-methods span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: #1685ef; color: #fff; font-size: 14px; }.payment-wechat span { background: #09ae42; }
.qr-panel { display: flex; align-items: center; flex-direction: column; gap: 14px; padding: 22px; border: 1px dashed #bed0e4; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel p { margin: 0; color: #62758d; }
.channel-warning { padding: 24px; border: 1px solid #f0d39b; border-radius: 10px; background: #fff9ed; color: #8b5b12; text-align: center; }.voucher-upload { display: grid; gap: 10px; }.voucher-upload :deep(.el-button) { display: inline-flex; align-items: center; gap: 6px; }.submit-error { margin: 0; color: #b42318; font-size: 13px; }.dialog-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }.dialog-actions .el-button { min-height: 46px; margin: 0; }
</style>
@@ -92,7 +92,7 @@ test('maps nested bill detail and customer-safe receipt allocations', () => {
receivedAmount: '40.00', outstandingAmount: '60.00',
version: 3, customerConfirmationStatus: 'PENDING_CONFIRMATION', customerConfirmedByName: null, customerConfirmedAt: null,
},
lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, inboundCode: 'NYEXX2608180003', feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
})
const receipts = financeAdapters.mapFinanceReceipts([{
id: 51, receiptNo: 'RCPT-51', receivedAmount: '40.00', receivedAt: '2026-08-12T10:00:00',
@@ -101,7 +101,7 @@ test('maps nested bill detail and customer-safe receipt allocations', () => {
}], '2')
assert.equal(detail.billNo, 'B-2')
assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', inboundCode: 'NYEXX2608180003', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
assert.deepEqual(receipts[0], { id: '51', receiptNo: 'RCPT-51', receivedAt: '2026-08-12T10:00:00', amount: '40.00', currencyCode: 'CNY', method: 'BANK_TRANSFER', allocatedAmount: '40.00' })
})
@@ -120,6 +120,12 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
for (const expected of ['未收余额', '账单详情', '确认账单', '客户确认信息', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
assert.match(viewSource, new RegExp(expected))
}
assert.match(viewSource, /BANK_TRANSFER: '银行转账'/)
assert.match(viewSource, /paymentMethodLabel\(row\.method\)/)
assert.match(viewSource, /label="入仓码"/)
assert.match(viewSource, /openInboundAssociation\(row\.inboundCode\)/)
assert.match(viewSource, /InboundCodeAssociationDialog/)
assert.match(viewSource, /:inbound-code="inboundAssociationCode"/)
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /size="small" type="success" plain/)
+18 -2
View File
@@ -4,6 +4,7 @@ import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/modules/auth/public'
import { InboundCodeAssociationDialog } from '@/modules/first-leg/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
@@ -16,6 +17,8 @@ const bills = ref<FinanceBillSummary[]>([])
const detail = ref<FinanceBillDetail | null>(null)
const selectedBillId = ref('')
const detailVisible = ref(false)
const inboundAssociationVisible = ref(false)
const inboundAssociationCode = ref('')
const confirmingBillId = ref('')
const confirmationKeys = new Map<string, string>()
const page = ref(1)
@@ -49,6 +52,11 @@ function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '-'
}
function paymentMethodLabel(method: string | null | undefined) {
if (!method) return '—'
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' })[method] ?? '其他'
}
function statusLabel(status: FinanceBillStatus) {
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
}
@@ -155,6 +163,13 @@ function handlePageChange(nextPage: number) {
void loadBills(nextPage)
}
function openInboundAssociation(inboundCode: string | null) {
const code = inboundCode?.trim()
if (!code) return
inboundAssociationCode.value = code
inboundAssociationVisible.value = true
}
onMounted(() => { void loadBills(1) })
</script>
@@ -206,12 +221,13 @@ onMounted(() => { void loadBills(1) })
<template v-else-if="detail">
<div class="detail-heading"><div><h2>{{ detail.billNo }}</h2><p>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 <strong class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</strong></p></div><div class="detail-statuses"><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag><el-tag :type="confirmationType(detail.customerConfirmationStatus)">{{ confirmationLabel(detail.customerConfirmationStatus) }}</el-tag></div></div>
<section class="confirmation-panel" aria-label="客户确认信息"><div><strong>{{ confirmationLabel(detail.customerConfirmationStatus) }}</strong><p v-if="detail.customerConfirmationStatus === 'CONFIRMED'">{{ detail.customerConfirmedByName || '当前企业成员' }} · {{ formatDateTime(detail.customerConfirmedAt) }}</p><p v-else>请核对本期金额和费用明细,确认后将记录当前登录成员与确认时间。</p></div><el-button v-if="detail.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" type="primary" :loading="confirmingBillId === detail.id" :disabled="Boolean(confirmingBillId)" @click="confirmStatement(detail)">确认账单</el-button></section>
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column prop="feeName" label="费用项目" min-width="180" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column prop="method" label="方式" min-width="100" /></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column label="入仓码" min-width="170"><template #default="{ row }"><el-button v-if="row.inboundCode" link type="primary" :aria-label="`查看入仓码 ${row.inboundCode} 详情`" @click="openInboundAssociation(row.inboundCode)">{{ row.inboundCode }}</el-button><span v-else>—</span></template></el-table-column><el-table-column prop="feeName" label="费用项目" min-width="180" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column label="方式" min-width="100"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
<p class="readonly-notice">如对账单内容有疑问,请先线下联系平台财务,核对无误后再确认。</p>
</template>
</div>
</el-drawer>
<InboundCodeAssociationDialog v-model="inboundAssociationVisible" :inbound-code="inboundAssociationCode" />
</div>
</template>
@@ -6,9 +6,17 @@ const source = readFileSync(new URL('./inboundBilling.ts', import.meta.url), 'ut
const typesSource = readFileSync(new URL('../types/inboundBilling.ts', import.meta.url), 'utf8')
test('queries the merchant order inbound-billing endpoint without tenant parameters', () => {
assert.match(source, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
assert.match(source, /method: 'GET'/)
assert.doesNotMatch(source, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
const readSource = source.slice(source.indexOf('export async function getMerchantInboundBilling'), source.indexOf('function createFeeConfirmationKey'))
assert.match(readSource, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
assert.match(readSource, /method: 'GET'/)
assert.doesNotMatch(readSource, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
})
test('confirms the latest customer fee version with an idempotency key', () => {
assert.match(source, /function confirmMerchantInboundFee/)
assert.match(source, /\/inbound-billing\/confirm/)
assert.match(source, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
assert.match(source, /data: \{ expectedFeeVersion \}/)
})
test('sanitizes the platform-shaped response into a merchant whitelist', () => {
@@ -9,6 +9,9 @@ import type {
interface MerchantInboundBillingWireResponse {
inbound: MerchantInboundResult
measurementSummary: MerchantInboundBilling['measurementSummary']
measurementItems: MerchantInboundBilling['measurementItems']
billingBasis: MerchantInboundBilling['billingBasis']
latestFee: MerchantInboundFeeSnapshot
}
@@ -62,6 +65,8 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
feeStatus: fee.feeStatus,
inboundId: fee.inboundId,
inboundCode: fee.inboundCode,
measurementId: fee.measurementId == null ? null : String(fee.measurementId),
measurementSource: fee.measurementSource,
chargeRuleId: fee.chargeRuleId,
ruleCode: fee.ruleCode,
chargeRuleVersion: fee.chargeRuleVersion,
@@ -98,6 +103,43 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
confirmedAt: fee.confirmedAt,
sentAt: fee.sentAt,
nextProcessStatus: fee.nextProcessStatus,
customerConfirmationStatus: fee.customerConfirmationStatus,
customerConfirmedBy: fee.customerConfirmedBy == null ? null : String(fee.customerConfirmedBy),
customerConfirmedByName: fee.customerConfirmedByName,
customerConfirmedAt: fee.customerConfirmedAt,
}
}
function sanitizeMeasurementSummary(item: MerchantInboundBilling['measurementSummary']): MerchantInboundBilling['measurementSummary'] {
return {
measurementId: item.measurementId == null ? null : String(item.measurementId),
sourceType: item.sourceType,
measurementStatus: item.measurementStatus,
actualPackageCount: item.actualPackageCount,
actualGrossWeightKg: item.actualGrossWeightKg,
actualNetWeightKg: item.actualNetWeightKg,
actualVolumeCbm: item.actualVolumeCbm,
volumeWeightKg: item.volumeWeightKg,
actualGoodsName: item.actualGoodsName,
calculationSource: item.calculationSource,
}
}
function sanitizeMeasurementItem(item: MerchantInboundBilling['measurementItems'][number]): MerchantInboundBilling['measurementItems'][number] {
return {
id: String(item.id),
itemNo: item.itemNo,
boxSpecification: item.boxSpecification,
packageCount: item.packageCount,
lengthCm: item.lengthCm,
widthCm: item.widthCm,
heightCm: item.heightCm,
unitVolumeCbm: item.unitVolumeCbm,
totalVolumeCbm: item.totalVolumeCbm,
volumeWeightKg: item.volumeWeightKg,
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
coefficientMode: item.coefficientMode,
calculationRuleVersion: item.calculationRuleVersion,
}
}
@@ -108,6 +150,9 @@ function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): M
publicRemark: response.inbound.publicRemark,
palletLocations: response.inbound.palletLocations.map(sanitizePalletLocation),
},
measurementSummary: sanitizeMeasurementSummary(response.measurementSummary),
measurementItems: (response.measurementItems || []).map(sanitizeMeasurementItem),
billingBasis: response.billingBasis,
latestFee: sanitizeFee(response.latestFee),
}
}
@@ -119,3 +164,19 @@ export async function getMerchantInboundBilling(orderId: string): Promise<Mercha
})
return sanitizeInboundBilling(response)
}
function createFeeConfirmationKey(orderId: string, feeVersion: number) {
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
return `inbound-fee-confirm-${orderId}-${feeVersion}-${randomPart}`
}
export async function confirmMerchantInboundFee(orderId: string, expectedFeeVersion: number,
idempotencyKey = createFeeConfirmationKey(orderId, expectedFeeVersion)) {
const response = await apiRequest<MerchantInboundFeeSnapshot>({
method: 'POST',
url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing/confirm`,
headers: { 'Idempotency-Key': idempotencyKey },
data: { expectedFeeVersion },
})
return sanitizeFee(response)
}
@@ -47,7 +47,7 @@ const emit = defineEmits<{
.editor-page-nav span { color: var(--muted); font-size: 12px; }
.editor-page-body { padding: 26px 32px 34px; }
.editor-page-footer { position: sticky; z-index: 3; bottom: 0; display: flex; justify-content: center; padding: 12px 24px; border-top: 1px solid #edf0f5; background: rgba(255, 255, 255, 0.96); box-shadow: 0 -6px 18px rgba(15, 31, 58, .06); backdrop-filter: blur(10px); }
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: flex-start; }
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: center; }
.editor-page-footer :deep(.editor-actions > .el-button) { min-width: 132px; min-height: 42px; }
.editor-page-footer :deep(.editor-actions > .el-button:last-child) { min-width: 172px; }
:global(.first-leg-editor-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; }
@@ -10,17 +10,33 @@ test('renders loading, pending, isolated error, and published states', () => {
assert.match(source, /role="alert"/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
assert.match(source, /role="status"/)
assert.match(source, /待分柜 · 费用已发布/)
assert.match(source, /费用已发布 · 待确认/)
assert.match(source, /\$emit\('retry'\)/)
})
test('shows the confirmed merchant inbound and fee fields without write actions', () => {
test('supports a standalone dialog and keeps the unpushed billing-weight state visible', () => {
assert.match(source, /dialogOnly\?: boolean/)
assert.match(source, /v-if="!props\.dialogOnly"/)
assert.match(source, /watch\(\(\) => props\.autoOpen/)
assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
assert.match(source, /平台推送计费重后,可在这里核对并确认/)
assert.match(source, /@closed="emit\('closed'\)"/)
})
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
assert.match(source, /formatConversionPrecision\(/)
assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
assert.match(source, /确认费用/)
assert.match(source, /二次确认费用/)
assert.match(source, /确认后该费用将锁定,平台不能再修改/)
assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/)
assert.match(source, /confirmMerchantInboundFee/)
assert.match(source, /measurementItems/)
assert.doesNotMatch(source, /调整费用|发送费用|下载照片/)
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
assert.match(source, /latestFee\.ruleCode/)
assert.match(source, /latestFee\.rateCode/)
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
import { confirmMerchantInboundFee } from '@/modules/first-leg/api/inboundBilling'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import {
formatConversionPrecision,
@@ -17,24 +20,74 @@ const props = withDefaults(defineProps<{
unavailable?: boolean
unavailableMessage?: string
error?: string
autoOpen?: boolean
dialogOnly?: boolean
canConfirm?: boolean
}>(), {
data: null,
loading: false,
unavailable: false,
unavailableMessage: '入库及费用结果尚未发布',
unavailableMessage: '还未测量推送',
error: '',
autoOpen: false,
dialogOnly: false,
canConfirm: false,
})
defineEmits<{
const emit = defineEmits<{
retry: []
confirmed: []
closed: []
}>()
const feeDialogOpen = ref(false)
const confirming = ref(false)
watch(() => props.autoOpen, (autoOpen) => {
if (autoOpen) feeDialogOpen.value = true
}, { immediate: true })
async function confirmFee() {
const fee = props.data?.latestFee
const orderId = props.data?.inbound.summary.orderId
if (!fee || !orderId || !props.canConfirm || confirming.value) return
if (fee.customerConfirmationStatus === 'CONFIRMED') {
ElMessage.info('当前费用已经确认,无需重复操作')
return
}
try {
await ElMessageBox.confirm(
`请再次确认费用单 ${fee.feeNo},合计 ${formatInboundMoney(fee.totalAmount, fee.currency)}。确认后该费用将锁定,平台不能再修改。`,
'二次确认费用',
{ type: 'warning', confirmButtonText: '确认费用无误', cancelButtonText: '返回核对' },
)
} catch {
return
}
confirming.value = true
try {
await confirmMerchantInboundFee(orderId, fee.feeVersion)
ElMessage.success('费用确认成功,当前费用已锁定')
feeDialogOpen.value = false
emit('confirmed')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '费用确认失败,请刷新后重试')
} finally {
confirming.value = false
}
}
</script>
<template>
<section class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
<section v-if="!props.dialogOnly" class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
<header class="inbound-billing-heading">
<h3 id="inbound-billing-title">入库与费用</h3>
<el-tag v-if="props.data" type="success" effect="light">待分柜 · 费用已发布</el-tag>
<div v-if="props.data" class="inbound-billing-actions">
<el-tag :type="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? 'success' : 'warning'" effect="light">
{{ props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? '客户已确认 · 费用已锁定' : '费用已发布 · 待确认' }}
</el-tag>
<el-button v-if="props.data.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" @click="feeDialogOpen = true">确认费用</el-button>
<el-button v-else plain @click="feeDialogOpen = true">查看费用</el-button>
</div>
</header>
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
@@ -55,7 +108,7 @@ defineEmits<{
<Clock3 :size="22" />
<div>
<strong>{{ props.unavailableMessage }}</strong>
<span>客服完成测量审核、入库并发布费用后,将在这里展示最终结果。</span>
<span>平台完成测量审核并确认推送费用后,将在这里展示测量与计费结果。</span>
</div>
</div>
@@ -68,7 +121,7 @@ defineEmits<{
</dl>
<div class="inbound-result-section">
<h4>实测结果</h4>
<div class="inbound-section-title"><h4>测量数据</h4><el-tag size="small" type="warning" effect="light">后台推送</el-tag></div>
<dl class="inbound-measurements">
<div><dt>实际箱数</dt><dd>{{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}</dd></div>
<div><dt>实际毛重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}</dd></div>
@@ -77,6 +130,17 @@ defineEmits<{
<div><dt>材积重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.volumeWeightKg, 'kg') }}</dd></div>
<div><dt>测量完成</dt><dd>{{ formatInboundDateTime(props.data.inbound.summary.measureCompletedAt) }}</dd></div>
</dl>
<el-table v-if="props.data.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
<el-table-column type="index" label="#" width="54" />
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
<el-table-column prop="packageCount" label="箱数" min-width="76" />
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
</el-table>
</div>
<div class="inbound-result-section">
@@ -138,12 +202,65 @@ defineEmits<{
<p>{{ props.data.inbound.publicRemark }}</p>
</div>
</template>
</section>
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body @closed="emit('closed')">
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
<el-skeleton :rows="4" animated />
<span class="sr-only">正在加载计费重</span>
</div>
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
<TriangleAlert :size="22" />
<div><strong>计费重加载失败</strong><span>{{ props.error }}</span></div>
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
</div>
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
<Clock3 :size="22" />
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送计费重后,可在这里核对并确认。</span></div>
</div>
<template v-else>
<dl class="inbound-measurements fee-dialog-summary">
<div><dt>计费总箱数</dt><dd>{{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}</dd></div>
<div><dt>计费总毛重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}</dd></div>
<div><dt>外箱规总体积</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}</dd></div>
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
</dl>
<el-table v-if="props.data?.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
<el-table-column type="index" label="#" width="54" />
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
<el-table-column prop="packageCount" label="箱数" min-width="76" />
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
<el-table-column prop="calculationRuleVersion" label="箱规规则" min-width="180" />
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
</el-table>
<el-alert
v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'"
class="fee-confirmed-alert"
:title="`客户已于 ${formatInboundDateTime(props.data.latestFee.customerConfirmedAt)} 确认,费用已锁定,不允许修改。`"
type="success"
:closable="false"
show-icon
/>
<el-alert v-else class="fee-confirmed-alert" title="请核对全部计费数据。点击确认后还会进行一次确认,完成后费用将锁定。" type="warning" :closable="false" show-icon />
</template>
<template #footer>
<el-button @click="feeDialogOpen = false">关闭</el-button>
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认费用</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.inbound-billing-panel { margin-top: 22px; }
.inbound-billing-heading, .inbound-section-title, .fee-total { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.inbound-billing-actions { display: flex; align-items: center; gap: 10px; }
.inbound-billing-heading h3, .inbound-result-section h4 { margin: 0; color: var(--navy); }
.inbound-billing-heading h3 { font-size: 15px; }
.inbound-result-section h4 { font-size: 13px; }
@@ -166,6 +283,9 @@ defineEmits<{
.inbound-section-title > span { color: var(--muted); font-size: 10px; text-align: right; }
.inbound-measurements { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; background: #f6f8f8; }
.inbound-measurements > div { min-width: 0; padding: 12px; }
.measurement-detail-table { margin-top: 12px; }
.fee-dialog-summary { margin-top: 0; }
.fee-confirmed-alert { margin-top: 14px; }
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.pallet-location-list article > svg { color: var(--primary); }
+1
View File
@@ -1,2 +1,3 @@
export { useFirstLegOrdersStore } from './store/firstLegOrders'
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue'
@@ -62,6 +62,8 @@ export interface MerchantInboundFeeSnapshot {
feeStatus: MerchantInboundFeeStatus
inboundId: string
inboundCode: string
measurementId?: string | null
measurementSource?: string | null
chargeRuleId: string
ruleCode: string
chargeRuleVersion: number
@@ -98,9 +100,55 @@ export interface MerchantInboundFeeSnapshot {
confirmedAt: string | null
sentAt: string | null
nextProcessStatus: MerchantInboundProcessStatus | null
customerConfirmationStatus: 'PENDING_CONFIRMATION' | 'CONFIRMED'
customerConfirmedBy: string | null
customerConfirmedByName: string | null
customerConfirmedAt: string | null
}
export interface MerchantMeasurementItem {
id: string
itemNo: string | null
boxSpecification: string | null
packageCount: number | null
lengthCm: number | null
widthCm: number | null
heightCm: number | null
unitVolumeCbm: number | null
totalVolumeCbm: number | null
volumeWeightKg: number | null
chargeRuleId: string | null
coefficientMode: 'ADD' | 'MULTIPLY' | null
calculationRuleVersion: string | null
}
export interface MerchantBillingBasis {
chargeMode: string
chargeUnit: string
chargeableBasis: string
actualWeightKg: number
actualVolumeCbm: number
convertedQuantity: number
chargeableQuantity: number
chargeableWeightKg: number | null
chargeableVolumeCbm: number | null
}
export interface MerchantInboundBilling {
inbound: MerchantInboundResult
measurementSummary: {
measurementId: string | null
sourceType: string | null
measurementStatus: string | null
actualPackageCount: number | null
actualGrossWeightKg: number | null
actualNetWeightKg: number | null
actualVolumeCbm: number | null
volumeWeightKg: number | null
actualGoodsName: string | null
calculationSource: 'OUTER' | string | null
}
measurementItems: MerchantMeasurementItem[]
billingBasis: MerchantBillingBasis
latestFee: MerchantInboundFeeSnapshot
}
@@ -26,6 +26,7 @@ test('opens order creation as a dedicated protected page while keeping draft edi
assert.match(editorShellSource, /width="94vw"/)
assert.doesNotMatch(editorShellSource, /<el-drawer/)
assert.match(editorShellSource, /返回订单列表/)
assert.match(editorShellSource, /\.editor-page-footer :deep\(\.editor-actions\) \{[^}]*justify-content: center;/)
})
test('exposes real list, draft, submit, and detail workflow entries without cancellation', () => {
@@ -330,6 +331,7 @@ test('collects the backend-required first-leg fields', () => {
test('distills order creation into the confirmed prototype sections with server pricing', () => {
const cargoTableSource = source.slice(source.indexOf('<table class="cargo-edit-table">'), source.indexOf('</table>', source.indexOf('<table class="cargo-edit-table">')))
const cargoToolbarSource = source.slice(source.indexOf('<div class="cargo-table-toolbar">'), source.indexOf('<div class="cargo-table-wrap">', source.indexOf('<div class="cargo-table-toolbar">')))
assert.match(source, /<h3>目的地信息<\/h3>/)
assert.match(source, /<h3>客户下单<\/h3>/)
assert.match(source, /<h3>货物明细<\/h3>/)
@@ -339,12 +341,20 @@ test('distills order creation into the confirmed prototype sections with server
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
assert.match(source, /<table class="cargo-edit-table">/)
assert.match(source, /auth\.hasPermission\('order:first-leg:create'\)[\s\S]*批量导入订单/)
assert.doesNotMatch(source, /create-batch-import-entry|需要一次创建多条订单|页面上方“Excel 批量导入”/)
assert.doesNotMatch(source, /create-batch-import-entry/)
assert.match(cargoToolbarSource, /isCreatePage && !routeDraftId && canCreateOrder/)
assert.match(cargoToolbarSource, /downloadBatchTemplate[\s\S]*下载 Excel 模板/)
assert.match(cargoToolbarSource, /openBatchImport[\s\S]*Excel 批量导入/)
assert.match(cargoToolbarSource, /Excel 导入用于一次创建多条订单草稿/)
assert.match(source, /导入成功的数据只创建草稿,不会直接提交审核/)
assert.match(source, /下载正式模板[\s\S]*选择 Excel/)
assert.match(source, /仅支持 \.xlsx,文件不超过 5MB、最多 50 行/)
assert.match(source, /cargo\.woodenProduct(true\/false)[\s\S]*customsInspection 必须填写 true/)
assert.match(source, /直接选择模板内的中文下拉项,不需要填写英文代码或内部编号/)
assert.match(source, /选择木制品时,模板会要求同步选择需要商检/)
assert.match(source, /下载 Excel 错误报告/)
assert.match(source, /\^excel-row-\(\\d\+\)\$[\s\S]*item\.index \+ 3/)
assert.match(source, /Excel 第 \{\{ batchImportExcelRow\(item\) \}\} 行/)
assert.doesNotMatch(source, /未填写行标识/)
assert.match(source, /batchImportError[\s\S]*requestId[\s\S]*traceId/)
assert.match(source, /添加行[\s\S]*删除所选行/)
assert.doesNotMatch(source, /货物明细导入模板\.xls/)
@@ -481,8 +491,9 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
assert.match(source, /<InboundBillingPanel/)
assert.match(source, /@retry="retryInboundBilling"/)
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /inboundBillingUnavailableMessage\.value = error\.message \|\| '入库及费用结果尚未发布'/)
assert.match(source, /error\.message \|\| unavailableMessage/)
assert.match(source, /order\.status === 'DRAFT'/)
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送计费重'[\s\S]*: '订单提交后可查看入库结果'/)
assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
})
@@ -492,11 +503,28 @@ test('keeps platform enterprise scope and tenant terminology out of the customer
assert.doesNotMatch(templateSource, /租户|tenantId|所属企业|企业筛选/)
})
test('discards stale inbound billing responses when the drawer closes or the user changes orders', () => {
test('opens billing-weight confirmation independently from order details', () => {
const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail'))
assert.match(handler, /feeConfirmationOpen\.value = true/)
assert.match(handler, /loadInboundBilling\(order, '未推送计费重'\)/)
assert.doesNotMatch(handler, /openDetail\(/)
assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/)
assert.match(source, /unavailable-message="未推送计费重"/)
})
test('shows the standalone billing-weight entry at every order node', () => {
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
const entries = templateSource.match(/<el-button link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? []
assert.equal(entries.length, 2)
assert.doesNotMatch(templateSource, /v-if="[^"]*status === 'WAIT_SPLIT'[^"]*"[^>]*>确认计费重/)
})
test('discards stale inbound billing responses when billing views close or the user changes orders', () => {
assert.match(source, /let inboundBillingRequestSequence = 0/)
assert.match(source, /const sequence = \+\+inboundBillingRequestSequence/)
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*detailOpen\.value[\s\S]*detailId\.value === orderId/)
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*inboundBillingOrderId\.value === orderId[\s\S]*detailOpen\.value \|\| feeConfirmationOpen\.value/)
assert.match(source, /watch\(detailOpen,[\s\S]*resetInboundBillingState\(\)/)
assert.match(source, /watch\(feeConfirmationOpen,[\s\S]*resetInboundBillingState\(\)/)
})
test('protects unsaved drafts and exposes recoverable load failures', () => {
@@ -72,6 +72,7 @@ const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
@@ -198,8 +199,10 @@ const detailCargoPageSize = ref(5)
const inboundBilling = ref<MerchantInboundBilling | null>(null)
const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
const inboundBillingUnavailableMessage = ref('还未测量推送')
const inboundBillingError = ref('')
const inboundBillingOrderId = ref('')
const feeConfirmationOpen = ref(false)
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false)
@@ -649,12 +652,16 @@ watch(isCreatePage, (isCreate, wasCreate) => {
}, { immediate: true })
watch(detailOpen, (open) => {
if (!open) {
if (!open && !feeConfirmationOpen.value) {
resetInboundBillingState()
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
}
})
watch(feeConfirmationOpen, (open) => {
if (!open && !detailOpen.value) resetInboundBillingState()
})
onBeforeUnmount(() => {
resetInboundBillingState()
channelRequestSequence += 1
@@ -1197,6 +1204,7 @@ async function openEdit(order: FirstLegOrder) {
}
async function openDetail(order: FirstLegOrder) {
feeConfirmationOpen.value = false
detailId.value = order.id
detailCargoPage.value = 1
detailOpen.value = true
@@ -1215,6 +1223,14 @@ async function openDetail(order: FirstLegOrder) {
}
}
function openFeeConfirmation(order: FirstLegOrder) {
detailOpen.value = false
resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送计费重'
feeConfirmationOpen.value = true
void loadInboundBilling(order, '未推送计费重')
}
async function retryDetail() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await openDetail(order)
@@ -1222,30 +1238,34 @@ async function retryDetail() {
function resetInboundBillingState() {
inboundBillingRequestSequence += 1
inboundBillingOrderId.value = ''
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingUnavailableMessage.value = '还未测量推送'
inboundBillingError.value = ''
}
function isCurrentInboundBillingRequest(sequence: number, orderId: string) {
return sequence === inboundBillingRequestSequence
&& detailOpen.value
&& detailId.value === orderId
&& inboundBillingOrderId.value === orderId
&& (detailOpen.value || feeConfirmationOpen.value)
}
async function loadInboundBilling(order: FirstLegOrder) {
async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '还未测量推送') {
const sequence = ++inboundBillingRequestSequence
inboundBillingOrderId.value = order.id
inboundBilling.value = null
inboundBillingLoading.value = false
inboundBillingUnavailable.value = false
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingUnavailableMessage.value = unavailableMessage
inboundBillingError.value = ''
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = '订单提交后可查看入库结果'
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
: '订单提交后可查看入库结果'
return
}
@@ -1258,7 +1278,9 @@ async function loadInboundBilling(order: FirstLegOrder) {
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = error.message || '入库及费用结果尚未发布'
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
: error.message || unavailableMessage
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
}
@@ -1268,8 +1290,12 @@ async function loadInboundBilling(order: FirstLegOrder) {
}
async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order)
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
}
function handleInboundFeeConfirmed() {
if (!feeConfirmationOpen.value) void retryInboundBilling()
}
async function confirmDiscardChanges() {
@@ -1491,6 +1517,11 @@ function openBatchImport() {
batchImportOpen.value = true
}
function batchImportExcelRow(item: { index: number; clientRowId: string }) {
const matched = /^excel-row-(\d+)$/.exec(item.clientRowId)
return matched ? Number(matched[1]) : item.index + 3
}
function selectBatchImportFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -1773,6 +1804,7 @@ async function searchOrders(model: QueryModel) {
<template #actions="{ row }">
<div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
</div>
@@ -1838,6 +1870,7 @@ async function searchOrders(model: QueryModel) {
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
<div>
<el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button>
<el-button link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
</div>
@@ -1928,7 +1961,12 @@ async function searchOrders(model: QueryModel) {
<section class="order-form-section prototype-section cargo-section">
<div class="order-section-heading cargo-table-heading"><div><h3>货物明细</h3></div></div>
<div class="cargo-table-toolbar">
<span class="cargo-table-hint">可逐行维护货物明细;批量订单请返回订单列表使用正式 Excel 导入。</span>
<div v-if="isCreatePage && !routeDraftId && canCreateOrder" class="cargo-import-actions">
<el-button class="cargo-download-button" :loading="batchTemplateDownloading" @click="downloadBatchTemplate"><Download :size="16" />下载 Excel 模板</el-button>
<el-button class="cargo-upload-button" type="primary" plain @click="openBatchImport"><Upload :size="16" />Excel 批量导入</el-button>
<span class="cargo-table-hint">Excel 导入用于一次创建多条订单草稿。</span>
</div>
<span v-else class="cargo-table-hint">可逐行维护货物明细。</span>
<div class="cargo-table-actions"><el-button @click="addCargoItem"><Plus :size="16" />添加行</el-button><el-button type="danger" plain :disabled="!selectedCargoIds.length" @click="removeSelectedCargoItems"><Trash2 :size="15" />删除所选行</el-button></div>
</div>
<div class="cargo-table-wrap">
@@ -1947,10 +1985,10 @@ async function searchOrders(model: QueryModel) {
<td><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="English Name" /></td><td><el-input v-model="item.hsCode" maxlength="20" placeholder="HS Code" /></td><td><el-input v-model="item.customsHsCode" maxlength="20" placeholder="HS Code" /></td>
<td><el-form-item :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct && form.customsInspection !== true">第 {{ index + 1 }} 条须商检</small></div></td>
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td>
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" placeholder="申报单价" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" placeholder="申报总价" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></div></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
</tr></tbody>
</table>
@@ -1998,7 +2036,7 @@ async function searchOrders(model: QueryModel) {
<el-dialog v-model="batchImportOpen" title="Excel 批量创建订单草稿" width="680px" destroy-on-close append-to-body>
<div class="batch-import-dialog">
<el-alert title="使用服务端正式模板创建订单草稿" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行;导入成功的数据只创建草稿,不会直接提交审核。模板包含 cargo.woodenProduct(true/false);任一货物填写 true 时,customsInspection 必须填写 true。" type="info" :closable="false" show-icon />
<el-alert title="使用中文模板创建订单草稿" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行;请直接选择模板内的中文下拉项,不需要填写英文代码或内部编号。导入成功的数据只创建草稿,不会直接提交审核;选择木制品时,模板会要求同步选择需要商检。" type="info" :closable="false" show-icon />
<el-alert v-if="batchImportError" title="批量导入处理失败" :description="batchImportError" type="error" :closable="false" show-icon />
<div class="batch-import-actions">
<input ref="batchImportInput" class="cargo-import-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="selectBatchImportFile" />
@@ -2017,8 +2055,8 @@ async function searchOrders(model: QueryModel) {
<el-button type="danger" plain :loading="batchErrorReportDownloading" @click="downloadBatchErrorReport"><Download :size="16" />下载 Excel 错误报告</el-button>
</div>
<ul v-if="batchImportResult.result.failed">
<li v-for="item in batchImportResult.result.items.filter((row) => !row.success)" :key="`${item.index}-${item.clientRowId}`">
第 {{ item.index + 1 }} 行({{ item.clientRowId || '未填写行标识' }}):{{ item.errorMessage || item.errorCode || '导入失败' }}
<li v-for="item in batchImportResult.result.items.filter((row) => !row.success)" :key="item.index">
Excel 第 {{ batchImportExcelRow(item) }} 行:{{ item.errorMessage || item.errorCode || '导入失败' }}
</li>
</ul>
</section>
@@ -2145,12 +2183,28 @@ async function searchOrders(model: QueryModel) {
:unavailable="inboundBillingUnavailable"
:unavailable-message="inboundBillingUnavailableMessage"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
/>
</template>
</div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-dialog>
<InboundBillingPanel
v-if="feeConfirmationOpen"
dialog-only
auto-open
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
unavailable-message="未推送计费重"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
@closed="feeConfirmationOpen = false"
/>
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"
@@ -102,7 +102,11 @@ test('binds exactly one destination warehouse through a destination dialog', ()
assert.match(source, /availableDestinationWarehouses\.value\.find/)
assert.match(source, /destinationWarehouseId: warehouse\.id/)
assert.match(source, /destinationWarehouseId: ''/)
assert.match(source, /const required = \[destinationForm\.privateName, destinationForm\.countryCode\]/)
assert.match(source, /getMerchantOrderAddresses\('PRIVATE_DESTINATION'\)/)
assert.match(source, /v-model="destinationForm\.privateAddressId"/)
assert.match(source, /privateDestinationAddressId: String\(address\.id\)/)
assert.match(source, /privateDestinationAddressId: row\.destinationType === 'PRIVATE' \? row\.privateDestinationAddressId : ''/)
assert.match(source, /hasCompletePrivateContact\(row\)/)
assert.doesNotMatch(source, /v-model="destinationForm\.privateName"/)
assert.match(source, /确认绑定/)
})
+46 -42
View File
@@ -6,7 +6,9 @@ import { MapPin, Plus, Send, Trash2 } from '@lucide/vue'
import { useAuthStore } from '@/modules/auth/store/auth'
import {
getMerchantInboundCodePolicy,
getMerchantOrderAddresses,
getMerchantPricingChannels,
type CustomerOrderAddress,
type InboundCodePolicy,
type MerchantPricingChannel,
} from '@/modules/first-leg/api/firstLeg'
@@ -32,6 +34,7 @@ interface QuickOrderRow {
destinationWarehouseName: string
destinationCountryCode: string
amazonWarehouseCode: string
privateDestinationAddressId: string
consigneeContactName: string
consigneePhone: string
consigneeCity: string
@@ -64,6 +67,8 @@ const inboundCodePolicy = ref<InboundCodePolicy | null>(null)
const channelOptions = ref<QuickChannelOption[]>([])
const destinationDialogOpen = ref(false)
const destinationLoading = ref(false)
const privateAddresses = ref<CustomerOrderAddress[]>([])
const privateAddressesError = ref('')
const editingDestinationRow = ref<QuickOrderRow>()
const draftResults = reactive<QuickDraftResult[]>([])
const resultDialogOpen = ref(false)
@@ -71,13 +76,7 @@ const activeBatchKey = ref('')
const destinationForm = reactive({
type: 'FBA' as DestinationType,
warehouseId: '',
privateName: '',
countryCode: 'US',
contactName: '',
phone: '',
city: '',
address: '',
postalCode: '',
privateAddressId: '',
})
let rowSequence = 0
let productSequence = 0
@@ -101,6 +100,7 @@ function createRow(): QuickOrderRow {
destinationWarehouseName: '',
destinationCountryCode: '',
amazonWarehouseCode: '',
privateDestinationAddressId: '',
consigneeContactName: '',
consigneePhone: '',
consigneeCity: '',
@@ -149,6 +149,10 @@ function selectedChannel(row: QuickOrderRow) {
return channelOptions.value.find((channel) => channelKey(channel.warehouseId, channel.channelCode) === row.channelKey)
}
function addressLabel(address: CustomerOrderAddress) {
return `${address.addressName} · ${address.contactName} · ${address.countryCode} ${address.city} ${address.addressLine}`
}
function createConsigneeSnapshot(row: QuickOrderRow) {
return {
id: '',
@@ -187,7 +191,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
privateDestinationAddressId: '',
privateDestinationAddressId: row.destinationType === 'PRIVATE' ? row.privateDestinationAddressId : '',
consigneePartySnapshot: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
? createConsigneeSnapshot(row) : null,
consignee: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
@@ -231,21 +235,24 @@ async function openDestinationDialog(row: QuickOrderRow, typeValue: string | num
Object.assign(destinationForm, {
type,
warehouseId: row.destinationType === type ? row.destinationWarehouseId : '',
privateName: row.destinationType === type ? row.destinationWarehouseName : '',
countryCode: row.destinationType === type ? row.destinationCountryCode || 'US' : 'US',
contactName: row.destinationType === type ? row.consigneeContactName : '',
phone: row.destinationType === type ? row.consigneePhone : '',
city: row.destinationType === type ? row.consigneeCity : '',
address: row.destinationType === type ? row.consigneeAddress : '',
postalCode: row.destinationType === type ? row.consigneePostalCode : '',
privateAddressId: row.destinationType === type ? row.privateDestinationAddressId : '',
})
destinationDialogOpen.value = true
if (type === 'PRIVATE') return
destinationLoading.value = true
try {
await store.loadWarehouseType(type)
if (type === 'PRIVATE') {
privateAddressesError.value = ''
privateAddresses.value = await getMerchantOrderAddresses('PRIVATE_DESTINATION')
} else {
await store.loadWarehouseType(type)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '目的仓加载失败,请稍后重试')
const message = error instanceof Error ? error.message : '目的仓加载失败,请稍后重试'
if (type === 'PRIVATE') {
privateAddresses.value = []
privateAddressesError.value = message
}
ElMessage.error(message)
} finally {
destinationLoading.value = false
}
@@ -266,6 +273,7 @@ function bindDestination() {
destinationWarehouseName: warehouse.name,
destinationCountryCode: warehouse.countryCode,
amazonWarehouseCode: destinationForm.type === 'FBA' ? warehouse.code : '',
privateDestinationAddressId: '',
consigneeContactName: '',
consigneePhone: '',
consigneeCity: '',
@@ -273,26 +281,23 @@ function bindDestination() {
consigneePostalCode: '',
})
} else {
const required = [destinationForm.privateName, destinationForm.countryCode]
if (required.some((value) => !value.trim())) {
ElMessage.warning('请填写私人仓名称和目的国家')
return
}
if (!/^[A-Za-z]{2}$/.test(destinationForm.countryCode.trim())) {
ElMessage.warning('目的国家请输入两位英文代码,例如 US')
const address = privateAddresses.value.find((item) => String(item.id) === destinationForm.privateAddressId)
if (!address) {
ElMessage.warning('请选择企业已保存的私人仓地址')
return
}
Object.assign(row, {
destinationType: 'PRIVATE',
destinationWarehouseId: '',
destinationWarehouseName: destinationForm.privateName.trim(),
destinationCountryCode: destinationForm.countryCode.trim().toUpperCase(),
destinationWarehouseName: address.addressName,
destinationCountryCode: address.countryCode,
amazonWarehouseCode: '',
consigneeContactName: destinationForm.contactName.trim(),
consigneePhone: destinationForm.phone.trim(),
consigneeCity: destinationForm.city.trim(),
consigneeAddress: destinationForm.address.trim(),
consigneePostalCode: destinationForm.postalCode.trim(),
privateDestinationAddressId: String(address.id),
consigneeContactName: address.contactName,
consigneePhone: address.phone,
consigneeCity: address.city,
consigneeAddress: address.addressLine,
consigneePostalCode: address.postalCode ?? '',
})
}
destinationDialogOpen.value = false
@@ -347,6 +352,7 @@ async function submitQuickOrder() {
return
}
const incompleteIndex = rows.findIndex((row) => !row.channelKey || !row.destinationWarehouseName
|| (row.destinationType === 'PRIVATE' && !row.privateDestinationAddressId)
|| row.products.some((product) => !product.sku.trim() || product.packageCount < 1))
if (incompleteIndex >= 0) {
ElMessage.warning(`请完善第 ${incompleteIndex + 1} 行的渠道、目的仓、产品 SKU 和箱数`)
@@ -481,7 +487,7 @@ onMounted(loadChannels)
<td class="products-cell">
<div v-for="(product, productIndex) in row.products" :key="product.id" class="product-fields">
<el-input v-model="product.sku" maxlength="64" placeholder="产品 SKU" />
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" />
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" placeholder="请输入箱数" />
<el-button v-if="row.products.length > 1" text type="danger" aria-label="删除产品" @click="removeProduct(row, productIndex)"><Trash2 :size="15" /></el-button>
</div>
</td>
@@ -513,15 +519,13 @@ onMounted(loadChannels)
<small v-if="!destinationLoading && availableDestinationWarehouses.length === 0">平台暂未维护可用目的仓,请联系平台管理员。</small>
</el-form-item>
<template v-else>
<el-form-item label="私人地址名称" required><el-input v-model="destinationForm.privateName" maxlength="128" placeholder="例如:洛杉矶客户自有仓" /></el-form-item>
<div class="destination-private-grid">
<el-form-item label="目的国家" required><el-input v-model="destinationForm.countryCode" maxlength="2" placeholder="US" /></el-form-item>
<el-form-item label="联系人" required><el-input v-model="destinationForm.contactName" maxlength="64" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="destinationForm.phone" maxlength="32" /></el-form-item>
<el-form-item label="城市" required><el-input v-model="destinationForm.city" maxlength="64" /></el-form-item>
</div>
<el-form-item label="详细地址" required><el-input v-model="destinationForm.address" maxlength="256" /></el-form-item>
<el-form-item label="邮编"><el-input v-model="destinationForm.postalCode" maxlength="20" /></el-form-item>
<el-form-item label="私人仓地址" required>
<el-select v-model="destinationForm.privateAddressId" filterable :loading="destinationLoading" placeholder="请选择企业已保存的私人仓地址">
<el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" />
</el-select>
<small v-if="privateAddressesError">私人仓地址加载失败,请关闭后重试。</small>
<small v-else-if="!destinationLoading && privateAddresses.length === 0">暂无已保存的私人仓地址,请先在普通下单页面维护。</small>
</el-form-item>
</template>
</el-form>
<template #footer>
@@ -25,3 +25,14 @@ test('keeps internal IDs only as non-rendered routing, selection, and request va
assert.match(overseas, /:value="candidate\.orderId"/)
assert.match(firstLeg, /detailId\.value = order\.id/)
})
test('keeps overseas warehouse operations first and renders readable list values', () => {
assert.match(overseas, /columns: \['操作', '贴标申请单号'/)
assert.match(overseas, /columns: \['操作', '转运单号'/)
assert.match(overseas, /columns: \['操作', '废弃申请单号'/)
assert.match(overseas, /columns: \['操作', '退货换标单号'/)
assert.match(overseas, /function statusLabel\(value: string\)/)
assert.match(overseas, /function deliveryMethodLabel\(value: string\)/)
assert.match(overseas, /function formatDateTime\(value: string\)/)
assert.match(overseas, /<td><button class="warehouse-link"[^>]*>.*?查看<\/button><\/td><td v-for="\(_, index\) in config\.columns\.slice\(1\)"/s)
})
@@ -102,6 +102,16 @@ export interface DeliveryQuery {
plannedTo?: string
}
export function normalizeDeliveryPage(data: PageResult<DeliveryRow>): PageResult<DeliveryRow> {
return {
...data,
page: Number(data.page),
size: Number(data.size),
total: Number(data.total),
pages: Number(data.pages),
}
}
export interface DeliveryTransferPayload {
dispatchNo: string
idempotencyKey: string
@@ -127,7 +137,7 @@ export function listDeliveryOrders(params: DeliveryQuery) {
method: 'GET',
url: '/merchant/v1/last-leg/delivery-orders',
params,
})
}).then(normalizeDeliveryPage)
}
export function getDeliveryOrder(deliveryNo: string) {
@@ -0,0 +1,73 @@
import { overseasApiRequest } from '@/api/overseasHttp'
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
export interface InboundPlanQuery {
page: number
size: number
planNo?: string
inboundCode?: string
status?: string
destinationWarehouseCode?: string
appointmentStatus?: string
dispatchStatus?: string
createdFrom?: string
createdTo?: string
}
export interface InboundPlanSummary {
planNo: string
inboundCode: string
orderType: string | null
warehouseCode: string | null
warehouseName: string | null
fbaWarehouseCode: string | null
packageCount: number
pickedPackageCount: number
volumeCbm: number | null
palletCount: number
status: string
appointmentNo: string | null
appointmentStatus: string | null
appointmentAt: string | null
expectedLoadingAt: string | null
dispatchStatus: string | null
createdAt: string
version: number
}
export interface InboundPlanLine {
inboundCode: string
fbaWarehouseCode: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
volumeCbm: number
requiredPackageCount: number
plannedPalletCount: number
}
export interface InboundPlanPallet {
palletNo: string
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
capacityCbm: number
allocatedVolumeCbm: number
}
export interface InboundPlanDetail {
planNo: string
containerNo: string | null
status: string
lineCount: number
palletCount: number
totalVolumeCbm: number
expectedPackageCount: number
pickedPackageCount: number
lines: InboundPlanLine[]
pallets: InboundPlanPallet[]
version: number
}
export async function listInboundPlans(params: InboundPlanQuery) {
const result = await overseasApiRequest<PageResult<InboundPlanSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/inbound-plans', params })
return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) }
}
export function getInboundPlan(planNo: string) {
return overseasApiRequest<InboundPlanDetail>({ method: 'GET', url: `/merchant/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}` })
}
@@ -47,6 +47,12 @@ test('renders the operation column first and covers loading, empty, error and pa
assert.match(listSource, /@click="loadOrders">重试/)
assert.match(listSource, /listDeliveryOrders\(buildQuery\(\)\)/)
assert.match(listSource, /<el-pagination/)
assert.match(listSource, /v-model:current-page="page"/)
assert.match(listSource, /v-model:page-size="pageSize"/)
assert.match(listSource, /@current-change="changePage"/)
assert.match(listSource, /@size-change="changePageSize"/)
assert.match(apiSource, /\.then\(normalizeDeliveryPage\)/)
assert.match(apiSource, /total: Number\(data\.total\)/)
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
})
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const api = readFileSync(new URL('./api/inboundPlans.ts', import.meta.url), 'utf8')
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const view = readFileSync(new URL('./views/InboundOrdersView.vue', import.meta.url), 'utf8')
test('activates the customer-scoped overseas inbound list and detail', () => {
assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
assert.match(api, /\/merchant\/v1\/last-leg\/inbound-plans/)
assert.match(api, /encodeURIComponent\(planNo\)/)
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
})
test('keeps actions first and exposes filters loading errors empty state pagination and business identifiers', () => {
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
for (const token of ['planNo', 'inboundCode', 'destinationWarehouseCode', 'appointmentStatus', 'dispatchStatus', 'createdDates']) assert.match(view, new RegExp(token))
assert.match(view, /v-loading="loading"/)
assert.match(view, /type="error"/)
assert.match(view, /empty-text="暂无入库订单"/)
assert.match(view, /<el-pagination/)
assert.match(view, /detail\.planNo/)
assert.doesNotMatch(view, /planId|orderId|warehouseId|customerId/)
})
+1 -1
View File
@@ -17,7 +17,7 @@ export const orderManagementModule = {
path: 'orders/inbound',
name: 'orders-inbound',
component: () => import('./views/InboundOrdersView.vue'),
release: 'prototype',
release: 'active',
meta: { title: '入库订单列表', description: '查询海外仓入库订单', permission: 'merchant:warehouse:inbound:list' },
navigation: { key: 'orders-inbound', icon: Package },
},
@@ -186,7 +186,7 @@ async function cancel() {
<el-table-column label="操作" width="88"><template #default="{ $index }"><el-button link type="danger" aria-label="删除费用" @click="removeFee($index)"><Trash2 :size="16" />删除</el-button></template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="{ row }"><el-input v-model="row.feeType" maxlength="64" placeholder="请输入费用类型" /></template></el-table-column>
<el-table-column label="币种" width="140"><template #default="{ row }"><el-input v-model="row.currency" maxlength="3" placeholder="USD" /></template></el-table-column>
<el-table-column label="金额" width="180"><template #default="{ row }"><el-input-number v-model="row.amount" :min="0" :precision="2" :step="1" controls-position="right" /></template></el-table-column>
<el-table-column label="金额" width="180"><template #default="{ row }"><el-input-number v-model="row.amount" :min="0" :precision="2" :step="1" controls-position="right" placeholder="请输入金额" /></template></el-table-column>
<el-table-column label="备注" min-width="220"><template #default="{ row }"><el-input v-model="row.remark" maxlength="200" placeholder="选填" /></template></el-table-column>
</el-table>
<div v-else class="delivery-fee-empty">请至少添加一条费用明细后再提交。</div>
@@ -144,7 +144,7 @@ onMounted(loadOrders)
<el-table-column prop="expectedArrivalAt" label="预计到达" min-width="160"><template #default="{ row }">{{ formatDateTime(row.expectedArrivalAt) }}</template></el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="160"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<div v-if="!errorMessage && (records.length > 0 || total > 0)" class="delivery-pagination"><span>共 {{ total }} 条</span><el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" layout="sizes, prev, pager, next" :total="total" @update:current-page="changePage" @update:page-size="changePageSize" /></div>
<div v-if="!errorMessage && (records.length > 0 || total > 0)" class="delivery-pagination"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" layout="sizes, prev, pager, next" :total="total" @current-change="changePage" @size-change="changePageSize" /></div>
</div>
</section>
</template>
@@ -1,5 +1,137 @@
<script setup lang="ts">
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getInboundPlan, listInboundPlans, type InboundPlanDetail, type InboundPlanSummary } from '../api/inboundPlans'
const loading = ref(false)
const error = ref('')
const rows = ref<InboundPlanSummary[]>([])
const detail = ref<InboundPlanDetail | null>(null)
const total = ref(0)
const query = reactive({
page: 1, size: 20, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '',
appointmentStatus: '', dispatchStatus: '', createdDates: [] as string[],
})
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
function statusLabel(value: unknown) {
return ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功', FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车' } as Record<string, string>)[String(value)] || text(value)
}
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
async function load() {
loading.value = true
error.value = ''
try {
const result = await listInboundPlans({
page: query.page, size: query.size, planNo: query.planNo || undefined, inboundCode: query.inboundCode || undefined,
destinationWarehouseCode: query.destinationWarehouseCode || undefined, status: query.status || undefined,
appointmentStatus: query.appointmentStatus || undefined, dispatchStatus: query.dispatchStatus || undefined,
createdFrom: query.createdDates[0] ? `${query.createdDates[0]}T00:00:00` : undefined,
createdTo: query.createdDates[1] ? `${query.createdDates[1]}T23:59:59` : undefined,
})
rows.value = result.records
total.value = result.total
} catch (cause) {
error.value = errorMessage(cause)
rows.value = []
total.value = 0
} finally { loading.value = false }
}
function resetQuery() {
Object.assign(query, { page: 1, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '', appointmentStatus: '', dispatchStatus: '', createdDates: [] })
void load()
}
async function openDetail(candidate: unknown) {
const row = candidate as InboundPlanSummary
loading.value = true
try { detail.value = await getInboundPlan(row.planNo) }
catch (cause) { ElMessage.error(errorMessage(cause)) }
finally { loading.value = false }
}
onMounted(() => void load())
</script>
<template><PrototypeFeatureView title="入库订单列表" description="查询海外仓入库订单" /></template>
<template>
<section v-loading="loading" class="inbound-orders" :aria-busy="loading">
<header class="page-head">
<div><h2>{{ detail ? '入库订单详情' : '入库订单列表' }}</h2><p>{{ detail ? `计划号:${detail.planNo}` : '查询当前企业在海外仓的入库计划与作业进度' }}</p></div>
<el-button v-if="detail" @click="detail = null">返回列表</el-button>
</header>
<template v-if="!detail">
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button size="small" @click="load">重新加载</el-button></template></el-alert>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="query.page = 1; load()">
<el-form-item label="计划号"><el-input v-model.trim="query.planNo" clearable placeholder="请输入计划号" /></el-form-item>
<el-form-item label="入仓码"><el-input v-model.trim="query.inboundCode" clearable placeholder="请输入入仓码" /></el-form-item>
<el-form-item label="目的仓"><el-input v-model.trim="query.destinationWarehouseCode" clearable placeholder="请输入仓库代码" /></el-form-item>
<el-form-item label="作业状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
<el-form-item label="预约状态"><el-select v-model="query.appointmentStatus" clearable placeholder="全部"><el-option label="未预约" value="UNAPPOINTED" /><el-option label="待处理" value="PENDING" /><el-option label="成功" value="SUCCESS" /><el-option label="失败" value="FAILED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
<el-form-item label="排车状态"><el-select v-model="query.dispatchStatus" clearable placeholder="全部"><el-option label="未排车" value="UNSCHEDULED" /><el-option label="已排车" value="SCHEDULED" /></el-select></el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="query.createdDates" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="rows" border empty-text="暂无入库订单">
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
<el-table-column prop="planNo" label="计划号" min-width="180" show-overflow-tooltip />
<el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="海外仓" min-width="160" show-overflow-tooltip><template #default="scope">{{ text(scope.row.warehouseName || scope.row.warehouseCode) }}</template></el-table-column>
<el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="scope">{{ text(scope.row.fbaWarehouseCode) }}</template></el-table-column>
<el-table-column prop="packageCount" label="计划箱数" width="100" />
<el-table-column prop="pickedPackageCount" label="已拣箱数" width="100" />
<el-table-column prop="palletCount" label="托盘数" width="90" />
<el-table-column label="状态" width="120"><template #default="scope">{{ statusLabel(scope.row.status) }}</template></el-table-column>
<el-table-column label="预约状态" width="120"><template #default="scope">{{ statusLabel(scope.row.appointmentStatus) }}</template></el-table-column>
<el-table-column label="排车状态" width="110"><template #default="scope">{{ statusLabel(scope.row.dispatchStatus) }}</template></el-table-column>
<el-table-column label="创建时间" width="180"><template #default="scope">{{ dateTime(scope.row.createdAt) }}</template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
</el-card>
</template>
<template v-else>
<el-card shadow="never">
<dl class="detail-grid">
<div><dt>计划号</dt><dd>{{ detail.planNo }}</dd></div><div><dt>集装箱号</dt><dd>{{ text(detail.containerNo) }}</dd></div>
<div><dt>状态</dt><dd>{{ statusLabel(detail.status) }}</dd></div><div><dt>入仓码数</dt><dd>{{ detail.lineCount }}</dd></div>
<div><dt>托盘数</dt><dd>{{ detail.palletCount }}</dd></div><div><dt>总体积(m³)</dt><dd>{{ detail.totalVolumeCbm }}</dd></div>
<div><dt>计划箱数</dt><dd>{{ detail.expectedPackageCount }}</dd></div><div><dt>已拣箱数</dt><dd>{{ detail.pickedPackageCount }}</dd></div>
</dl>
</el-card>
<el-card shadow="never" header="入仓明细">
<el-table :data="detail.lines" border empty-text="暂无入仓明细">
<el-table-column prop="inboundCode" label="入仓码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓代码" min-width="140" />
<el-table-column prop="destinationWarehouseName" label="目的仓名称" min-width="160" /><el-table-column prop="requiredPackageCount" label="计划箱数" width="100" />
<el-table-column prop="plannedPalletCount" label="计划托数" width="100" /><el-table-column prop="volumeCbm" label="体积(m³)" width="110" />
</el-table>
</el-card>
<el-card shadow="never" header="托盘明细">
<el-table :data="detail.pallets" border empty-text="暂无托盘">
<el-table-column prop="palletNo" label="托盘码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓" min-width="150" />
<el-table-column prop="capacityCbm" label="容量(m³)" width="120" /><el-table-column prop="allocatedVolumeCbm" label="已分配体积(m³)" width="140" />
</el-table>
</el-card>
</template>
</section>
</template>
<style scoped>
.inbound-orders { display: grid; gap: 18px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
.filter-card :deep(.el-form-item) { margin-bottom: 12px; }
.filter-card :deep(.el-input), .filter-card :deep(.el-select) { width: 190px; }
.pagination { justify-content: flex-end; margin-top: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.detail-grid div { padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; }
</style>
@@ -91,6 +91,14 @@ test('shares the merchant permission map and preserves resources not rendered by
assert.match(contractsSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/)
assert.match(contractsSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/)
assert.match(contractsSource, /'company:member:create': \['merchant:member:create'\]/)
for (const permission of [
'merchant:warehouse:inbound:list',
'merchant:cross-border:procurement',
'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping',
]) {
assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`))
}
assert.match(contractsSource, /'company:member:edit': \['merchant:member:edit'\]/)
assert.match(contractsSource, /'company:member:grant-role': \['merchant:member:grant-role'\]/)
assert.match(contractsSource, /'company:member:status': \['merchant:member:status'\]/)
@@ -38,6 +38,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
key: 'finance',
label: '财务账单',
items: [
{ code: 'merchant:finance:account:view', label: '查看资金账户', description: '查看本企业实际、冻结和可用余额及资金流水' },
{ code: 'merchant:finance:recharge:create', label: '提交充值申请', description: '上传付款截图并提交本企业充值申请' },
{ code: 'finance:bill:view', label: '查看客户账单', description: '查看本企业账单、收款记录和未收余额' },
{ code: 'finance:bill:confirm', label: '确认客户账单', description: '确认本企业已出账且待客户确认的对账单' },
],
@@ -93,6 +95,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
]
export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, readonly string[]>> = {
'merchant:finance:account:view': ['merchant:finance:account:view'],
'merchant:finance:recharge:create': ['merchant:finance:recharge:create'],
'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'],
'order:first-leg:create': ['merchant:order:create'],
'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'],
@@ -101,6 +105,10 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
'order:first-leg:cancel': ['merchant:order:cancel'],
'merchant:warehouse:delivery:list': ['merchant:warehouse:delivery:list'],
'merchant:warehouse:delivery:create': ['merchant:warehouse:delivery:create'],
'merchant:warehouse:inbound:list': ['merchant:warehouse:inbound:list'],
'merchant:cross-border:procurement': ['merchant:cross-border:procurement'],
'merchant:cross-border:domestic-trucking': ['merchant:cross-border:domestic-trucking'],
'merchant:cross-border:pallet-shipping': ['merchant:cross-border:pallet-shipping'],
'company:profile:view': ['merchant:profile:view'],
'company:profile:edit': ['merchant:profile:edit'],
'company:qualification:upload': ['merchant:qualification:upload'],
@@ -83,7 +83,6 @@ const normalizedQualificationCertificateNo = computed(() => qualificationCertifi
const draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file))
const legalPersonIdFileSelected = computed(() => Boolean(qualificationDraftItem('LEGAL_PERSON_ID')?.file))
const standaloneLegalPersonIdUnsupported = computed(() => legalPersonIdFileSelected.value && !businessLicenseFileSelected.value)
const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value)
const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value)
const qualificationCreditCodeReady = computed(() => requiredPackageChangeSelected.value
@@ -122,11 +121,9 @@ const qualificationUploadDisabledReason = computed(() => {
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
&& !standaloneLegalPersonIdUnsupported.value
&& requiredPackageComplete.value)
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (standaloneLegalPersonIdUnsupported.value) return '法人身份证为选填项;当前文件服务尚未开放单独保存,请取消本次选择后保存其他资质。'
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
return ''
@@ -340,20 +337,15 @@ async function submitQualification() {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
if (standaloneLegalPersonIdUnsupported.value) {
const legalPersonId = qualificationDraftItem('LEGAL_PERSON_ID')
const message = '当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交。'
if (legalPersonId) { legalPersonId.error = message; legalPersonId.uploadState = 'error' }
ElMessage.warning(message)
return
}
if (!requiredPackageComplete.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
return
}
const optionalItems = qualificationDrafts.filter((item) => !item.required && item.value !== 'LEGAL_PERSON_ID' && item.file)
const optionalItems = qualificationDrafts.filter((item) => !item.required
&& item.file
&& (item.value !== 'LEGAL_PERSON_ID' || !requiredPackageChangeSelected.value))
const includedLegalPersonId = legalPersonIdFileSelected.value
qualificationSubmitting.value = true
let uploadedCount = 0
@@ -430,8 +422,10 @@ async function submitQualification() {
}
qualificationOpen.value = false
if (refreshed) ElMessage.success(includedLegalPersonId
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
: '营业执照和统一社会信用代码已保存,尚未提交审核')
? (requiredPackageChangeSelected.value
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
: '法人身份证已保存,尚未提交审核')
: '资质文件已保存,尚未提交审核')
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
} finally {
qualificationSubmitting.value = false
@@ -73,12 +73,14 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /requiredQualificationTypes\.every/)
assert.doesNotMatch(profileSource, /四类必填资质/)
assert.doesNotMatch(profileSource, /法人身份证需与本次营业执照一起保存/)
assert.match(profileSource, /const standaloneLegalPersonIdUnsupported = computed/)
assert.match(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交/)
assert.doesNotMatch(profileSource, /standaloneLegalPersonIdUnsupported/)
assert.doesNotMatch(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换/)
assert.match(profileSource, /item\.value !== 'LEGAL_PERSON_ID' \|\| !requiredPackageChangeSelected\.value/)
assert.match(profileSource, /法人身份证已保存,尚未提交审核/)
assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(profileSource, /提交企业审核/)
assert.match(profileSource, /营业执照和统一社会信用代码已保存,尚未提交审核/)
assert.match(profileSource, /资质文件已保存,尚未提交审核/)
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
assert.match(profileSource, /qualificationStatusMeta/)
+13 -4
View File
@@ -53,17 +53,21 @@ test('keeps only implemented navigation groups visible', () => {
title: '头程物流',
children: ['订单列表', '快速下单'],
},
{
title: '出海下单',
children: ['海外采购', '美国本土卡', '美本土散板'],
},
{
title: '海外仓',
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
},
{
title: '订单管理',
children: ['派送订单列表'],
children: ['入库订单列表', '派送订单列表'],
},
{
title: '财务结算',
children: ['客户账单'],
children: ['资金账户', '客户账单'],
},
{
title: '企业管理',
@@ -95,11 +99,16 @@ test('flattens visible leaves with unique paths', () => {
'/',
'/orders/first-leg',
'/first-leg/quick-order',
'/cross-border/procurement',
'/cross-border/domestic-trucking',
'/cross-border/pallet-shipping',
'/overseas-warehouse/labeling',
'/overseas-warehouse/storage-transfer',
'/overseas-warehouse/cargo-disposal',
'/overseas-warehouse/return-relabel',
'/orders/inbound',
'/orders/delivery',
'/finance/account',
'/finance/bills',
'/company/profile',
'/organization/members',
@@ -317,7 +326,7 @@ test('keeps the dashboard route permission synchronized with navigation', () =>
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
})
test('keeps prototype views cataloged while allowing implemented order and overseas warehouse pages', () => {
test('keeps only the unified my-orders placeholder cataloged while activating implemented business pages', () => {
const routerSource = [
'first-leg',
'cross-border',
@@ -339,7 +348,7 @@ test('keeps prototype views cataloged while allowing implemented order and overs
]
for (const [path, view] of routes) {
if (path === 'first-leg/quick-order' || path.startsWith('overseas-warehouse/') || path === 'orders/delivery') {
if (path === 'first-leg/quick-order' || path.startsWith('cross-border/') || path.startsWith('overseas-warehouse/') || path === 'orders/inbound' || path === 'orders/delivery') {
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`))
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true)
continue
+2 -1
View File
@@ -32,7 +32,8 @@ router.beforeEach(async (to) => {
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated) {
const isImpersonationHandoff = to.name === 'login' && typeof to.query.impersonationOrigin === 'string'
if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated && !isImpersonationHandoff) {
return getFirstAccessiblePath(auth.permissionCodes)
}
const permission = typeof to.meta.permission === 'string' ? to.meta.permission : undefined