优化
This commit is contained in:
+6
-1
@@ -1,4 +1,9 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
API_PROXY_TARGET=http://8.163.30.128:9000
|
||||
# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。
|
||||
API_PROXY_TARGET=http://localhost:9000
|
||||
# 海外业务浏览器请求使用独立代理,代理会将 /overseas-api 重写为海外 Gateway 的 /api。
|
||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
||||
# 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。
|
||||
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
|
||||
# Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead.
|
||||
VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||
|
||||
@@ -1,147 +1,79 @@
|
||||
# Admin Web Instructions
|
||||
|
||||
## 项目定位
|
||||
## 快速读取顺序
|
||||
|
||||
- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。
|
||||
- 当前范围包括工作台、客户管理、头程订单、仓储与国内出仓协同、财务管理、四期海外接柜入库与库存管理、内部用户、角色管理和菜单权限。
|
||||
- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。
|
||||
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
|
||||
2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.md`。
|
||||
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;四期任务先读 `../../docs/四期/README.md`。
|
||||
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。
|
||||
|
||||
## 技术基线
|
||||
## 项目定位与技术基线
|
||||
|
||||
- 使用 Vue 3、TypeScript、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue。
|
||||
- TypeScript 保持严格模式;不要使用 `any` 绕过业务模型,优先从 `@chongchong/shared` 复用跨项目类型。
|
||||
- 使用现有 `@` 别名引用 `src/`。保持 ES Module 和现有无分号代码风格。
|
||||
- Element Plus 组件使用现有自动导入配置;`ref`、`computed` 等 Vue API 必须显式导入。`src/auto-imports.d.ts` 与 `src/components.d.ts` 属于生成声明,不手工维护业务内容。
|
||||
- 本项目是虫虫出海内部运营后台,仅服务平台员工;不承载企业客户自助、官网、小程序、独立 APP 或 PDA 作业。
|
||||
- 使用 Vue 3、TypeScript 严格模式、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue;保持 ES Module、现有无分号风格和 `@` 对 `src/` 的别名。
|
||||
- 不用 `any` 绕过业务模型。模块专属类型留在模块,本应用多模块稳定复用的类型放 `src/shared/`;服务端契约以 Controller/OpenAPI 为准。
|
||||
- Element Plus 使用现有自动导入;Vue API 显式导入。`src/auto-imports.d.ts`、`src/components.d.ts` 是生成声明,不手工维护业务内容。
|
||||
- 不更换框架、路由、状态管理、组件库或构建工具,除非任务明确要求并完成影响评估。
|
||||
|
||||
## 目录与职责
|
||||
## 目录与依赖方向
|
||||
|
||||
- `src/app/`:应用装配层,包含布局、根路由、全局守卫和启动入口;不承载具体业务或被业务模块反向依赖。
|
||||
- `src/core/`:认证会话、访问控制、导航配置、未保存工作保护和路由安全工具等全应用基础能力;可以被 `app` 和业务模块使用,但不得依赖业务模块。
|
||||
- `src/modules/`:按客户、仓库、头程订单、主数据、下载中心、IAM 等稳定业务领域组织页面、API、类型、组件和路由。新增业务页面必须进入对应模块,不再建立根级 `views/`。
|
||||
- `src/shared/`:与具体业务无关的后台公共 HTTP、组件和工具。模块专属能力先留在模块内部,只有被无关模块真实复用时才上移。
|
||||
- `src/prototype/`:未纳入当前迭代的原型页面和原型路由;正式模块不得依赖原型代码。
|
||||
- `src/modules/*/pages/`:路由级页面和业务编排;复杂展示、表单、抽屉和交互下沉到同模块组件或 composable。
|
||||
- `src/modules/*/routes.ts`:模块路由片段;由 `src/app/router/index.ts` 统一装配。新增模块时同步维护 `src/core/navigation/adminNavigation.ts`。
|
||||
- `src/core/*/` 与 `src/modules/*/stores/`:前者只放全应用基础状态,后者放领域状态;仅页面局部使用的状态留在页面或 composable。
|
||||
- `src/modules/*/api/`:领域接口适配;公共 Axios 客户端位于 `src/shared/api/http.ts`,页面与组件不得散落创建 Axios 实例。
|
||||
- `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。
|
||||
- 模块专属类型放在对应模块;本应用多个模块稳定复用的前端类型和工具放到应用内 `shared/`。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。
|
||||
- `src/styles/`:设计令牌和全局样式;优先复用现有 `--cc-*` 变量和组件样式。
|
||||
| 目录 | 职责 |
|
||||
|---|---|
|
||||
| `src/app/` | 应用装配、布局、根路由、全局守卫和启动;不承载业务 |
|
||||
| `src/core/` | 认证、访问控制、导航、路由安全等全应用能力;不得依赖业务模块 |
|
||||
| `src/modules/` | 稳定业务领域的页面、API、类型、组件和模块路由;新增业务页面不放根级 `views/` |
|
||||
| `src/shared/` | 无业务归属的 HTTP、组件和工具;只有真实跨模块复用才上移 |
|
||||
| `src/prototype/` | 未纳入当前迭代的原型实现;正式模块不得依赖 |
|
||||
| `src/styles/` | 设计令牌和全局样式,优先复用 `--cc-*` 变量 |
|
||||
|
||||
依赖方向保持为 `app -> modules/core/shared`、`modules -> core/shared`、`core -> shared`、`shared -> 无上层依赖`。模块间确需调用时只能引用对方根入口公开的 API 或契约,不得引用其他模块的页面、内部组件或内部目录。
|
||||
- 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。
|
||||
- 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。
|
||||
- 领域接口放 `modules/*/api/`;国内、海外公共客户端分别为 `shared/api/http.ts`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
|
||||
- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
|
||||
|
||||
## 开发环境样式验收
|
||||
## 模块快速路由
|
||||
|
||||
- 默认验收身份:`platform_admin`,用于“后台管理”的样式、路由、权限展示和交互验收。
|
||||
- 密码不写入仓库;从本机未提交环境变量 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若凭据不存在,再向用户索取,不重复询问账号名。
|
||||
- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。
|
||||
- 由人工使用该身份进入真实页面,按 `1920×1080` 视口和 `100%` 浏览器缩放完成操作与截图,再把截图发送给 AI 核对加载、空态、错误态、权限态和桌面布局;AI 默认不主动调用截图或视觉类技能取证。
|
||||
|
||||
## 模块快速查询
|
||||
|
||||
进入模块后按 `routes.ts → pages/ → api/、types/、stores/ 或 composables/` 查询;没有对应目录时再查相邻代码,不先扫描全部 `src/`。
|
||||
|
||||
| 业务 | 模块入口 | 联查模块或服务端 |
|
||||
| 业务 | Admin 入口 | 联查 |
|
||||
|---|---|---|
|
||||
| 工作台与业务总览 | `modules/dashboard/` | 按具体指标联查其所属业务模块和服务端,不在工作台复制领域契约 |
|
||||
| 客户、企业审核 | `modules/customers/` | `iam/`、`file/` |
|
||||
| 供应商中心 | `modules/suppliers/` | Backend `warehouse/`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`;平台公共作用域,列表权限包含详情只读 |
|
||||
| 国内整柜业务 | `modules/domestic-containers/` | Backend `warehouse/domesticcontainer/`;OpenAPI `warehouse/src/main/resources/openapi/domestic-container-v1.yaml`;核对入库、出库、库存详情和幂等 |
|
||||
| 客户运营指标与操作日志 | 客户详情指标在 `modules/customers/`;日志组件与 API 在 `modules/audit/` | 统计联查 Backend `firstleg/customerstatistics/`;审计查询联查 `audit-service/`,列表与详情使用 `platform:audit:list` |
|
||||
| 平台用户与 RBAC | `modules/iam/`、`core/access/` | `auth/`、`iam/` |
|
||||
| 平台消息管理与模板 | `modules/notifications/` | 仅通过顶部“消息中心”进入,不进入左侧菜单;“我的消息”仅属于 Client。Backend `iam/src/main/java/com/baiyue/iam/notification/`;接口契约 `iam/src/main/resources/openapi/notification-v1.yaml`,触发消息的业务事件仍联查对应业务服务 |
|
||||
| 导航与原型菜单图标 | `core/navigation/adminNavigation.ts`、`core/navigation/prototypeMenuIcons.ts` | 菜单结构、权限与路由同步核对;已有原型图标优先使用映射资源 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
|
||||
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;费用入口 `modules/unified-orders/components/InboundFeeDetailManager.vue`、`modules/unified-orders/api/inboundFeeDetails.ts` | `order/`、`firstleg/`;费用来源审核发布联查 `firstleg/AGENTS.md` |
|
||||
| 入库、箱唛、手工/设备测量和计费工作流 | `modules/inbounds/pages/InboundOrdersView.vue`;测量接口与页面从 `modules/inbounds/measurement/` 进入;审核、称重和费用继续按 `modules/inbounds/` 相邻子域定位;计价主数据在 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/`;箱码生成、打印与测量绑定先读 `firstleg/AGENTS.md` |
|
||||
| 财务账户、费用来源、应收统计、对账单、收付款与成本 | `modules/finance/`;应收统计从 `pages/FinanceReceivableStatisticsView.vue`、`api/finance.ts` 进入 | `finance/`;应收统计只汇总指定生成月份的 CNY 正式应收结算单,导出使用 `FINANCE_RECEIVABLE_STATISTICS` 进入 File 下载中心;费用来源与柜次成本快照联查 `firstleg/`,凭证联查 `file/` |
|
||||
| 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` |
|
||||
| 预装详情、装柜清单与柜次费用 | `modules/loading-lists/pages/PreloadPlanDetailView.vue`、`modules/loading-lists/components/LoadingListDetailDrawer.vue`、`modules/loading-lists/components/ContainerFeesPanel.vue`、`modules/loading-lists/api/containerFees.ts` | `firstleg/loading/`、`firstleg` 柜次费用、`finance/` 成本分摊、`file/`、`warehouse/` |
|
||||
| 三期海运订舱与出仓前船期资料 | `modules/ocean-bookings/`、`modules/port-schedules/`;订舱状态、柜型需求和 API 分别从 `status.ts`、`containerRequirements.ts`、`api/platformOceanBookings.ts` 进入 | `firstleg/oceanbooking/`、`firstleg/portschedule/` 的 `PlatformPortScheduleController`;订舱仅有 `DRAFT`、`RELEASED`,草稿可编辑/删除,放舱后永久只读;创建和放舱只保存订舱聚合与柜型需求,不生成真实柜号或货柜分配。渠道来自起运仓当前 ACTIVE 价格版本;订舱代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml`。`CarrierScheduleQuery` 及柜子出仓后的船节点查询归四期 |
|
||||
| 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/`;承运商联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/truck-appointments-v1.yaml` |
|
||||
| 报关协同 | `modules/customs/` | `firstleg/customs/`、`file/`;报关代理联查 `modules/suppliers/` 与 `warehouse/src/main/resources/openapi/supplier-v1.yaml`,服务端契约见 `firstleg/src/main/resources/openapi/customs-v1.yaml` |
|
||||
| 国内出仓确认 | `modules/outbound-dispatch/` | `firstleg/outbound/dispatch/` |
|
||||
| 主数据与下载 | `modules/master-data/`、`modules/downloads/` | `warehouse/`、`file/`;Finance 导出联查 `finance/` 内部 Export Provider 与 File 导出定义 |
|
||||
| 四期海外仓接柜、入库与库内库存 | `modules/overseas-warehouse/`;先读 `../../docs/四期/README.md`,不得以 `src/prototype/` 作为业务实现 | 浏览器统一经 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务进入 `lastleg-service/`,海外仓库、地址、能力、库位、实物托盘和资源锁进入 `warehouse-overseas-service/`。原型读取 `03-overseas-warehouse.html`;不包含出库、派送及其他库存离仓能力 |
|
||||
| 四期海外业务 | `modules/overseas-business/`;先读 `../../docs/四期/README.md` | 浏览器经 `gateway-overseas/` 进入 `lastleg-service/` 的 `procurement/`、`trucking/` 及本土散板稳定子域;不得接到国内 `gateway/`、`firstleg/` 或 `warehouse/`。原型读取 `07-overseas-business.html` |
|
||||
| 四期仓库与通用资料扩展 | 海外仓合作仓类型进入 `modules/warehouses/`;海外仓报价、汇率和车辆进入 `modules/overseas-master-data/` | 国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型;实际海外仓、地址、能力、库位、托盘和资源锁进入 `warehouse-overseas-service/`,浏览器仍经 `gateway-overseas/`。报价、汇率和车辆的具体端点继续以海外服务 Controller/OpenAPI 为准;原型读取 `10-general.html` |
|
||||
| 四期国内出仓后船节点定时查询 | 现有 `CarrierScheduleQuery` 整体归此范围;入口为 `modules/port-schedules/`、`modules/ocean-bookings/` 及货柜详情,先读 `../../docs/四期/README.md` | `firstleg/portschedule/`、`integration-service/`;从确认离开国内仓库后查询,展示最新节点、来源、观测/更新时间和历史并衔接目的港提柜;不归三期或海外派送 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
|
||||
| 入库、测量与计价主数据 | `modules/inbounds/`、`fba-pricing/`、`dimension-config/`、`fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
||||
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
|
||||
| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` |
|
||||
| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 |
|
||||
| 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
|
||||
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
|
||||
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
|
||||
| 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`,至目的港到达 |
|
||||
|
||||
## Admin 全量验收快速索引
|
||||
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
|
||||
|
||||
当前按全量业务闭环验收。阶段文档仍用于核对功能来源和验收证据,不再限制本次验收的模块范围;按“模块目录 → `routes.ts`/页面 → `api`/类型”定位。本表只描述 Admin 页面、路由和前端契约,不替代后端模块指引。
|
||||
## 业务、安全与共享装配
|
||||
|
||||
| 验收域 | Admin 范围与入口 | 核对重点 |
|
||||
|---|---|---|
|
||||
| 一期:头程订单闭环 | `modules/auth/` 登录;`customers/` 客户与企业审核;`iam/` 内部用户、角色、权限;`warehouses/` 平台收货仓;`master-data/` 快递公司、往来单位;`first-leg-orders/` 头程订单;`dashboard/` 工作台 | 企业审核归客户列表,平台 RBAC 仅维护平台员工与平台权限资源 |
|
||||
| 二期及三期履约 | `modules/inbounds/`、`warehouse-resources/`、`fba-pricing/`、`dimension-config/`、`fee-items/`、`unified-orders/`、`container-loading/`、`loading-lists/`、`ocean-bookings/`、`port-schedules/`、`trucking/`、`customs/`、`outbound-dispatch/` | 按真实服务端契约核对全链路状态、权限、异常和证据 |
|
||||
| 平台公共运营 | `modules/suppliers/`、`domestic-containers/`、`audit/`、`notifications/`,客户运营指标复用 `customers/` | 核对公共作用域、列表/详情只读权限、写操作独立权限、审计脱敏和统计口径 |
|
||||
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
||||
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
||||
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
||||
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
||||
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
|
||||
|
||||
阶段文档入口(路径相对本文件所在目录):全量验收按业务选择 `../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/` 或 `../../docs/财务管理/` 的需求确认、契约和验收规则;四期开发范围单独读取 `../../docs/四期/README.md`,不因进入开发就视为已纳入全量验收。进度、排期或验收状态问题再读取相应进度表和验收报告。
|
||||
## 开发验收身份
|
||||
|
||||
## 业务与安全约束
|
||||
- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。
|
||||
- 由人工操作真实页面并截图,AI 核对加载、空态、错误态、权限和桌面布局;缺少截图时标记“待人工截图确认”,不自行批量取证。
|
||||
|
||||
- 当前产品层级只有平台和客户(企业)两级;Admin 不把“租户”作为用户可见概念,不新增租户选择器、目标租户文案或依赖租户上下文才能使用的页面流程。多租户产品能力后续单独设计。
|
||||
- 平台仓库是公共主数据,不与客户绑定。托盘、库位等仓储资源页面必须能够独立查询全部客户和全部仓库,选择客户不得缩小、清空或禁用仓库候选。
|
||||
- 后端字段 `tenantId` 仅留在 API 适配层和内部类型中,不在页面文案或交互中暴露;需要客户范围的写操作仍显式选择客户,长整型客户 ID 按字符串透传。
|
||||
## 命令与质量门禁
|
||||
|
||||
- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。
|
||||
- 列表权限包含列表、详情和只读辅助数据访问;有正式列表页时,导航、列表路由和详情路由统一只使用 `*:list`。当前未上线,不兼容旧独立 `*:view`,不得通过 `permissionAnyOf` 或页面等价判断继续接受它;新增、编辑、删除、启停、审核、导出、上传、完成等操作仍分别校验独立权限。
|
||||
- 供应商菜单、列表和详情固定只使用 `platform:supplier:list`,`platform:supplier:view` 必须停用/移除。修改 `modules/suppliers/`、`core/navigation/adminNavigation.ts` 或根路由时,不得恢复或兼容 `platform:supplier:view`。
|
||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 属于共享装配文件;并行业务任务不得用局部模块结论覆盖菜单与权限总控已经确认的权限语义,发现重叠改动时保留并回传总控处理。
|
||||
- 认证统一经过 `src/core/auth/auth.ts` 和 `src/shared/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。
|
||||
- 四期海外浏览器请求必须使用统一认证/HTTP 层选择海外 API Base 和短时海外 Token,经 `gateway-overseas/` 发出;海外 Token 的 Audience 固定为 `lastleg-api`。业务页面不得直接复用国内 Base 绕到国内 `gateway/`,也不得把海外业务接口接到 `firstleg/` 或国内 `warehouse/`。
|
||||
- 订舱代理、拖车承运商和报关代理必须提交服务端契约要求的供应商 ID,并从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;名称只作为展示或一致性提示,不得把名称输入当作 ID 的替代。修改这些请求参数时,必须同步检查 API 封装、类型、表单、详情展示、Mock/Fixture、契约测试和 Firstleg/Warehouse OpenAPI,不能只改页面或只改后端。
|
||||
- 当前登录和部分数据仍可能是模拟实现。接入 `baiyue-firstleg` Gateway 真实接口时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。
|
||||
- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。
|
||||
- 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。
|
||||
- Admin 维护客户专属数据时,必须通过平台客户列表显式选择目标客户,不得从本地存储推断客户。平台仓库等公共主数据不要求先选择客户;切换客户时不得保留上一客户的编辑、详情或导入上下文。
|
||||
- Finance 六个正式列表页(客户账户、客户应收应付、应收款统计表、客户对账单、应收结算单、应付结算单)的客户筛选统一加载全部 `ACTIVE` 客户并使用可筛选下拉框;用户可见选项只显示客户名称 `tenantName`,以 `customerId` 作为接口筛选值。不得恢复自由输入客户关键词,也不得把客户编码、联系人或手机号拼入下拉标签。应收款统计表必须按 `yyyy-MM` 统计月份查询,可按当前页打印,并通过 File 下载中心异步导出;业务单号、费用项目等非客户关键词仍使用各自独立筛选字段。
|
||||
|
||||
## UI 与交互
|
||||
|
||||
- Admin 是高信息密度业务系统,默认使用紧凑的表格、键值行、分组标题和连续分区呈现信息;不将普通字段、统计值或流程节点拆成大面积卡片,不为少量信息制造大留白。一屏应优先展示完成当前业务判断所需的多项关键信息。
|
||||
- 需要突出结果时,仅对总额、异常、待办或当前状态使用克制的强调;其余信息保持同一紧凑信息层级,不重复标题、说明和装饰性容器。
|
||||
- 详情页的短字段优先使用无独立卡片外框的多列键值网格或描述表;在 `1920×1080` 下通常并排展示 `4–6` 组短字段,长文本、地址、备注和异常原因再按内容跨列。不得把“一个标签 + 一个值”做成一张卡片,也不得用嵌套卡片、重复阴影和多层圆角制造层级。
|
||||
- 详情内容按真实业务域使用紧凑分组标题、细分隔线和连续白底分区;重复记录使用表格或紧凑列表,只有独立指标、明确告警、待办或需要单独操作的业务对象才使用卡片。卡片是否采用以信息结构为准,不以已有组件方便为理由机械套用。
|
||||
- 详情页顶部的返回、标题、核心状态和主要操作应尽量合并在同一紧凑头部;不得让返回按钮、状态提示、标题说明各自独占一整行。路由壳层已经展示标题时,正文不得再次重复标题或说明。
|
||||
- 同一状态或流程结论只保留一个主要视觉落点:优先在页头或概览中使用紧凑状态标签;除真实错误、阻断、危险或必须立即处理的告警外,不再叠加大面积彩色横幅、彩色卡片和重复状态条。页面主色操作通常只保留一个,其余动作使用次要或文字层级。
|
||||
- 不以“信息密度”为由使用过小字号、压缩可点击区域、删除标签或强制截断关键值;长值可以换行、跨列或提供完整悬停内容。目标是减少无效容器、重复说明和纵向留白,而不是牺牲可读性与可操作性。
|
||||
- 同一详情页必须使用一致的内容底色和容器语言。采用连续白底时,页头、概览、明细、费用和历史应位于同一主内容面内,以分隔线和间距分区;不得出现顶部透明或灰底、下方连续独立白卡的割裂布局。确需独立卡片时,各同级区域的背景、圆角、边框和外间距必须统一。
|
||||
- 在 `1920×1080` 验收视口中,如果一组柜次号、集装箱号、订单号、提单号或时间集中显示为省略号,说明列数或宽度设计失败,应减少列数、允许合理换行或让长字段跨列,而不是继续缩小字号。详情概览的标签样式应使用直接子级选择器,避免给 `StatusBadge` 等嵌套组件误加冒号、字号或截断规则。
|
||||
- 样式调整必须用真实长编号、空值、状态徽标及至少一组多字段数据核对首屏;自动化测试只能证明结构和行为,不能替代人工截图对背景连续性、有效信息量、截断和留白的视觉验收。
|
||||
|
||||
- 修改已有 Admin 原型页面前先读 `../../docs/Admin-Client前端原型还原验收规则.md`,按页面和操作形成原型核对矩阵;本节维护 Admin 的实现约束,不复制跨前端验收文档全文。
|
||||
- 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。
|
||||
- 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。
|
||||
- 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。
|
||||
- 上述原型还原规则优先于本文件中“延续现有视觉体系”、默认组件样式和默认图标库等一般性规则。只有用户当前明确要求、阶段范围、真实字段长度、响应式、可访问性或接口事实与原型冲突时才允许做最小调整,并须在修改前说明具体差异、原因和影响范围;不得以技术实现方便为由偏离原型。
|
||||
- “Admin 高信息密度”是当前已确认的产品级约束:当历史原型仅因大量字段卡片、重复状态块或过度留白与本节冲突时,应保留原型字段、顺序、业务分组和交互语义,但将承载方式收敛为上述紧凑布局;不得以机械复刻历史卡片外观覆盖当前密度要求。
|
||||
- 详情、新增、编辑、审核等操作采用独立页面、居中弹框、侧滑面板或其他承载方式,必须逐页面按对应原型确认,不得全局写死为某一种容器。原型为独立页面时使用正式路由和页面布局;原型为居中弹框时使用模态弹框;只有原型明确为侧滑面板时才使用抽屉。不得因为已有公共组件或改动方便,把原型中的独立页面改成弹框、把弹框改成抽屉,或反向替换。
|
||||
- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。
|
||||
- 路由级页面标题和说明统一由 `AppLayout` 顶部 `page-heading` 根据路由 `meta.title`、`meta.description` 展示。标准列表页正文不得重复渲染 `PageToolbar`、页面标题或说明,只保留查询区与表格区。`QueryToolbar` 只承载筛选项及其内置查询、重置按钮,不把新增、导入、导出或批量操作放入其 `actions` 插槽;重新执行查询即刷新当前结果,标准列表页任何位置都不得再提供独立刷新按钮。新增、导入、导出和批量操作统一放入 `DataTable` 的 `toolbar-actions` 插槽并靠表格工具栏右侧展示。仅没有查询组件的实时工作台或详情页在业务确实需要主动重载时,才可保留刷新;仅详情页或工作台在路由明确设置 `showPageHeading: false` 时,才可在正文使用独立详情头。
|
||||
- 标准列表页的查询面板与表格面板使用 `ListPageLayout` 保持单一 `16px` 间距,不再叠加页面级 margin、空白操作区或额外容器间距。`DataTable` 必须位于连续的 `surface-panel table-panel` 中,使右侧业务按钮、列设置、表头和分页属于同一表格面板;不得使用 `toolbar-start` 重复显示列表标题、数量或说明,数量统一放在 `pagination-left`。当工具栏只有右侧操作和列设置时使用紧凑高度,不保留空白的左侧标题区域。
|
||||
- 操作按钮统一使用 Lucide 图标和固定语义:详情/查看 `Eye`、编辑 `Pencil`、更多 `MoreHorizontal`、新增 `Plus`、删除 `Trash2`、启停 `Power`、刷新 `RefreshCw`、导入 `FileUp`、导出/下载 `Download`、返回 `ArrowLeft`、保存 `Save`、复制 `Copy`。表格高频操作使用“图标 + 文案”,更多操作使用仅图标按钮并提供 `aria-label` 与悬停提示;同一语义不得在不同页面混用其他图标、纯文字按钮或手绘 SVG。危险操作继续使用明确文案、危险色和确认流程,不能只显示图标。
|
||||
- 表格单行操作不超过 3 个时全部直接展示“图标 + 文案”;从第 4 个操作开始才使用仅图标的 `MoreHorizontal` 更多菜单,并优先直接保留详情、编辑、下载等高频操作。进入更多菜单的启停、删除等危险操作仍须显示明确文案和危险语义,并沿用原确认流程;不得为了布局方便把仅有 1-3 个操作提前折叠到更多菜单。
|
||||
- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。
|
||||
- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。
|
||||
- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。
|
||||
- 当前样式开发与验收只处理 `1920×1080` 桌面视口,不检查或整改 `720px`、`320px` 及其他窄屏;这些视口不作为测试、截图或交付门禁。保留现有响应式 CSS 和组件能力,不为当前阶段主动删除,用户恢复多视口验收后再统一处理。
|
||||
|
||||
## 公共前端组件复用
|
||||
|
||||
- 开发或重构页面前,先检查 `src/shared/components/`,不得在业务页面重复实现已有的查询栏、数据表格、批量导入、弹窗、抽屉、加载错误、状态和复制交互。公共组件无法满足时,先判断能力是否具有跨模块复用价值:通用能力扩展公共组件并补测试;纯业务特例留在所属模块,不用页面级 CSS 覆盖破坏公共组件契约。
|
||||
- 查询与筛选统一使用 `src/shared/components/QueryToolbar.vue`。通过 `fields` 配置 `input`、`select`、`date`、`daterange`,使用 `v-model` 管理查询模型,并监听 `search`、`reset`;额外操作放入 `actions` 插槽。不要在每个列表页重新编写筛选容器、查询/重置按钮和移动端换行样式。
|
||||
- 业务数据列表统一使用 `src/shared/components/DataTable.vue`。通过 `columns` 和命名插槽定义列,使用 `v-model:page`、`v-model:page-size` 及 `total` 接入分页;无需分页时可省略 `total`。不得重复拼装 `el-table` 与分页器;“操作”列仍须遵守仓库根规则,放在第一列,固定时固定在左侧。
|
||||
- 标准 XLSX 批量导入/上传统一使用 `src/shared/components/BatchImportDialog.vue`。页面只维护弹窗开关,传入标题、实体名称、模板文件名、`downloadTemplate` 和 `importFile`,并在 `success` 后刷新列表;文件格式和大小校验、模板下载、提交状态、接口错误、整批校验结果、CSV 错误报告及响应式样式由组件负责。不要在页面内重复创建文件输入框、上传状态、错误报告逻辑或导入弹窗 CSS。
|
||||
- 普通模态框和侧滑编辑/详情优先使用 `src/shared/components/AppDialog.vue`、`AppDrawer.vue`,复用提交锁定、加载态、底部按钮和窄屏约束;仅当业务交互与其契约明显不同时直接使用 Element Plus 原始容器。
|
||||
- 列表或详情加载失败优先组合 `src/shared/components/LoadErrorState.vue` 与 `ApiErrorMeta.vue`,保留重试入口及 `requestId`、`traceId`;状态展示和复制操作分别优先使用 `StatusBadge.vue`、`CopyButton.vue`。
|
||||
- 公共组件的使用示例优先参考已接入页面:查询和表格见 `modules/master-data/express-companies/pages/ExpressCompaniesView.vue`,批量导入见该页面及 `modules/master-data/counterparties/pages/PartnersView.vue`,抽屉见 `modules/downloads/pages/DownloadCenterView.vue`。扩展公共组件时同步检查所有现有消费者,并至少运行组件测试和直接受影响页面测试。
|
||||
|
||||
## 本地命令与质量门禁
|
||||
|
||||
在 `apps/admin-web` 目录执行:
|
||||
在 `apps/admin-web` 执行:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
@@ -151,13 +83,7 @@ pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- 日常变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`,后者也会执行类型检查。
|
||||
- 当前项目未声明 lint 脚本,不要声称 lint 已通过。若任务新增或变更质量脚本,应同时更新 `package.json`、`README.md` 和本文件。
|
||||
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目的 `package.json` 与锁文件。
|
||||
|
||||
## 全量验收脚本
|
||||
|
||||
- Admin 全量验收唯一入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,默认模块列表覆盖平台全模块;单模块定位先查 `../../baiyue-firstleg/scripts/admin-test-env-modules/README.md`。
|
||||
- 该入口通过测试环境 HTTP 验证真实服务、权限、状态、幂等和跨服务链路,会写入或停用测试数据,必须获得当前对话明确授权。
|
||||
- `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Admin 服务已完成全量验收。
|
||||
- HTTP 验收通过后,由人工使用开发验收账号逐页检查真实接口调用、原型、权限、错误态、空态和 `1920×1080` 桌面布局,并将截图发送给 AI 分析;未收到人工截图时标记为待人工确认,不追加 AI 截图或窄屏浏览器验收。
|
||||
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||
|
||||
@@ -45,10 +45,17 @@ pnpm test
|
||||
|
||||
```dotenv
|
||||
VITE_API_BASE_URL=/api
|
||||
API_PROXY_TARGET=http://8.163.30.128:9000
|
||||
API_PROXY_TARGET=http://localhost:9000
|
||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
||||
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
|
||||
VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||
```
|
||||
|
||||
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时设置容器环境变量 `CLIENT_WEB_URL`,例如 `http://8.163.30.128:9002`;容器启动时会生成 `/runtime-config.js`。本地 Vite 开发才使用 `VITE_CLIENT_WEB_URL` 作为兜底,当前 Client 默认端口为 `5174`。
|
||||
国内业务继续使用 `http` 和 `/api` 代理;四期海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。
|
||||
|
||||
切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。
|
||||
|
||||
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时设置容器环境变量 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。
|
||||
|
||||
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
|
||||
expiresIn: 1800,
|
||||
refreshToken,
|
||||
refreshExpiresIn: 604800,
|
||||
overseasAccessToken: 'overseas-token',
|
||||
overseasExpiresIn: 900,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -8,6 +8,7 @@ import {
|
||||
type ApiEnvelope,
|
||||
} from '@/shared/api/http'
|
||||
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
||||
import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp'
|
||||
import { decodeJwtClaims } from './jwt'
|
||||
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
||||
@@ -23,6 +24,8 @@ interface LoginResponse {
|
||||
expiresIn: number
|
||||
refreshToken: string
|
||||
refreshExpiresIn: number
|
||||
overseasAccessToken: string | null
|
||||
overseasExpiresIn: number
|
||||
}
|
||||
|
||||
class ExpiredAccessTokenError extends Error {}
|
||||
@@ -87,6 +90,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
async function applySession(result: LoginResponse, expectedGeneration: number) {
|
||||
const session = sessionFromToken(result.accessToken)
|
||||
ensureCurrentGeneration(expectedGeneration)
|
||||
replaceOverseasAccessToken(result.overseasAccessToken, result.overseasExpiresIn)
|
||||
token.value = result.accessToken
|
||||
refreshToken.value = result.refreshToken
|
||||
user.value = session.user
|
||||
@@ -106,6 +110,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
|
||||
function clearSession() {
|
||||
sessionGeneration += 1
|
||||
clearOverseasAccessToken()
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
user.value = null
|
||||
@@ -133,6 +138,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
try {
|
||||
const session = sessionFromToken(storedToken)
|
||||
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
|
||||
if (token.value !== storedToken) clearOverseasAccessToken()
|
||||
token.value = storedToken
|
||||
refreshToken.value = storedRefreshToken
|
||||
user.value = session.user
|
||||
@@ -187,7 +193,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
|
||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
|
||||
await applySession({ tokenType: 'Bearer', accessToken: storedAccessToken, expiresIn: 0, refreshToken: storedRefreshToken, refreshExpiresIn: 0 }, expectedGeneration)
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: storedRefreshToken },
|
||||
)
|
||||
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
|
||||
},
|
||||
refresh: async (currentRefreshToken) => {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Admin Finance Module Instructions
|
||||
|
||||
## 范围与入口
|
||||
|
||||
- 本文件适用于 `src/modules/finance/`。产品范围和状态读取 `../../../../../docs/财务管理/` 的产品确认与验收契约,后端读取 `../../../../../baiyue-firstleg/finance/AGENTS.md`。
|
||||
- 页面从 `pages/` 进入,统一 API 从 `api/finance.ts` 进入;费用来源和柜次成本快照联查 `firstleg/`,凭证与异步导出联查 `file/`。
|
||||
- 正式范围包括客户账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单;不要从历史原型自动扩大财务功能。
|
||||
|
||||
## 客户筛选与统计
|
||||
|
||||
- 六个正式列表页统一加载全部 `ACTIVE` 客户并使用可筛选下拉框;用户可见选项只显示 `tenantName`,以 `customerId` 作为接口筛选值。
|
||||
- 不恢复自由输入客户关键词,不把客户编码、联系人或手机号拼入下拉标签;业务单号和费用项目继续使用独立筛选字段。
|
||||
- 应收款统计从 `pages/FinanceReceivableStatisticsView.vue`、`api/finance.ts` 进入,只汇总指定生成月份的 CNY 正式应收结算单。
|
||||
- 统计月份使用 `yyyy-MM`;当前页打印留在页面,Excel 通过 `FINANCE_RECEIVABLE_STATISTICS` 进入 File 下载中心异步导出。
|
||||
|
||||
## 权限与验证
|
||||
|
||||
- 列表和详情只读遵守 `*:list` 统一语义;确认、审核、导出、上传等写入或产出文件的动作继续使用独立权限。
|
||||
- 修改筛选、DTO 或请求参数时同步检查页面、API 类型、Mock/Fixture、测试、Finance OpenAPI 和实际消费者。
|
||||
- 至少运行 Finance 模块受影响测试和 `pnpm typecheck`;交付前按项目指引运行 `pnpm test` 与 `pnpm build`。
|
||||
@@ -1,17 +1,16 @@
|
||||
# 海外业务待补契约
|
||||
# 海外业务真实接口接入与剩余缺口
|
||||
|
||||
后端服务边界已经确认:浏览器统一经 `gateway-overseas/`(Audience=`lastleg-api`)访问 `lastleg-service/`;海外采购进入 `procurement/`,本土卡车进入 `trucking/`,本土散板进入该服务的独立稳定子域。现有平台采购与美国卡车 Controller 是接口适配事实来源,不得将这些请求接到国内 `gateway/`、`firstleg/` 或 `warehouse/`。
|
||||
模块通过 `@/shared/api/overseasHttp` 访问海外 Gateway,业务路径只传 `/platform/v1/...`。页面不读取 Token、不调用兑换端点,也不感知区域 Base。已接入:
|
||||
|
||||
模块当前仍只冻结 TypeScript 请求/响应模型和页面结构;在统一海外 Base/Token 选择与页面 DTO 适配完成前,不创建临时 URL,不使用示例数据,也不模拟提交成功。
|
||||
- 海外采购列表、详情及服务端允许字段的 `PATCH` 编辑;
|
||||
- 本土卡车列表及详情数据(详情只用于原型既有编辑页的数据承载,不新增详情路由);
|
||||
- 长整型 ID、金额/重量/体积的字符串归一化,真实状态映射,以及加载、失败、重试和空态。
|
||||
|
||||
后端接入前需冻结:
|
||||
仍缺少或明确不接入:
|
||||
|
||||
- 统一认证层对海外 Gateway Base 与短时海外 Token 的自动选择方式;
|
||||
- 将采购订单映射到 `lastleg-service` 的 `/api/platform/v1/last-leg/overseas-procurements`,将卡车订单映射到 `/api/platform/v1/last-leg/us-truck-orders`,并补齐当前 Admin 原型所需动作与 DTO;
|
||||
- 本土散板的正式平台端点及 DTO;
|
||||
- 分页信封、长整型 ID 字符串透传、金额/币种精度和时间格式;
|
||||
- 状态机、允许编辑状态、乐观锁版本、幂等键及错误码;
|
||||
- 客户、采购仓库等只读候选接口与权限;
|
||||
- 对应 `list/create/edit` 权限资源在海外服务端的鉴权落点。
|
||||
- 海外采购:后端详情只有出库记录,没有原型“进销存明细”所需的完整入库/余额流水;客户与采购仓库候选接口尚未冻结。平台 `review` 与 `outbounds` 不在本专项接入范围。
|
||||
- 本土卡车:平台端只有列表、详情、状态流转和收托盘,没有平台新增或普通编辑接口;新增/编辑提交继续明确阻断,不借用 Merchant、状态流转或收托盘端点。
|
||||
- 本土散板:`lastleg-service` 尚无独立稳定 Controller/DTO/OpenAPI,列表、新增、编辑均保持缺口,不借用卡车端点。
|
||||
- 权限:前端冻结为 `platform:overseas-business:*`,当前海外服务端采购和卡车管理权限分别为 `lastleg:overseas-procurement:manage`、`lastleg:us-trucking:manage`。冲突只记录,页面不绕过;由共享权限总控统一收口。
|
||||
|
||||
接入时应在统一双区域 HTTP/认证层完成 Base 与 Token 选择,业务页面不得自行读写 Token、创建第二套登录或绕回国内 Gateway。
|
||||
任何后续写接口都应继续以海外服务 Controller、DTO、OpenAPI 和状态机为准,不发明路径、不模拟成功、不连接国内 Gateway。
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type {
|
||||
LocalLooseboardOrder,
|
||||
LocalTransportOrder,
|
||||
LocalTransportQuery,
|
||||
OverseasBusinessPage,
|
||||
OverseasPurchaseOrder,
|
||||
OverseasPurchaseQuery,
|
||||
SaveLocalLooseboardOrderRequest,
|
||||
SaveLocalTransportOrderRequest,
|
||||
UpdateOverseasPurchaseRequest,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 海外区域 API 的前端边界。
|
||||
*
|
||||
* 服务边界固定为 gateway-overseas -> lastleg-service。当前统一海外 Base/Token
|
||||
* 选择以及部分页面端点/DTO 尚未完成适配,因此这里只保存请求/响应形状,
|
||||
* 不创建模块级 Axios 实例、不拼接临时 URL,也不提供模拟实现。
|
||||
*/
|
||||
export interface OverseasBusinessApi {
|
||||
listPurchases(query: OverseasPurchaseQuery): Promise<OverseasBusinessPage<OverseasPurchaseOrder>>
|
||||
getPurchase(purchaseId: string): Promise<OverseasPurchaseOrder>
|
||||
updatePurchase(purchaseId: string, payload: UpdateOverseasPurchaseRequest): Promise<OverseasPurchaseOrder>
|
||||
listTrucks(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalTransportOrder>>
|
||||
getTruck(orderId: string): Promise<LocalTransportOrder>
|
||||
createTruck(payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
|
||||
updateTruck(orderId: string, payload: SaveLocalTransportOrderRequest): Promise<LocalTransportOrder>
|
||||
listLooseboards(query: LocalTransportQuery): Promise<OverseasBusinessPage<LocalLooseboardOrder>>
|
||||
getLooseboard(orderId: string): Promise<LocalLooseboardOrder>
|
||||
createLooseboard(payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
|
||||
updateLooseboard(orderId: string, payload: SaveLocalLooseboardOrderRequest): Promise<LocalLooseboardOrder>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }))
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: overseas }))
|
||||
|
||||
import { getOverseasPurchase, getUsTruckOrder, listOverseasPurchases, listUsTruckOrders, updateOverseasPurchase } from './platformOverseasBusiness'
|
||||
|
||||
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
|
||||
const purchaseSummary = {
|
||||
id: '9007199254740993', orderNo: 'OP-1', customerId: 'C-1', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
|
||||
productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995',
|
||||
purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 2, inventoryQuantity: 3, status: 'APPROVED', version: 4,
|
||||
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
const truckSummary = {
|
||||
id: '9007199254740997', customerId: 'C-2', orderNo: 'TR-1', transportType: 'FTL', origin: 'LA', destination: 'ONT',
|
||||
containerType: '40HQ', palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
|
||||
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', contactName: 'A', contactInfo: '1',
|
||||
requestedDate: '2026-08-20', status: 'WAITING_INBOUND', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
|
||||
}
|
||||
|
||||
describe('platform overseas business API adapter', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses the overseas client and normalizes procurement pages', async () => {
|
||||
overseas.get.mockResolvedValue(envelope({ records: [purchaseSummary], page: 1, size: 20, total: 1, pages: 1 }))
|
||||
const result = await listOverseasPurchases({ page: 1, size: 20, orderNo: ' OP-1 ' })
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements', expect.objectContaining({ params: expect.objectContaining({ orderNo: 'OP-1' }) }))
|
||||
expect(result.records[0]).toMatchObject({ id: '9007199254740993', purchaseWarehouseId: '9007199254740995', budgetUnitPrice: '12.34' })
|
||||
})
|
||||
|
||||
it('loads and patches procurement details with customer isolation', async () => {
|
||||
const detail = { summary: purchaseSummary, productUrl: null, outbounds: [{ id: '9007199254740999', outboundQuantity: 2 }], histories: [] }
|
||||
overseas.get.mockResolvedValue(envelope(detail))
|
||||
expect((await getOverseasPurchase('OP/1', 'C-1')).outbounds[0]?.id).toBe('9007199254740999')
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { params: { customerId: 'C-1' } })
|
||||
|
||||
overseas.patch.mockResolvedValue(envelope(detail))
|
||||
const payload = { version: 4, productName: '新商品' }
|
||||
await updateOverseasPurchase('OP-1', 'C-1', payload)
|
||||
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: 'C-1' } })
|
||||
})
|
||||
|
||||
it('normalizes truck list/detail numeric fields without exposing write methods', async () => {
|
||||
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' }))
|
||||
const page = await listUsTruckOrders({ routeKeyword: ' LA ' })
|
||||
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', expect.objectContaining({ params: expect.objectContaining({ routeKeyword: 'LA' }) }))
|
||||
expect(page.records[0]).toMatchObject({ id: '9007199254740997', totalWeightKg: '123.45', totalVolumeCbm: '6.7', budgetAmount: '88.5' })
|
||||
|
||||
overseas.get.mockResolvedValueOnce(envelope({ summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }))
|
||||
const detail = await getUsTruckOrder('TR-1', 'C-2')
|
||||
expect(detail.pallets[0]).toMatchObject({ id: '9007199254741001', warehousePalletId: '9007199254741003' })
|
||||
expect(overseas.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', { params: { customerId: 'C-2' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery, OverseasBusinessHistory, OverseasBusinessPage, OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseQuery, OverseasPurchaseSummary, UpdateOverseasPurchaseRequest } from '../types'
|
||||
|
||||
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
|
||||
const truckBasePath = '/platform/v1/last-leg/us-truck-orders'
|
||||
type Numeric = number | string
|
||||
interface PagePayload<T> { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
interface ProcurementSummaryPayload extends Omit<OverseasPurchaseSummary, 'id' | 'purchaseWarehouseId' | 'budgetUnitPrice'> { id: Numeric; purchaseWarehouseId?: Numeric | null; budgetUnitPrice?: Numeric | null }
|
||||
interface ProcurementOutboundPayload extends Omit<OverseasProcurementOutbound, 'id'> { id: Numeric }
|
||||
interface ProcurementDetailPayload {
|
||||
summary: ProcurementSummaryPayload; productUrl?: string | null; receivingAddress?: string | null
|
||||
contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null
|
||||
outbounds?: ProcurementOutboundPayload[]; histories?: OverseasBusinessHistory[]
|
||||
}
|
||||
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
|
||||
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
|
||||
interface TruckDetailPayload {
|
||||
summary: TruckSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
|
||||
remark?: string | null; createdBy?: string | null; pallets?: TruckPalletPayload[]; histories?: OverseasBusinessHistory[]
|
||||
}
|
||||
|
||||
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
|
||||
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
|
||||
}
|
||||
function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseSummary {
|
||||
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
|
||||
}
|
||||
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
|
||||
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, outbounds: (value.outbounds || []).map((item) => ({ ...item, id: String(item.id) })), histories: value.histories || [] }
|
||||
}
|
||||
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
}
|
||||
function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
|
||||
return { ...truckSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, pallets: (value.pallets || []).map((item) => ({ ...item, id: String(item.id), warehousePalletId: item.warehousePalletId == null ? null : String(item.warehousePalletId) })), histories: value.histories || [] }
|
||||
}
|
||||
|
||||
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PagePayload<ProcurementSummaryPayload>>, ApiEnvelope<PagePayload<ProcurementSummaryPayload>>>(procurementBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, customerKeyword: query.customerKeyword?.trim() || undefined, purchaseCategory: query.purchaseCategory?.trim() || undefined, productName: query.productName?.trim() || undefined, status: query.status || undefined, purchaseWarehouseId: query.purchaseWarehouseId || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), procurementSummary)
|
||||
}
|
||||
export async function getOverseasPurchase(orderNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function updateOverseasPurchase(orderNo: string, customerId: string, payload: UpdateOverseasPurchaseRequest) {
|
||||
const response = await overseasHttp.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function listUsTruckOrders(query: LocalTransportQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), truckSummary)
|
||||
}
|
||||
export async function getUsTruckOrder(orderNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
|
||||
return truckDetail(unwrapApiResponse(response))
|
||||
}
|
||||
@@ -4,8 +4,8 @@ withDefaults(defineProps<{ domain: string; compact?: boolean }>(), { compact: fa
|
||||
|
||||
<template>
|
||||
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
|
||||
<template #title>{{ domain }}海外区域接口待接入</template>
|
||||
<p v-if="!compact">后端边界已固定为 gateway-overseas → lastleg-service;统一海外 Base/Token 选择及本页面端点、DTO 与权限适配完成前,不展示演示数据,也不会模拟查询或写入成功。</p>
|
||||
<template #title>{{ domain }}接口能力待后端补齐</template>
|
||||
<p v-if="!compact">页面请求边界固定为 gateway-overseas → lastleg-service。当前没有可用于该操作的平台端点或稳定业务子域,页面保持阻断,不会借用 Merchant、状态推进或其他相邻接口模拟成功。</p>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,50 +4,63 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(process.cwd(), `src/modules/overseas-business/${path}`), 'utf8')
|
||||
|
||||
describe('overseas business pending contract pages', () => {
|
||||
describe('overseas business real contract pages', () => {
|
||||
it('keeps operation columns first and renders no sample records', () => {
|
||||
const purchase = read('pages/PurchaseOrdersView.vue')
|
||||
const transport = read('pages/TransportOrdersView.vue')
|
||||
const purchaseColumns = purchase.slice(purchase.indexOf('const columns'))
|
||||
const transportColumns = transport.slice(transport.indexOf('const baseColumns'))
|
||||
const truckColumns = transport.slice(transport.indexOf('const truckColumns'))
|
||||
const looseboardColumns = transport.slice(transport.indexOf('const looseboardColumns'))
|
||||
expect(purchaseColumns.indexOf("key: 'actions'")).toBeLessThan(purchaseColumns.indexOf("key: 'orderNo'"))
|
||||
expect(transportColumns.indexOf("key: 'actions'")).toBeLessThan(transportColumns.indexOf("key: 'orderNo'"))
|
||||
expect(purchase).toContain('const rows = ref<OverseasPurchaseOrder[]>([])')
|
||||
expect(truckColumns.indexOf("key: 'actions'")).toBeLessThan(truckColumns.indexOf("key: 'orderNo'"))
|
||||
expect(looseboardColumns.indexOf("key: 'actions'")).toBeLessThan(looseboardColumns.indexOf("key: 'orderNo'"))
|
||||
expect(purchase).toContain('const rows = ref<OverseasPurchaseSummary[]>([])')
|
||||
expect(transport).toContain('const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])')
|
||||
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
|
||||
})
|
||||
|
||||
it('gives every formal list a stable query preference key without sharing transport preferences', () => {
|
||||
it('gives every formal list a stable query preference key', () => {
|
||||
const purchase = read('pages/PurchaseOrdersView.vue')
|
||||
const transport = read('pages/TransportOrdersView.vue')
|
||||
const purchaseToolbar = purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]
|
||||
const transportToolbar = transport.match(/<QueryToolbar[^>]*\/>/)?.[0]
|
||||
|
||||
expect(purchaseToolbar).toContain('preference-key="overseas-business-purchases-query"')
|
||||
expect(transportToolbar).toContain(':preference-key="queryPreferenceKey"')
|
||||
expect(purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain('preference-key="overseas-business-purchases-query"')
|
||||
expect(transport.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain(':preference-key="queryPreferenceKey"')
|
||||
expect(transport).toContain("'overseas-business-truck-query'")
|
||||
expect(transport).toContain("'overseas-business-looseboard-query'")
|
||||
})
|
||||
|
||||
it('does not invent an overseas endpoint or success fallback', () => {
|
||||
const apiContract = read('api/contracts.ts')
|
||||
const allPages = ['PurchaseOrdersView.vue', 'PurchaseOrderDetailView.vue', 'PurchaseOrderEditView.vue', 'TransportOrdersView.vue', 'TransportOrderFormView.vue']
|
||||
.map((file) => read(`pages/${file}`)).join('\n')
|
||||
expect(apiContract).not.toContain("from '@/shared/api/http'")
|
||||
expect(apiContract).not.toMatch(/\/platform\/v1|\/overseas\/v1|axios\.create/)
|
||||
expect(allPages).not.toMatch(/ElMessage\.success|模拟成功|提交成功/)
|
||||
expect(allPages).toContain('海外区域写端点待冻结')
|
||||
expect(allPages).toContain('海外区域更新端点待冻结')
|
||||
it('loads procurement list/detail and saves only the backend update contract', () => {
|
||||
const list = read('pages/PurchaseOrdersView.vue')
|
||||
const detail = read('pages/PurchaseOrderDetailView.vue')
|
||||
const edit = read('pages/PurchaseOrderEditView.vue')
|
||||
expect(list).toContain('listOverseasPurchases')
|
||||
expect(list).toContain("query: { customerId: row.customerId }")
|
||||
expect(detail).toContain('getOverseasPurchase(purchaseId.value, customerId.value)')
|
||||
expect(edit).toContain('updateOverseasPurchase(purchaseId.value, customerId.value, payload)')
|
||||
expect(edit).toContain('inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined')
|
||||
expect(edit).toContain("form.status === 'APPROVED'")
|
||||
expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/)
|
||||
expect(`${list}${detail}${edit}`).toContain('LoadErrorState')
|
||||
})
|
||||
|
||||
it('keeps prototype-backed forms as routes with validation and unsaved-work registration', () => {
|
||||
it('loads truck list/detail but keeps unsupported writes and looseboard blocked', () => {
|
||||
const list = read('pages/TransportOrdersView.vue')
|
||||
const form = read('pages/TransportOrderFormView.vue')
|
||||
expect(list).toContain('listUsTruckOrders')
|
||||
expect(list).toContain("query: { customerId: row.customerId }")
|
||||
expect(form).toContain('getUsTruckOrder(orderId.value, customerId.value)')
|
||||
expect(form).not.toMatch(/createUsTruck|updateUsTruck/)
|
||||
expect(form).toContain('平台创建/普通编辑端点待后端补齐')
|
||||
expect(list).toContain('本土散板接口待后端补齐')
|
||||
expect(form).toContain('disabled><Save')
|
||||
})
|
||||
|
||||
it('keeps unsaved-work protection on both prototype-backed forms', () => {
|
||||
const purchaseEdit = read('pages/PurchaseOrderEditView.vue')
|
||||
const transportForm = read('pages/TransportOrderFormView.vue')
|
||||
expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
|
||||
expect(purchaseEdit).toContain('出库量不能大于入库量')
|
||||
expect(purchaseEdit).toContain(':min="form.outboundQuantity"')
|
||||
expect(transportForm).toContain('useUnsavedWorkRegistration')
|
||||
expect(transportForm).toContain("message: '请输入起运地'")
|
||||
expect(transportForm).toContain("message: '请输入目的地'")
|
||||
expect(transportForm).toContain('disabled><Save')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { InventoryTransaction } from '../types'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import type { OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseStatus } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const fields = ['客户名称', '采购仓库', '采购品类', '商品名称', '商品规格', '商品链接']
|
||||
const stockFields = ['入库量', '出库量', '库存量', '库存状态']
|
||||
const orderFields = ['订单编号', '采购数量', '预算单价', '预算总价', '订单状态', '联系人', '联系电话', '收货地址', '订单备注']
|
||||
const courierFields = ['国内承运商', '国内运单号', '海外承运商', '海外运单号', '预计到达时间']
|
||||
const transactionColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 155 },
|
||||
{ key: 'documentType', prop: 'documentType', label: '单据类型', minWidth: 120 },
|
||||
{ key: 'documentNo', prop: 'documentNo', label: '关联单号', minWidth: 160 },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
|
||||
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'balanceQuantity', prop: 'balanceQuantity', label: '结存量', width: 95, align: 'right' },
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
|
||||
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
|
||||
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
|
||||
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
|
||||
const outboundColumns: DataTableColumn[] = [
|
||||
{ key: 'outboundAt', label: '时间', minWidth: 155, formatter: (row: OverseasProcurementOutbound) => formatDateTime(row.outboundAt) },
|
||||
{ key: 'documentType', label: '单据类型', width: 110, formatter: () => '采购出库' },
|
||||
{ key: 'outboundNo', prop: 'outboundNo', label: '关联单号', minWidth: 160 },
|
||||
{ key: 'quantity', prop: 'quantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 },
|
||||
{ key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 150 },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const transactions: InventoryTransaction[] = []
|
||||
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
|
||||
async function load() {
|
||||
detail.value = null; loadError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
|
||||
loading.value = true
|
||||
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
|
||||
}
|
||||
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><DetailPageLayout aria-label="海外采购订单详情">
|
||||
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<ContractPendingNotice domain="海外采购订单详情" />
|
||||
<div class="detail-surface surface-panel">
|
||||
<template #header><div class="detail-header"><div><h2>海外采购订单详情</h2><span>订单编号:{{ detail?.orderNo || purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购订单详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="detail-surface surface-panel">
|
||||
<div class="detail-grid">
|
||||
<section><h3>商品信息</h3><dl><div v-for="label in fields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section><h3>库存信息</h3><dl><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section><h3>订单信息</h3><dl><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section><h3>快递信息</h3><dl><div v-for="label in courierFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl></section>
|
||||
<section class="full"><h3>进销存明细</h3><DataTable :data="transactions" :columns="transactionColumns" row-key="id" empty-text="进销存明细接口待接入" /></section>
|
||||
<section><h3>商品信息</h3><dl><div v-for="field in productFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>库存信息</h3><dl><div v-for="field in stockFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>订单信息</h3><dl><div v-for="field in orderFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>快递信息</h3><dl><div v-for="field in courierFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section class="full"><div class="section-heading"><h3>进销存明细</h3><span>当前接口仅返回采购出库记录,完整入库流水与结存流水字段待后端补齐。</span></div><DataTable :data="detail?.outbounds || []" :columns="outboundColumns" row-key="id" empty-text="暂无采购出库记录" /></section>
|
||||
</div>
|
||||
</div>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-last-child(-n+2) { border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; color:var(--cc-text); font-size:13px; }
|
||||
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading { display:flex; align-items:baseline; justify-content:space-between; gap:16px; }.section-heading span { color:var(--cc-muted); font-size:12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,47 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Save } from '@lucide/vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { getOverseasPurchase, updateOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseStatus, UpdateOverseasPurchaseRequest } from '../types'
|
||||
|
||||
interface PurchaseEditModel extends Omit<UpdateOverseasPurchaseRequest, 'expectedVersion'> { orderNo: string }
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
interface PurchaseEditModel {
|
||||
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
|
||||
inboundQuantity: number; outboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
|
||||
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null; remark: string | null; version: number
|
||||
}
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive<PurchaseEditModel>({ orderNo: '', category: '', productName: '', specification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null })
|
||||
const validateOutbound = (_rule: unknown, value: number, callback: (error?: Error) => void) => value > form.inboundQuantity ? callback(new Error('出库量不能大于入库量')) : callback()
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0 })
|
||||
const form = reactive<PurchaseEditModel>(initial())
|
||||
const rules: FormRules<PurchaseEditModel> = {
|
||||
category: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
||||
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
||||
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
inboundQuantity: [{ required: true, type: 'number', min: 0, message: '入库量不能小于 0', trigger: 'change' }],
|
||||
outboundQuantity: [{ required: true, type: 'number', min: 0, message: '出库量不能小于 0', trigger: 'change' }, { validator: validateOutbound, trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
}
|
||||
const statusOptions: Array<{ value: OverseasPurchaseStatus; label: string }> = [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' }, { value: 'CONFIRMED', label: '已确认' }, { value: 'PURCHASING', label: '采购中' }, { value: 'COMPLETED', label: '已完成' },
|
||||
]
|
||||
useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
const inboundEditable = computed(() => form.status === 'APPROVED')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null; submitError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
|
||||
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, outboundQuantity: detail.outboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, purchaseWarehouseName: detail.purchaseWarehouseName, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true; submitError.value = null
|
||||
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: optional(form.productSpecification), productUrl: optional(form.productUrl), inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: optional(form.purchaseWarehouseId), purchaseWarehouseName: optional(form.purchaseWarehouseName), domesticCarrier: optional(form.domesticCarrier), domesticTrackingNo: optional(form.domesticTrackingNo), overseasCarrier: optional(form.overseasCarrier), overseasTrackingNo: optional(form.overseasTrackingNo), estimatedArrivalAt: form.estimatedArrivalAt || null, remark: optional(form.remark) }
|
||||
try {
|
||||
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
|
||||
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
|
||||
await router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: updated.orderNo }, query: { customerId: updated.customerId } })
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, '海外采购订单保存失败') } finally { saving.value = false }
|
||||
}
|
||||
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable"><DetailPageLayout aria-label="编辑海外采购订单">
|
||||
<template #header><div class="form-header"><div><h2>编辑海外采购订单</h2><span>订单标识:{{ purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<ContractPendingNotice domain="海外采购订单编辑" />
|
||||
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled placeholder="接口接入后回填" /></el-form-item><el-form-item label="采购品类" prop="category"><el-input v-model="form.category" maxlength="100" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="160" /></el-form-item><el-form-item class="span-2" label="商品规格" prop="specification"><el-input v-model="form.specification" maxlength="255" /></el-form-item><el-form-item class="span-3" label="商品链接" prop="productUrl"><el-input v-model="form.productUrl" maxlength="1000" /></el-form-item></div></FormSection>
|
||||
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="出库量" prop="outboundQuantity"><el-input-number v-model="form.outboundQuantity" :min="0" controls-position="right" /></el-form-item><el-form-item label="状态" prop="status"><el-select v-model="form.status"><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="120" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="120" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="120" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="120" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-tooltip content="海外区域更新端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />保存</el-button></span></el-tooltip><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId } })">取消</el-button></footer></section>
|
||||
<AdminPage width="comfortable"><OperationErrorToast :details="submitError" /><DetailPageLayout aria-label="编辑海外采购订单">
|
||||
<template #header><div class="form-header"><div><h2>编辑海外采购订单</h2><span>订单编号:{{ form.orderNo || purchaseId || '—' }}</span></div><el-button @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" /></el-form-item></div></FormSection>
|
||||
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="form.outboundQuantity" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input-number v-model="form.outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库编码"><el-input v-model="form.purchaseWarehouseId" maxlength="128" /></el-form-item><el-form-item label="采购仓库"><el-input v-model="form.purchaseWarehouseName" maxlength="255" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; gap:0 20px; }.form-grid.three { grid-template-columns:repeat(3,minmax(0,1fr)); }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
</style>
|
||||
|
||||
@@ -1,44 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import type { OverseasPurchaseOrder } from '../types'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOverseasPurchases } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
|
||||
|
||||
const rows = ref<OverseasPurchaseOrder[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ orderNo: '', category: '', productName: '' })
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ orderNo: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
|
||||
{ key: 'category', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
|
||||
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180 },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160 },
|
||||
{ key: 'category', prop: 'category', label: '采购品类', minWidth: 130 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130 },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
|
||||
{ key: 'specification', prop: 'specification', label: '商品规格', minWidth: 180 },
|
||||
{ key: 'unitPrice', prop: 'unitPrice', label: '采购单价', width: 120, align: 'right' },
|
||||
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
|
||||
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
|
||||
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
|
||||
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
|
||||
{ key: 'stockQuantity', prop: 'stockQuantity', label: '库存量', width: 95, align: 'right' },
|
||||
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
|
||||
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
|
||||
]
|
||||
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
|
||||
function purchaseStatusLabel(status: OverseasPurchaseStatus) { return statusLabels[status] || status }
|
||||
function formatAmount(value: string | null, currency: string) { if (value == null) return '—'; const amount = Number(value); return `${Number.isFinite(amount) ? amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value} ${currency}` }
|
||||
async function load() {
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外采购订单加载失败,请稍后重试') } finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="海外采购订单列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice domain="海外采购订单" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-business-purchases" row-key="id" empty-text="海外采购订单查询契约待接入">
|
||||
<template #actions><div class="table-actions"><el-button size="small" type="primary" plain disabled><Eye :size="15" />详情</el-button><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #pagination-left>共 0 条海外采购订单</template>
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row as OverseasPurchaseSummary)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1,62 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Save } from '@lucide/vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalTransportStatus, SaveLocalLooseboardOrderRequest } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
const route = useRoute(); const router = useRouter(); const formRef = ref<FormInstance>()
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const editing = computed(() => Boolean(orderId.value))
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard')
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
|
||||
const requiredPermission = computed(() => {
|
||||
if (isLooseboard.value) return editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate
|
||||
return editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate
|
||||
})
|
||||
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { status: LocalTransportStatus }
|
||||
const form = reactive<TransportFormModel>({ transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactPhone: null, containerType: '20GP', pieces: 0, grossWeightKg: null, volumeCbm: null, budgetAmount: null, currency: 'CNY', requiredDate: null, status: 'PENDING_ACCEPTANCE' })
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const editing = computed(() => Boolean(orderId.value)); const isLooseboard = computed(() => props.domain === 'looseboard')
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
|
||||
const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate))
|
||||
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { orderNo: string; status: LocalTransportStatus }
|
||||
const initial = (): TransportFormModel => ({ orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: '20GP', palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'CNY', requestedDate: null, remark: null, status: 'PENDING_REVIEW', version: 0 })
|
||||
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const rules: FormRules<TransportFormModel> = {
|
||||
transportMode: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
|
||||
palletRequired: [{ validator: (_rule, value, callback) => !isLooseboard.value || value ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
|
||||
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }],
|
||||
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
|
||||
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
pieces: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
|
||||
grossWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }],
|
||||
volumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }],
|
||||
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
|
||||
transportMode: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
|
||||
palletRequired: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
|
||||
transportType: [{ required: true, message: '请选择运输类型', 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' }], packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
|
||||
totalWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }], totalVolumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }], budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
|
||||
}
|
||||
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
|
||||
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
|
||||
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' }]
|
||||
useUnsavedWorkRegistration({ key: computed(() => `overseas-${props.domain}-editor`).value, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
const statusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
async function load() {
|
||||
Object.assign(form, initial()); loadError.value = null
|
||||
if (isLooseboard.value || !editing.value) { captureBaseline(); return }
|
||||
if (!customerId.value) { loadError.value = { message: '缺少客户标识,请从本土卡车列表重新进入' }; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const detail = await getUsTruckOrder(orderId.value, customerId.value)
|
||||
Object.assign(form, { orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单详情加载失败') } finally { loading.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: listRouteName.value }) }
|
||||
watch([orderId, customerId, isLooseboard], () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable"><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
|
||||
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单标识:{{ orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||
<ContractPendingNotice :domain="`${domainLabel}订单${editing ? '编辑' : '新增'}`" />
|
||||
<section class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单编号:{{ form.orderNo || orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><ContractPendingNotice :domain="isLooseboard ? '本土散板订单' : (editing ? '本土卡车普通编辑' : '本土卡车平台新增')" />
|
||||
<section v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<FormSection v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
|
||||
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="160" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="160" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="255" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="255" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="80" placeholder="联系人姓名" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactPhone" maxlength="40" placeholder="联系电话" /></el-form-item></div></FormSection>
|
||||
<FormSection title="📦 货物信息"><div class="form-grid four"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item label="件数" prop="pieces"><el-input-number v-model="form.pieces" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="grossWeightKg"><el-input v-model="form.grossWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="volumeCbm"><el-input v-model="form.volumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
|
||||
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价(¥)" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" /></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requiredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-select v-model="form.status"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip content="海外区域写端点待冻结"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></footer></section>
|
||||
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="128" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactInfo" maxlength="128" /></el-form-item></div></FormSection>
|
||||
<FormSection title="📦 货物信息"><div class="form-grid five"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
|
||||
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价"><el-input v-model="form.budgetAmount" inputmode="decimal"><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
|
||||
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建/普通编辑端点待后端补齐'"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></footer></section></template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:760px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.four { grid-template-columns:repeat(4,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:900px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.five { grid-template-columns:repeat(5,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.span-2 { grid-column:1 / -1; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
|
||||
</style>
|
||||
|
||||
@@ -1,67 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Pencil, Plus } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { listUsTruckOrders } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportStatus } from '../types'
|
||||
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
|
||||
|
||||
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard')
|
||||
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const queryPreferenceKey = computed(() => isLooseboard.value
|
||||
? 'overseas-business-looseboard-query'
|
||||
: 'overseas-business-truck-query')
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
|
||||
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
|
||||
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
|
||||
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' })
|
||||
const statusOptions: Array<{ value: LocalTransportStatus; label: string }> = [
|
||||
{ value: 'PENDING_ACCEPTANCE', label: '待接单' }, { value: 'DISPATCHED', label: '已派车' }, { value: 'IN_TRANSIT', label: '运输中' }, { value: 'COMPLETED', label: '已完成' },
|
||||
]
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: statusOptions },
|
||||
const editPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.truckEdit)
|
||||
const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: isLooseboard.value ? ['待接单', '已派车', '运输中', '已完成'].map((label) => ({ label, value: label })) : Object.entries(truckStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
|
||||
]
|
||||
const baseColumns: DataTableColumn[] = [
|
||||
])
|
||||
const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactPhone', prop: 'contactPhone', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 },
|
||||
{ key: 'grossWeightKg', prop: 'grossWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' },
|
||||
{ key: 'requiredDate', prop: 'requiredDate', label: '要求时间', width: 120 },
|
||||
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
|
||||
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
|
||||
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] || row.status },
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 }, { key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
|
||||
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' }, { key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 },
|
||||
{ key: 'status', prop: 'status', label: '状态', width: 105 },
|
||||
]
|
||||
const columns = computed<DataTableColumn[]>(() => isLooseboard.value ? [
|
||||
baseColumns[0]!, baseColumns[1]!,
|
||||
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 },
|
||||
{ key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
|
||||
...baseColumns.slice(2),
|
||||
] : baseColumns)
|
||||
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
|
||||
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
|
||||
async function load() {
|
||||
rows.value = []; total.value = 0; loadError.value = null
|
||||
if (isLooseboard.value) return
|
||||
loading.value = true
|
||||
try { const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery); rows.value = result.records; total.value = result.total } catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单加载失败,请稍后重试') } finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (isLooseboard.value) return; void router.push({ name: 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout :aria-label="`${domainLabel}订单列表`">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice :domain="`${domainLabel}订单`" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`${domainLabel}订单查询契约待接入`">
|
||||
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" content="海外区域创建端点待冻结"><span><el-button type="primary" disabled @click="openCreate"><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
|
||||
<template #actions><el-button size="small" disabled><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #pagination-left>共 0 条{{ domainLabel }}订单</template>
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
|
||||
<template v-if="isLooseboard" #state><ContractPendingNotice domain="本土散板订单" /></template>
|
||||
<template v-else-if="loadError" #state><LoadErrorState title="本土卡车订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="isLooseboard || !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="isLooseboard ? '本土散板稳定子域与接口待后端补齐' : '暂无符合筛选条件的本土卡车订单'">
|
||||
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建端点待后端补齐'"><span><el-button type="primary" disabled><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can(editPermission)" size="small" :disabled="isLooseboard" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1,137 +1,70 @@
|
||||
export interface OverseasBusinessPage<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
export interface OverseasBusinessPage<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
|
||||
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED'
|
||||
export interface OverseasPurchaseQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
orderNo?: string
|
||||
category?: string
|
||||
productName?: string
|
||||
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
|
||||
purchaseCategory?: string; productName?: string; status?: OverseasPurchaseStatus | ''
|
||||
purchaseWarehouseId?: string; createdFrom?: string; createdTo?: string
|
||||
}
|
||||
|
||||
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'CONFIRMED' | 'PURCHASING' | 'COMPLETED'
|
||||
|
||||
export interface InventoryTransaction {
|
||||
id: string
|
||||
occurredAt: string
|
||||
documentType: string
|
||||
documentNo: string
|
||||
inboundQuantity: number
|
||||
outboundQuantity: number
|
||||
balanceQuantity: number
|
||||
remark: string | null
|
||||
export interface OverseasPurchaseSummary {
|
||||
id: string; orderNo: string; customerId: string; customerName: string | null
|
||||
purchaseCategory: string; productName: string; productSpecification: string | null
|
||||
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
|
||||
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
|
||||
inboundQuantity: number; outboundQuantity: number; inventoryQuantity: number
|
||||
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
|
||||
export interface OverseasPurchaseOrder {
|
||||
id: string
|
||||
orderNo: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
purchaseWarehouseId: string
|
||||
purchaseWarehouseName: string
|
||||
category: string
|
||||
productName: string
|
||||
specification: string | null
|
||||
productUrl: string | null
|
||||
purchaseQuantity: number
|
||||
unitPrice: string | null
|
||||
currency: string
|
||||
inboundQuantity: number
|
||||
outboundQuantity: number
|
||||
stockQuantity: number
|
||||
receivingAddress: string | null
|
||||
domesticCarrier: string | null
|
||||
domesticTrackingNo: string | null
|
||||
overseasCarrier: string | null
|
||||
overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
status: OverseasPurchaseStatus
|
||||
remark: string | null
|
||||
inventoryTransactions: InventoryTransaction[]
|
||||
version: number
|
||||
export interface OverseasProcurementOutbound {
|
||||
id: string; quantity: number; outboundNo: string; carrier: string | null; trackingNo: string | null
|
||||
outboundAt: string | null; remark: string | null; createdBy: string | null; createdAt: string
|
||||
}
|
||||
export interface OverseasBusinessHistory {
|
||||
fromStatus: string | null; toStatus: string; operatorId: string | null; remark: string | null; createdAt: string
|
||||
}
|
||||
export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
|
||||
productUrl: string | null; receivingAddress: string | null; contactName: string | null; contactPhone: string | null
|
||||
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null; remark: string | null; createdBy: string | null; reviewedBy: string | null
|
||||
reviewRemark: string | null; reviewedAt: string | null; outbounds: OverseasProcurementOutbound[]; histories: OverseasBusinessHistory[]
|
||||
}
|
||||
|
||||
export interface UpdateOverseasPurchaseRequest {
|
||||
category: string
|
||||
productName: string
|
||||
specification: string | null
|
||||
productUrl: string | null
|
||||
inboundQuantity: number
|
||||
outboundQuantity: number
|
||||
status: OverseasPurchaseStatus
|
||||
domesticCarrier: string | null
|
||||
domesticTrackingNo: string | null
|
||||
overseasCarrier: string | null
|
||||
overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null
|
||||
remark: string | null
|
||||
expectedVersion: number
|
||||
version: number; purchaseCategory?: string | null; productName?: string | null; productSpecification?: string | null
|
||||
productUrl?: string | null; inboundQuantity?: number | null; purchaseWarehouseId?: string | null
|
||||
purchaseWarehouseName?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
}
|
||||
|
||||
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
|
||||
export type LocalTransportStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED'
|
||||
export type LocalTransportStatus = 'PENDING_REVIEW' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PALLET_OVERAGE_REVIEW' | 'PARTIALLY_ALLOCATED' | 'ALLOCATED' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'REJECTED' | 'CANCELLED'
|
||||
export interface LocalTransportQuery {
|
||||
page?: number; size?: number; orderNo?: string; routeKeyword?: string; status?: LocalTransportStatus | ''
|
||||
transportType?: LocalTransportType | ''; customerId?: string; requestedFrom?: string; requestedTo?: string
|
||||
}
|
||||
export interface LocalTransportOrder {
|
||||
id: string; customerId: string; orderNo: string; transportType: LocalTransportType | null
|
||||
origin: string; destination: string; containerType: string | null; palletCount: number; packageCount: number
|
||||
receivedPalletCount: number; allocatedPalletCount: number; shippedPalletCount: number
|
||||
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
|
||||
contactName: string; contactInfo: string; requestedDate: string; status: LocalTransportStatus
|
||||
version: number; createdAt: string; updatedAt: string
|
||||
}
|
||||
export interface LocalTransportPallet {
|
||||
id: string; palletNo: string; sequenceNo: number; source: string; status: string
|
||||
warehousePalletId: string | null; packageCount: number | null; receivedAt: string | null
|
||||
}
|
||||
export interface LocalTransportDetail extends LocalTransportOrder {
|
||||
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
|
||||
pallets: LocalTransportPallet[]; histories: OverseasBusinessHistory[]
|
||||
}
|
||||
export interface SaveLocalTransportOrderRequest {
|
||||
version?: number; transportType: LocalTransportType | ''; origin: string; destination: string
|
||||
originAddress: string | null; destinationAddress: string | null; contactName: string | null; contactInfo: string | null
|
||||
containerType: string | null; palletCount: number; packageCount: number; totalWeightKg: string | null
|
||||
totalVolumeCbm: string | null; budgetAmount: string | null; currency: string; requestedDate: string | null; remark: string | null
|
||||
}
|
||||
|
||||
// 本土散板后端稳定子域尚不存在;仅保留原型表单模型,不映射到卡车端点。
|
||||
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
|
||||
export type PalletRequired = 'YES' | 'NO'
|
||||
|
||||
export interface LocalTransportOrder {
|
||||
id: string
|
||||
orderNo: string
|
||||
transportType: LocalTransportType
|
||||
origin: string
|
||||
destination: string
|
||||
originAddress: string | null
|
||||
destinationAddress: string | null
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
containerType: string | null
|
||||
pieces: number
|
||||
grossWeightKg: string | null
|
||||
volumeCbm: string | null
|
||||
budgetAmount: string | null
|
||||
currency: string
|
||||
requiredDate: string | null
|
||||
status: LocalTransportStatus
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface LocalLooseboardOrder extends LocalTransportOrder {
|
||||
transportMode: LooseboardTransportMode
|
||||
palletRequired: PalletRequired
|
||||
}
|
||||
|
||||
export interface LocalTransportQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
status?: LocalTransportStatus | ''
|
||||
routeKeyword?: string
|
||||
}
|
||||
|
||||
export interface SaveLocalTransportOrderRequest {
|
||||
transportType: LocalTransportType | ''
|
||||
origin: string
|
||||
destination: string
|
||||
originAddress: string | null
|
||||
destinationAddress: string | null
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
containerType: string | null
|
||||
pieces: number
|
||||
grossWeightKg: string | null
|
||||
volumeCbm: string | null
|
||||
budgetAmount: string | null
|
||||
currency: string
|
||||
requiredDate: string | null
|
||||
expectedVersion?: number
|
||||
}
|
||||
|
||||
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest {
|
||||
transportMode: LooseboardTransportMode | ''
|
||||
palletRequired: PalletRequired | ''
|
||||
}
|
||||
export interface LocalLooseboardOrder extends LocalTransportOrder { transportMode: LooseboardTransportMode; palletRequired: PalletRequired }
|
||||
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest { transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | '' }
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
# 海外通用资料接口缺口
|
||||
|
||||
海外浏览器请求的统一入口已确认为 `gateway-overseas/`(Audience=`lastleg-api`)。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型;实际海外仓、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。海外仓候选和仓储资源不得继续从国内 Warehouse API 读取。
|
||||
本文件记录 Admin 海外通用资料与当前 Java 契约之间的实际差距。前端页面类型只是原型展示模型,不是已经发布的服务端 DTO。
|
||||
|
||||
当前仍缺少海外仓报价、汇率管理和车辆管理的已确认归属端点、请求/响应 DTO、错误码或写操作权限资源;统一海外 Base/Token 前端选择机制也尚未接入。
|
||||
## 已确认的服务入口
|
||||
|
||||
因此本模块当前只冻结正式路由、页面结构、字段、承载方式和只读权限,不包含内存示例数据或模拟成功:
|
||||
- `gateway-overseas` 将 `/api/platform/v1/last-leg/**` 路由到 `lastleg-service`。
|
||||
- `gateway-overseas` 将 `/api/platform/v1/warehouses/**`、`/api/platform/v1/warehouse-admin/**` 路由到 `warehouse-overseas-service`。
|
||||
- 网关海外 Token Audience 为 `lastleg-api`。
|
||||
- 两个海外服务没有提交对应业务的静态 OpenAPI YAML;运行时契约由 Springdoc 注解生成,网关暴露 `/lastleg-service/v3/api-docs/**` 和 `/warehouse-overseas-service/v3/api-docs/**`。
|
||||
|
||||
`warehouse-overseas-service` 已有实际海外仓聚合接口:
|
||||
|
||||
| 操作 | 端点 | 请求/响应 | 当前限制 |
|
||||
|---|---|---|---|
|
||||
| 仓库列表 | `GET /api/platform/v1/warehouse-admin` | 必填 `customerId`;`page`、`size`、`countryCode`、`capabilityCode`、`status`;返回 `PageResult<WarehouseEntity>` | 仅校验平台身份,没有与本模块冻结权限对应的细粒度权限;按客户隔离,不能直接当作平台全局报价仓候选 |
|
||||
| 仓库详情 | `GET /api/platform/v1/warehouse-admin/{id}` | 必填 `customerId`;返回仓库、地址、能力聚合 | 需要先冻结报价/车辆记录与客户、海外仓的外键口径 |
|
||||
| 仓库新增/编辑/启停 | `POST`、`PUT /{id}`、`PATCH /{id}/status` | `WarehouseRequests.SaveRequest`;状态仅 `ACTIVE/INACTIVE` | 属于实际海外仓资源,不是国内公共合作仓类型,也不是报价、汇率或车辆主数据接口 |
|
||||
|
||||
上述接口不在本模块接入:它们是按客户隔离的实际海外仓资源,不是平台公共合作仓,也不是海外报价、汇率或车辆主数据。业务页面不得为了补候选数据而跨区域拼接这两类仓库资源。
|
||||
|
||||
国内 Warehouse 已正式支持公共类型 `OVERSEAS_PARTNER`,Admin 在现有 `/api/platform/v1/warehouse-admin` 仓库页完成筛选、新增和编辑。国内服务另有受 `X-Internal-Service-Token` 保护的 `GET /internal/warehouses/overseas-partners`,仅供未来海外报价服务内部读取 `ACTIVE + OVERSEAS_PARTNER` 候选;前端不得调用该内部端点,报价页面也不跨区域拉取国内合作仓列表。
|
||||
|
||||
## 页面与操作缺口
|
||||
|
||||
| 页面/操作 | 端点 | DTO | 权限 | 状态机/规则 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 海外仓报价列表、筛选、详情 | 缺失 | 缺少报价概要、仓储费用、中转派送价格及分页 DTO | 已冻结只读权限 `platform:overseas-master-data:quote:list`,服务端未实现 | 需定义生效区间、版本唯一性及当前有效版本规则 | 保持结构化空态 |
|
||||
| 海外仓报价新增、编辑 | 缺失 | 缺少基础信息、费用明细、派送价格保存 DTO;海外仓必须使用真实 ID,不能只传名称 | create/edit 动作权限未冻结 | 需定义并发版本、日期重叠、费用组合去重与金额精度 | 写按钮禁用 |
|
||||
| 海外仓报价启停、删除、导出 | 缺失 | 缺少状态命令、删除约束和导出任务 DTO | status/delete/export 动作权限未冻结 | 需定义 `ACTIVE/INACTIVE`、历史引用后的删除规则和导出方式 | 写按钮禁用 |
|
||||
| 汇率摘要、列表、详情 | 缺失 | 缺少 `yyyy-MM`、USD→CNY 月平均值、更新人/时间及备注 DTO | 已冻结只读权限 `platform:overseas-master-data:exchange-rate:list`,服务端未实现 | 固定方向应为 `1 USD = X CNY`;需定义精度和月份时区 | 保持结构化空态 |
|
||||
| 汇率新增、覆盖编辑 | 缺失 | 缺少月度汇率保存 DTO | edit 动作权限未冻结 | 需定义同月唯一、覆盖/乐观锁及下游引用后的修改规则 | 写按钮禁用 |
|
||||
| 车辆列表、详情 | 缺失 | 缺少车辆档案、归属、区域、里程、司机/外派单位、派送订单与更换历史 DTO | 已冻结只读权限 `platform:overseas-master-data:vehicle:list`,服务端未实现 | 需定义车辆状态及其与派送订单的关联来源 | 保持结构化空态 |
|
||||
| 车辆新增、编辑、删除、导出 | 缺失 | 缺少车辆保存、删除约束和导出 DTO | create/edit/delete/export 动作权限未冻结 | 需定义车牌唯一性、归属变化、里程校验和被引用车辆删除规则 | 写按钮禁用 |
|
||||
|
||||
`lastleg-service` 的 `GET /api/platform/v1/last-leg/us-truck-orders` 等接口管理的是本土卡车业务订单,写操作权限为 `lastleg:us-trucking:manage`,状态从 `PENDING_REVIEW` 经收货、分配、派车、运输推进至 `COMPLETED`。它不是车辆档案,不得用订单字段拼装车辆主数据或复用其权限。
|
||||
|
||||
## 前端权限边界
|
||||
|
||||
- 海外仓报价:`platform:overseas-master-data:quote:list`
|
||||
- 汇率管理:`platform:overseas-master-data:exchange-rate:list`
|
||||
- 车辆列表/详情:`platform:overseas-master-data:vehicle:list`
|
||||
|
||||
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限,但编码与接口需等海外区域契约确认后再接入。列表权限只包含对应列表、详情和只读辅助数据访问。
|
||||
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限。列表权限只包含对应列表、详情和只读辅助数据访问。
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前实现 | 结论 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类页签 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 不新增重复页面 | 建议类型码 `OVERSEAS_PARTNER` 未生效,不得冒充 `OVERSEAS` |
|
||||
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 现有仓库页暂不开放该类型 | 后端正式接受合作仓类型后复用现有仓库弹框和写权限 |
|
||||
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 现有仓库页新增 `OVERSEAS_PARTNER` 类型筛选,不新增重复页面 | 使用国内公共仓库接口与 `platform:warehouse:list`;普通 `OVERSEAS` 语义不变 |
|
||||
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 复用现有仓库居中弹框,开放 `OVERSEAS_PARTNER` 新增和编辑 | 分别复用 `platform:warehouse:create`、`platform:warehouse:edit`,不跨区域查询报价候选 |
|
||||
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 正式模块列表页、结构化空态 | 保留原型字段;等待海外查询接口 |
|
||||
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 写入口禁用,详情无记录时不生成示例对象 | 接口接入后使用居中弹框,不改抽屉或独立页 |
|
||||
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
|
||||
|
||||
@@ -73,4 +73,14 @@ describe('overseas master data pending-contract pages', () => {
|
||||
for (const modal of ['modal-gen-overseas-quote-detail', 'modal-gen-overseas-quote', 'modal-exchange-rate-edit', 'modal-vehicle-edit']) expect(matrix).toContain(modal)
|
||||
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
|
||||
})
|
||||
|
||||
it('records audited service boundaries without treating truck orders as vehicle master data', () => {
|
||||
const gaps = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/CONTRACT_GAPS.md'), 'utf8')
|
||||
expect(gaps).toContain('/api/platform/v1/warehouse-admin')
|
||||
expect(gaps).toContain('/api/platform/v1/last-leg/us-truck-orders')
|
||||
expect(gaps).toContain('它不是车辆档案')
|
||||
expect(gaps).toContain('/internal/warehouses/overseas-partners')
|
||||
expect(gaps).toContain('前端不得调用该内部端点')
|
||||
expect(gaps).toContain('保持结构化空态')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,10 +19,9 @@ describe('overseas master data routes', () => {
|
||||
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
|
||||
})
|
||||
|
||||
it('documents but does not activate the suggested partner warehouse type', () => {
|
||||
it('activates the confirmed partner type in the existing warehouse domain without a duplicate route', () => {
|
||||
const apiSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/api/platformWarehouses.ts'), 'utf8')
|
||||
const gap = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/PARTNER_WAREHOUSE_CONTRACT_GAP.md'), 'utf8')
|
||||
expect(gap).toContain('OVERSEAS_PARTNER')
|
||||
expect(apiSource).not.toContain("'OVERSEAS_PARTNER'")
|
||||
expect(apiSource).toContain("'OVERSEAS_PARTNER'")
|
||||
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* 四期原型展示模型。
|
||||
*
|
||||
* 报价、汇率和车辆主数据尚无已确认的海外服务端 DTO;这些类型不能用于猜测
|
||||
* API 路径或直接序列化请求。真实接口落地后应按 Controller/OpenAPI 单独建立
|
||||
* API DTO,并在适配层转换为以下页面模型。
|
||||
*/
|
||||
export type MasterDataStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type QuoteCurrency = 'USD' | 'CNY'
|
||||
export type VehicleRegion = 'DOMESTIC' | 'OVERSEAS'
|
||||
@@ -86,4 +93,3 @@ export interface VehicleDetail extends VehicleSummary {
|
||||
deliveryOrders: VehicleDeliveryOrder[]
|
||||
replacementHistory: VehicleReplacementHistory[]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 海外仓真实接口接入矩阵
|
||||
|
||||
浏览器只通过共享 `overseasHttp` 请求海外网关,模块路径统一从 `/platform/v1/...` 开始。长整型 ID 全程按字符串处理。以下结论来自 `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service` 当前 Controller/DTO;三者当前没有静态 OpenAPI YAML,本轮未把 Springdoc 注解之外的路径推断为正式接口。
|
||||
|
||||
| 原型页面 | 页面 ID | 当前真实接口 | 接入状态 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| 尾程订单列表 | `fba-last-mile-order` | `GET /platform/v1/last-leg/orders` | 已接入 | 显式 `customerId`;支持 `page/size/orderNo/inboundCode/containerNo/status`。原型其余筛选保留但禁用,不发送无效参数。 |
|
||||
| 尾程订单详情 | `fba-last-mile-order-detail` | `GET /platform/v1/last-leg/orders/{id}` | 已接入 | 展示订单、货物、状态历史和既有单据编号;出库/派送编号只读,不提供离仓动作。 |
|
||||
| 入库单列表/详情 | `fba-inbound-order*` | 仅有 `GET /platform/v1/last-leg/inbound-plans/{planNo}` | 阻断 | 没有分页列表,无法由详情接口反推列表;当前页面继续明确显示契约待接入。 |
|
||||
| 换标单列表/详情 | `fba-relabel-order*` | `GET /platform/v1/last-leg/relabel-applications`、`GET /{relabelNo}` | 已接入只读 | 列表 DTO 可展示单号、SKU、箱数和状态;详情补产品名、标签要求、文件。预算/费用继续显示空值并记录缺口,未伪造金额。 |
|
||||
| 库存/进销存/详情 | `fba-inventory*` | `GET /platform/v1/last-leg/inventory/summary|balances|ledger` | 阻断 | API 是入库单/包裹余额口径,原型是客户/仓库/SKU/产品口径,缺 SKU、产品名及原型指标。 |
|
||||
| 托盘列表/详情 | `fba-pallet*` | `GET /platform/v1/warehouse-admin/{warehouseId}/pallets`、`GET /{id}` | 阻断 | API 强制 `customerId + warehouseId` 且资源客户绑定;产品契约要求平台公共资源、独立查询全部客户/仓库。字段还缺 FBA 仓和采购价。 |
|
||||
| 箱号列表/详情 | `fba-carton*` | 无平台查询接口 | 阻断 | 只有内部接收事件产生快照,浏览器不得调用内部端点。 |
|
||||
| 产品库列表/详情 | `fba-product*` | 无平台查询接口 | 阻断 | 只有内部一件代发快照写入,未提供平台查询/维护端点。 |
|
||||
|
||||
状态映射仅使用服务端已有事实:`PENDING_ASSIGNMENT`(待分配)、`DESTINATION_ASSIGNED`(已分配目的仓)、`INBOUND_PLANNED`(已生成入库计划)。未定义的原型状态不发送到接口。
|
||||
|
||||
当前所有上述海外业务 Controller 均未声明与前端 `platform:overseas-warehouse:*` 对应的服务端 `@PreAuthorize`。因此本轮只接入尾程只读查询;已有写端点在服务端权限、幂等和原型字段未一致前不向 Admin 暴露。
|
||||
@@ -1,11 +1,10 @@
|
||||
# 四期海外仓前端待补契约
|
||||
|
||||
第一批正式页面只冻结路由、权限、字段与操作承载方式。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。
|
||||
正式页面已开始接入真实海外接口。后端服务边界现已确认:浏览器请求统一进入 `gateway-overseas/`(RS256/JWKS,Audience=`lastleg-api`);尾程订单、入仓计划、换标、产品/箱号快照和库存业务归 `lastleg-service/`;海外仓库、地址、能力、库位、实物托盘和资源锁归 `warehouse-overseas-service/`。国内 `warehouse/` 只维护“海外仓合作仓”公共主数据类型。逐页现状见 `API_INTEGRATION_MATRIX.md`。
|
||||
|
||||
以下前端适配或页面级契约补齐前,页面不得用演示数据或本地成功提示替代真实调用:
|
||||
|
||||
- 统一认证/HTTP 层对海外 API Base、短时海外 Access Token 的存储、刷新和自动选择,以及与现有国内 `http` 客户端一致的错误处理;业务页面不得自行读写 Token,也不得把请求发往国内 `gateway/`、`firstleg/` 或 `warehouse/`。
|
||||
- 尾程订单分页查询、详情、导入模板、批量导入、异步导出、批量转仓和入库计划接口;分页查询需明确“全部 / 待分配”页签的请求映射,以及出库单号、派送单号只读关联字段。
|
||||
- 尾程订单导入模板、批量导入、异步导出任务及对应独立权限。现有同步 CSV 导出不等同于 Admin 下载中心异步导出。尾程分页与详情已经接入;“待分配”映射为 `PENDING_ASSIGNMENT`,出库单号、派送单号仅展示既有只读关联事实。
|
||||
- 入库单分页查询、详情、创建、编辑、预约、智能排车接口,以及状态机、幂等键和操作权限。
|
||||
- 库存汇总、分页查询、详情、库位库存、托盘箱号和进销存记录接口。
|
||||
- 换标单分页、详情、创建、编辑/审核、文件上传与费用明细接口,以及状态机和幂等约定。
|
||||
@@ -15,6 +14,16 @@
|
||||
- 四期权限资源在 IAM 初始化数据中的正式登记。前端暂按本模块 `permissions.ts` 的稳定编码装配。
|
||||
- 所有分页响应、错误码、`requestId`/`traceId`、时间/金额精度和长整型 ID 字符串传输约定。
|
||||
|
||||
## 当前缺口的精确请求形状
|
||||
|
||||
- 入库单列表:缺 `GET /api/platform/v1/last-leg/inbound-plans`;至少需要 `customerId,page,size,planNo,inboundCode,warehouseId,fbaWarehouseCode,status,appointmentStatus`,响应需返回 `records/page/size/total/pages` 及原型列表字段。现有状态起点为 `PENDING_ASSIGNMENT`,转换端点 `POST /convert` 不能代替列表。
|
||||
- 产品库:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/products[/{id}]`;查询/响应至少覆盖客户、SKU、中英文品名、品类、长宽高、重量,写入需 SKU 唯一性、精度、幂等和状态约束。
|
||||
- 箱号:缺平台 `GET/POST/PUT /api/platform/v1/last-leg/cartons[/{id}]`;查询/响应至少覆盖客户、箱号、FBA 仓、SKU、产品名、数量、重量、库存状态、托盘、入库单及只读出库关联。内部 `/internal/v1/lastleg/dropship/receive` 不得暴露给浏览器。
|
||||
- 库存:现有 `GET /api/platform/v1/last-leg/inventory/balances|ledger` 需增加 SKU/产品快照维度,或另提供与原型一致的 SKU 聚合端点;响应需覆盖现有量、可用量、暂扣、散板、在途、采购、状态和更新时间。状态流转仍只能由入库/库内事实驱动,不增加离仓操作。
|
||||
- 换标:当前列表响应需补 `productName,labelRequirement,relabelFile,budgetTotal/relabelCost`,或产品明确调整原型字段;写接口必须补正式 `create/edit` 权限。现有状态机为 `PENDING_PROCESSING -> PROCESSING/REJECTED/CANCELLED`、`PROCESSING -> COMPLETED/REJECTED/CANCELLED`。
|
||||
- 托盘:需先收敛资源作用域。产品要求平台公共查询全部客户/仓库,现接口 `GET /api/platform/v1/warehouse-admin/{warehouseId}/pallets?customerId=...` 强制客户绑定;响应还需明确 FBA 仓与采购价如何对应当前 `locationId/unitPrice`。在此冲突解决前不把客户筛选错误地当作公共资源范围。
|
||||
- 权限:尾程、入库、换标、库存、海外仓、库位、托盘相关 Controller 均需为列表/详情使用各资源 `*:list`,写操作分别使用 `*:create`、`*:edit` 等稳定权限;当前仅有应用范围与客户隔离,不能以页面按钮权限替代服务端鉴权。
|
||||
|
||||
范围排除:海外仓出库、退货出库、海外派送和其他库存离仓接口。
|
||||
|
||||
`lastleg-service` 已提供尾程订单、入仓计划、库存查询等 Controller,`warehouse-overseas-service` 已提供平台海外仓、库位和托盘 Controller;接入时以这些服务的 Controller/DTO/OpenAPI 为准。当前 `types.ts` 只冻结原型派生的页面展示字段,不是海外 API DTO;不得据此推导未覆盖页面的分页包络、URL、请求参数、时间/金额精度或写接口。统一区域客户端和对应页面契约完成后再建立实际 API 适配层,并逐页替换空态数据源。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getLastMileOrder, listLastMileOrders } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
describe('last-mile order overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
|
||||
it('uses the gateway-relative platform list endpoint and explicit customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
|
||||
await expect(listLastMileOrders({ customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' })).resolves.toMatchObject({ page: 2 })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
|
||||
params: { customerId: '190000000000000001', page: 2, size: 20, status: 'PENDING_ASSIGNMENT' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a long order id as a string in the detail path', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { order: { id: '190000000000000099' }, cargo: [], statusHistory: [] } })
|
||||
await getLastMileOrder('190000000000000099', '190000000000000001')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099', {
|
||||
params: { customerId: '190000000000000001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call outbound or delivery endpoints', () => {
|
||||
const source = `${listLastMileOrders}${getLastMileOrder}`
|
||||
expect(source).not.toMatch(/outbound|delivery|dispatch/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface OverseasPageResult<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface LastMileOrderQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
orderNo?: string
|
||||
inboundCode?: string
|
||||
containerNo?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface LastMileOrderSummaryDto {
|
||||
id: string
|
||||
orderNo: string
|
||||
orderType?: string | null
|
||||
inboundCode?: string | null
|
||||
sourceOrderNo?: string | null
|
||||
customerName?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
transferWarehouseName?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
containerNo?: string | null
|
||||
containerStatus?: string | null
|
||||
abnormal?: boolean | null
|
||||
detained?: boolean | null
|
||||
palletCount?: number | null
|
||||
packageCount?: number | null
|
||||
volumeCbm?: number | null
|
||||
productQuantity?: number | null
|
||||
volumeWeightKg?: number | null
|
||||
estimatedArrivalAt?: string | null
|
||||
actualArrivalAt?: string | null
|
||||
estimatedWarehouseAt?: string | null
|
||||
inboundOrderNo?: string | null
|
||||
outboundOrderNo?: string | null
|
||||
deliveryOrderNo?: string | null
|
||||
relabelOrderNo?: string | null
|
||||
relabelStatus?: string | null
|
||||
palletMergeFlag?: string | null
|
||||
currentStatus?: string | null
|
||||
privateRemark?: string | null
|
||||
customerRemark?: string | null
|
||||
warehouseRemark?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileOrderDto extends LastMileOrderSummaryDto {
|
||||
customerId: string
|
||||
sourceOrderNo?: string | null
|
||||
subOrderStatus?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileCargoDto {
|
||||
id: string
|
||||
skuCode?: string | null
|
||||
productNameZh?: string | null
|
||||
productNameEn?: string | null
|
||||
packageCount?: number | null
|
||||
productQuantity?: number | null
|
||||
palletCount?: number | null
|
||||
grossWeightKg?: number | null
|
||||
dimensionalWeightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
destinationWarehouseName?: string | null
|
||||
fbaTrackingNo?: string | null
|
||||
shipmentNo?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileStatusHistoryDto {
|
||||
id: string
|
||||
fromStatus?: string | null
|
||||
toStatus?: string | null
|
||||
eventId?: string | null
|
||||
remark?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface LastMileOrderDetailDto {
|
||||
order: LastMileOrderDto
|
||||
cargo: LastMileCargoDto[]
|
||||
statusHistory: LastMileStatusHistoryDto[]
|
||||
}
|
||||
|
||||
export async function listLastMileOrders(query: LastMileOrderQuery) {
|
||||
const { customerId, ...params } = query
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>, ApiEnvelope<OverseasPageResult<LastMileOrderSummaryDto>>>(
|
||||
'/platform/v1/last-leg/orders',
|
||||
{ params: { customerId, ...params } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getLastMileOrder(orderId: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<LastMileOrderDetailDto>, ApiEnvelope<LastMileOrderDetailDto>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getRelabelOrder, listRelabelOrders } from './relabelOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
describe('relabel overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
|
||||
it('uses explicit customer scope for the real page endpoint', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
|
||||
await listRelabelOrders({ customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', { params: { customerId: '190000000000000001', page: 1, size: 20, skuCode: 'SKU-1' } })
|
||||
})
|
||||
|
||||
it('encodes the relabel number and does not call a mutation endpoint', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { application: { id: '1', relabelNo: 'RL/1' }, items: [], history: [] } })
|
||||
await getRelabelOrder('RL/1', '190000000000000001')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1', { params: { customerId: '190000000000000001' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OverseasPageResult } from './lastMileOrders'
|
||||
|
||||
export interface RelabelOrderQuery {
|
||||
customerId: string
|
||||
page: number
|
||||
size: number
|
||||
skuCode?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface RelabelSummaryDto {
|
||||
id: string
|
||||
relabelNo: string
|
||||
lastlegOrderId?: string | null
|
||||
lastlegOrderNo?: string | null
|
||||
customerOrderNo?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
labelDeadline?: string | null
|
||||
contactName?: string | null
|
||||
contactPhone?: string | null
|
||||
status?: string | null
|
||||
itemCount?: number | null
|
||||
totalPackageCount?: number | null
|
||||
notifiedPackageCount?: number | null
|
||||
skuCodes?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface RelabelItemDto {
|
||||
id: string
|
||||
skuCode?: string | null
|
||||
productNameZh?: string | null
|
||||
totalPackageCount?: number | null
|
||||
notifiedPackageCount?: number | null
|
||||
newSkuCode?: string | null
|
||||
labelRequirement?: string | null
|
||||
relabelFileName?: string | null
|
||||
relabelFileUrl?: string | null
|
||||
}
|
||||
|
||||
export interface RelabelHistoryDto {
|
||||
fromStatus?: string | null
|
||||
toStatus?: string | null
|
||||
operatorId?: string | null
|
||||
remark?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface RelabelDetailDto {
|
||||
application: RelabelSummaryDto
|
||||
remark?: string | null
|
||||
items: RelabelItemDto[]
|
||||
history: RelabelHistoryDto[]
|
||||
}
|
||||
|
||||
export async function listRelabelOrders(query: RelabelOrderQuery) {
|
||||
const { customerId, ...params } = query
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>, ApiEnvelope<OverseasPageResult<RelabelSummaryDto>>>(
|
||||
'/platform/v1/last-leg/relabel-applications', { params: { customerId, ...params } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getRelabelOrder(relabelNo: string, customerId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
|
||||
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`, { params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -3,13 +3,15 @@ import { ArrowLeft } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ContractPendingNotice from './ContractPendingNotice.vue'
|
||||
|
||||
defineProps<{
|
||||
withDefaults(defineProps<{
|
||||
title: string
|
||||
referenceLabel: string
|
||||
referenceValue: string
|
||||
backLabel: string
|
||||
domain: string
|
||||
}>()
|
||||
pending?: boolean
|
||||
statusText?: string
|
||||
}>(), { pending: true, statusText: '接口待接入' })
|
||||
|
||||
defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
@@ -22,10 +24,10 @@ defineEmits<{ back: [] }>()
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ referenceLabel }} <strong>{{ referenceValue }}</strong></p>
|
||||
</div>
|
||||
<span class="overseas-detail-status" role="status">接口待接入</span>
|
||||
<span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span>
|
||||
</header>
|
||||
|
||||
<ContractPendingNotice :domain="domain" />
|
||||
<ContractPendingNotice v-if="pending" :domain="domain" />
|
||||
<slot />
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -38,4 +40,5 @@ defineEmits<{ back: [] }>()
|
||||
.overseas-detail-title p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.overseas-detail-title strong { color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.overseas-detail-status.is-live { border-color: var(--el-color-success-light-7); background: var(--el-color-success-light-9); color: var(--el-color-success-dark-2); }
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,11 @@ defineProps<{
|
||||
backLabel: string
|
||||
domain: string
|
||||
groups: DetailGroup[]
|
||||
fieldValues?: Record<string, string | number>
|
||||
pending?: boolean
|
||||
statusText?: string
|
||||
loading?: boolean
|
||||
showContent?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ back: [] }>()
|
||||
@@ -25,12 +30,15 @@ defineEmits<{ back: [] }>()
|
||||
:reference-value="referenceValue"
|
||||
:back-label="backLabel"
|
||||
:domain="domain"
|
||||
:pending="pending"
|
||||
:status-text="statusText"
|
||||
@back="$emit('back')"
|
||||
>
|
||||
<section class="read-only-detail" :aria-label="title">
|
||||
<slot name="state" />
|
||||
<section v-if="showContent !== false" v-loading="loading" class="read-only-detail" :aria-label="title" :aria-busy="loading">
|
||||
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
|
||||
<h2>{{ group.title }}</h2>
|
||||
<dl class="detail-fields"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>—</dd></div></dl>
|
||||
<dl class="detail-fields"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>{{ fieldValues?.[field] ?? '—' }}</dd></div></dl>
|
||||
</section>
|
||||
</section>
|
||||
</OverseasDetailShell>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toLastMileOrderListItem } from './lastMilePresentation'
|
||||
|
||||
describe('last-mile order presentation adapter', () => {
|
||||
it('maps backend codes, associations and long ids without inventing data', () => {
|
||||
const row = toLastMileOrderListItem({
|
||||
id: '190000000000000099', orderNo: 'LL-001', orderType: 'ONE_CLICK_DROPSHIP', currentStatus: 'PENDING_ASSIGNMENT',
|
||||
abnormal: true, detained: false, packageCount: 12, deliveryOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
|
||||
}, '190000000000000001')
|
||||
expect(row).toMatchObject({
|
||||
id: '190000000000000099', customerId: '190000000000000001', orderType: '一件代发', status: '待分配',
|
||||
warning: '异常', abnormal: '是', held: '否', cartons: 12, dispatchOrderNo: 'DEL-READ-ONLY', outboundOrderNo: 'OUT-READ-ONLY',
|
||||
})
|
||||
expect(row.rejectedAt).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { LastMileOrderSummaryDto } from './api/lastMileOrders'
|
||||
import type { LastMileOrderListItem } from './types'
|
||||
|
||||
const orderTypeLabels: Record<string, string> = {
|
||||
ONE_CLICK_DROPSHIP: '一件代发',
|
||||
FIRST_LEG: '头程',
|
||||
REJECTED: '拒收订单',
|
||||
EXPRESS: '快递订单',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING_ASSIGNMENT: '待分配',
|
||||
DESTINATION_ASSIGNED: '已分配目的仓',
|
||||
INBOUND_PLANNED: '已生成入库计划',
|
||||
PENDING_PROCESSING: '待处理',
|
||||
PROCESSING: '处理中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || '—'
|
||||
}
|
||||
|
||||
function count(value: number | null | undefined) {
|
||||
return Number.isFinite(value) ? Number(value) : 0
|
||||
}
|
||||
|
||||
function yesNo(value: boolean | null | undefined) {
|
||||
return value == null ? '—' : value ? '是' : '否'
|
||||
}
|
||||
|
||||
export function lastMileStatusLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
return statusLabels[code] || text(code)
|
||||
}
|
||||
|
||||
export function lastMileOrderTypeLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
return orderTypeLabels[code] || text(code)
|
||||
}
|
||||
|
||||
export function formatOverseasDateTime(value: unknown) {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return '—'
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? raw : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto, customerId: string): LastMileOrderListItem {
|
||||
return {
|
||||
id: String(dto.id),
|
||||
customerId,
|
||||
orderNo: text(dto.orderNo),
|
||||
warning: dto.abnormal ? '异常' : '—',
|
||||
orderType: lastMileOrderTypeLabel(dto.orderType),
|
||||
rejectedAt: '—',
|
||||
inboundCode: text(dto.inboundCode),
|
||||
owner: text(dto.customerName),
|
||||
warehouse: text(dto.overseasWarehouseName),
|
||||
transferWarehouse: text(dto.transferWarehouseName),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode),
|
||||
containerNo: text(dto.containerNo),
|
||||
containerStatus: text(dto.containerStatus),
|
||||
abnormal: yesNo(dto.abnormal),
|
||||
held: yesNo(dto.detained),
|
||||
pallets: count(dto.palletCount),
|
||||
cartons: count(dto.packageCount),
|
||||
volume: count(dto.volumeCbm),
|
||||
quantity: count(dto.productQuantity),
|
||||
volumeWeight: count(dto.volumeWeightKg),
|
||||
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
|
||||
portAta: formatOverseasDateTime(dto.actualArrivalAt),
|
||||
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
|
||||
inboundOrderNo: text(dto.inboundOrderNo),
|
||||
outboundOrderNo: text(dto.outboundOrderNo),
|
||||
dispatchOrderNo: text(dto.deliveryOrderNo),
|
||||
relabelOrderNo: text(dto.relabelOrderNo),
|
||||
relabelStatus: lastMileStatusLabel(dto.relabelStatus),
|
||||
consolidated: text(dto.palletMergeFlag),
|
||||
status: lastMileStatusLabel(dto.currentStatus),
|
||||
internalRemark: text(dto.privateRemark),
|
||||
customerRemark: text(dto.customerRemark),
|
||||
warehouseRemark: text(dto.warehouseRemark),
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
|
||||
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
|
||||
interface DetailField { label: string; value: string | number }
|
||||
interface DocumentRow { type: string; number: string; createdAt: string; quote: string }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderId = computed(() => String(route.params.orderId || '—'))
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const detail = ref<LastMileOrderDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
|
||||
|
||||
const referenceValue = computed(() => text(detail.value?.order.orderNo || orderId.value))
|
||||
const orderFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '订单类型', value: lastMileOrderTypeLabel(order?.orderType) },
|
||||
{ label: '拒收时间', value: '—' },
|
||||
{ label: '实际入库时间', value: '—' },
|
||||
{ label: '头程单号', value: text(order?.sourceOrderNo) },
|
||||
{ label: '货主', value: text(order?.customerName) },
|
||||
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
|
||||
{ label: '转仓目的仓', value: text(order?.transferWarehouseName) },
|
||||
{ label: 'FBA仓库', value: text(order?.fbaWarehouseCode) },
|
||||
{ label: '集装箱号', value: text(order?.containerNo) },
|
||||
{ label: '货柜异常', value: yesNo(order?.abnormal) },
|
||||
{ label: '异常原因', value: order?.abnormal ? text(order.subOrderStatus) : '—' },
|
||||
{ label: '运输方式', value: '—' },
|
||||
{ label: '预计到仓', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
||||
{ label: '清关状态', value: text(order?.containerStatus) },
|
||||
]
|
||||
})
|
||||
const detailFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '订单类型', value: lastMileOrderTypeLabel(order?.orderType) },
|
||||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||||
{ label: '目的仓', value: text(order?.transferWarehouseName) },
|
||||
{ label: '托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: '箱数', value: Number(order?.packageCount || 0) },
|
||||
{ label: '数量', value: Number(order?.productQuantity || 0) },
|
||||
{ label: '体积重量', value: Number(order?.volumeWeightKg || 0) },
|
||||
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
|
||||
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
|
||||
{ label: '预计到仓', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
||||
{ label: '是否暂扣', value: yesNo(order?.detained) },
|
||||
{ label: '备注(仅自己)', value: text(order?.privateRemark) },
|
||||
{ label: '备注(客户)', value: text(order?.customerRemark) },
|
||||
{ label: '备注(仓库)', value: text(order?.warehouseRemark) },
|
||||
]
|
||||
})
|
||||
const planFields = computed<DetailField[]>(() => {
|
||||
const order = detail.value?.order
|
||||
return [
|
||||
{ label: '入仓码', value: text(order?.inboundCode) },
|
||||
{ label: '计划入库仓', value: text(order?.transferWarehouseName || order?.overseasWarehouseName) },
|
||||
{ label: '合托批次', value: text(order?.palletMergeFlag) },
|
||||
{ label: '合托后托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: 'FBA预约号', value: '—' },
|
||||
{ label: '预约状态', value: '—' },
|
||||
{ label: '换标状态', value: lastMileStatusLabel(order?.relabelStatus) },
|
||||
]
|
||||
})
|
||||
const documents = computed<DocumentRow[]>(() => {
|
||||
const order = detail.value?.order
|
||||
if (!order) return []
|
||||
return [
|
||||
['入库单', order.inboundOrderNo],
|
||||
['出库单(只读关联)', order.outboundOrderNo],
|
||||
['派送单(只读关联)', order.deliveryOrderNo],
|
||||
['换标单', order.relabelOrderNo],
|
||||
].filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' }))
|
||||
})
|
||||
const cargo = computed<CargoRow[]>(() => (detail.value?.cargo || []).map((item) => ({
|
||||
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
|
||||
cartons: Number(item.packageCount || 0), quantity: Number(item.productQuantity || 0), weight: Number(item.grossWeightKg || 0), volume: Number(item.volumeCbm || 0),
|
||||
})))
|
||||
const logs = computed<LogRow[]>(() => (detail.value?.statusHistory || []).map((item) => ({
|
||||
id: String(item.id), occurredAt: formatOverseasDateTime(item.createdAt), node: lastMileStatusLabel(item.toStatus), operator: '—',
|
||||
result: text(item.remark || `${lastMileStatusLabel(item.fromStatus)} → ${lastMileStatusLabel(item.toStatus)}`),
|
||||
})))
|
||||
|
||||
const orderFields = ['订单类型', '拒收时间', '实际入库时间', '头程单号', '货主', '海外仓', '转仓目的仓', 'FBA仓库', '集装箱号', '货柜异常', '异常原因', '运输方式', '预计到仓', '清关状态']
|
||||
const detailFields = ['订单类型', '入仓码', '目的仓', '托数', '箱数', '数量', '体积重量', '预计到港时间', '实际到港时间', '预计到仓', '是否暂扣', '备注(仅自己)', '备注(客户)', '备注(仓库)']
|
||||
const planFields = ['入仓码', '计划入库仓', '合托批次', '合托后托数', 'FBA预约号', '预约状态', '换标状态']
|
||||
const documentColumns: DataTableColumn[] = [
|
||||
{ key: 'type', label: '单据类型', minWidth: 120 },
|
||||
{ key: 'number', label: '单据编号', minWidth: 180 },
|
||||
{ key: 'createdAt', label: '生成时间', minWidth: 160 },
|
||||
{ key: 'quote', label: '报价', minWidth: 140 },
|
||||
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 120 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 180 },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 160 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 140 },
|
||||
]
|
||||
const cargoColumns: DataTableColumn[] = [
|
||||
{ key: 'sku', label: 'SKU', minWidth: 150 },
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'pallets', label: '托数', width: 90, align: 'right' },
|
||||
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'quantity', label: '件数', width: 90, align: 'right' },
|
||||
{ key: 'weight', label: '重量(kg)', width: 110, align: 'right' },
|
||||
{ key: 'volume', label: '体积(m³)', width: 110, align: 'right' },
|
||||
{ key: 'sku', prop: 'sku', label: 'SKU', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'pallets', prop: 'pallets', label: '托数', width: 90, align: 'right' }, { key: 'cartons', prop: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'quantity', prop: 'quantity', label: '件数', width: 90, align: 'right' }, { key: 'weight', prop: 'weight', label: '重量(kg)', width: 110, align: 'right' },
|
||||
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 110, align: 'right' },
|
||||
]
|
||||
const logColumns: DataTableColumn[] = [
|
||||
{ key: 'occurredAt', label: '时间', minWidth: 160 },
|
||||
{ key: 'node', label: '操作节点', minWidth: 140 },
|
||||
{ key: 'operator', label: '操作人', minWidth: 130 },
|
||||
{ key: 'result', label: '处理结果', minWidth: 220 },
|
||||
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 160 }, { key: 'node', prop: 'node', label: '操作节点', minWidth: 140 },
|
||||
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 130 }, { key: 'result', prop: 'result', label: '处理结果', minWidth: 220 },
|
||||
]
|
||||
|
||||
function back() {
|
||||
void router.push({ name: 'overseas-last-mile-orders' })
|
||||
async function load() {
|
||||
if (!orderId.value || !customerId.value) {
|
||||
loadError.value = { message: '详情缺少客户范围,请从尾程订单列表重新进入' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try { detail.value = await getLastMileOrder(orderId.value, customerId.value) }
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '尾程订单详情加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: 'overseas-last-mile-orders' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="orderId" back-label="返回尾程订单" domain="尾程订单详情" @back="back">
|
||||
<div class="detail-grid">
|
||||
<section class="detail-section" aria-labelledby="last-mile-order-heading">
|
||||
<h2 id="last-mile-order-heading">订单信息</h2>
|
||||
<dl class="detail-fields"><div v-for="label in orderFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-lines-heading">
|
||||
<h2 id="last-mile-lines-heading">订单明细</h2>
|
||||
<dl class="detail-fields"><div v-for="label in detailFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-plan-heading">
|
||||
<h2 id="last-mile-plan-heading">计划信息</h2>
|
||||
<dl class="detail-fields"><div v-for="label in planFields" :key="label"><dt>{{ label }}</dt><dd>—</dd></div></dl>
|
||||
</section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-documents-heading">
|
||||
<h2 id="last-mile-documents-heading">关联单据</h2>
|
||||
<DataTable :data="[]" :columns="documentColumns" column-settings-mode="hide" empty-text="海外区域关联单据契约待接入" />
|
||||
</section>
|
||||
<section class="detail-section table-section is-wide" aria-labelledby="last-mile-cargo-heading">
|
||||
<h2 id="last-mile-cargo-heading">货物信息</h2>
|
||||
<DataTable :data="[]" :columns="cargoColumns" column-settings-mode="hide" empty-text="海外区域货物明细契约待接入" />
|
||||
</section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-logs-heading">
|
||||
<h2 id="last-mile-logs-heading">操作记录</h2>
|
||||
<DataTable :data="[]" :columns="logColumns" column-settings-mode="hide" empty-text="海外区域操作记录契约待接入" />
|
||||
</section>
|
||||
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="detail-grid" :aria-busy="loading">
|
||||
<section class="detail-section" aria-labelledby="last-mile-order-heading"><h2 id="last-mile-order-heading">订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-lines-heading"><h2 id="last-mile-lines-heading">订单明细</h2><dl class="detail-fields"><div v-for="field in detailFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="detail-section" aria-labelledby="last-mile-plan-heading"><h2 id="last-mile-plan-heading">计划信息</h2><dl class="detail-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-documents-heading"><h2 id="last-mile-documents-heading">关联单据</h2><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据" /></section>
|
||||
<section class="detail-section table-section is-wide" aria-labelledby="last-mile-cargo-heading"><h2 id="last-mile-cargo-heading">货物信息</h2><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细" /></section>
|
||||
<section class="detail-section table-section" aria-labelledby="last-mile-logs-heading"><h2 id="last-mile-logs-heading">操作记录</h2><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录" /></section>
|
||||
</div>
|
||||
</OverseasDetailShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-grid { display: grid; min-width: 0; min-height: 240px; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-section { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-section:nth-child(even) { border-right: 0; }
|
||||
.detail-section.is-wide { grid-column: 1 / -1; border-right: 0; }
|
||||
|
||||
@@ -1,32 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Eye } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import { listLastMileOrders } from '../api/lastMileOrders'
|
||||
import { toLastMileOrderListItem } from '../lastMilePresentation'
|
||||
import type { LastMileOrderListItem } from '../types'
|
||||
|
||||
const rows = ref<LastMileOrderListItem[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const loading = ref(false)
|
||||
const customersLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const router = useRouter()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const statusTab = ref<'全部' | '待分配'>('全部')
|
||||
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
|
||||
const fields: QueryField[] = [
|
||||
const filters = ref<QueryModel>({ customerId: '', orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 240, placeholder: '请选择客户', props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [] },
|
||||
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '全部FBA仓库', options: [] },
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部订单类型', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'owner', type: 'input', label: '货主', placeholder: '请输入货主' },
|
||||
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
|
||||
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '接口暂不支持', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value, disabled: true })), props: { disabled: true } },
|
||||
{ key: 'owner', type: 'input', label: '货主', placeholder: '请通过客户名称选择', props: { disabled: true } },
|
||||
{ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '请输入集装箱号' },
|
||||
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: ['待入库计划', '待清关', '清关完成', '已合托', '待预约', '预约中', '预约成功'].map((value) => ({ label: value, value })) },
|
||||
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '全部', options: [{ label: '是', value: 'yes' }, { label: '否', value: 'no' }] },
|
||||
{ key: 'portEta', type: 'daterange', label: '预计到港时间' },
|
||||
]
|
||||
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: [
|
||||
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
|
||||
{ label: '已分配目的仓', value: 'DESTINATION_ASSIGNED' },
|
||||
{ label: '已生成入库计划', value: 'INBOUND_PLANNED' },
|
||||
] },
|
||||
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '接口暂不支持', options: [{ label: '是', value: 'yes', disabled: true }, { label: '否', value: 'no', disabled: true }], props: { disabled: true } },
|
||||
{ key: 'portEta', type: 'daterange', label: '预计到港时间', props: { disabled: true, startPlaceholder: '接口暂不支持', endPlaceholder: '接口暂不支持' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
|
||||
@@ -62,27 +78,90 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
|
||||
]
|
||||
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true
|
||||
customersError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试')
|
||||
} finally {
|
||||
customersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (!customerId) {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await listLastMileOrders({
|
||||
customerId,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
orderNo: String(filters.value.orderNo || '').trim() || undefined,
|
||||
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
|
||||
containerNo: String(filters.value.containerNo || '').trim() || undefined,
|
||||
status: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
|
||||
})
|
||||
rows.value = result.records.map((item) => toLastMileOrderListItem(item, customerId))
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '尾程订单加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function changeStatusTab() { page.value = 1; void load() }
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderNo } })
|
||||
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id }, query: { customerId: row.customerId } })
|
||||
}
|
||||
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="尾程订单列表">
|
||||
<template #toolbar>
|
||||
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
|
||||
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="page = 1">
|
||||
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
|
||||
<el-radio-button value="全部">全部</el-radio-button>
|
||||
<el-radio-button value="待分配">待分配</el-radio-button>
|
||||
</el-radio-group>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="page = 1" />
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
|
||||
</section>
|
||||
</template>
|
||||
<template #state><ContractPendingNotice domain="尾程订单" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-last-mile-orders" row-key="orderNo" empty-text="海外区域尾程订单查询契约待接入">
|
||||
<template v-if="customersError || loadError" #state>
|
||||
<LoadErrorState title="尾程订单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()">
|
||||
<template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template>
|
||||
</LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" :empty-text="filters.customerId ? '暂无尾程订单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||
<template #pagination-left>共 0 条尾程订单</template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getRelabelOrder, type RelabelDetailDto } from '../api/relabelOrders'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const orderId = computed(() => String(route.params.orderId || '—'))
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const detail = ref<RelabelDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '创建时间', '更新时间'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
|
||||
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
|
||||
{ title: '费用明细', fields: ['开箱费', '挑货费', '换标费', '换箱唛标费', '换标费总计'] },
|
||||
]
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function status(value: unknown) {
|
||||
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
return labels[String(value || '')] || text(value)
|
||||
}
|
||||
const referenceValue = computed(() => text(detail.value?.application.relabelNo || orderId.value))
|
||||
const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
const value = detail.value
|
||||
const items = value?.items || []
|
||||
return {
|
||||
换标单号: text(value?.application.relabelNo),
|
||||
关联订单: text(value?.application.lastlegOrderNo || value?.application.customerOrderNo),
|
||||
状态: status(value?.application.status),
|
||||
创建时间: formatOverseasDateTime(value?.application.createdAt),
|
||||
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
|
||||
商品SKU: items.map((item) => text(item.skuCode)).join('、') || '—',
|
||||
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
|
||||
箱数: Number(value?.application.totalPackageCount || 0),
|
||||
预算总价: '—',
|
||||
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
|
||||
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
|
||||
备注: text(value?.remark),
|
||||
开箱费: '—', 挑货费: '—', 换标费: '—', 换箱唛标费: '—', 换标费总计: '—',
|
||||
}
|
||||
})
|
||||
async function load() {
|
||||
if (!orderId.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从换标单列表重新进入' }; return }
|
||||
loading.value = true; loadError.value = null
|
||||
try { detail.value = await getRelabelOrder(orderId.value, customerId.value) }
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '换标单详情加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template><OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="orderId" back-label="返回换标单" domain="换标单详情" :groups="groups" @back="back" /></template>
|
||||
<template><OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back"><template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></OverseasReadOnlyDetail></template>
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, Pencil, Plus, Upload } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
|
||||
import ContractPendingFormDialog from '../components/ContractPendingFormDialog.vue'
|
||||
import { listRelabelOrders } from '../api/relabelOrders'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import type { RelabelOrderListItem } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const rows = ref<RelabelOrderListItem[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const loading = ref(false)
|
||||
const customersLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = ref<QueryModel>({ sku: '', status: '' })
|
||||
const fields: QueryField[] = [
|
||||
const total = ref(0)
|
||||
const filters = ref<QueryModel>({ customerId: '', sku: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', placeholder: '请选择客户', width: 240, props: { filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: ['待处理', '处理中', '已完成'].map((value) => ({ label: value, value })) },
|
||||
]
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', label: '换标单号', minWidth: 170 },
|
||||
@@ -48,18 +59,50 @@ function openForm(mode: 'create' | 'edit', source?: object) {
|
||||
|
||||
function openDetail(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) } })
|
||||
void router.push({ name: 'overseas-relabel-order-detail', params: { orderId: String(row.orderNo) }, query: { customerId: row.customerId } })
|
||||
}
|
||||
|
||||
function relabelStatus(value: unknown) {
|
||||
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
return labels[String(value || '')] || String(value || '—')
|
||||
}
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true; customersError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
|
||||
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败,请重试') }
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (!customerId) { rows.value = []; total.value = 0; loadError.value = null; return }
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listRelabelOrders({ customerId, page: page.value, size: pageSize.value, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
|
||||
rows.value = result.records.map((item) => ({
|
||||
customerId, orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
|
||||
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), remark: '',
|
||||
}))
|
||||
total.value = result.total
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '换标单加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="换标单列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" @search="page = 1" /></template>
|
||||
<template #state><ContractPendingNotice domain="换标单" /></template>
|
||||
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="海外区域换标单查询契约待接入">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" @search="search" @reset="reset" /></template>
|
||||
<template v-if="customersError || loadError" #state><LoadErrorState title="换标单加载失败" :message="(customersError || loadError)?.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customersError || loadError)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" :empty-text="filters.customerId ? '暂无换标单' : '请先选择客户后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderEdit)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
|
||||
<template #pagination-left>共 0 条换标单</template>
|
||||
<template #pagination-left>共 {{ total }} 条换标单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<ContractPendingFormDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" domain="换标单" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'">
|
||||
|
||||
@@ -104,8 +104,13 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).not.toContain('海外派送')
|
||||
})
|
||||
|
||||
it('shows contract-pending empty states instead of demo data or fake success actions', () => {
|
||||
for (const fileName of ['LastMileOrderDetailView.vue', 'InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
|
||||
it('connects the tail-leg detail to the real overseas API and keeps missing contracts pending', () => {
|
||||
const tailLeg = source('LastMileOrderDetailView.vue')
|
||||
expect(tailLeg).toContain('getLastMileOrder')
|
||||
expect(tailLeg).toContain(':data="cargo"')
|
||||
expect(tailLeg).toContain('<LoadErrorState')
|
||||
expect(tailLeg).not.toContain('ElMessage.success')
|
||||
for (const fileName of ['InboundOrderDetailView.vue', 'InventoryDetailView.vue']) {
|
||||
const value = source(fileName)
|
||||
expect(value).toContain(':data="[]"')
|
||||
expect(value).toContain('契约待接入')
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* identifiers, timestamps, money precision, or mutation request contracts.
|
||||
*/
|
||||
export interface LastMileOrderListItem {
|
||||
id: string
|
||||
customerId: string
|
||||
orderNo: string
|
||||
warning: string
|
||||
orderType: string
|
||||
@@ -71,12 +73,13 @@ export interface InventoryListItem {
|
||||
}
|
||||
|
||||
export interface RelabelOrderListItem {
|
||||
customerId: string
|
||||
orderNo: string
|
||||
relatedOrderNo: string
|
||||
sku: string
|
||||
productName: string
|
||||
cartons: number
|
||||
budgetTotal: number
|
||||
budgetTotal: number | null
|
||||
labelRequirement: string
|
||||
relabelCost?: number
|
||||
fileName: string
|
||||
|
||||
@@ -17,7 +17,11 @@ const query = {
|
||||
trackingType: 'CONTAINER_NO',
|
||||
trackingNo: 'CSNU1234567',
|
||||
status: 'MANUAL_REQUIRED',
|
||||
accessMode: 'DYNAMIC_IFRAME',
|
||||
attemptCount: '2',
|
||||
lastAttemptStatus: 'MANUAL_REQUIRED',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED',
|
||||
lastErrorMessage: '必须人工查询',
|
||||
resultPortScheduleId: 404,
|
||||
version: '3',
|
||||
}
|
||||
@@ -29,7 +33,7 @@ describe('platformCarrierScheduleQueries', () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ query, expired: true, maxAttempts: '3', nextAllowedAt: null }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
const result = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' })
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries', { params: { page: 1, size: 200, oceanBookingId: '202', containerBookingId: '303' } })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', attemptCount: 2, version: 3 }, expired: true, maxAttempts: 3 })
|
||||
expect(result.records[0]).toMatchObject({ query: { id: '9007199254740993', customerId: '101', oceanBookingId: '202', containerBookingId: '303', resultPortScheduleId: '404', accessMode: 'DYNAMIC_IFRAME', attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED', lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询', version: 3 }, expired: true, maxAttempts: 3 })
|
||||
})
|
||||
|
||||
it('normalizes attempts and immutable history without creating query operations', async () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue'
|
||||
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
|
||||
vi.mock('../api/platformCarrierScheduleQueries', () => ({
|
||||
listCarrierScheduleQueries: vi.fn(),
|
||||
getCarrierScheduleQuery: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() }))
|
||||
|
||||
const queryView = {
|
||||
query: {
|
||||
id: '11', queryNo: 'CSQ11', customerId: '1', oceanBookingId: '2', containerBookingId: '3',
|
||||
carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567',
|
||||
officialUrl: 'https://elines.coscoshipping.com/ebusiness/', accessMode: 'DYNAMIC_IFRAME',
|
||||
status: 'MANUAL_REQUIRED' as const, attemptCount: 2, lastAttemptStatus: 'MANUAL_REQUIRED',
|
||||
lastErrorCode: 'DYNAMIC_IFRAME_REQUIRED', lastErrorMessage: '必须人工查询',
|
||||
resultPortScheduleId: '21', version: 1,
|
||||
},
|
||||
expired: true,
|
||||
maxAttempts: 3,
|
||||
nextAllowedAt: '2026-08-22T10:00:00',
|
||||
}
|
||||
|
||||
describe('CarrierScheduleQueryPanel loading states', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps list facts visible when history and linked schedule both fail', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [queryView], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getCarrierScheduleQuery).mockRejectedValue(new Error('detail unavailable'))
|
||||
vi.mocked(getPortSchedule).mockRejectedValue(new Error('schedule unavailable'))
|
||||
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(listCarrierScheduleQueries).toHaveBeenCalledWith({ page: 1, size: 200, oceanBookingId: '2', containerBookingId: '3' })
|
||||
expect(getCarrierScheduleQuery).toHaveBeenCalledWith('11')
|
||||
expect(getPortSchedule).toHaveBeenCalledWith('21')
|
||||
expect(wrapper.text()).toContain('CSQ11')
|
||||
expect(wrapper.text()).toContain('查询尝试与变更历史暂不可用')
|
||||
expect(wrapper.text()).toContain('关联船期结果暂不可用')
|
||||
expect(wrapper.text()).toContain('DYNAMIC_IFRAME_REQUIRED')
|
||||
})
|
||||
|
||||
it('shows an explicit non-fabricated empty state and performs no detail reads', async () => {
|
||||
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('不会模拟节点或自动创建任务')
|
||||
expect(getCarrierScheduleQuery).not.toHaveBeenCalled()
|
||||
expect(getPortSchedule).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -15,15 +15,25 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
|
||||
it('shows source, observation/update times, history, failures and expiry truthfully', () => {
|
||||
expect(source).toContain('最新来源')
|
||||
expect(source).toContain('访问方式')
|
||||
expect(source).toContain('查询次数')
|
||||
expect(source).toContain('观测时间')
|
||||
expect(source).toContain('来源更新时间')
|
||||
expect(source).toContain('任务更新时间')
|
||||
expect(source).toContain('查询尝试')
|
||||
expect(source).toContain('变更历史')
|
||||
expect(source).toContain('query.lastErrorMessage')
|
||||
expect(source).toContain('query.lastErrorCode')
|
||||
expect(source).toContain("return '结果已过期'")
|
||||
})
|
||||
|
||||
it('keeps query-history and linked-schedule partial failures independently visible', () => {
|
||||
expect(source).toContain('queryDetailError')
|
||||
expect(source).toContain('scheduleError')
|
||||
expect(source).toContain('查询尝试与变更历史暂不可用')
|
||||
expect(source).toContain('关联船期结果暂不可用')
|
||||
})
|
||||
|
||||
it('keeps the missing node-event contract explicit instead of fabricating data', () => {
|
||||
expect(source).toContain('待接口提供独立节点事实')
|
||||
expect(source).toContain('不会模拟节点或自动创建任务')
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { CarrierScheduleQueryDetail, CarrierScheduleQueryView, PortSchedule
|
||||
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null }>()
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const queryDetailError = ref<ApiErrorDetails | null>(null)
|
||||
const scheduleError = ref<ApiErrorDetails | null>(null)
|
||||
const selected = ref<CarrierScheduleQueryView | null>(null)
|
||||
const detail = ref<CarrierScheduleQueryDetail | null>(null)
|
||||
const latestSchedule = ref<PortScheduleView | null>(null)
|
||||
@@ -30,12 +31,13 @@ const histories = computed(() => [...(detail.value?.histories || [])].reverse())
|
||||
const attempts = computed(() => [...(detail.value?.attempts || [])].reverse())
|
||||
|
||||
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record<string, string>)[value] || value }
|
||||
function attemptText(status: string) { return ({ SUCCESS: '成功', FAILED: '失败', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[status] || status }
|
||||
function historyText(action: string) { return ({ CREATE: '建立查询任务', ATTEMPT: '执行查询', MANUAL_RESULT: '关联人工船期结果' } as Record<string, string>)[action] || action }
|
||||
function latestTimestamp(item: CarrierScheduleQueryView) { return Date.parse(item.query.updatedAt || item.query.createdAt || '') || 0 }
|
||||
|
||||
async function load() {
|
||||
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; detailError.value = null
|
||||
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
|
||||
if (!props.oceanBookingId && !props.containerBookingId) return
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -43,15 +45,14 @@ async function load() {
|
||||
const matching = props.portScheduleId ? page.records.filter((item) => item.query.resultPortScheduleId === String(props.portScheduleId)) : page.records
|
||||
selected.value = [...matching].sort((left, right) => latestTimestamp(right) - latestTimestamp(left))[0] || null
|
||||
if (!selected.value) return
|
||||
const tasks: [Promise<CarrierScheduleQueryDetail>, Promise<{ portSchedule: PortScheduleView } | null>] = [
|
||||
getCarrierScheduleQuery(selected.value.query.id),
|
||||
selected.value.query.resultPortScheduleId ? getPortSchedule(selected.value.query.resultPortScheduleId) : Promise.resolve(null),
|
||||
]
|
||||
const [queryResult, scheduleResult] = await Promise.allSettled(tasks)
|
||||
const [queryResult] = await Promise.allSettled([getCarrierScheduleQuery(selected.value.query.id)])
|
||||
if (queryResult.status === 'fulfilled') detail.value = queryResult.value
|
||||
else detailError.value = getApiErrorDetails(queryResult.reason, '查询历史加载失败')
|
||||
if (scheduleResult.status === 'fulfilled') latestSchedule.value = scheduleResult.value?.portSchedule || null
|
||||
else detailError.value = getApiErrorDetails(scheduleResult.reason, '最新船期结果加载失败')
|
||||
else queryDetailError.value = getApiErrorDetails(queryResult.reason, '查询历史加载失败')
|
||||
const resultPortScheduleId = detail.value?.query.query.resultPortScheduleId || selected.value.query.resultPortScheduleId
|
||||
if (resultPortScheduleId) {
|
||||
try { latestSchedule.value = (await getPortSchedule(resultPortScheduleId)).portSchedule }
|
||||
catch (cause) { scheduleError.value = getApiErrorDetails(cause, '最新船期结果加载失败') }
|
||||
}
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '船节点查询状态加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -67,14 +68,17 @@ watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleI
|
||||
<el-empty v-else-if="!query" :image-size="52" description="尚未建立关联的船节点查询任务;当前页面不会模拟节点或自动创建任务。" />
|
||||
<template v-else>
|
||||
<el-alert v-if="query.lastErrorMessage || query.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="query.lastErrorMessage || query.manualReason || '自动查询暂不可用,需要人工补充可靠结果。'" />
|
||||
<el-alert v-if="detailError" type="warning" :closable="false" show-icon title="部分查询详情暂不可用"><template #default><ApiErrorMeta :details="detailError" /></template></el-alert>
|
||||
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="查询尝试与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
|
||||
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>
|
||||
<dl class="query-facts">
|
||||
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ query.trackingNo || '—' }}</dd></div>
|
||||
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div>
|
||||
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div>
|
||||
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>访问方式</dt><dd>{{ query.accessMode || '—' }}</dd></div>
|
||||
<div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>查询次数</dt><dd>{{ query.attemptCount }} / {{ queryView?.maxAttempts ?? '—' }}</dd></div>
|
||||
<div><dt>最新节点</dt><dd>{{ latestSchedule ? '船期快照已更新' : '待接口提供独立节点事实' }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
|
||||
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>任务更新时间</dt><dd>{{ formatTime(query.updatedAt) }}</dd></div>
|
||||
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }}</dd></div>
|
||||
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }} · {{ query.lastAttemptStatus ? attemptText(query.lastAttemptStatus) : '—' }}</dd></div>
|
||||
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>下次允许查询</dt><dd>{{ formatTime(queryView?.nextAllowedAt) }}</dd></div>
|
||||
<div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div>
|
||||
</dl>
|
||||
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
|
||||
<div class="history-grid">
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
|
||||
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
|
||||
expect(source).toContain('class="relation-note"')
|
||||
expect(source).toContain('<slot name="relation-note-actions" />')
|
||||
expect(source).toContain('justify-content:space-between')
|
||||
expect(source).not.toContain('class="relation-alert">历史关联快照')
|
||||
expect(source).toContain('<el-divider content-position="left">可选关联</el-divider>')
|
||||
expect(source).toContain('label="关联柜次(可选)"')
|
||||
|
||||
@@ -121,7 +121,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<p class="relation-note">订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</p>
|
||||
<div class="relation-note"><span>订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-row :gutter="16">
|
||||
@@ -168,7 +168,10 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: 4px; }
|
||||
.relation-note { margin:0 0 8px; padding:8px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
|
||||
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:16px; margin:0 0 8px; padding:6px 8px 6px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
|
||||
.relation-note > span { min-width:0; }
|
||||
.relation-note__actions { display:flex; flex:0 0 auto; align-items:center; }
|
||||
.relation-note__actions :deep(.el-button) { margin-left:0; }
|
||||
.relation-alert { margin-bottom: 16px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
:deep(.el-divider) { margin: 14px 0 10px; }
|
||||
@@ -177,6 +180,8 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
|
||||
@media (max-width: 480px) {
|
||||
.truck-form { padding-inline: 2px; }
|
||||
.relation-note { align-items:flex-start; flex-direction:column; }
|
||||
.relation-note__actions { width:100%; }
|
||||
.form-actions { position: sticky; bottom: 0; z-index: 2; margin: 8px -2px -4px; padding: 10px 2px; background: var(--el-bg-color); border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.form-actions :deep(.el-button) { flex: 1; min-height: 40px; }
|
||||
}
|
||||
|
||||
@@ -17,4 +17,11 @@ describe('TruckAppointmentCreateView independent creation contract', () => {
|
||||
expect(source).toContain('<ApiErrorMeta :details="error" />')
|
||||
expect(source).toContain('<TruckAppointmentForm v-model="form"')
|
||||
})
|
||||
|
||||
it('places the return action beside the relation note instead of in a separate header row', () => {
|
||||
expect(source).toContain('<template #relation-note-actions>')
|
||||
expect(source).toContain('<ArrowLeft :size="16" />返回列表')
|
||||
expect(source).not.toContain('<template #header>')
|
||||
expect(source).not.toContain('class="editor-actions"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,11 +19,10 @@ async function submit() { saving.value = true; error.value = null; try { await c
|
||||
<template>
|
||||
<AdminPage as="div" class="truck-editor-page">
|
||||
<DetailPageLayout aria-label="新增拖车预约">
|
||||
<template #header><div class="editor-actions"><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></div></template>
|
||||
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>.truck-editor-page { min-width: 0; }.editor-actions { display:flex; justify-content:flex-end; }.form-panel { padding:24px; } @media (max-width:720px) { .editor-actions { justify-content:flex-start; }.form-panel { padding:16px; } }</style>
|
||||
<style scoped>.truck-editor-page { min-width: 0; }.form-panel { padding:24px; } @media (max-width:720px) { .form-panel { padding:16px; } }</style>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# 海外合作仓类型契约缺口
|
||||
|
||||
四期原型把海外合作仓作为现有仓库主数据中的一种仓库类型,而不是独立库存领域。
|
||||
|
||||
- 建议类型码:`OVERSEAS_PARTNER`。
|
||||
- 当前状态:仅为前端与后端对齐用的建议值,尚未生效。
|
||||
- 当前 Warehouse Service 的 DTO、校验与 OpenAPI 只接受 `DOMESTIC`、`FBA`、`OVERSEAS`。
|
||||
- 在服务端正式接受并返回合作仓类型前,Admin 不得把合作仓按 `OVERSEAS` 创建或更新,也不新增一套重复的合作仓列表。
|
||||
- 合作仓列表和详情继续复用 `platform:warehouse:list`;未来创建、编辑、启停分别复用现有 `platform:warehouse:create`、`platform:warehouse:edit`、`platform:warehouse:status`。
|
||||
- 服务端契约补齐时需同步核对仓库 DTO、Controller、OpenAPI、初始化 SQL、导入模板及全部消费者。
|
||||
|
||||
@@ -110,6 +110,34 @@ describe('platform warehouse API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the confirmed overseas partner warehouse type through list and save contracts', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200',
|
||||
data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: '19020' })
|
||||
const partnerPayload: SavePlatformWarehouseRequest = {
|
||||
...payload,
|
||||
warehouseCode: 'PARTNER-US-01',
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-8',
|
||||
address: { ...payload.address, countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles' },
|
||||
}
|
||||
|
||||
await listPlatformWarehouses({ page: 1, size: 20, warehouseType: 'OVERSEAS_PARTNER' })
|
||||
await expect(createPlatformWarehouse(partnerPayload)).resolves.toBe('19020')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||
params: { page: 1, size: 20, warehouseType: 'OVERSEAS_PARTNER' },
|
||||
})
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
|
||||
...partnerPayload,
|
||||
tenantId: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads every warehouse page for complete resource selectors', async () => {
|
||||
const firstRecord = { id: '1', warehouseCode: 'ABE2', warehouseType: 'FBA' }
|
||||
const domesticRecord = { id: '201', warehouseCode: 'CN-YIWU-01', warehouseType: 'DOMESTIC' }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type PlatformWarehouseStatus = 'ACTIVE' | 'INACTIVE'
|
||||
|
||||
export type WarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
|
||||
export type WarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS' | 'OVERSEAS_PARTNER'
|
||||
|
||||
export interface PlatformWarehouseResource {
|
||||
id: number | string
|
||||
|
||||
@@ -126,6 +126,20 @@ const fbaDetail: PlatformWarehouseDetailResource = {
|
||||
capabilities: [],
|
||||
}
|
||||
|
||||
const partnerWarehouse: PlatformWarehouseSummaryResource = {
|
||||
...fbaWarehouse,
|
||||
id: '19021',
|
||||
warehouseCode: 'PARTNER-US-01',
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
}
|
||||
|
||||
const partnerDetail: PlatformWarehouseDetailResource = {
|
||||
warehouse: partnerWarehouse,
|
||||
addresses: [{ ...fbaAddress, id: '19022', warehouseId: '19021', stateProvince: 'CA', city: 'Los Angeles' }],
|
||||
capabilities: [],
|
||||
}
|
||||
|
||||
const detail: PlatformWarehouseDetailResource = {
|
||||
warehouse: domesticWarehouse,
|
||||
addresses: [domesticAddress],
|
||||
@@ -413,6 +427,69 @@ describe('WarehousesView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates and filters the confirmed overseas partner warehouse type through the existing warehouse page', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||
keyword: '',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
keyword: undefined,
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
|
||||
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'OVERSEAS_PARTNER')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.warehouse-editor-dialog')?.textContent).toContain('新增海外仓合作仓')
|
||||
|
||||
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择州')
|
||||
setField('input[placeholder="请输入仓库名称"]', '洛杉矶合作仓')
|
||||
stateSelect!.vm.$emit('update:modelValue', 'CA')
|
||||
stateSelect!.vm.$emit('change', 'CA')
|
||||
setField('input[placeholder="例如 Long Beach"]', 'Los Angeles')
|
||||
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '100 Partner Ave')
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformWarehouse).toHaveBeenCalledWith(expect.objectContaining({
|
||||
warehouseCode: expect.stringMatching(/^PARTNER-WH-/),
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-8',
|
||||
address: expect.objectContaining({ stateProvince: 'CA', city: 'Los Angeles' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('edits an existing overseas partner warehouse without converting it to an ordinary overseas warehouse', async () => {
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValueOnce({ records: [partnerWarehouse], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce(partnerDetail)
|
||||
const wrapper = await mountView()
|
||||
|
||||
await wrapper.find('.el-table__body-wrapper .table-actions button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.warehouse-editor-dialog')?.textContent).toContain('编辑海外仓合作仓')
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(updatePlatformWarehouse).toHaveBeenCalledWith('19021', expect.objectContaining({
|
||||
warehouseCode: 'PARTNER-US-01',
|
||||
warehouseName: '洛杉矶合作仓',
|
||||
warehouseType: 'OVERSEAS_PARTNER',
|
||||
}))
|
||||
})
|
||||
|
||||
it('edits FBA warehouses with selectors for country, timezone and state', async () => {
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValueOnce({ records: [fbaWarehouse, domesticWarehouse], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce(fbaDetail)
|
||||
|
||||
@@ -34,6 +34,7 @@ const warehouseTypeLabels: Record<WarehouseType, string> = {
|
||||
DOMESTIC: '国内收货仓',
|
||||
FBA: 'FBA 地址',
|
||||
OVERSEAS: '海外仓',
|
||||
OVERSEAS_PARTNER: '海外仓合作仓',
|
||||
}
|
||||
const timezoneOptions = [
|
||||
'UTC-12', 'UTC-11', 'UTC-10', 'UTC-9', 'UTC-8', 'UTC-7', 'UTC-6', 'UTC-5', 'UTC-4', 'UTC-3',
|
||||
@@ -145,6 +146,7 @@ const queryFields: QueryField[] = [
|
||||
{ label: warehouseTypeLabels.DOMESTIC, value: 'DOMESTIC' },
|
||||
{ label: warehouseTypeLabels.FBA, value: 'FBA' },
|
||||
{ label: warehouseTypeLabels.OVERSEAS, value: 'OVERSEAS' },
|
||||
{ label: warehouseTypeLabels.OVERSEAS_PARTNER, value: 'OVERSEAS_PARTNER' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -188,7 +190,7 @@ type WarehouseForm = Omit<Warehouse, 'updatedAt'> & {
|
||||
}
|
||||
|
||||
function generatedCode(type: WarehouseType) {
|
||||
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : 'US'
|
||||
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : type === 'OVERSEAS_PARTNER' ? 'PARTNER' : 'US'
|
||||
return `${prefix}-WH-${Date.now().toString(36).slice(-7).toUpperCase()}`
|
||||
}
|
||||
|
||||
@@ -708,7 +710,7 @@ onMounted(() => void loadWarehouses())
|
||||
<el-button :disabled="!warehouseReady" @click="openImport"><FileUp :size="16" />导入仓库</el-button>
|
||||
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
|
||||
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item><el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item><el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item></el-dropdown-menu></template>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item><el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item><el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item><el-dropdown-item command="OVERSEAS_PARTNER"><MapPin :size="15" />新增海外仓合作仓</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Admin Shared API Instructions
|
||||
|
||||
## 适用范围与入口
|
||||
|
||||
- 本文件适用于 `src/shared/api/` 及其测试。业务模块只通过领域 `api/` 适配层调用这里的客户端,不在页面和组件直接创建 Axios 实例。
|
||||
- 国内 API 客户端为 `http.ts`;海外 API 客户端为 `overseasHttp.ts`。认证会话编排在 `../../core/auth/auth.ts`,不得在本目录复制登录状态。
|
||||
|
||||
## 国内与海外请求
|
||||
|
||||
- `http` 使用 `VITE_API_BASE_URL`,默认走国内代理;国内登录、刷新和退出仍由统一 Auth 处理。
|
||||
- `overseasHttp` 使用 `VITE_OVERSEAS_API_BASE_URL`,默认 `/overseas-api`;Vite 将该前缀改写为海外 Gateway 的 `/api`。代理上游由开发环境配置,不在源码或指引中记录真实地址。
|
||||
- 登录和刷新响应一次性返回国内 Token、`overseasAccessToken`、`overseasExpiresIn`;`core/auth/auth.ts` 必须原子替换两套访问凭据。不得恢复 Token 兑换端点或让业务模块单独刷新海外 Token。
|
||||
- 海外 Token 只由 `replaceOverseasAccessToken`、`clearOverseasAccessToken` 管理;业务模块不得读写缓存、LocalStorage 或手工拼装 `Authorization`。
|
||||
- 国内、海外 401 共用统一会话刷新编排。海外请求最多统一刷新一次并重放一次;刷新失败按会话失效处理,不循环重试或回退假数据。
|
||||
|
||||
## 领域调用边界
|
||||
|
||||
- 国内业务领域 API 使用 `http`;四期海外尾程、海外仓资源和海外本土业务使用 `overseasHttp`,不得经国内 Gateway 中转。
|
||||
- 页面只调用所属模块的 API 封装;模块 API 负责路径、DTO 和错误适配,不把 Axios 响应或区域选择泄漏到页面。
|
||||
- 跨区域 Token、Audience、JWKS 和服务通信公共契约读取 `../../../../../docs/跨区域认证与服务通信契约.md`。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 修改客户端、拦截器、刷新、Token 生命周期或 Base URL 时,联查 `http.test.ts`、`overseasHttp.test.ts`、`core/auth/auth.test.ts`、Vite 代理、`.env.example` 和全部实际消费者。
|
||||
- 至少运行相关 API/Auth 测试和 `pnpm typecheck`;交付前按项目指引运行完整测试与构建。
|
||||
+18
-13
@@ -61,6 +61,23 @@ export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHan
|
||||
authSessionExpiredHandler = handler
|
||||
}
|
||||
|
||||
export async function refreshAuthenticatedSession(): Promise<string> {
|
||||
if (!authRefreshHandler) throw new Error('认证刷新处理器尚未初始化')
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler()
|
||||
.catch(async (refreshError) => {
|
||||
try {
|
||||
await authSessionExpiredHandler?.()
|
||||
} catch {
|
||||
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
||||
}
|
||||
throw refreshError
|
||||
})
|
||||
.finally(() => { activeRefresh = null })
|
||||
}
|
||||
return activeRefresh
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('cc-admin-token')
|
||||
const isAnonymousAuthEndpoint = typeof config.url === 'string'
|
||||
@@ -82,19 +99,7 @@ http.interceptors.response.use(
|
||||
|
||||
config._authRetry = true
|
||||
try {
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler()
|
||||
.catch(async (refreshError) => {
|
||||
try {
|
||||
await authSessionExpiredHandler?.()
|
||||
} catch {
|
||||
// 会话已清理,跳转失败不应覆盖原始认证错误。
|
||||
}
|
||||
throw refreshError
|
||||
})
|
||||
.finally(() => { activeRefresh = null })
|
||||
}
|
||||
const nextToken = await activeRefresh
|
||||
const nextToken = await refreshAuthenticatedSession()
|
||||
config.headers.Authorization = `Bearer ${nextToken}`
|
||||
return http.request(config)
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { registerAuthRefreshHandler } from './http'
|
||||
import { clearOverseasAccessToken, overseasHttp, replaceOverseasAccessToken } from './overseasHttp'
|
||||
|
||||
const originalOverseasAdapter = overseasHttp.defaults.adapter
|
||||
|
||||
function success(config: InternalAxiosRequestConfig, data: unknown) {
|
||||
return Promise.resolve({ status: 200, statusText: 'OK', headers: {}, config, data })
|
||||
}
|
||||
|
||||
describe('overseas HTTP client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('cc-admin-token', 'domestic-token')
|
||||
clearOverseasAccessToken()
|
||||
registerAuthRefreshHandler(async () => 'domestic-token')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
overseasHttp.defaults.adapter = originalOverseasAdapter
|
||||
localStorage.clear()
|
||||
clearOverseasAccessToken()
|
||||
})
|
||||
|
||||
it('uses the overseas token supplied by the login session for concurrent requests', async () => {
|
||||
replaceOverseasAccessToken('overseas-token', 900)
|
||||
const authorizations: unknown[] = []
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorizations.push(config.headers.Authorization)
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
overseasHttp.get('/platform/v1/last-leg/orders'),
|
||||
overseasHttp.get('/platform/v1/warehouse-admin'),
|
||||
])
|
||||
|
||||
expect(authorizations).toEqual(['Bearer overseas-token', 'Bearer overseas-token'])
|
||||
})
|
||||
|
||||
it('refreshes the unified session once and replays once after an overseas 401', async () => {
|
||||
replaceOverseasAccessToken('overseas-1', 900)
|
||||
const refresh = vi.fn(async () => {
|
||||
replaceOverseasAccessToken('overseas-2', 900)
|
||||
return 'domestic-2'
|
||||
})
|
||||
registerAuthRefreshHandler(refresh)
|
||||
let requests = 0
|
||||
const authorizations: unknown[] = []
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorizations.push(config.headers.Authorization)
|
||||
if (requests++ === 0) {
|
||||
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: {},
|
||||
config,
|
||||
data: { code: 'A0401', message: '海外令牌失效' },
|
||||
})
|
||||
}
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await overseasHttp.get('/platform/v1/last-leg/orders')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(requests).toBe(2)
|
||||
expect(authorizations).toEqual(['Bearer overseas-1', 'Bearer overseas-2'])
|
||||
})
|
||||
|
||||
it('refreshes the unified session when no usable overseas token is present', async () => {
|
||||
const refresh = vi.fn(async () => {
|
||||
replaceOverseasAccessToken('overseas-refreshed', 900)
|
||||
return 'domestic-refreshed'
|
||||
})
|
||||
registerAuthRefreshHandler(refresh)
|
||||
let authorization: unknown
|
||||
overseasHttp.defaults.adapter = async (config) => {
|
||||
authorization = config.headers.Authorization
|
||||
return success(config, { code: '200', message: 'success', data: [] })
|
||||
}
|
||||
|
||||
await overseasHttp.get('/platform/v1/last-leg/orders')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(authorization).toBe('Bearer overseas-refreshed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { refreshAuthenticatedSession } from './http'
|
||||
|
||||
interface CachedOverseasToken {
|
||||
accessToken: string
|
||||
refreshAfter: number
|
||||
}
|
||||
|
||||
type RetryableOverseasRequestConfig = InternalAxiosRequestConfig & { _overseasAuthRetry?: boolean }
|
||||
|
||||
const TOKEN_REFRESH_SAFETY_WINDOW_MS = 30_000
|
||||
|
||||
let cachedToken: CachedOverseasToken | null = null
|
||||
|
||||
export const overseasHttp = axios.create({
|
||||
baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api',
|
||||
timeout: 15_000,
|
||||
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
|
||||
})
|
||||
|
||||
function currentOverseasAccessToken() {
|
||||
return cachedToken && cachedToken.refreshAfter > Date.now() ? cachedToken.accessToken : ''
|
||||
}
|
||||
|
||||
async function requireOverseasAccessToken() {
|
||||
const current = currentOverseasAccessToken()
|
||||
if (current) return current
|
||||
await refreshAuthenticatedSession()
|
||||
const refreshed = currentOverseasAccessToken()
|
||||
if (!refreshed) throw new Error('当前环境未签发海外访问令牌')
|
||||
return refreshed
|
||||
}
|
||||
|
||||
export function replaceOverseasAccessToken(accessToken: string | null, expiresIn: number) {
|
||||
if (!accessToken && expiresIn === 0) {
|
||||
cachedToken = null
|
||||
return
|
||||
}
|
||||
if (!accessToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
||||
throw new Error('海外访问令牌响应无效')
|
||||
}
|
||||
cachedToken = {
|
||||
accessToken,
|
||||
refreshAfter: Date.now() + Math.max(0, expiresIn * 1000 - TOKEN_REFRESH_SAFETY_WINDOW_MS),
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOverseasAccessToken() {
|
||||
cachedToken = null
|
||||
}
|
||||
|
||||
overseasHttp.interceptors.request.use(async (config) => {
|
||||
const accessToken = await requireOverseasAccessToken()
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return config
|
||||
})
|
||||
|
||||
overseasHttp.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const config = error.config as RetryableOverseasRequestConfig | undefined
|
||||
if (error.response?.status !== 401 || !config || config._overseasAuthRetry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
config._overseasAuthRetry = true
|
||||
clearOverseasAccessToken()
|
||||
try {
|
||||
await refreshAuthenticatedSession()
|
||||
const accessToken = await requireOverseasAccessToken()
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return overseasHttp.request(config)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Admin Shared Components Instructions
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 本文件适用于 `src/shared/components/` 及其直接测试。业务页面使用这些组件时,同时遵守 `../../../AGENTS.md` 和 `../../../../../docs/Admin-Client前端原型还原验收规则.md`。
|
||||
- 这里只放与具体业务无关、已被多个模块真实复用的后台组件;纯业务字段、状态和流程留在所属模块。
|
||||
|
||||
## 标准列表组合
|
||||
|
||||
- 标准列表使用 `ListPageLayout` 组织查询区和表格区,保持单一 `16px` 间距;不叠加页面级空白操作区或重复面板间距。
|
||||
- 查询与筛选使用 `QueryToolbar.vue`,通过 `fields`、`v-model`、`search`、`reset` 接入;查询即刷新当前结果,不另设独立刷新按钮。
|
||||
- 数据列表使用 `DataTable.vue`,通过 `columns`、命名插槽、`v-model:page`、`v-model:page-size` 和 `total` 接入,不重复拼装 `el-table` 与分页器。
|
||||
- “操作”列放在第一列,需要固定时固定在左侧。单行操作不超过 3 个时直接显示“图标 + 文案”;第 4 个起使用 `MoreHorizontal` 菜单,并优先直接保留详情、编辑、下载等高频操作。
|
||||
- 新增、导入、导出和批量操作放入 `DataTable` 的 `toolbar-actions`;不放入查询栏,不用 `toolbar-start` 重复列表标题、数量或说明,数量统一放在 `pagination-left`。
|
||||
- 路由标题和说明默认由 `AppLayout` 的 `page-heading` 根据路由元数据展示;标准列表正文不重复标题。只有明确关闭路由页头的详情页或工作台才自带独立页头。
|
||||
|
||||
## 复用入口
|
||||
|
||||
- XLSX 批量导入使用 `BatchImportDialog.vue`;页面只提供模板下载、导入调用和成功后刷新,不复制文件校验、上传状态、错误报告或弹窗 CSS。
|
||||
- 模态框和抽屉优先使用 `AppDialog.vue`、`AppDrawer.vue`;但独立页面、弹框或抽屉的选择必须服从对应原型,不因已有组件方便而替换承载方式。
|
||||
- 加载失败组合 `LoadErrorState.vue` 与 `ApiErrorMeta.vue`,保留重试和 `requestId`/`traceId`;状态与复制优先使用 `StatusBadge.vue`、`CopyButton.vue`。
|
||||
- 参考页面:查询和表格见 `../../modules/master-data/express-companies/pages/ExpressCompaniesView.vue`,批量导入见该页面与 `../../modules/master-data/counterparties/pages/PartnersView.vue`,抽屉见 `../../modules/downloads/pages/DownloadCenterView.vue`。
|
||||
|
||||
## 图标、状态与可访问性
|
||||
|
||||
- 原型已定义图标时复用原型资源;原型未定义时使用 Lucide:查看 `Eye`、编辑 `Pencil`、更多 `MoreHorizontal`、新增 `Plus`、删除 `Trash2`、启停 `Power`、刷新 `RefreshCw`、导入 `FileUp`、导出/下载 `Download`、返回 `ArrowLeft`、保存 `Save`、复制 `Copy`。
|
||||
- 危险操作保留明确文案、危险色、确认过程和成功/失败反馈,不只显示图标。
|
||||
- 组件按适用语义覆盖加载、空数据、错误、禁用和无权限状态;保留键盘操作、可见焦点、表单标签、图标按钮名称和颜色对比度,不只用颜色表达状态。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 扩展公共组件前定向检查全部消费者;通用能力扩展公共组件,纯业务特例留在模块,不用页面级 CSS 覆盖破坏公共契约。
|
||||
- 至少运行组件测试、直接受影响页面测试和 `pnpm typecheck`;交付前按项目指引运行完整测试与构建。
|
||||
@@ -8,6 +8,7 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const proxyTarget = env.API_PROXY_TARGET || 'http://localhost:8080'
|
||||
const overseasProxyTarget = env.OVERSEAS_API_PROXY_TARGET || 'http://localhost:9300'
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
@@ -38,6 +39,16 @@ export default defineConfig(({ mode }) => {
|
||||
})
|
||||
},
|
||||
},
|
||||
'/overseas-api': {
|
||||
target: overseasProxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/overseas-api/, '/api'),
|
||||
configure(proxy) {
|
||||
proxy.on('proxyReq', (proxyRequest) => {
|
||||
proxyRequest.removeHeader('origin')
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user