Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a781db819a | ||
|
|
291535e242 | ||
|
|
1e80470078 | ||
|
|
6f7f8e7632 | ||
|
|
03b1e7163e | ||
|
|
5679b156ca | ||
|
|
3879a47673 | ||
|
|
b34626bd4b | ||
|
|
7005228b52 | ||
|
|
836e210b09 | ||
|
|
41a498657c | ||
|
|
f4224e5d3a | ||
|
|
3267679758 |
@@ -38,44 +38,46 @@
|
||||
|
||||
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
|
||||
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。
|
||||
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
|
||||
|
||||
| 业务 | 首个入口 |
|
||||
|---|---|
|
||||
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` |
|
||||
| 登录、会话与统一 Token | `modules/auth/AGENTS.md`、`core/auth/` |
|
||||
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
|
||||
| 工作台与指标 | `modules/dashboard/` |
|
||||
| 工作台与指标 | `modules/dashboard/AGENTS.md` |
|
||||
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
|
||||
| 船期日历 | `modules/sailing-calendar/` |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`、`modules/overseas-warehouse/` |
|
||||
| 船期日历 | `modules/sailing-calendar/AGENTS.md` |
|
||||
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/AGENTS.md`、`modules/overseas-warehouse/` |
|
||||
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts`、`createManualInboundQuote` |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts` |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/` |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/AGENTS.md` |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/AGENTS.md` |
|
||||
| 供应商、国内整柜 | `modules/suppliers/AGENTS.md`、`modules/domestic-containers/AGENTS.md` |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/AGENTS.md`、`modules/warehouse-resources/AGENTS.md` |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/AGENTS.md`、`core/access/` |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
|
||||
| 消息中心 | `modules/notifications/` |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` |
|
||||
| 消息中心 | `modules/notifications/AGENTS.md` |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/AGENTS.md`、`modules/first-leg-orders/` |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`modules/unified-orders/api/unifiedOrders.ts` |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`modules/inbounds/measurement/InboundMeasurementPanel.vue` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/AGENTS.md`、`modules/dimension-config/AGENTS.md` |
|
||||
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/DropshipPalletPrintPanel.vue` |
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/AGENTS.md`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/AGENTS.md` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | `modules/container-loading/pages/DomesticContainersView.vue`、`modules/container-loading/pages/OverseasContainersView.vue` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、装柜照片与证据、真实柜号后补与整柜报关 | `modules/container-loading/AGENTS.md`、`modules/loading-lists/AGENTS.md` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/pages/UnifiedOrdersView.vue` |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue`、`modules/container-loading/pages/GlobalSmartLoadingView.vue` |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue` |
|
||||
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts`、`/candidates` |
|
||||
| 目的港码头拖车与还空预警 | `modules/container-loading/pages/OverseasContainersView.vue`、`modules/container-loading/pages/OverseasContainerDetailView.vue` |
|
||||
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` |
|
||||
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/` |
|
||||
| 海外仓业务合作仓与通用资料 | `modules/warehouses/`、`modules/overseas-master-data/` |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/` |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/AGENTS.md` |
|
||||
| 订舱级拖车预约 | `modules/trucking/AGENTS.md` |
|
||||
| 目的港码头拖车与还空预警 | `modules/container-loading/AGENTS.md` |
|
||||
| 历史报关案件、报关回执与国内出仓 | `modules/customs/AGENTS.md`、`modules/outbound-dispatch/AGENTS.md` |
|
||||
| 主数据、下载、退避任务重试 | `modules/master-data/AGENTS.md`、`modules/downloads/AGENTS.md` |
|
||||
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
|
||||
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
|
||||
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外采购、卡车、散板 | `modules/overseas-business/AGENTS.md` |
|
||||
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
|
||||
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
|
||||
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
@@ -53,6 +53,11 @@
|
||||
|
||||
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||
|
||||
|
||||
## 货柜工作台规则
|
||||
|
||||
- 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/`
|
||||
|
||||
## 海外模块内部快速索引(海外仓全流程)
|
||||
|
||||
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||
|
||||
+3
-2
@@ -5,8 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
|
||||
"preview": "vite preview --port 4173"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Admin 预约日历模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/appointment-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,预约规则见 `../../../docs/business-index.md`;工作日历正式规则在**工作区** `docs/海外仓业务/预约工作日历V13变更说明.md`。
|
||||
- 本模块扁平存放页面、面板与 API,无 `pages/`、`components/` 分层:`*.vue` 多为面板,`*Api.ts` 为对应接口,`calendar.ts`/`schedule.ts`/`tripCalendar.ts` 为日历与航段计算,`useWorkDraft.ts`/`useWorkSyncRead.ts` 为草稿与只读同步。
|
||||
- 后端只查 `lastleg-service/`(`inboundplan/`、`appointmentplanning/`);到港事实只读 `firstleg/` 的船节点,纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 预约列表与日历主页面 | `AppointmentCalendarView.vue`;`appointment-calendar` | `api.ts`、`calendar.ts`;`lastleg-service/inboundplan/` |
|
||||
| 工作日历、车次与送仓待办 | `WorkCalendarPanel.vue`、`TripAppointmentPanel.vue` | `workApi.ts`、`tripCalendar.ts`;`lastleg-service/appointmentplanning/` |
|
||||
| 新建/批量预约、多客户共车 | `AppointmentCreatePanel.vue`、`AppointmentBatchForm.vue`、`WorkSharedTripForm.vue` | `workApi.ts`、`batchApi.ts` |
|
||||
| 货物验证与入仓码关系 | `AppointmentCargoPanel.vue`、`AppointmentCargoTabs.vue`、`AppointmentInboundCodes.vue`、`CargoVerificationForm.vue` | `cargoVerificationApi.ts` |
|
||||
| 改期、取消与取消记录 | `AppointmentImpactDialog.vue`、`AppointmentCancellationDialog.vue`、`AppointmentCancellationHistory.vue`、`AppointmentCancellationRecords.vue` | `impactApi.ts` |
|
||||
| 派送安排与分批后送 | `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue` | `workApi.ts`;`lastleg-service/delivery/` |
|
||||
| 收货范围与收货计划 | `ReceivingScope.vue`、`WorkReceivingPanel.vue`、`WorkReceiptPlans.vue` | `receivingApi.ts` |
|
||||
| 外部候选取货与跟进 | `AppointmentExternalCandidates.vue`、`AppointmentFollowupDialog.vue` | `planningApi.ts`、`workApi.ts` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 同仓跨客户共用一张预约,客户归属在货物明细,不为每个客户拆预约;同车多客户按 V13 规则处理。
|
||||
- 到港事实只读 Firstleg 船节点,送仓与排车归 Lastleg;不得用预计到港替代计划到仓,也不得反向写船节点。
|
||||
- 改期、取消、重新预约必须按服务端影响范围与版本提交,取消记录只追加,不改写历史。
|
||||
- 未定日期事项进入待办跟进,不伪造日期;分批后送的批次关系以服务端返回为准。
|
||||
- 面板之间共享草稿状态,`useWorkDraft.ts` 是唯一草稿入口;刷新时用 `useWorkSyncRead.ts` 合并服务端事实并保留本机未提交输入。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/appointment-calendar/<目标测试>`;日历与批次规则变更同时核对 V13 规则文档与 `lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,135 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||
import { batchContext } from './batch.test-support'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
import { getWorkDetail, type WorkAction } from './workApi'
|
||||
import { workDetail, workItem } from './work.test-support'
|
||||
|
||||
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
|
||||
const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' }
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
|
||||
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
|
||||
})
|
||||
async function open() {
|
||||
const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: {
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '<section data-calendar />' },
|
||||
} } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
async function create(wrapper: Awaited<ReturnType<typeof open>>) {
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')!
|
||||
const content = creationBody()
|
||||
await content.get('input[type="checkbox"]').setValue(true)
|
||||
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
|
||||
return dialog
|
||||
}
|
||||
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
|
||||
it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => {
|
||||
const wrapper = await open()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||
expect(dialog.props('width')).toBe('520px')
|
||||
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
|
||||
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
|
||||
await dialog.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect((dialog.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
|
||||
})
|
||||
it('locks the real arrival input after an unknown outcome and reuses its immutable request', async () => {
|
||||
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||
await dialog.get('input').setValue('2026-10-22T09:30')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(dialog.get('input').attributes('disabled')).toBeDefined()
|
||||
expect(dialog.text()).toContain('上次提交结果尚未确认')
|
||||
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('重试确认保存结果')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
|
||||
})
|
||||
it('keeps complete long cargo names accessible in the selectable table', async () => {
|
||||
const wrapper = await open()
|
||||
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
|
||||
expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName)
|
||||
expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('')
|
||||
expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/)
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preserves real FBA fields and entered values when expanding the create dialog', async () => {
|
||||
const wrapper = await open(); const dialog = await create(wrapper)
|
||||
const fields = dialog.getComponent({ name: 'FbaAppointmentFields' })
|
||||
expect(fields.props('continuous')).toBe(true)
|
||||
const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]!
|
||||
date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises()
|
||||
await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(date.props('modelValue')).toBe('2026-10-25')
|
||||
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'PENDING' }), idempotencyKey: expect.any(String) }))
|
||||
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(creationBody().text()).toContain('AP-001')
|
||||
})
|
||||
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
|
||||
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
|
||||
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
|
||||
const wrapper = await open(); await create(wrapper)
|
||||
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
const receipt = creationBody().get('[aria-label="预约保存回执"]')
|
||||
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
|
||||
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
|
||||
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
|
||||
})
|
||||
it('does not open or request creation data without its original write permission', async () => {
|
||||
routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment')
|
||||
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises()
|
||||
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false)
|
||||
})
|
||||
it('uses the same real continuous form through the original work detail deep link', async () => {
|
||||
routing.query.workItem = 'work-delivery-1'
|
||||
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } }
|
||||
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }))
|
||||
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
|
||||
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
|
||||
await content.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
|
||||
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'PENDING' }) }))
|
||||
expect(content.text()).toContain('本次保存已完成')
|
||||
})
|
||||
@@ -21,6 +21,9 @@ export function renderSchedule() {
|
||||
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
|
||||
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
|
||||
ElInput: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
|
||||
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
|
||||
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
|
||||
} } })
|
||||
|
||||
@@ -175,15 +175,21 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<template v-if="editor">
|
||||
<p>{{ editor.title }}</p>
|
||||
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
|
||||
<div v-if="editor" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||||
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||||
</section>
|
||||
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
|
||||
<label class="arrival-field">计划到仓时间<input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required></label>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
|
||||
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
|
||||
@@ -194,7 +200,5 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||
</template>
|
||||
<style scoped>
|
||||
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
.arrival-field { display: flex; flex-direction: column; gap: var(--admin-space-2); margin-block: var(--admin-space-5); }
|
||||
.arrival-field input { box-sizing: border-box; min-height: var(--admin-control-height); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); padding: var(--admin-space-2) var(--admin-space-3); font: inherit; }
|
||||
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
|
||||
</style>
|
||||
|
||||
@@ -43,7 +43,7 @@ async function save() {
|
||||
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
|
||||
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
|
||||
<el-form label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit placeholder="请填写实际取消原因" /></el-form-item>
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -87,7 +87,6 @@ onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
||||
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
|
||||
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
|
||||
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
|
||||
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></details>
|
||||
</template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
|
||||
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
|
||||
|
||||
@@ -89,7 +89,9 @@ it('submits the real form, shows the saved appointment, and prevents another sub
|
||||
expect(wrapper.emitted('created')).toEqual([[batchContext().appointment]])
|
||||
expect(wrapper.get('[role="status"]').text()).toContain(batchContext().appointment.appointmentNo)
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '保存预约安排')).toBe(false)
|
||||
expect(wrapper.get('[data-date]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-date]').exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="预约保存回执"]').text()).toContain('等待对方确认')
|
||||
expect(wrapper.get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the original request and blocks returning or closing after an unknown creation result', async () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { FbaAppointmentFields, createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
|
||||
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
|
||||
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
|
||||
import type { SourceReference } from './workApi'
|
||||
@@ -34,9 +36,9 @@ let generation = 0, selectionGeneration = 0, controller: AbortController | null
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 140 },
|
||||
{ prop: 'customerName', label: '客户', minWidth: 140, slotName: 'customer' },
|
||||
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140 },
|
||||
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
|
||||
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||
{ prop: 'packages', label: '箱数', width: 80 },
|
||||
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
|
||||
@@ -119,27 +121,40 @@ onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="新增FBA送仓预约">
|
||||
<section class="density-dialog-sections" aria-label="新增FBA送仓预约">
|
||||
<template v-if="created">
|
||||
<p role="status">预约已保存:<strong>{{ created.appointmentNo || '预约编号未返回' }}</strong></p>
|
||||
<p>预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
|
||||
<el-button v-if="canWrite" @click="again">继续新增预约</el-button>
|
||||
<FbaAppointmentFields :model="createdForm" :appointment-no="created.appointmentNo" :timezone="created.timezone" :disabled="true" />
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="预约保存回执">
|
||||
<h3 class="density-dialog-section__title" role="status">预约已保存:{{ created.appointmentNo || '预约编号未返回' }}</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">系统单号</th><td>{{ created.appointmentNo || '—' }}</td><th scope="row">目的仓时区</th><td>{{ created.timezone || '待核实' }}</td></tr>
|
||||
<tr><th scope="row">计划送仓日期</th><td>{{ createdForm.plannedDate || '—' }}</td><th scope="row">本次登记</th><td>{{ ({ PENDING: '等待对方确认', SUCCESS: '已取得对方正式确认', FAILED: '未约成', CANCELLED: '已取消' })[createdForm.appointmentStatus] }}</td></tr>
|
||||
<tr><th scope="row">计划开始时段</th><td>{{ createdForm.plannedAt || '—' }}</td><th scope="row">计划结束时段</th><td>{{ createdForm.plannedEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">外部约号</th><td colspan="3">{{ createdForm.confirmationNo || '—' }}</td></tr>
|
||||
<tr><th scope="row">对方确认开始时间</th><td>{{ createdForm.appointmentAt || '—' }}</td><th scope="row">对方确认结束时间</th><td>{{ createdForm.appointmentEndAt || '—' }}</td></tr>
|
||||
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
|
||||
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="density-dialog-note">预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
|
||||
</section>
|
||||
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
|
||||
</template>
|
||||
<template v-else-if="references">
|
||||
<el-button @click="back">返回选择货物</el-button>
|
||||
<div class="density-actions"><el-button @click="back">返回选择货物</el-button></div>
|
||||
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p>
|
||||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" empty-text="暂无可预约货物" @update:page="load($event)">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="选择预约货物">
|
||||
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" max-height="" empty-text="暂无可预约货物" @update:page="load($event)">
|
||||
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
|
||||
<template #toolbar-actions><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||||
<template #toolbar-actions><InlineHelp title="选货说明"><p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p></InlineHelp><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
|
||||
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
|
||||
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
|
||||
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
|
||||
<template #customer="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #origin="{ row }"><LineClampText :content="row.originWarehouseName" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -105,10 +105,10 @@ onBeforeUnmount(() => { ++generation })
|
||||
<ul v-if="coverage?.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||||
<el-form v-if="canWrite && canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in allowedStages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
|
||||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
|
||||
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
|
||||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
</el-form>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
|
||||
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
|
||||
|
||||
@@ -58,7 +58,7 @@ onMounted(load)
|
||||
</template>
|
||||
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="实际处理结果"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录已联系、核对或改约的实际结果;此处不会自动改约" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写后续处理事项" /></el-form-item>
|
||||
<el-form-item label="下一步"><el-input v-model="form.nextAction" maxlength="500" /></el-form-item>
|
||||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</el-form>
|
||||
<div v-if="canWrite && detail.impact.status === 'OPEN'">
|
||||
|
||||
@@ -44,18 +44,23 @@ async function save() {
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="新建预约安排">
|
||||
<h3>新建预约安排</h3>
|
||||
<section class="work-form density-dialog-sections" aria-label="新建预约安排">
|
||||
<LoadErrorState v-if="error" title="预约货物预览失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对本次货物范围…</p>
|
||||
<template v-if="preview && !error">
|
||||
<p>{{ preview.originWarehouseName }} → {{ preview.destinationWarehouseCode }} · {{ preview.group.sourceCount }} 票 · {{ preview.group.packages ?? '箱数待核实' }}</p>
|
||||
<p v-for="source in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</p>
|
||||
<p v-for="field in preview.missingFields" :key="field">{{ workReason(field) }}</p>
|
||||
<p v-if="preview.group.readinessIncomplete">货物备妥窗口待核实。</p>
|
||||
<FbaAppointmentFields :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="本次预约货物">
|
||||
<h3 class="density-dialog-section__title">本次预约货物</h3>
|
||||
<table class="density-table"><tbody>
|
||||
<tr><th scope="row">起运仓</th><td>{{ preview.originWarehouseName || '—' }}</td><th scope="row">目的FBA仓</th><td>{{ preview.destinationWarehouseCode || '—' }}</td></tr>
|
||||
<tr><th scope="row">票数</th><td>{{ preview.group.sourceCount }}</td><th scope="row">箱数</th><td>{{ preview.group.packages ?? '箱数待核实' }}</td></tr>
|
||||
<tr><th scope="row">货物业务号</th><td colspan="3"><template v-for="(source, index) in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ index ? '、' : '' }}{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</template></td></tr>
|
||||
</tbody></table>
|
||||
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
|
||||
</section>
|
||||
<FbaAppointmentFields continuous :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
|
||||
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button>
|
||||
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -18,10 +18,10 @@ const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
const batch = 'FBA_APPOINTMENT:1'
|
||||
function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } }
|
||||
function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } }
|
||||
function candidate(value = row()): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] } }
|
||||
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
|
||||
function detail(no = 'DS/001', version = 1): DispatchDetail {
|
||||
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
|
||||
}
|
||||
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
|
||||
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -37,7 +38,7 @@ function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(st
|
||||
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
||||
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
||||
function reasons(row: BatchRow) { return row.blockReasons }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.map(row => row.native?.selection.warehousePalletId || row.native?.sourceKey || row.key)).size }
|
||||
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
||||
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
||||
async function load() {
|
||||
if (!readable.value || pending.value || corrupted.value) return
|
||||
@@ -77,7 +78,7 @@ async function load() {
|
||||
const sources = receiving[index]!.batch.items
|
||||
const blocks = [...context.blockReasons, ...row.blockReasons]
|
||||
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
||||
if (candidate) blocks.push(...candidate.blockReasons)
|
||||
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
||||
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
||||
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
||||
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
||||
@@ -131,7 +132,11 @@ async function executeStep() {
|
||||
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
||||
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
||||
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(source.sourceKey)
|
||||
const group = requireSelectionGroup(source)
|
||||
assertSelectionGroupVersions([expected!.native], [source])
|
||||
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
||||
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
||||
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
||||
}
|
||||
request.draft = draft
|
||||
}
|
||||
@@ -163,7 +168,7 @@ async function save() {
|
||||
else {
|
||||
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
||||
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: cargo.map(source => source!.selection), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +204,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<section class="work-form" aria-label="按预约批次创建整车排车">
|
||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }}</h3><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p>
|
||||
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
||||
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
||||
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
||||
|
||||
@@ -8,7 +8,7 @@ const access = vi.hoisted(() => ({ allowed: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn() }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), resolveBatchWork: vi.fn() }))
|
||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||
function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } }
|
||||
function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div data-same-dispatch />' } } } }) }
|
||||
const button = (wrapper: ReturnType<typeof render>, label: string) => wrapper.findAll('button').find(row => row.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getDispatch, stageLabel, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
@@ -51,7 +52,7 @@ watch(() => props.dispatches, load, { immediate: true, deep: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
<template>
|
||||
<section aria-label="沿用原整车排车"><h3>沿用原整车排车</h3><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p>
|
||||
<section aria-label="沿用原整车排车"><h3>沿用原整车排车 <InlineHelp title="排车说明"><p>在原排车单维护车辆、司机和货物;一张排车单就是一趟车。</p></InlineHelp></h3>
|
||||
<p v-if="!readable">查看原排车单及批次关联需要相应列表权限。</p><p v-if="loading">正在核对原排车单与批次…</p>
|
||||
<LoadErrorState v-if="error" title="原排车单读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<table v-if="!loading && !error && readable" class="density-table"><thead><tr><th>操作</th><th>排车单号</th><th>车牌号</th><th>箱数</th><th>阶段</th></tr></thead><tbody><tr v-for="row in rows" :key="row.dispatchNo"><td><el-button v-if="canEdit(row)" :disabled="addingBusy" @click="emit('external', path(row.dispatchNo))">继续维护本车</el-button><el-button v-if="canAdd(row)" :disabled="addingBusy" @click="add(row)">为本车添加同仓货物</el-button><el-button @click="emit('external', path(row.dispatchNo))">查看原排车单</el-button></td><td>{{ row.dispatchNo }}</td><td>{{ row.vehicleInfo.vehicle?.plate || '—' }}</td><td>{{ row.counts.packageCount }}</td><td>{{ stageLabel(row.stage) }}</td></tr></tbody></table>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
||||
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
@@ -35,3 +35,46 @@ it('retries lookup as a read and keeps the original detail navigation', async ()
|
||||
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
await button(wrapper, '查看原排车单').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS-60']]); wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('refreshes the original add-cargo gate after a rejection and preserves its message when GET fails: %s', async readFails => {
|
||||
const wrapper = render(); await flushPromises(); await button(wrapper, '选择同仓未安排货物').trigger('click')
|
||||
if (readFails) vi.mocked(getDispatch).mockRejectedValueOnce(new Error('read unavailable'))
|
||||
else vi.mocked(getDispatch).mockResolvedValueOnce(detail('DS-60', false))
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源已变化,请重新核对', code: 'A0409' })
|
||||
dialog.vm.$emit('update:modelValue', false); await flushPromises()
|
||||
expect(getDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('updated')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('来源已变化,请重新核对')
|
||||
expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
|
||||
const recovery = useDispatchCommandRecovery(() => identity.id)
|
||||
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
|
||||
recovery.pending.value!.attempted = true
|
||||
const completed: CommandResult = {
|
||||
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
|
||||
occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 },
|
||||
replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] },
|
||||
}
|
||||
recovery.pending.value!.result = completed
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
|
||||
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
|
||||
await button(wrapper, '刷新加货资料').trigger('click')
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
|
||||
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
|
||||
dialog.vm.$emit('rejected', { message: '来源版本需重新核对', code: 'A0409' }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源版本需重新核对')
|
||||
dialog.vm.$emit('saved'); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('来源版本需重新核对')
|
||||
expect(wrapper.emitted('updated')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{ dispatchNo: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ updated: []; busy: [value: boolean]; external: [path: string] }>()
|
||||
const access = useAccessPreviewStore(), auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
|
||||
const detail = ref<DispatchDetail | null>(null), open = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const readable = computed(() => access.can('platform:overseas-warehouse:dispatch:list'))
|
||||
const allowed = computed(() => props.canWrite && readable.value && access.can('platform:overseas-warehouse:dispatch:edit') && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.actions.ADD_CARGO?.allowed === true)
|
||||
const busy = computed(() => loading.value || open.value || Boolean(recovery.pending.value))
|
||||
@@ -29,11 +30,12 @@ async function load() {
|
||||
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本车加货资格读取失败') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) open.value = true }
|
||||
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
|
||||
function recover() {
|
||||
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
|
||||
}
|
||||
function saved() { open.value = false; emit('updated'); void load() }
|
||||
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
|
||||
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
|
||||
watch(() => props.dispatchNo, load, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
</script>
|
||||
@@ -42,10 +44,11 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
|
||||
<h4>为原排车添加同仓货物</h4><p>本车 {{ dispatchNo }}。只选择尚未安排的真实货物加入本车,不合并另一张排车单或搬迁已扫码货物。</p>
|
||||
<p>箱数无法对应完整实物时,先在原库存作业完成拆托;预约分批不代替实物拆托。</p>
|
||||
<LoadErrorState v-if="error" title="本车加货资格读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
|
||||
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
|
||||
<p v-if="recovery.pending.value">原排车请求尚未核对,不能另发加货命令。<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">核对原加货请求</el-button></p>
|
||||
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</el-button></p>
|
||||
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" />
|
||||
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Admin 操作日志与审计模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/audit/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 本模块**没有独立页面**:审计日志是客户详情里的内嵌面板(`components/CustomerAuditLogsPanel.vue`),查询接口在 `api/platformAudit.ts`。
|
||||
- 后端查 `audit-service/`(`PlatformAuditLogController`、`AuditLogQueryService`);若是“操作人没被记录”,还要查采集侧 `common-audit/`(`BusinessAuditAspect.java`、`AuditEvent.java`)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户详情内的操作日志面板、分页与详情 | `components/CustomerAuditLogsPanel.vue`(props:`customerId`、`customerName`) | `api/platformAudit.ts` 的 `listPlatformAuditLogs`、`getPlatformAuditLog`;`audit-service/` |
|
||||
| 日志字段展示、时间与文案映射 | `presentation.ts` | 服务端返回结构 |
|
||||
| 日志列表/详情的对外类型 | `index.ts` | `audit-service` DTO |
|
||||
| 操作人缺失、字段为空 | 面板空态与列定义 | 先查 `common-audit/` 是否采集该字段,再查 `audit-service/` 摄取 DTO 与表结构 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 列表不返回变更前后快照,详情才返回已脱敏 JSON;不要在列表里展开完整 payload。
|
||||
- 客户账号不得访问平台审计查询;面板只在平台侧客户详情内使用。
|
||||
- 审计字段缺失属于“采集侧漏采”或“查询侧未映射”,两者分属 `common-audit/` 与 `audit-service/`,不要只改前端展示。
|
||||
- 审计链路故障不得阻塞核心交易,前端也不得据此推断业务失败。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/audit/<目标测试>`;字段或查询变化同时核对 `audit-service` 的查询接口与表结构。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -1,17 +1,22 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs } from '../api/platformAudit'
|
||||
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail } from '../api/platformAudit'
|
||||
import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
|
||||
|
||||
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
|
||||
|
||||
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
|
||||
|
||||
const detail: PlatformAuditLogDetail = { ...row, resourceId: '9007199254740993123', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' }
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
@@ -20,7 +25,7 @@ function mountPanel(permissions: string[]) {
|
||||
}
|
||||
|
||||
describe('CustomerAuditLogsPanel', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(getPlatformAuditLog).mockResolvedValue(detail); vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) })
|
||||
|
||||
it('queries logs with the current customer id', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
@@ -91,17 +96,24 @@ describe('CustomerAuditLogsPanel', () => {
|
||||
})
|
||||
|
||||
it('loads detail and formats json snapshots', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...row, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
|
||||
const privateId = '9007199254740993123'
|
||||
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [{ ...row, id: privateId }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, id: privateId, eventType: 'INBOUND_MEASURE_APPROVE', resourceType: 'INBOUND_MEASUREMENT', serviceName: 'firstleg-service', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{"name":"旧名称"}', afterData: '{"name":"新名称"}' })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('button[aria-label="查看操作日志1详情"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledWith('1')
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledWith(privateId)
|
||||
expect(wrapper.text()).toContain('旧名称')
|
||||
expect(wrapper.text()).toContain('新名称')
|
||||
expect(wrapper.text()).toContain('入库测量审核通过')
|
||||
expect(wrapper.text()).toContain('入库测量')
|
||||
expect(wrapper.text()).toContain('头程履约服务')
|
||||
expect(wrapper.text()).toContain('更新')
|
||||
expect(wrapper.find('.audit-detail-grid').exists()).toBe(true)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(true)
|
||||
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '操作日志详情 · 柏悦物流', size: 'min(920px, 96vw)', showFooter: false })
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('req')
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('trace')
|
||||
expect(wrapper.find('.audit-detail-table').text()).not.toContain(privateId)
|
||||
expect(wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').attributes('aria-label')).not.toContain(privateId)
|
||||
})
|
||||
|
||||
it('loads the selected audit page', async () => {
|
||||
@@ -131,4 +143,100 @@ describe('CustomerAuditLogsPanel', () => {
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '9', page: 1, size: 20, keyword: undefined, success: undefined, occurredFrom: undefined, occurredTo: undefined })
|
||||
})
|
||||
it('preserves failure details and complete long or non-json snapshots as text', async () => {
|
||||
const longText = '长内容'.repeat(1200)
|
||||
const raw = '<script>原始内容</script>\n不是JSON'
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: '数据版本冲突', beforeData: JSON.stringify({ note: longText }), afterData: raw })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
const drawer = wrapper.findComponent(AppDrawer)
|
||||
expect(drawer.text()).toContain('操作失败')
|
||||
expect(drawer.get('[role="alert"]').text()).toContain('数据版本冲突')
|
||||
const snapshots = drawer.findAll('.density-code-block')
|
||||
expect(snapshots[0].text()).toBe(JSON.stringify({ note: longText }, null, 2))
|
||||
expect(snapshots[1].text()).toBe(raw)
|
||||
expect(drawer.find('script').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps missing snapshots compact and a zero duration visible', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockResolvedValue({ ...detail, success: false, errorMessage: null, errorCode: 'OPERATION_CONFLICT', durationMs: 0, beforeData: '', afterData: null, requestId: null, traceId: null })
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.density-code-block').map(snapshot => snapshot.text())).toEqual(['无', '无'])
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('0 ms')
|
||||
expect(wrapper.find('.audit-detail-table').text()).not.toContain('链路标识')
|
||||
expect(wrapper.find('.audit-detail-table').text()).toContain('OPERATION_CONFLICT')
|
||||
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).toContain('操作失败,请查看诊断信息')
|
||||
expect(wrapper.findComponent(AppDrawer).get('[role="alert"]').text()).not.toContain('OPERATION_CONFLICT')
|
||||
})
|
||||
|
||||
it('retries the selected log after a detail request failure', async () => {
|
||||
vi.mocked(getPlatformAuditLog).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(detail)
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('详情加载失败')
|
||||
await wrapper.findComponent(AppDrawer).get('.load-error-state button').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(getPlatformAuditLog).mock.calls).toEqual([['1'], ['1']])
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
|
||||
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)('ignores a late %s from the previously selected log', async outcome => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
let fail!: (reason: Error) => void
|
||||
const previous = new Promise<PlatformAuditLogDetail>((resolve, reject) => { finish = resolve; fail = reject })
|
||||
vi.mocked(listPlatformAuditLogs).mockResolvedValue({ records: [row, { ...row, id: '2', description: '第二条记录' }], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(getPlatformAuditLog).mockImplementation(id => id === '1' ? previous : Promise.resolve({ ...detail, id: '2', description: '第二条详情' }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
const actions = wrapper.findAll('.el-table__body button[aria-label^="查看操作日志:"]')
|
||||
await actions[0].trigger('click'); await flushPromises()
|
||||
await actions[1].trigger('click'); await flushPromises()
|
||||
if (outcome === 'resolve') finish({ ...detail, description: '不应重新展示的旧详情' })
|
||||
else fail(new Error('旧请求失败'))
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('loading')).toBe(false)
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('第二条详情')
|
||||
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('不应重新展示的旧详情')
|
||||
expect(wrapper.findComponent(AppDrawer).find('.load-error-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('closes and clears pending detail when switching customers', async () => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
await wrapper.setProps({ customerId: '9', customerName: '另一客户' }); await flushPromises()
|
||||
finish(detail); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
expect(listPlatformAuditLogs).toHaveBeenLastCalledWith(expect.objectContaining({ accountId: '9' }))
|
||||
})
|
||||
|
||||
it('ignores pending responses after closing and permits a fresh reopen', async () => {
|
||||
let finish!: (value: PlatformAuditLogDetail) => void
|
||||
vi.mocked(getPlatformAuditLog).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent(AppDrawer).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
finish({ ...detail, description: '过期详情' }); await flushPromises()
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
expect(getPlatformAuditLog).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findComponent(AppDrawer).text()).toContain('新名称')
|
||||
expect(wrapper.findComponent(AppDrawer).text()).not.toContain('过期详情')
|
||||
})
|
||||
|
||||
it('clears detail when audit permission is revoked', async () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
await wrapper.get('.el-table__body button[aria-label^="查看操作日志:"]').trigger('click'); await flushPromises()
|
||||
useAuthStore().permissions = []
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(wrapper.find('.audit-detail-table').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('暂无操作日志查看权限')
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ref, watch } from 'vue'
|
||||
import { CircleCheck, CircleX, ScrollText } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ScrollText } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -24,6 +24,9 @@ const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<PlatformAuditLogDetail | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedLogId = ref<string | null>(null)
|
||||
const detailTitle = computed(() => props.customerName.trim() ? `操作日志详情 · ${props.customerName.trim()}` : '操作日志详情')
|
||||
let detailRequestSerial = 0
|
||||
const emptyFilters = (): QueryModel => ({ keyword: '', success: '', occurredRange: [] })
|
||||
const filters = ref<QueryModel>(emptyFilters())
|
||||
const appliedFilters = ref<QueryModel>(emptyFilters())
|
||||
@@ -80,7 +83,7 @@ async function load() {
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
if (!customerId || !access.can('platform:audit:list')) return
|
||||
if (!customerId || !access.can('platform:audit:list')) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listPlatformAuditLogs(toQuery(customerId))
|
||||
@@ -104,17 +107,45 @@ function applyFilters(model: QueryModel) {
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformAuditLogListItem) {
|
||||
detailOpen.value = true
|
||||
function clearDetail() {
|
||||
detailRequestSerial += 1
|
||||
selectedLogId.value = null
|
||||
detailOpen.value = false
|
||||
detailLoading.value = false
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const logId = selectedLogId.value
|
||||
const customerId = props.customerId.trim()
|
||||
if (!logId || !detailOpen.value || !access.can('platform:audit:list')) return
|
||||
const serial = ++detailRequestSerial
|
||||
const isCurrent = () => serial === detailRequestSerial && customerId === props.customerId.trim() && detailOpen.value
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
try { detail.value = await getPlatformAuditLog(row.id) }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试') }
|
||||
finally { detailLoading.value = false }
|
||||
try {
|
||||
const result = await getPlatformAuditLog(logId)
|
||||
if (isCurrent()) detail.value = result
|
||||
} catch (error) {
|
||||
if (isCurrent()) detailError.value = getApiErrorDetails(error, '操作日志详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (isCurrent()) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.customerId, () => {
|
||||
async function openDetail(row: PlatformAuditLogListItem) {
|
||||
selectedLogId.value = row.id
|
||||
detailOpen.value = true
|
||||
await loadDetail()
|
||||
}
|
||||
|
||||
watch(detailOpen, open => { if (!open) clearDetail() })
|
||||
onBeforeUnmount(() => { requestSerial += 1; detailRequestSerial += 1 })
|
||||
|
||||
watch([() => props.customerId, () => access.can('platform:audit:list')], () => {
|
||||
clearDetail()
|
||||
filters.value = emptyFilters()
|
||||
appliedFilters.value = emptyFilters()
|
||||
if (page.value === 1) void load()
|
||||
@@ -133,7 +164,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
<QueryToolbar v-model="filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
|
||||
<LoadErrorState v-if="loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志:${auditEventLabel((row as PlatformAuditLogListItem).eventType)},${formatDate((row as PlatformAuditLogListItem).occurredAt)}`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
|
||||
<template #description="{ row }"><el-tooltip :content="auditDescriptionLabel((row as PlatformAuditLogListItem).description)" placement="top" :show-after="400"><span class="cell-clamp">{{ auditDescriptionLabel((row as PlatformAuditLogListItem).description) }}</span></el-tooltip></template>
|
||||
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
|
||||
<template #eventType="{ row }"><el-tooltip :content="(row as PlatformAuditLogListItem).eventType" placement="top" :show-after="500"><span class="event-label">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</span></el-tooltip></template>
|
||||
@@ -145,29 +176,31 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<AppDrawer class="customer-audit-drawer admin-density-drawer" v-model="detailOpen" title="操作日志详情" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<section class="audit-detail-hero" :class="detail.success ? 'is-success' : 'is-failed'">
|
||||
<span class="result-icon" aria-hidden="true"><CircleCheck v-if="detail.success" :size="22" /><CircleX v-else :size="22" /></span>
|
||||
<div class="hero-copy"><span>事件类型</span><h3>{{ auditEventLabel(detail.eventType) }}</h3><p>{{ auditDescriptionLabel(detail.description) }}</p></div>
|
||||
<el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag>
|
||||
<AppDrawer class="customer-audit-drawer admin-density-drawer admin-density-drawer--detail" v-model="detailOpen" :title="detailTitle" size="min(920px, 96vw)" :loading="detailLoading" :show-footer="false">
|
||||
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<div v-else-if="detail" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">{{ auditEventLabel(detail.eventType) }} <el-tag :type="detail.success ? 'success' : 'danger'" effect="light">{{ detail.success ? '操作成功' : '操作失败' }}</el-tag></h3>
|
||||
<p class="audit-event-description">{{ auditDescriptionLabel(detail.description) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="audit-detail-grid" aria-label="操作日志基础信息">
|
||||
<div><span>操作人</span><strong>{{ auditOperatorLabel(detail.username) }}</strong></div>
|
||||
<div><span>发生时间</span><strong>{{ formatDate(detail.occurredAt) }}</strong></div>
|
||||
<div><span>来源服务</span><strong>{{ auditServiceLabel(detail.serviceName) }}</strong><small>{{ display(detail.serviceName) }}</small></div>
|
||||
<div><span>客户端 IP</span><strong>{{ display(detail.clientIp) }}</strong></div>
|
||||
<div><span>业务资源</span><strong>{{ auditResourceLabel(detail.resourceType) }}</strong><small>{{ display(detail.resourceId) }}</small></div>
|
||||
<div><span>处理耗时</span><strong>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</strong></div>
|
||||
<div class="wide request-cell"><span>请求地址</span><div><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></div></div>
|
||||
<div class="wide identity-cell"><span>请求标识</span><strong>{{ display(detail.requestId) }}</strong><small v-if="detail.traceId">链路标识:{{ detail.traceId }}</small></div>
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">操作信息</h3>
|
||||
<table class="density-table audit-detail-table" aria-label="操作日志基础信息"><tbody>
|
||||
<tr><th scope="row">操作人</th><td>{{ auditOperatorLabel(detail.username) }}</td><th scope="row">发生时间</th><td>{{ formatDate(detail.occurredAt) }}</td></tr>
|
||||
<tr><th scope="row">来源服务</th><td>{{ auditServiceLabel(detail.serviceName) }}<small class="audit-service-code density-dialog-note">{{ display(detail.serviceName) }}</small></td><th scope="row">客户端 IP</th><td>{{ display(detail.clientIp) }}</td></tr>
|
||||
<tr><th scope="row">业务资源</th><td>{{ auditResourceLabel(detail.resourceType) }}</td><th scope="row">处理耗时</th><td>{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</td></tr>
|
||||
<tr><th scope="row">请求地址</th><td colspan="3"><span class="audit-request"><el-tag size="small" effect="plain">{{ auditHttpMethodLabel(detail.httpMethod) }}</el-tag><code>{{ display(detail.requestUri) }}</code></span></td></tr>
|
||||
<tr><th scope="row">请求标识</th><td colspan="3"><code>{{ display(detail.requestId) }}</code></td></tr>
|
||||
<tr v-if="!detail.success && detail.errorCode"><th scope="row">错误码(诊断)</th><td colspan="3"><code>{{ detail.errorCode }}</code></td></tr>
|
||||
<tr v-if="detail.traceId"><th scope="row">链路标识</th><td colspan="3"><code>{{ detail.traceId }}</code></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<el-alert v-if="!detail.success" class="failure-alert" type="error" :closable="false" show-icon :title="display(detail.errorMessage || detail.errorCode)" />
|
||||
<div class="snapshot-grid"><section><h3>操作前快照</h3><p>操作执行前保留的业务数据</p><pre>{{ formatSnapshot(detail.beforeData) }}</pre></section><section><h3>操作结果快照</h3><p>操作执行后返回或保存的业务数据</p><pre>{{ formatSnapshot(detail.afterData) }}</pre></section></div>
|
||||
</template>
|
||||
<el-alert v-if="!detail.success" type="error" :closable="false" show-icon :title="detail.errorMessage || '操作失败,请查看诊断信息'" />
|
||||
<div class="audit-snapshots">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><p class="density-dialog-note">操作执行前保留的业务数据</p><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><p class="density-dialog-note">操作执行后返回或保存的业务数据</p><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
|
||||
</div>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -178,8 +211,13 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
|
||||
.customer-audit-panel { min-width:0; }.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-6, 16px); margin-bottom:var(--admin-space-3, 8px); padding-bottom:var(--admin-space-3, 8px); border-bottom:1px solid var(--cc-border); }.panel-title h2 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }.panel-title > svg { color:var(--cc-primary); }.audit-query { margin-bottom:var(--admin-space-5, 12px); }.module-empty { display:flex; min-height:var(--admin-empty-height, 64px); align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:var(--admin-font-body, 13px); }.module-empty p { margin:0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.cell-clamp { display:-webkit-box; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2; line-height:1.45; overflow-wrap:anywhere; }.event-label { display:inline-flex; max-width:100%; padding:3px 9px; overflow:hidden; border:1px solid #dbeafe; border-radius:999px; background:#eff6ff; color:#1d4ed8; font-size:var(--admin-font-caption, 12px); line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.service-label { display:block; overflow:hidden; color:#475467; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.audit-detail-hero { display:flex; align-items:flex-start; gap:13px; padding:var(--admin-space-3, 8px) var(--admin-space-4, 10px); border:1px solid #d1fadf; border-radius:0; background:#f6fef9; }.audit-detail-hero.is-failed { border-color:#fee4e2; background:#fff7f6; }.result-icon { display:grid; width:38px; height:var(--admin-tab-height, 38px); flex:0 0 auto; place-items:center; border-radius:50%; background:#dcfae6; color:#079455; }.is-failed .result-icon { background:#fee4e2; color:#d92d20; }.hero-copy { min-width:0; flex:1; }.hero-copy > span { color:#667085; font-size:var(--admin-font-caption, 12px); }.hero-copy h3 { margin:3px 0 var(--admin-space-1, 4px); color:#101828; font-size:var(--admin-font-section, 14px); }.hero-copy p { margin:0; color:#475467; font-size:var(--admin-font-body, 13px); line-height:1.55; }
|
||||
.audit-detail-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin-top:var(--admin-space-6, 16px); overflow:hidden; border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.audit-detail-grid > div { display:flex; min-width:0; min-height:var(--admin-row-height, 36px); flex-direction:column; justify-content:center; gap:5px; padding:var(--admin-space-2, 6px) var(--admin-space-4, 10px); border-right:1px solid #eaecf0; border-bottom:1px solid #eaecf0; }.audit-detail-grid > div:nth-child(3n) { border-right:0; }.audit-detail-grid > div.wide { grid-column:1/-1; border-right:0; }.audit-detail-grid > div:last-child { border-bottom:0; }.audit-detail-grid span { color:#667085; font-size:var(--admin-font-caption, 12px); }.audit-detail-grid strong { overflow-wrap:anywhere; color:#1d2939; font-size:var(--admin-font-section, 14px); font-weight:600; }.audit-detail-grid small { overflow-wrap:anywhere; color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.request-cell > div { display:flex; min-width:0; align-items:center; gap:9px; }.request-cell code { min-width:0; overflow-wrap:anywhere; color:#344054; font:12px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; }.failure-alert { margin-top:var(--admin-space-6, 16px); }
|
||||
.snapshot-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-6, 16px); margin-top:18px; }.snapshot-grid section { min-width:0; padding:var(--admin-space-4, 10px); border:1px solid #e4e7ec; border-radius:0; background:var(--admin-surface, #fff); }.snapshot-grid h3 { margin:0; color:#344054; font-size:var(--admin-font-section, 14px); }.snapshot-grid p { margin:var(--admin-space-1, 4px) 0 var(--admin-space-4, 10px); color:#98a2b3; font-size:var(--admin-font-caption, 12px); }.snapshot-grid pre { min-height:160px; max-height:360px; margin:0; padding:14px; overflow:auto; border:1px solid #eaecf0; border-radius:8px; background:var(--admin-label-bg, #f8fafc); color:#344054; font:12px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
@media(max-width:720px){.audit-detail-grid{grid-template-columns:1fr}.audit-detail-grid>div,.audit-detail-grid>div:nth-child(3n){border-right:0}.audit-detail-grid>div.wide{grid-column:auto}.snapshot-grid{grid-template-columns:1fr}.audit-detail-hero{align-items:flex-start}.audit-detail-hero>.el-tag{flex:0 0 auto}}
|
||||
.audit-event-description { margin:0; overflow-wrap:anywhere; }
|
||||
.audit-detail-table { table-layout:fixed; }
|
||||
.audit-detail-table th { width:92px; }
|
||||
.audit-service-code { display:block; }
|
||||
.audit-request { display:flex; align-items:center; gap:var(--admin-space-3); }
|
||||
.audit-request code { min-width:0; }
|
||||
.audit-snapshots { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-5); }
|
||||
.audit-snapshots > .density-dialog-section { margin-bottom:0; }
|
||||
@media(max-width:720px) { .audit-snapshots { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 登录模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/auth/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 模块只有一个页面 `pages/LoginView.vue`;认证状态、Token 刷新与路由守卫在 `../../core/auth/` 与 `../../shared/api/AGENTS.md`,本模块不复制这些逻辑。
|
||||
- 后端只查 `auth/`(登录、刷新、退出、改密)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 平台员工登录页、表单校验与错误提示 | `pages/LoginView.vue` | `../../core/auth/auth.ts` → `../../shared/api/http.ts`;`auth/` |
|
||||
| 登录态与路由跳转 | 页面不持有状态 | `../../core/auth/auth.ts`、`../../core/access/` |
|
||||
| 401 刷新与重放 | 页面不处理 | `../../shared/api/http.ts`、`../../shared/api/AGENTS.md` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 页面不读写 Token、不拼 `Authorization`、不自行持久化密码;令牌与刷新编排只在 `src/core/auth/` 与 `src/shared/api/http.ts`。
|
||||
- 认证失败提示按服务端返回的 `code` 与文案展示,不把 401/403 混为同一种提示。
|
||||
- 登录成功后跳转由路由守卫决定,页面不硬编码默认落地页。
|
||||
- 记住账号类能力只保存登录标识,不持久化密码或 Token。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/auth/<目标测试>`;登录或守卫变化同时核对 `src/core/auth/` 与 `src/shared/api/http.ts`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# Admin 货柜模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/container-loading/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,页面/接口细则见 `../../../docs/business-index.md`。
|
||||
- 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;接口、状态、金额或权限变化时再读详细索引命中行。
|
||||
- 后端只查直接消费方:`firstleg/`(`loading/`、`customs/container/`、`terminaldrayage/`、`oceanbooking/`)、`lastleg-service/`(柜级到仓与尾程)、`file/`、`print/`。纯页面与文案不读后端。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 国内装柜列表、模拟柜、订舱关联 | `pages/ContainersView.vue`、`pages/DomesticContainersView.vue`;`container-loading/domestic` | `api/platformContainerLoading.ts`;`components/ContainerSimulationCreate.vue`、`components/OceanBookingLinkDialog.vue`;`firstleg/loading/` |
|
||||
| 送柜报关详情、装柜清单页签、报关、出仓 | `pages/DomesticContainerDetailView.vue`、共享 `pages/ContainerDetailView.vue`;`container-loading/domestic/:containerId` | `components/ContainerOverviewPanel.vue`、`components/ContainerCustomsPanel.vue`;`api/platformContainerCustoms.ts`;`firstleg/customs/container/`、`file/` |
|
||||
| 智能装柜工作台、柜次容量与分配 | `pages/SmartLoadingWorkbenchView.vue`;`container-loading/domestic/:containerId/smart-loading` | `api/platformContainerLoading.ts`;`../unified-orders/workflows/components/SmartLoadingWorkbench.vue`;`firstleg/service/SmartLoadingService.java` |
|
||||
| 全局智能装柜弹框、入仓码候选与箱数 | `pages/GlobalSmartLoadingView.vue`(内嵌,无独立路由)、`components/SmartLoadingDialog.vue`、`components/SmartLoadingCandidateInbounds.vue` | `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`smartLoadingPlanGroups.ts`;后端 `/inbound-candidates`、`/containers`、`/inbound-assignments` |
|
||||
| 海外清关提柜工作台、船节点、清关与 DO/PU | `pages/OverseasContainersView.vue`;`container-loading/overseas` | `api/platformContainerLoading.ts`;`firstleg/` 船节点与 `customs/`;`components/ContainerOverviewPanel.vue` |
|
||||
| 目的港码头拖车、车队与还空 | `pages/OverseasContainerDetailView.vue`(薄包装)、共享 `pages/ContainerDetailView.vue`(实际内容);`container-loading/overseas/:containerId` | `api/terminalDrayages.ts` 由共享详情调用;`firstleg/terminaldrayage/`(`TerminalDrayageService.java`、`DirectDeliveryService.java`) |
|
||||
| 整柜预计/实际到仓 | 列表与详情“到仓”列 | `api/containerArrivalTimes.ts`;`lastleg-service/` 柜级订单 |
|
||||
| 装柜照片与证据的展示/读取(上传在 PDA 与 Firstleg) | `components/ContainerOverviewPanel.vue` | 证据读取走 `file/`;生产与上传见 `firstleg/loading/service/CabinetLoadingEvidenceService.java` 与国内 PDA |
|
||||
| 海外柜费用 | `components/ContainerLastLegFeesPanel.vue` | `api/overseasContainerFees.ts`;`finance/`、`firstleg/` 费用来源 |
|
||||
|
||||
`pages/ContainerWorkbenchView.vue` 是工作台外壳,按权限在“国内装柜”“清关提柜”间切换;`pages/ContainersView.vue` 与 `ContainerDetailView.vue` 为共享实现,改两份前先确认是否同源。
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,写资格只取服务端 `allowedActions`;不用浏览器筛选、本地推导或柜内明细数量代替。
|
||||
- 创建柜次时自动生成唯一预装计划;订舱关联允许真实集装箱号为空,但**确认预装和创建 PDA 任务前必须补齐**,历史上已建缺号任务可在预装详情首次补录。
|
||||
- 还空截止、超时天数只展示服务端按权威卸柜完成时间加 72 小时计算的结果,前端不重算、不用拖车状态当阻断门禁。
|
||||
- 国内“送柜报关”只消费 `/containers/domestic`,海外“清关提柜”只消费 `/containers/overseas`;两端统一用 `containerBookingId`,禁止混用两套列表接口。
|
||||
- 智能装柜候选状态只作筛选展示,按钮资格以服务端 `canAssign` 为准;计划箱数只消费 `totalPackageCount/remainingPackageCount`,不从候选页或本地规格重算。
|
||||
- 前端不计算托数、不逐票建计划、不预占托盘;柜级转换必须原样回传服务端 `snapshotVersion/snapshotHash`,冲突后重新预览。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/container-loading/<目标测试>`;API 或类型变化同时运行 `pnpm typecheck`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`;离线检查不代替真实 HTTP 与人工截图验收。
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
|
||||
|
||||
@@ -14,6 +15,7 @@ const api = vi.hoisted(() => ({
|
||||
downloadSource: vi.fn(),
|
||||
downloadReceipt: vi.fn(),
|
||||
downloadFinal: vi.fn(),
|
||||
recordResult: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/platformContainerCustoms', () => ({
|
||||
@@ -27,6 +29,7 @@ vi.mock('../api/platformContainerCustoms', () => ({
|
||||
submitContainerCustomsToBroker: vi.fn(),
|
||||
sendContainerCustomsToBroker: api.sendToBroker,
|
||||
getDeclarationBatches: api.getDeclarationBatches,
|
||||
recordClearanceResult: api.recordResult,
|
||||
}))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
|
||||
@@ -77,6 +80,51 @@ describe('ContainerCustomsPanel', () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('保留其他原因必填与批次版本,统一窗口确认后仍提交原始结果', async () => {
|
||||
const batch: DeclarationBatch = {
|
||||
id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402',
|
||||
batchNo: 'DEC-TEST-001', status: 'SUBMITTED', sourceVersion: 3,
|
||||
customsEntryNo: 'ENTRY-001', version: 7, submittedAt: null,
|
||||
submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: [],
|
||||
}
|
||||
api.getDeclarationBatches.mockResolvedValue([batch])
|
||||
api.get.mockResolvedValue({ ...customs('DECLARING', true), brokerSubmittedAt: '2026-10-19T09:00:00' })
|
||||
const wrapper = mount(ContainerCustomsPanel, {
|
||||
props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
|
||||
} }).setupState
|
||||
state.clearanceResultType = 'OTHER'
|
||||
await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('标记海关异常/结果 · DEC-TEST-001')
|
||||
expect(dialog.props('lockWhileSubmitting')).toBe(false)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(api.recordResult).not.toHaveBeenCalled()
|
||||
state.clearanceRemark = ' 已核实海关通知 '
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(api.recordResult).toHaveBeenCalledWith(batch, 'OTHER', '已核实海关通知')
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||
const wrapper = await render('READY_TO_START')
|
||||
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(api.start).not.toHaveBeenCalled()
|
||||
confirm.mockRestore()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders all customs result reasons in Chinese with the recorded remarks', async () => {
|
||||
const reasons: Array<[ClearanceResultType, string]> = [
|
||||
['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'],
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import {
|
||||
completeAllBuyContainerCustoms,
|
||||
createDeclarationBatch,
|
||||
@@ -113,13 +115,18 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function customsDialogTitle(action: string, batch?: DeclarationBatch | null) {
|
||||
const code = batch?.batchNo?.trim()
|
||||
return code ? `${action} · ${code}` : containerDialogTitle(action, customs.value)
|
||||
}
|
||||
|
||||
function declarationStatusLabel(value: DeclarationBatch['status']) {
|
||||
return ({ READY: '待提交', SUBMITTED: '已提交', ACKNOWLEDGED: '已受理', RESULT_PENDING: '待处理结果', CLOSED: '已关闭', CANCELLED: '已取消' } as Record<string, string>)[value] || value
|
||||
}
|
||||
|
||||
async function createFormalDeclaration() {
|
||||
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
|
||||
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', '创建正式申报批次', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
|
||||
@@ -128,7 +135,7 @@ async function createFormalDeclaration() {
|
||||
|
||||
async function submitFormalDeclaration(batch: DeclarationBatch) {
|
||||
try {
|
||||
const answer = await ElMessageBox.prompt('请输入报关单号', '提交正式申报', { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
|
||||
const answer = await ElMessageBox.prompt('请输入报关单号', customsDialogTitle('提交正式申报', batch), { customClass: 'admin-density-confirm', inputValidator: (value) => value.trim() ? true : '请输入报关单号', confirmButtonText: '确认提交' })
|
||||
actionLoading.value = true; actionError.value = null
|
||||
await submitDeclarationBatch(batch, answer.value)
|
||||
await load(); ElMessage.success('正式申报已提交')
|
||||
@@ -163,7 +170,7 @@ async function recordFormalResult() {
|
||||
|
||||
async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) {
|
||||
try {
|
||||
const answer = await ElMessageBox.prompt('请输入处理说明', '处理海关结果', { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
|
||||
const answer = await ElMessageBox.prompt('请输入处理说明', customsDialogTitle('处理海关结果', batch), { customClass: 'admin-density-confirm', inputValue: result.resolution || '', inputValidator: (value) => value.trim() ? true : '请输入处理说明', confirmButtonText: '确认处理' })
|
||||
actionLoading.value = true; actionError.value = null
|
||||
await resolveClearanceResult(result, batch.version, answer.value)
|
||||
await load(); ElMessage.success('海关结果已处理')
|
||||
@@ -176,7 +183,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
|
||||
async function start() {
|
||||
if (!customs.value || customs.value.status !== 'READY_TO_START') return
|
||||
try {
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
||||
} catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
|
||||
@@ -242,7 +249,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
|
||||
async function submitToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
|
||||
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', '发送报关行执行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
|
||||
@@ -251,7 +258,7 @@ async function submitToBroker() {
|
||||
|
||||
async function sendToBroker() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', '首次发送报关行', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
|
||||
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
|
||||
@@ -260,7 +267,7 @@ async function sendToBroker() {
|
||||
|
||||
async function completeAllBuy() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
|
||||
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', customsDialogTitle('完成全买单柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
@@ -346,10 +353,10 @@ onMounted(load)
|
||||
<div v-if="customs.status === 'COMPLETED'" class="departure-state"><strong>国内离仓状态:</strong><el-tag :type="departureState === 'DEPARTED' ? 'success' : 'warning'">{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}</el-tag></div>
|
||||
</template>
|
||||
<el-empty v-else-if="canList" description="当前装柜计划尚未完成,完成装柜后可处理报关" :image-size="56" />
|
||||
<el-dialog v-model="clearanceDialogVisible" class="admin-density-dialog" title="标记海关异常/结果" width="460px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="clearance-result-form">
|
||||
<AppDialog v-model="clearanceDialogVisible" class="admin-density-dialog" :title="customsDialogTitle('标记海关异常/结果', clearanceBatch)" width="460px" :close-on-click-modal="false" :lock-while-submitting="false" :confirm-loading="actionLoading" confirm-text="确认标记" @confirm="recordFormalResult">
|
||||
<el-form label-position="top" class="density-form density-form--flush-end">
|
||||
<el-form-item label="选择理由" required>
|
||||
<el-select v-model="clearanceResultType" class="clearance-result-select" placeholder="请选择理由">
|
||||
<el-select v-model="clearanceResultType" placeholder="请选择理由">
|
||||
<el-option v-for="option in clearanceResultOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -357,11 +364,10 @@ onMounted(load)
|
||||
<el-input v-model="clearanceRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="clearanceResultType === 'OTHER' ? '请填写具体情况' : '可填写短信内容或人工处理说明(可选)'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="clearanceDialogVisible = false">取消</el-button><el-button type="primary" :loading="actionLoading" @click="recordFormalResult">确认标记</el-button></template>
|
||||
</el-dialog>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}.clearance-result-form{padding-top:var(--admin-space-2)}.clearance-result-select{width:100%}
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--cc-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--cc-border);background:var(--cc-surface-muted)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--cc-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--cc-muted)}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElFormItem, ElSelect } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [] }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [{ id: '21', supplierName: '供应商甲', category: 'TERMINAL_DRAYAGE' }] }))
|
||||
vi.mock('../api/overseasContainerFees', () => ({
|
||||
listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }],
|
||||
createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(),
|
||||
}))
|
||||
|
||||
async function setup(readonly = false) {
|
||||
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', containerNo: 'CONT-99', readonly }, global: { plugins: [ElementPlus], stubs: {
|
||||
async function setup(readonly = false, realDialog = false, containerNo: string | null = 'CONT-99') {
|
||||
const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', cabinetNo: 'CAB-99', containerNo, readonly }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : {
|
||||
AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '<section v-if="modelValue"><h3>{{ title }}</h3><slot /><button :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></section>' },
|
||||
} } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
type FormState = { form: { customerId: string; settlementMode: string }; dialogOpen: boolean }
|
||||
type FormState = { form: { customerId: string; settlementMode: string; direction: string; amount: number | null; businessDate: string; supplierId: string; paidAt: string; paymentReferenceNo: string }; dialogOpen: boolean }
|
||||
async function upload(wrapper: Awaited<ReturnType<typeof setup>>, name: string) {
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] })
|
||||
@@ -37,6 +38,60 @@ describe('尾程付款凭证', () => {
|
||||
mocks.download.mockResolvedValue(new Blob(['image']))
|
||||
})
|
||||
|
||||
it('新增连续表切换付款字段和全屏后保留输入,保存仍使用真实集装箱号', async () => {
|
||||
const wrapper = await setup(false, true)
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增尾程费用'))!
|
||||
expect(dialog.props('title')).toBe('新增尾程费用 · CAB-99')
|
||||
const labels = () => dialog.findAllComponents(ElFormItem).map(item => item.props('label'))
|
||||
expect(labels()).toHaveLength(7)
|
||||
state.form.settlementMode = 'DIRECT_PAID'
|
||||
state.form.amount = 120.25
|
||||
state.form.supplierId = '21'
|
||||
state.form.businessDate = '2026-10-19'
|
||||
state.form.paidAt = '2026-10-19T09:00:00'
|
||||
state.form.paymentReferenceNo = ' PAY-001 '
|
||||
await flushPromises()
|
||||
expect(labels()).toHaveLength(11)
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
expect(state.form.amount).toBe(120.25)
|
||||
await dialog.get('[aria-label="退出全屏"]').trigger('click')
|
||||
expect(state.form.paymentReferenceNo).toBe(' PAY-001 ')
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
customerId: '11', containerBookingId: '99', containerNo: 'CONT-99', businessNo: 'CONT-99',
|
||||
amount: 120.25, supplierId: '21', settlementMode: 'DIRECT_PAID',
|
||||
paidAt: '2026-10-19T09:00:00', paymentReferenceNo: 'PAY-001', currency: 'CNY',
|
||||
}), expect.stringContaining('container-fee-create-'))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('调整的所属客户显示只读名称,应收费用隐藏供应商和付款字段', async () => {
|
||||
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', sourceVersion: 7, customerId: '11', customerName: '客户甲', feeItemCode: 'PORT_FEE', amount: 10, businessDate: '2026-10-19', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', paymentVoucherFileIds: [] }])
|
||||
const wrapper = await setup(false, true)
|
||||
await wrapper.findAll('button').find(button => button.text() === '调整')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('调整尾程费用'))!
|
||||
const fields = dialog.findAllComponents(ElFormItem)
|
||||
expect(fields.map(item => item.props('label'))).toEqual(['客户', '费用项目', '费用方向', '业务日期', '金额(CNY)'])
|
||||
const customer = fields[0]!
|
||||
expect(customer.text()).toContain('客户甲')
|
||||
expect(customer.findComponent(ElSelect).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('只有柜次号仍不能维护费用,保留补录真实集装箱号门禁', async () => {
|
||||
const wrapper = await setup(false, true, null)
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('尚未补录真实集装箱号')
|
||||
expect(mocks.create).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => {
|
||||
mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }])
|
||||
const wrapper = await setup()
|
||||
|
||||
@@ -7,11 +7,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { listContainerFeeSupplierOptions, uploadContainerFeePaymentVoucher, type ContainerFeePaymentMethod, type ContainerFeeSettlementMode, type ContainerFeeSupplierOption } from '@/modules/loading-lists'
|
||||
import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerCustomer, type OverseasContainerFeeDirection, type OverseasContainerFeeItem, type OverseasContainerFeeSaveRequest, type OverseasContainerFeeSource } from '../api/overseasContainerFees'
|
||||
|
||||
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
|
||||
const props = withDefaults(defineProps<{ title?: string; containerBookingId: string; cabinetNo?: string | null; containerNo: string | null; readonly?: boolean }>(), { title: '目的港及海外段费用(尾程)', readonly: false })
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<OverseasContainerFeeSource[]>([])
|
||||
const customers = ref<OverseasContainerCustomer[]>([])
|
||||
@@ -146,17 +147,17 @@ async function uploadVoucher(event: Event) {
|
||||
}
|
||||
}
|
||||
async function submit(row: OverseasContainerFeeSource) {
|
||||
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, containerDialogTitle('提交费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
|
||||
}
|
||||
async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'REJECT') {
|
||||
let reason: string | null = null
|
||||
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
|
||||
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', containerDialogTitle('驳回费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
|
||||
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, containerDialogTitle('审核费用', props), { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
|
||||
}
|
||||
async function voidFee(row: OverseasContainerFeeSource) {
|
||||
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
|
||||
const result = await ElMessageBox.prompt('请输入作废原因', containerDialogTitle('作废费用', props), { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
|
||||
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态')
|
||||
}
|
||||
onMounted(load)
|
||||
@@ -182,13 +183,24 @@ onMounted(load)
|
||||
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
<el-form class="density-form" label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form" label-position="left">
|
||||
<table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE'"><td :colspan="form.settlementMode === 'DIRECT_PAID' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item></td><td v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label-position="left" label-width="110px" label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td :colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
|
||||
<AppDialog class="admin-density-dialog last-leg-voucher-dialog" v-model="previewOpen" :title="containerDialogTitle('付款凭证', props)" :fullscreenable="Boolean(previewUrl) && previewIsImage" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
|
||||
<p v-if="previewLoading">正在读取付款凭证…</p>
|
||||
<div v-else-if="previewError"><el-alert type="error" :closable="false" :title="previewError.message" /><ApiErrorMeta :details="previewError" /></div>
|
||||
<div v-else-if="previewUrl" class="voucher-preview"><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
|
||||
<div v-else-if="previewUrl" class="density-dialog-section voucher-preview" data-tone="source"><p class="density-dialog-note">{{ previewName }}</p><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -201,10 +213,13 @@ onMounted(load)
|
||||
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); min-width: 0; }
|
||||
.voucher-item { display: flex; gap: var(--admin-space-3); max-width: 100%; }
|
||||
.voucher-list :deep(.el-button) { white-space: normal; overflow-wrap: anywhere; margin-left: 0; }
|
||||
.voucher-preview { display: grid; gap: var(--admin-space-5); }
|
||||
.voucher-preview { display: grid; gap: var(--admin-space-5); min-width: 0; overflow-wrap: anywhere; }
|
||||
.voucher-preview img { max-width: 100%; max-height: 65vh; object-fit: contain; margin: auto; }
|
||||
.row-actions { white-space: nowrap; }
|
||||
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }
|
||||
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
|
||||
.fee-editor-table { table-layout: fixed; }
|
||||
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-editor-table, .fee-editor-table tbody, .fee-editor-table tr, .fee-editor-table td { display: block; width: 100%; } }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.last-leg-voucher-dialog.is-fullscreen .voucher-preview img { max-height: 80vh; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import type { PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
|
||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ContainerSimulationCreate from './ContainerSimulationCreate.vue'
|
||||
|
||||
// Node's supported CJS export keeps the real form validator's constructor intact.
|
||||
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listWarehouseLogisticsChannels: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ createContainerSimulation: vi.fn() }))
|
||||
const warehouse: PlatformWarehouseSummaryResource = { id: '8', tenantId: 'platform', warehouseCode: 'CN-01', internalCode: null, warehouseName: '测试国内仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }]) })
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('ContainerSimulationCreate opted-in form', () => {
|
||||
it.each([true, false])('preserves required fields and the fixed request in dense=%s mode', async (dense) => {
|
||||
const wrapper = mount(ContainerSimulationCreate, { props: { dense, loadWarehouseOptions: async () => [warehouse] }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await wrapper.findAll('button').find(button => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).not.toHaveBeenCalled()
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(fields).toHaveLength(4)
|
||||
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
|
||||
await flushPromises()
|
||||
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
|
||||
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
|
||||
await flushPromises()
|
||||
const specification = fields.find(field => field.props('label') === '统一规格')!
|
||||
expect(specification.find('input').exists()).toBe(!dense)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -96,13 +96,8 @@ async function saveSimulation() {
|
||||
<template>
|
||||
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
||||
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
|
||||
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
||||
<el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<section :class="{ 'density-dialog-section': dense }" data-tone="work"> <el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" :label-position="dense ? 'left' : 'top'" :label-width="dense ? '120px' : undefined" class="simulation-form" :class="{ 'density-form': dense }"><template v-if="dense"><table :class="{ 'density-table': dense, 'density-table--editable': dense }" aria-label="模拟柜资料"><tbody><tr><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item></td><td><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="统一规格"><span>{{ `${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg` }}</span><small :class="{ 'density-dialog-note': dense }">{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></td></tr></tbody></table></template><template v-else><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></template></el-form></section>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import OceanBookingLinkDialog from './OceanBookingLinkDialog.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
|
||||
@@ -35,6 +36,10 @@ describe('OceanBookingLinkDialog', () => {
|
||||
it('loads matching released-booking requirements when opened', async () => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('关联已放舱订舱 · CAB-1')
|
||||
const identity = wrapper.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('label') === '柜次号')!
|
||||
expect(identity.text()).toContain('CAB-1')
|
||||
expect(identity.find('input').exists()).toBe(false)
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
|
||||
wrapper.unmount()
|
||||
@@ -49,6 +54,9 @@ describe('OceanBookingLinkDialog', () => {
|
||||
const vm = wrapper.vm as unknown as { candidateKey: string; containerNo: string; save: () => Promise<void> }
|
||||
vm.candidateKey = '101:202'
|
||||
vm.containerNo = input
|
||||
await wrapper.findComponent(AppDialog).find('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(vm.containerNo).toBe(input)
|
||||
expect(linkContainerOceanBooking).not.toHaveBeenCalled()
|
||||
await vm.save()
|
||||
expect(linkContainerOceanBooking).toHaveBeenCalledExactlyOnceWith('9001', {
|
||||
oceanBookingId: '101', requirementId: '202', containerNo: expected, expectedVersion: 7,
|
||||
|
||||
@@ -8,6 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; container: ContainerBookingResource | null }>()
|
||||
@@ -98,18 +99,16 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<!-- <el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" /> -->
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
|
||||
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
|
||||
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
|
||||
</el-form>
|
||||
<!-- <p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p> -->
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" :title="containerDialogTitle('关联已放舱订舱', container)" fullscreenable width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form" label-position="left" label-width="160px">
|
||||
<table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
|
||||
<tr><td><el-form-item label="柜次号"><span>{{ container?.cabinetNo || '—' }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<LoadErrorState v-if="error" compact title="关联失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-hint { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('SmartLoadingDialog', () => {
|
||||
expect(wrapper.find('.smart-loading-dialog__warehouses').exists()).toBe(false)
|
||||
expect(document.querySelector('.smart-loading-dialog .el-dialog__body')?.classList.contains('el-dialog__body')).toBe(true)
|
||||
expect(document.querySelector('.smart-loading-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(document.querySelector('.smart-loading-dialog')?.classList.contains('admin-density-dialog')).toBe(true)
|
||||
expect(wrapper.find('button[aria-label="退出全屏"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.get('button[aria-label="退出全屏"]').trigger('click')
|
||||
@@ -45,6 +46,7 @@ describe('SmartLoadingDialog', () => {
|
||||
expect(source).toContain('.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)')
|
||||
expect(source).toContain('height: 94vh; max-height: 94vh;')
|
||||
expect(source).not.toContain('.smart-loading-dialog.app-dialog.el-dialog) { width: min(1600px, 98vw)')
|
||||
expect(source).toContain('.smart-loading-dialog.app-dialog.admin-density-dialog .el-dialog__body')
|
||||
expect(source).toContain('flex: 1; padding: 0; overflow: hidden;')
|
||||
expect(source).toContain('min-height: 0; flex: 1; overflow: hidden;')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import GlobalSmartLoadingView from '../pages/GlobalSmartLoadingView.vue'
|
||||
|
||||
@@ -8,7 +9,7 @@ const open = defineModel<boolean>({ default: false })
|
||||
<template>
|
||||
<AppDialog
|
||||
v-model="open"
|
||||
class="smart-loading-dialog"
|
||||
class="smart-loading-dialog admin-density-dialog"
|
||||
title="智能装柜"
|
||||
width="min(1600px, 98vw)"
|
||||
fullscreenable
|
||||
@@ -23,7 +24,7 @@ const open = defineModel<boolean>({ default: false })
|
||||
|
||||
<style scoped>
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: min(1600px, 98vw); height: 94vh; max-height: 94vh; margin-block: 3vh !important; }
|
||||
:global(.smart-loading-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
|
||||
:global(.smart-loading-dialog.app-dialog.admin-density-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; padding: 0; overflow: hidden; }
|
||||
:global(.smart-loading-dialog .app-dialog__body) { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
|
||||
@media (max-width: 720px) {
|
||||
:global(.smart-loading-dialog.app-dialog.el-dialog:not(.is-fullscreen)) { width: calc(100vw - 12px); height: calc(100svh - 12px); max-height: calc(100svh - 12px); margin-block: 6px !important; }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { containerDialogTitle } from './containerDialogTitle'
|
||||
|
||||
describe('containerDialogTitle business identity', () => {
|
||||
it('uses the stable cabinet number when both business identifiers exist', () => {
|
||||
expect(containerDialogTitle('编辑货柜资料', { cabinetNo: ' CB-001 ', containerNo: 'MSCU1234567' })).toBe('编辑货柜资料 · CB-001')
|
||||
})
|
||||
it('uses a real container number if the cabinet number is missing', () => {
|
||||
expect(containerDialogTitle('确认还柜', { cabinetNo: ' ', containerNo: ' MSCU1234567 ' })).toBe('确认还柜 · MSCU1234567')
|
||||
})
|
||||
it('never substitutes an internal ID for a missing business identifier', () => {
|
||||
const container = { id: 'private-container-id', cabinetNo: null, containerNo: ' ' }
|
||||
expect(containerDialogTitle('直送卸货登记', container)).toBe('直送卸货登记')
|
||||
expect(containerDialogTitle('直送卸货登记', null)).toBe('直送卸货登记')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
type ContainerIdentity = { cabinetNo?: string | null; containerNo?: string | null }
|
||||
|
||||
export function containerDialogTitle(action: string, container?: ContainerIdentity | null) {
|
||||
const code = container?.cabinetNo?.trim() || container?.containerNo?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
}
|
||||
+33
-9
@@ -93,14 +93,16 @@ describe('ContainerDetailView empty return', () => {
|
||||
const wrapper = await mountDetail()
|
||||
expect(getDirectDeliveries).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00')
|
||||
for (const [label, value] of [['地点时区(IANA)', 'America/Los_Angeles'], ['签收人', '测试签收人'], ['真实签收凭据号', 'POD-001']]) {
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === label)!.findComponent(ElInput).vm.$emit('update:modelValue', value)
|
||||
}
|
||||
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
await flushPromises(); await dialog.find('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.findComponent(ElDatePicker).props('modelValue')).toBe('2026-10-16T01:00:00')
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(recordDirectDelivery).mock.calls[0]!
|
||||
expect(first.slice(0, 3)).toEqual(['9001', row.requirementKey, { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
expect(dialog.text()).not.toContain('9007199254740999')
|
||||
@@ -114,24 +116,46 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
const wrapper = await mountDetail()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
|
||||
expect(dialog.findAll('input, textarea')).toHaveLength(0)
|
||||
expect(dialog.text()).toContain('POD-001')
|
||||
expect(dialog.text()).toContain('America/Los_Angeles')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mislabel a cabinet number as a real container number or an unnamed fleet as unassigned', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
const withoutNumber = { ...(await getContainer('9001')), containerNo: '' }
|
||||
const unnamedFleet = { ...(await getTerminalDrayage('9001')), containerNo: '', fleetSupplierId: 'private-supplier-id', fleetSupplierName: '' }
|
||||
vi.mocked(getContainer).mockResolvedValue(withoutNumber)
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue(unnamedFleet)
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
const fields = dialog.findAllComponents(ElFormItem)
|
||||
const number = fields.find(field => field.props('label') === '真实集装箱号')!
|
||||
expect(number.text()).toBe('真实集装箱号—')
|
||||
const fleet = fields.find(field => field.props('label') === '当前车队')!
|
||||
expect(fleet.text()).toBe('当前车队—')
|
||||
expect(fleet.text()).not.toContain('private-supplier-id')
|
||||
expect(fleet.text()).not.toContain('尚未安排')
|
||||
})
|
||||
|
||||
it('opens the pickup action and saves an actual overseas supplier before the explicit pickup appointment', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
const current = { ...base.terminalDrayage, actualPickupAt: null, allowedActions: ['SELECT_FLEET', 'SCHEDULE_PICKUP'] }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValueOnce(current as never).mockResolvedValue({ ...current, version: 4 } as never)
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740999', name: '测试海外车队' }], total: 1 } as never)
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
expect(dialog.props('modelValue')).toBe(true); expect(selectTerminalDrayageFleet).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElSelect).vm.$emit('visible-change', true); await flushPromises()
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '' })
|
||||
dialog.findComponent(ElSelect).vm.$emit('update:modelValue', '9007199254740999'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存码头车队')!.trigger('click'); await flushPromises()
|
||||
const fleetButton = dialog.findAll('button').find(button => button.text() === '保存码头车队')!
|
||||
expect(fleetButton.element.closest('.el-dialog__footer')).not.toBeNull()
|
||||
await fleetButton.trigger('click'); await flushPromises()
|
||||
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '9007199254740999', fleetSupplierName: '测试海外车队', reason: undefined }, expect.any(String))
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
@@ -143,7 +167,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, allowedActions: ['SCHEDULE_PICKUP'] } as never)
|
||||
vi.mocked(scheduleTerminalDrayagePickup).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(scheduleTerminalDrayagePickup).mock.calls[0]!
|
||||
@@ -155,7 +179,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, actualPickupAt: null } as never)
|
||||
vi.mocked(recordTerminalPickup).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('提柜安排与执行'))!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises(); expect(recordTerminalPickup).not.toHaveBeenCalled()
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '实际提柜当地时间(登记实际时必填)')!.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-14T23:00:00')
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '处理说明')!.findComponent(ElInput).vm.$emit('update:modelValue', '司机提供已提柜记录')
|
||||
@@ -175,7 +199,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
await flushPromises()
|
||||
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('已确认离仓')
|
||||
expect(wrapper.text()).toContain('整柜已确认离仓;')
|
||||
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
|
||||
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
|
||||
})
|
||||
|
||||
@@ -211,7 +235,7 @@ describe('ContainerDetailView empty return', () => {
|
||||
expect(wrapper.text()).not.toContain('装柜完成')
|
||||
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '确认还柜')!
|
||||
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('确认还柜'))!
|
||||
emptyDialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-09-09T12:00:00'); await flushPromises()
|
||||
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
|
||||
confirm.click()
|
||||
|
||||
@@ -75,7 +75,10 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
|
||||
expect(source).toContain('v-if="canViewFirstLegFees && firstLegFeesOpened"')
|
||||
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
|
||||
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
|
||||
const firstLegFees = source.match(/<ContainerFeesPanel\b[^>]*\/>/)?.[0] || ''
|
||||
for (const attribute of ['title="国内段费用(头程)"', ':plan-id="overview.loadingPlanId"', ':container-booking-id="detail.id"', ':cabinet-no="detail.cabinetNo"', ':container-no="detail.containerNo"', ':customers="feeCustomers"', ':business-references="feeBusinessReferences"']) {
|
||||
expect(firstLegFees).toContain(attribute)
|
||||
}
|
||||
expect(source).not.toContain(':readonly="isOverseas"')
|
||||
expect(source).toContain('v-if="canViewLastLegFees && lastLegFeesOpened"')
|
||||
expect(source).toContain('title="目的港及海外段费用(尾程)"')
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||||
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
|
||||
import { containerChannelLabel } from '../containerChannelLabel'
|
||||
@@ -172,7 +173,7 @@ async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
|
||||
async function leaveTerminal() {
|
||||
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', '未保存的安排', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', containerDialogTitle('未保存的安排', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
|
||||
onBeforeRouteLeave(leaveTerminal)
|
||||
@@ -186,7 +187,7 @@ const directPending = ref<{ containerId: string; requirementKey: string; body: D
|
||||
async function leaveDirect() {
|
||||
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
onBeforeRouteLeave(leaveDirect)
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
@@ -287,7 +288,7 @@ const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEm
|
||||
async function leaveEmptyReturn() {
|
||||
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
|
||||
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
|
||||
onBeforeRouteLeave(leaveEmptyReturn)
|
||||
@@ -486,7 +487,7 @@ async function confirmDeparture() {
|
||||
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
||||
outboundConfirming.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
|
||||
} catch { outboundConfirming.value = false; return }
|
||||
outboundError.value = null
|
||||
try {
|
||||
@@ -691,7 +692,7 @@ onMounted(async () => {
|
||||
<h2 id="container-voyage-heading">船名航次信息</h2>
|
||||
<ContainerPortSchedulePanel :container="detail" @saved="load" />
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||
@@ -710,8 +711,8 @@ onMounted(async () => {
|
||||
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
||||
<template v-else-if="outboundDetail">
|
||||
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
|
||||
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
|
||||
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">{{ customsDepartureState === 'DEPARTED' ? '整柜已确认离仓;展开可查看去重后的完整门禁。' : '全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。' }}</p>
|
||||
|
||||
|
||||
<div class="gate-list">
|
||||
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
|
||||
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
|
||||
@@ -736,49 +737,45 @@ onMounted(async () => {
|
||||
|
||||
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
|
||||
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
|
||||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" />
|
||||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form class="density-form" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="directOpen" title="直送卸货登记" width="1080px" :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<p>按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。</p>
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<p class="density-dialog-note">按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p>
|
||||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||
<table v-if="!directError && !directLoading" class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table>
|
||||
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></section>
|
||||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||
<template v-if="directSelected"><h3>{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="top" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><el-form-item label="实际卸货当地时间" required><el-date-picker v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="地点时区(IANA)" required><el-input v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item><el-form-item label="签收人" required><el-input v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item><el-form-item label="真实签收凭据号" required><el-input v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item><el-form-item label="备注"><el-input v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></template>
|
||||
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="terminalOpen" title="提柜安排与执行" width="780px" :show-confirm="false" :before-close="closeTerminal">
|
||||
<AppDialog class="admin-density-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
|
||||
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||
<template v-if="terminal"><p>{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}</p><p>实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。</p>
|
||||
<el-form class="density-form" label-position="top" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError">
|
||||
<el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item>
|
||||
<LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
<el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<template v-if="canRecordPickup"><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></template>
|
||||
<el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="density-actions"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button></div>
|
||||
<el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button>
|
||||
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
|
||||
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><table class="density-table density-table--editable container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
|
||||
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</AppDialog>
|
||||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">取消</el-button><template v-if="terminal"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button><el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button></template></div></template></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-workflow-table { table-layout: fixed; }
|
||||
.direct-remark { white-space: pre-wrap; }
|
||||
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
||||
.loading-list-heading h2 { margin: 0; font-size: var(--admin-font-title); }
|
||||
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('ContainersView', () => {
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
|
||||
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
|
||||
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
|
||||
await returnDialog.find('textarea').setValue('还柜凭证已核对')
|
||||
const callsBefore = vi.mocked(listContainers).mock.calls.length
|
||||
@@ -176,7 +176,7 @@ describe('ContainersView', () => {
|
||||
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ scope: 'ACTIVE' }), 'OVERSEAS')
|
||||
expect(wrapper.text()).toContain('MSCU-1')
|
||||
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
|
||||
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title') === '确认还柜' && dialog.props('modelValue'))).toBe(false)
|
||||
expect(wrapper.findAllComponents(AppDialog).some((dialog) => dialog.props('title').startsWith('确认还柜') && dialog.props('modelValue'))).toBe(false)
|
||||
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
|
||||
expect(getTerminalDrayage).not.toHaveBeenCalled()
|
||||
if (missing === 'historical unloaded snapshot') {
|
||||
@@ -200,7 +200,7 @@ describe('ContainersView', () => {
|
||||
const open = async () => {
|
||||
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
|
||||
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('确认还柜'))!
|
||||
}
|
||||
const first = await open()
|
||||
first.vm.$emit('update:modelValue', false)
|
||||
@@ -270,7 +270,19 @@ describe('ContainersView', () => {
|
||||
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
|
||||
expect(listSuppliers).toHaveBeenCalledTimes(1)
|
||||
expect(listOceanBookings).toHaveBeenCalledTimes(1)
|
||||
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
|
||||
const editor = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('编辑货柜资料'))!
|
||||
expect(editor.props('title')).toBe('编辑货柜资料 · CB-1')
|
||||
const editorFields = editor.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(editorFields).toHaveLength(17)
|
||||
for (const label of ['真实集装箱号', '柜次号', '货柜容量(m³)', '重量上限(kg)']) expect(editorFields.find(field => field.props('label') === label)!.find('input').exists()).toBe(false)
|
||||
const vessel = editorFields.find(field => field.props('label') === '船名')!.findComponent({ name: 'ElInput' })
|
||||
vessel.vm.$emit('update:modelValue', '编辑中的船名')
|
||||
await flushPromises()
|
||||
await editor.find('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(editor.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(vessel.props('modelValue')).toBe('编辑中的船名')
|
||||
expect(updateContainer).not.toHaveBeenCalled()
|
||||
editor.vm.$emit('update:modelValue', false)
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '编辑')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
|
||||
@@ -417,9 +429,9 @@ describe('ContainersView', () => {
|
||||
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
|
||||
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
|
||||
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
|
||||
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
|
||||
expect(source).toContain('width="min(1080px, calc(100vw - 28px))"')
|
||||
expect(source).toContain('admin-density-dialog')
|
||||
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
|
||||
expect(source).toContain('class="density-table density-table--editable container-editor-table"')
|
||||
expect(source).not.toContain('AppDrawer')
|
||||
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
@@ -588,7 +600,7 @@ describe('ContainersView', () => {
|
||||
expect(link).toBeDefined()
|
||||
link.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '关联已放舱订舱')?.props('modelValue')).toBe(true)
|
||||
expect(wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))?.props('modelValue')).toBe(true)
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
@@ -617,8 +629,8 @@ describe('ContainersView', () => {
|
||||
expect(simulationSource).toContain("channelGroupCode: '', warehouseId: '', containerType: DEFAULT_CONTAINER_TYPE, capacityCbm: 68, maxWeightKg: 19500")
|
||||
expect(source).toContain('CONTAINER_SPECIFICATIONS')
|
||||
expect(simulationSource).toContain('selectSimulationContainerType')
|
||||
expect(simulationSource).toContain('服务端会生成稳定的柜次号')
|
||||
expect(source).toContain(':model-value="form.cabinetNo" disabled placeholder="系统生成"')
|
||||
expect(simulationSource).not.toContain('创建后自动生成柜次号')
|
||||
expect(source).toContain("{{ form.cabinetNo || '—' }}")
|
||||
expect(source).not.toContain('v-model="form.cabinetNo"')
|
||||
expect(source).not.toContain('cabinetNo: form.cabinetNo.trim()')
|
||||
expect(source).not.toContain('openCreate')
|
||||
@@ -725,7 +737,7 @@ describe('ContainersView', () => {
|
||||
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents(AppDialog)
|
||||
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
|
||||
.find((dialog) => dialog.props('title').startsWith('关联已放舱订舱'))!.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').trigger('click')
|
||||
|
||||
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||||
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
import { containerChannelLabel } from '../containerChannelLabel'
|
||||
@@ -147,7 +148,7 @@ async function retryEmail(mail: TerminalDrayageEmail) {
|
||||
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
|
||||
retryingEmail.value = mail.id
|
||||
try {
|
||||
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', containerDialogTitle('重试邮件', emailTarget.value), { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
|
||||
await retryTerminalDrayageEmail(target.id, mail.id)
|
||||
ElMessage.success('邮件已重新加入发送队列')
|
||||
await openEmailHistory(target)
|
||||
@@ -197,7 +198,7 @@ const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:termi
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||
const editorTitle = computed(() => form.id ? containerDialogTitle('编辑货柜资料', form) : '新增')
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
||||
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
|
||||
interface PortOption { value: string; code: string | null; name: string | null }
|
||||
@@ -775,59 +776,52 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
|
||||
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(1080px, calc(100vw - 28px))" fullscreenable :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
|
||||
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form">
|
||||
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
|
||||
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
|
||||
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
|
||||
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
||||
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
|
||||
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
|
||||
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
|
||||
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
|
||||
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
|
||||
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
|
||||
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
|
||||
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
|
||||
</el-form>
|
||||
<p class="density-dialog-note">货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</p>
|
||||
<section v-if="!warehouseError" class="density-dialog-section" data-tone="work">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form">
|
||||
<table class="density-table density-table--editable container-editor-table" aria-label="货柜基础资料"><tbody>
|
||||
<tr><td><el-form-item label="真实集装箱号"><span>{{ form.containerNo || '待补录' }}</span></el-form-item></td><td><el-form-item label="柜次号"><span>{{ form.cabinetNo || '—' }}</span></el-form-item></td><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" /></el-form-item></td><td><el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item></td><td><el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item></td><td><el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item></td><td><el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item></td><td><el-form-item label="货柜容量(m³)"><span>{{ form.capacityCbm == null ? '—' : `${form.capacityCbm} m³` }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="重量上限(kg)"><span>{{ form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg` }}</span></el-form-item></td><td><el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item></td><td colspan="2"><el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="containerDialogTitle(drayageActionText(drayageAction), drayageTarget)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
|
||||
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
|
||||
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
|
||||
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" :title="containerDialogTitle('车队邮件记录', emailTarget)" fullscreenable width="min(1100px, calc(100vw - 28px))" :show-footer="false">
|
||||
<p class="density-dialog-note">集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
|
||||
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
|
||||
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
|
||||
<section v-else class="density-dialog-section" data-tone="source"><DataTable :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
|
||||
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
|
||||
<template #emailActions="{ row }"><el-button class="density-action" v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
|
||||
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
|
||||
</DataTable>
|
||||
</DataTable></section>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" :title="containerDialogTitle('码头拖车记录', drayageTarget)" fullscreenable width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
|
||||
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
|
||||
<div v-else class="density-dialog-sections"><section v-if="drayageTarget && drayageFor(drayageTarget.id)" class="density-dialog-section" data-tone="summary"><el-descriptions :column="4" border class="density-descriptions"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions></section><section class="density-dialog-section" data-tone="source"><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></section></div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
|
||||
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
|
||||
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
|
||||
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
@media(max-width:720px){.form-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { ContainerBookingResource } from '../types'
|
||||
import {
|
||||
assignPendingReviewSmartLoadingOrder,
|
||||
@@ -118,6 +119,49 @@ function mockContainerSearch(records: ContainerBookingResource[]) {
|
||||
}
|
||||
|
||||
describe('GlobalSmartLoadingView', () => {
|
||||
it.each(['PACKAGE', 'SEALED_PALLET'] as const)('分配窗口全屏保留来源及输入,按%s原接口提交', async (unitType) => {
|
||||
if (unitType === 'SEALED_PALLET') {
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue(candidatePage([{
|
||||
...candidate, unitType, palletId: '801', palletNo: 'DP-TEST-1', indivisible: true,
|
||||
groupMembers: [
|
||||
{ accountId: '1', inboundId: '180', orderId: '180', orderNo: 'O180', inboundCode: 'I180', customerName: '客户甲', availablePackageCount: 3, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '181', orderId: '181', orderNo: 'O181', inboundCode: 'I181', customerName: '客户甲', availablePackageCount: 9, orderStatusSnapshot: 'WAIT_SPLIT', expectedArrivalAt: null },
|
||||
],
|
||||
}]))
|
||||
}
|
||||
vi.mocked(assignSmartLoadingItem).mockResolvedValue(plan)
|
||||
vi.mocked(assignSmartLoadingPallet).mockResolvedValue(plan)
|
||||
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.get('.candidate-pane .el-table__body-wrapper').findAll('button').find(button => button.text() === '加入')!.trigger('click')
|
||||
await flushPromises()
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.get('table[aria-label="全局装柜分配来源"]').text()).toContain('CB-1')
|
||||
if (unitType === 'PACKAGE') {
|
||||
const input = popup.getComponent(ElInputNumber)
|
||||
expect(input.props('modelValue')).toBe(candidate.availablePackageCount)
|
||||
expect(input.props('max')).toBe(candidate.availablePackageCount)
|
||||
input.vm.$emit('update:modelValue', 4)
|
||||
} else {
|
||||
expect(popup.findComponent(ElInputNumber).exists()).toBe(false)
|
||||
expect(popup.get('details').text()).toContain('I181:9 箱')
|
||||
expect(popup.props('title')).toBe('整托加入当前柜次 · DP-TEST-1')
|
||||
}
|
||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(popup.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
if (unitType === 'PACKAGE') expect(popup.getComponent(ElInputNumber).props('modelValue')).toBe(4)
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
if (unitType === 'PACKAGE') {
|
||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(plan.id, { inboundId: candidate.inboundId, packageCount: 4, expectedVersion: plan.version })
|
||||
expect(assignSmartLoadingPallet).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(assignSmartLoadingPallet).toHaveBeenCalledWith(plan.id, { palletId: '801', expectedVersion: plan.version })
|
||||
expect(assignSmartLoadingItem).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(popup.props('modelValue')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
|
||||
@@ -139,6 +183,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
it('loads real containers, candidates and the selected plan into the three-column workbench', async () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.container-option.selected').attributes('aria-pressed')).toBe('true')
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith({ channelGroupCodes: ['MATSON_CLX'], page: 1, size: 20 })
|
||||
@@ -498,8 +543,13 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
expect(document.body.textContent).toContain('目标柜次:CB-1 · 集装箱号:MSCU-1')
|
||||
expect(document.body.textContent).toContain('发货仓:义乌仓 · 柜次渠道:美森正班')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
||||
const contextRows = popup.findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||
expect(contextRows[0]!.findAll('td').map(cell => cell.text())).toEqual(['CB-1', 'MSCU-1'])
|
||||
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
|
||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(popup.get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
|
||||
expect(document.body.textContent).not.toContain('本次分配箱数')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
@@ -533,10 +583,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(add.classes()).not.toContain('is-disabled')
|
||||
await add.trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('平均单箱毛重 待测量')
|
||||
expect(document.body.textContent).toContain('平均单箱体积 待测量')
|
||||
expect(document.body.textContent).not.toContain('平均单箱毛重 0 kg')
|
||||
expect(document.body.textContent).toContain('订单渠道:合德海卡')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('加入当前柜次 · EXXHK2609020361')
|
||||
const measurements = popup.findAll('table[aria-label="全局装柜分配填写"] tr').find(row => row.text().includes('平均单箱毛重'))!
|
||||
expect(measurements.findAll('td').map(cell => cell.text())).toEqual(['待测量', '待测量'])
|
||||
const channel = popup.findAll('table[aria-label="全局装柜分配来源"] tr').find(row => row.text().includes('订单渠道'))!
|
||||
expect(channel.findAll('td')[0]!.text()).toBe('合德海卡')
|
||||
expect(document.body.textContent).toContain('这是人工装柜分配,确认后仍会加入')
|
||||
expect(document.body.textContent).toContain('智能推荐不会跨渠道')
|
||||
;([...document.body.querySelectorAll('button')]
|
||||
@@ -876,6 +928,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
await removeAllButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.any(String), '移除未拣明细 · CB-1', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledTimes(1)
|
||||
expect(removeAllUnpickedSmartLoadingItems).toHaveBeenCalledWith(plan.id, 2)
|
||||
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
|
||||
@@ -1066,7 +1119,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(source).toContain('.channel-tags :deep(.el-checkbox-button__inner)')
|
||||
expect(source).toContain('flex-wrap: nowrap; gap: 6px;')
|
||||
expect(source).toContain('height: 34px; padding: 0 10px;')
|
||||
expect(source).toContain('font-size: 13px; line-height: 32px;')
|
||||
expect(source).toContain('font-size: var(--admin-font-body); line-height: 32px;')
|
||||
expect(source).not.toContain('class="summary-band"')
|
||||
expect(source).toContain('class="capacity-grid"')
|
||||
expect(source).toContain('智能推荐')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Pencil, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
||||
@@ -65,6 +66,12 @@ const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const noDraft = ref(false)
|
||||
const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
|
||||
const assignmentTitle = computed(() => {
|
||||
const candidate = selectedCandidate.value
|
||||
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'
|
||||
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim() || candidate?.orderNo?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
})
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
|
||||
@@ -590,6 +597,10 @@ async function adjust(source: object) {
|
||||
ElMessage.success('箱数已调整')
|
||||
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
||||
}
|
||||
function workbenchDialogTitle(action: string) {
|
||||
const code = selectedContainer.value?.cabinetNo?.trim() || (hasRealContainerNo(selectedContainer.value) ? selectedContainer.value?.containerNo?.trim() : '')
|
||||
return code ? `${action} · ${code}` : action
|
||||
}
|
||||
async function remove(source: object) {
|
||||
const item = itemRow(source)
|
||||
if (!plan.value || !item.canRemove) return
|
||||
@@ -600,8 +611,8 @@ async function remove(source: object) {
|
||||
: sealedPallet
|
||||
? `确认从当前柜次整托移除托盘“${item.palletNo || '—'}”(${item.packageCount} 箱)吗?`
|
||||
: `确认从当前柜次移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`,
|
||||
row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细', {
|
||||
type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
|
||||
workbenchDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : sealedPallet ? '整托移除' : '移除装柜明细'), {
|
||||
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : sealedPallet ? '整托移除' : '移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
@@ -632,8 +643,8 @@ async function removeAll() {
|
||||
const removableItems = displayedPlanItems.value.filter((item) => item.canRemove)
|
||||
if (!removableItems.length) return
|
||||
const itemCount = removableItems.length
|
||||
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, '移除未拣明细', {
|
||||
type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
|
||||
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的 ${itemCount} 个未拣入仓码吗?已拣入仓码会保留。`, workbenchDialogTitle('移除未拣明细'), {
|
||||
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认移除', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
@@ -676,7 +687,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
|
||||
<component class="admin-density" :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
|
||||
<section class="global-loading-page">
|
||||
<div v-if="channelOptions.length" class="channel-summary-row">
|
||||
<el-checkbox-group :model-value="selectedChannelCodes" class="channel-tags" aria-label="渠道筛选,至少选择一项" @change="selectChannels($event as string[])">
|
||||
@@ -743,9 +754,10 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<template #pagination-left="{ total, page, pageCount }"><span v-if="pageCount > 0">共 {{ total }} 条 · {{ page }}/{{ pageCount }} 页</span><span v-else>共 0 条</span></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button
|
||||
class="density-action" data-tone="primary"
|
||||
v-if="canAssign && assignable"
|
||||
size="small"
|
||||
type="text"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="mutating || candidateBlocked(row as SmartLoadingCandidateRow)"
|
||||
@click="openAssign(row)"
|
||||
@@ -774,6 +786,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
type="button"
|
||||
class="container-option"
|
||||
:class="{ selected: item.bookingId === selectedContainerId }"
|
||||
:aria-pressed="item.bookingId === selectedContainerId"
|
||||
@click="selectContainer(item.bookingId)"
|
||||
>
|
||||
<span class="container-option__top">
|
||||
@@ -825,7 +838,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
||||
</div>
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
@@ -837,28 +850,38 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '加入当前柜次'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
||||
<div v-if="selectedCandidate" class="assign-dialog">
|
||||
<p><strong>{{ selectedCandidate.groupNo || selectedCandidate.customerName || '客户待补充' }}</strong></p>
|
||||
<p>目标柜次:{{ displayCabinetNo(selectedContainer) }} · 集装箱号:{{ displayActualContainerNo(selectedContainer) }}</p>
|
||||
<p>发货仓:{{ selectedContainer?.warehouseName || '—' }} · 柜次渠道:{{ containerChannelGroup(selectedContainer)?.name || '—' }}</p>
|
||||
<p>订单渠道:{{ selectedCandidate.channelName || '—' }}</p>
|
||||
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(920px, calc(100vw - 28px))" fullscreenable :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认加入'" @confirm="assign">
|
||||
<div v-if="selectedCandidate" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<table class="density-table assignment-context-table" aria-label="全局装柜分配来源"><tbody>
|
||||
<tr><th>目标柜次</th><td>{{ displayCabinetNo(selectedContainer) }}</td><th>集装箱号</th><td>{{ displayActualContainerNo(selectedContainer) }}</td></tr>
|
||||
<tr><th>发货仓</th><td>{{ selectedContainer?.warehouseName || '—' }}</td><th>柜次渠道</th><td>{{ containerChannelGroup(selectedContainer)?.name || '—' }}</td></tr>
|
||||
<tr><th>订单渠道</th><td>{{ selectedCandidate.channelName || '—' }}</td><th>{{ selectedCandidate.coLoadGroupId ? '同柜组' : '客户' }}</th><td>{{ selectedCandidate.coLoadGroupId ? selectedCandidate.groupNo : selectedCandidate.customerName || '客户待补充' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
|
||||
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
|
||||
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>托盘号</th><td>{{ selectedCandidate.palletNo || '—' }}</td><th>整托箱数</th><td>整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<template v-else>
|
||||
<tr><th>入仓码</th><td>{{ selectedCandidate.inboundCode || '—' }}</td><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<tr><th>平均单箱毛重</th><td>{{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }}</td><th>平均单箱体积</th><td>{{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</td></tr>
|
||||
<tr><td colspan="4"><el-form-item label="本次分配箱数" label-position="left" label-width="128px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
|
||||
</template>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p>
|
||||
<p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p>
|
||||
<section class="density-dialog-section" data-tone="source"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></section>
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||
<p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p>
|
||||
<SmartLoadingCandidateInbounds :candidate="selectedCandidate" />
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>入仓码 {{ selectedCandidate.inboundCode || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱 · 平均单箱毛重 {{ formatMeasurement(selectedCandidate.unitGrossWeightKg, 'kg', 1) }} · 平均单箱体积 {{ formatMeasurement(selectedCandidate.averageUnitVolumeCbm, 'm³') }}</p>
|
||||
<small>本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</small>
|
||||
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form>
|
||||
</template>
|
||||
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -867,24 +890,26 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assignment-context-table { table-layout: fixed; }
|
||||
.assignment-context-table th { width: 120px; }
|
||||
.global-loading-page { display: flex; min-width: 0; min-height: 0; flex-direction: column; gap: 12px; }
|
||||
.embedded-smart-loading { display: flex; width: 100%; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
|
||||
.embedded-smart-loading .global-loading-page { width: 100%; min-height: 0; flex: 1; overflow: hidden; }
|
||||
.channel-summary-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; min-width: 0; align-items: center; border-bottom: 2px solid var(--cc-border); background: #fff; }
|
||||
.channel-tags { display: flex; min-width: 0; align-items: center; flex-wrap: nowrap; gap: 6px; padding: 6px 0; overflow-x: auto; }
|
||||
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: 13px; font-weight: 600; }
|
||||
.channel-tags__label { flex: 0 0 auto; margin-right: 2px; color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.channel-tags :deep(.el-checkbox-button) { flex: 0 0 auto; }
|
||||
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: 13px; line-height: 32px; }
|
||||
.channel-tags :deep(.el-checkbox-button__inner) { height: 34px; padding: 0 10px; border: 1px solid var(--cc-border) !important; border-radius: 6px !important; box-shadow: none !important; font-size: var(--admin-font-body); line-height: 32px; }
|
||||
.compact-summary { display: flex; min-height: 46px; align-items: center; justify-content: flex-end; gap: 12px; margin: 0; padding: 5px 8px; white-space: nowrap; }
|
||||
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
|
||||
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
|
||||
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
|
||||
.compact-summary dt { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-caption); }
|
||||
.compact-summary strong { color: var(--el-color-primary); font-size: var(--admin-font-section); }
|
||||
:deep(.smart-loading-query.query-toolbar) { padding: 0; border: 0; background: transparent; }
|
||||
:deep(.smart-loading-query .query-toolbar__filters) { flex-wrap: nowrap; gap: 8px; overflow-x: auto; }
|
||||
:deep(.smart-loading-query .query-toolbar__tail) { flex-wrap: nowrap; gap: 8px; }
|
||||
:deep(.smart-loading-query .query-toolbar__actions) { flex-wrap: nowrap; gap: 6px; }
|
||||
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
|
||||
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.dispatch-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 2fr) minmax(0, 3fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.embedded-smart-loading .dispatch-grid { height: auto; min-height: 0; flex: 1; }
|
||||
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
|
||||
@@ -902,9 +927,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
|
||||
.container-option { display: grid; width: 100%; min-height: 112px; height: auto; align-content: start; gap: 6px; padding: 14px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.4; text-align: left; cursor: pointer; }
|
||||
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
|
||||
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.container-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
|
||||
.container-option__identity { color: var(--cc-text); font-size: 13px; font-weight: 600; }
|
||||
.container-option__identity { color: var(--cc-text); font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.container-option__hint { color: var(--el-color-warning); font-weight: 600; }
|
||||
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
.plan-header { flex-wrap: wrap; }
|
||||
@@ -914,17 +939,15 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
|
||||
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
|
||||
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
|
||||
.readonly-status-card strong { font-size: 16px; }
|
||||
.readonly-status-card strong { font-size: var(--admin-font-title); }
|
||||
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: var(--admin-font-caption); }
|
||||
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
|
||||
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
|
||||
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.package-count-summary strong { font-size: 13px; font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
|
||||
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 4px; }
|
||||
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
|
||||
.candidate-group :deep(.line-clamp-text) { width: 100%; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { AxiosError } from 'axios'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
@@ -75,7 +76,9 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).not.toContain('LP-99')
|
||||
expect(wrapper.text()).toContain('IN-1')
|
||||
expect(wrapper.text()).not.toContain('ORD-1')
|
||||
expect(wrapper.text()).toContain('150% 容量上限')
|
||||
expect(wrapper.text()).not.toContain('150% 容量上限')
|
||||
await wrapper.get('button[aria-label="查看装柜说明"]').trigger('mouseenter')
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('150% 容量上限'))
|
||||
})
|
||||
|
||||
it('shows the full inbound composition for an assigned sealed pallet', async () => {
|
||||
@@ -189,6 +192,11 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')).toHaveLength(1)
|
||||
await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text() === '加入')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('整组加入当前柜次 · CLG-9007199254740999')
|
||||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.get('table[aria-label="同柜组成员"]').text()).toContain('ORD-1:IN-201、IN-203')
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
expect(assignSmartLoadingGroup).toHaveBeenCalledWith(planResponse.id, '201', planResponse.version)
|
||||
@@ -222,9 +230,14 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await wrapper.find('.el-table__body-wrapper button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('本次计划装柜箱数')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('按入仓码分配箱数 · IN-1')
|
||||
const count = dialog.getComponent({ name: 'ElInputNumber' })
|
||||
expect(count.props('max')).toBe(12)
|
||||
count.vm.$emit('update:modelValue', 5)
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认分配') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 1, expectedVersion: planResponse.version })
|
||||
expect(assignSmartLoadingItem).toHaveBeenCalledWith(planResponse.id, { inboundId: '2', packageCount: 5, expectedVersion: planResponse.version })
|
||||
expect(assignSmartLoadingGroup).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -245,6 +258,10 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
|
||||
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认整托分配') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import SmartLoadingCandidateInbounds from '../components/SmartLoadingCandidateInbounds.vue'
|
||||
import { containerDialogTitle } from '../containerDialogTitle'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -38,6 +40,12 @@ const noDraft = ref(false)
|
||||
const editingQuantityId = ref('')
|
||||
const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidateRow | null>(null)
|
||||
const assignmentTitle = computed(() => {
|
||||
const candidate = selectedCandidate.value
|
||||
const action = candidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : candidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'
|
||||
const code = candidate?.palletNo?.trim() || candidate?.groupNo?.trim() || candidate?.inboundCode?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
})
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const planGroupsByInboundId = ref(new Map<string, SmartLoadingPlanCoLoadGroup>())
|
||||
@@ -307,7 +315,7 @@ async function remove(source: object) {
|
||||
const message = row.coLoadGroup
|
||||
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
|
||||
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
await ElMessageBox.confirm(message, containerDialogTitle(row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', container.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
if (!confirmed || !plan.value) return
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
@@ -386,7 +394,7 @@ onMounted(load)
|
||||
<el-alert v-if="plan.status === 'LOADING' && plan.canEdit" type="warning" :closable="false" show-icon title="已下发 PDA,仍可维护未完成的装柜明细" description="新增、增加或移除未拣明细会刷新 PDA 明细与版本;PDA 端需要刷新后继续操作。" />
|
||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案 <InlineHelp title="装柜说明"><p>智能建议必须经人工核对。</p><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></InlineHelp></h2></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
|
||||
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
@@ -396,7 +404,7 @@ onMounted(load)
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
|
||||
<template #toolbar-start><div><h2>候选货物</h2></div></template>
|
||||
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">加入</el-button></template>
|
||||
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
@@ -408,7 +416,35 @@ onMounted(load)
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 整托 {{ selectedCandidate.availablePackageCount }} 箱</p><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign">
|
||||
<div v-if="selectedCandidate" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
|
||||
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
|
||||
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
|
||||
<tr v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><th>整托箱数</th><td colspan="3">整托 {{ selectedCandidate.availablePackageCount }} 箱</td></tr>
|
||||
<template v-else>
|
||||
<tr><th>货物摘要</th><td colspan="3"><LineClampText :content="selectedCandidate.goodsSummary || '货物摘要暂缺'" /></td></tr>
|
||||
<tr><th>可用箱数</th><td>{{ selectedCandidate.availablePackageCount }} 箱</td><td colspan="2"><el-form-item label="本次计划装柜箱数" label-position="left" label-width="140px" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></td></tr>
|
||||
</template>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<template v-if="selectedCandidate.coLoadGroupId">
|
||||
<section class="density-dialog-section" data-tone="source"><table class="density-table" aria-label="同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></section>
|
||||
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
</template>
|
||||
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
|
||||
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
|
||||
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" />
|
||||
<p class="density-dialog-note">只记录数量,具体上柜箱号未核验。</p>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -444,8 +480,8 @@ onMounted(load)
|
||||
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 var(--admin-space-4); }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
.assignment-table { table-layout: fixed; }
|
||||
.assignment-table th { width: 90px; }
|
||||
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
|
||||
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
|
||||
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 客户模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/customers/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,客户与权限语义见 `../../../docs/business-index.md`、`docs/平台客户层级与权限契约.md`。
|
||||
- 客户主档、成员与角色归 `../iam/`,账号与登录标识归 `backend auth/`,资质文件归 `file/`;本模块只做客户视图、审核入口与客户级计费规则。
|
||||
- 后端只查 `iam/`(客户主档与审核)、`auth/`(登录标识)、`file/`(资质)、`firstleg/customerstatistics/`(运营统计)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户列表、筛选与统计 | `pages/CustomersView.vue`;`customers` | `../iam/api/platformIam.ts`;`firstleg/customerstatistics/` |
|
||||
| 企业审核入口 | `pages/CustomersView.vue`;`customers/reviews` 模式 | `iam/` 审核接口;资质 `file/` |
|
||||
| 客户详情、计费规则与运营指标 | `pages/CustomerDetailView.vue`、`components/CustomerBillingRulesPanel.vue`、`components/CustomerOperatingMetricCard.vue`;`customers/:tenantId` | `iam/`、`firstleg/` |
|
||||
| 海外仓阶梯费用 | `pages/OverseasStorageFeesView.vue`、`CustomerOverseasStorageFeePanel.vue`;`customers/overseas-storage-fees` | `overseasStorageFee.ts`、`overseasStorageFeeTiers.ts`;`lastleg-service/` |
|
||||
| 客户箱规与计费主数据 | `components/CustomerBillingRulesPanel.vue` | `firstleg/` 箱规与计价主数据 |
|
||||
| 详情浮层标题与密度 | `customerDialogTitle.ts`、`customerDensity.css` | 本地实现,按公共密度样式约束 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 公司联系电话只作企业联系信息,不修改管理员/成员登录手机号,也不要求跨企业唯一;编辑客户资料不提交账号手机号变更。
|
||||
- 审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换。
|
||||
- 客户列表属 `*:list` 权限,已含详情只读;写操作使用独立权限,前端守卫不替代服务端鉴权。
|
||||
- 平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。
|
||||
- 长整型客户 ID 按字符串透传,不用内部 ID 作业务编号展示。
|
||||
- 海外仓阶梯费用按客户与仓库维度提交版本,金额与生效结果只认服务端返回。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/customers/<目标测试>`;审核必填或费用阶梯变更同时核对 `iam`/`lastleg-service` 合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -9,6 +9,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { customerDialogTitle } from './customerDialogTitle'
|
||||
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from './overseasStorageFee'
|
||||
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
|
||||
|
||||
@@ -45,10 +46,13 @@ async function openEditor() {
|
||||
form.currency = config.value.currency || 'USD'; form.tiers = cloneTiers(config.value.tiers); form.version = config.value.version
|
||||
await nextTick(); dirty.value = false; visible.value = true; await nextTick(); captureBaseline(); formRef.value?.clearValidate()
|
||||
}
|
||||
async function closeEditor() {
|
||||
async function beforeEditorClose(done: () => void) {
|
||||
if (saving.value) return
|
||||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||||
visible.value = false
|
||||
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', customerDialogTitle('放弃编辑', { tenantName: props.customerName }), { customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
|
||||
done()
|
||||
}
|
||||
async function closeEditor() {
|
||||
await beforeEditorClose(() => { visible.value = false })
|
||||
}
|
||||
function validate() {
|
||||
const message = validateFeeTiers(form.tiers)
|
||||
@@ -72,7 +76,7 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<section class="overseas-fee-panel admin-density customer-density">
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||
<header class="panel-heading"><div><h2>海外仓费用</h2></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor">编辑费用</el-button></header>
|
||||
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load">重新加载</el-button></div>
|
||||
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
|
||||
<template v-else-if="config">
|
||||
@@ -80,18 +84,25 @@ onMounted(load)
|
||||
<div class="fee-groups"><div><h3>仓租({{ config.currency }}/CBM/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div><div><h3>退货仓储费({{ config.currency }}/托/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'RETURN_STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div></div>
|
||||
</template>
|
||||
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><table><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入价格" /></td></tr></tbody></table></div></div></el-form>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="visible" :title="customerDialogTitle('编辑海外仓费用', { tenantName: customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
<div class="customer-fee-tier-groups">
|
||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
|
||||
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
||||
<div class="customer-fee-tier-table-wrap"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
||||
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入价格" /></td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.edit-groups h3 { margin:0; color:var(--cc-text); }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.edit-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.edit-groups table { table-layout:fixed; }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.edit-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child { width:76px; }.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; gap:var(--admin-space-3, 8px); }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||||
.edit-group-heading h3 { min-width:0; }
|
||||
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
@media (max-width:900px) { .edit-groups { grid-template-columns:1fr; } }
|
||||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups > div { overflow-x:auto; }.edit-groups table { min-width:560px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||||
.overseas-fee-panel { border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }.panel-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--cc-border); }.panel-heading h2 { margin:0; color:var(--cc-text); }.fee-groups h3 { margin:0; color:var(--cc-text); }.fee-meta { display:flex; flex-wrap:wrap; gap:var(--admin-space-7, 24px); padding:var(--admin-space-5, 12px) 18px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); background:var(--admin-label-bg, #f8fafc); }.fee-meta strong { color:var(--cc-text); }.fee-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }.fee-groups > div { min-width:0; }.fee-groups table { width:100%; border-collapse:collapse; font-size:var(--admin-font-caption, 12px); }.fee-groups th { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups td { border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th { color:var(--cc-muted); font-weight:650; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:var(--admin-font-caption, 12px); }.fee-empty { display:grid; place-items:center; color:var(--cc-muted); }
|
||||
|
||||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups { grid-template-columns:1fr; padding:12px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElDialog, ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -136,13 +136,23 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
})
|
||||
|
||||
it('submits all four rows in one update request', async () => {
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流', customerCode: 'C0008' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑箱规尺寸 / 计算规则 · C0008')
|
||||
const length = document.querySelector<HTMLInputElement>('.editor-table input[placeholder="请输入内径长"]')!
|
||||
length.value = '31'
|
||||
length.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
length.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLButtonElement>('button[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-density-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(length.value).toBe('31.000')
|
||||
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存规则')
|
||||
saveButton?.click(); await flushPromises()
|
||||
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', { rules: expect.arrayContaining([expect.objectContaining({ boxType: 'SMALL', version: 3 }), expect.objectContaining({ boxType: 'EXTRA_LARGE', version: 3 })]) })
|
||||
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', { rules: expect.arrayContaining([expect.objectContaining({ boxType: 'SMALL', innerLengthCm: 31, version: 3 }), expect.objectContaining({ boxType: 'EXTRA_LARGE', version: 3 })]) })
|
||||
const submittedRules = vi.mocked(updateCustomerDimensionConfig).mock.calls[0][1].rules
|
||||
expect(submittedRules).toHaveLength(4)
|
||||
expect(Object.keys(submittedRules[0]).sort()).toEqual([
|
||||
@@ -190,4 +200,43 @@ describe('CustomerBillingRulesPanel', () => {
|
||||
await flushPromises()
|
||||
expect(updateCustomerDimensionDefaultCalculationMode).toHaveBeenCalledWith('8', 'MULTIPLY')
|
||||
})
|
||||
it('keeps dimension changes when header abandonment is declined', async () => {
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流', customerCode: 'C0008' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '编辑规则')!.click()
|
||||
await flushPromises()
|
||||
const input = document.querySelector<HTMLInputElement>('.editor-table input[placeholder="请输入内径长"]')!
|
||||
input.value = '32'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||
document.querySelector<HTMLButtonElement>('.customer-density-dialog .el-dialog__headerbtn')!.click()
|
||||
await flushPromises()
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('尚未保存'), '放弃编辑 · C0008', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(input.value).toBe('32.000')
|
||||
confirmation.mockRestore()
|
||||
})
|
||||
|
||||
it('locks header and footer closing while coefficient rules are saving', async () => {
|
||||
let finish!: (rule: CustomerChargeRule) => void
|
||||
vi.mocked(createCustomerChargeRule).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '编辑规则')!.click()
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '保存系数规则')!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
expect(wrapper.findComponent(ElDialog).props('showClose')).toBe(false)
|
||||
expect(wrapper.findComponent(ElDialog).props('closeOnPressEscape')).toBe(false)
|
||||
const done = vi.fn()
|
||||
await wrapper.findComponent(AppDialog).props('beforeClose')!(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
finish(chargeRule)
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { customerDialogTitle } from '../customerDialogTitle'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import { createCustomerChargeRule, listCustomerChargeRules, setCustomerChargeRuleDefault, type CustomerChargeRule, type CreateCustomerChargeRuleRequest } from '@/modules/fba-pricing'
|
||||
import { getPlatformTenantDimensionInitializationStatus } from '@/modules/iam'
|
||||
@@ -196,7 +197,7 @@ async function openEditor() {
|
||||
|
||||
async function confirmAbandon(): Promise<boolean> {
|
||||
try {
|
||||
await ElMessageBox.confirm('箱规尺寸尚未保存,确定放弃修改吗?', '放弃编辑', {
|
||||
await ElMessageBox.confirm('箱规尺寸尚未保存,确定放弃修改吗?', customerDialogTitle('放弃编辑', { tenantCode: props.customerCode, tenantName: props.customerName }), {
|
||||
customClass: 'admin-density-confirm', confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||
})
|
||||
return true
|
||||
@@ -206,12 +207,12 @@ async function confirmAbandon(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function beforeEditorClose(done: () => void) {
|
||||
if (editorSaving.value) return
|
||||
if (editorSaving.value || chargeSaving.value) return
|
||||
if (!editorDirty.value || await confirmAbandon()) done()
|
||||
}
|
||||
|
||||
async function closeEditor() {
|
||||
if (editorSaving.value) return
|
||||
if (editorSaving.value || chargeSaving.value) return
|
||||
if (editorDirty.value && !await confirmAbandon()) return
|
||||
editorVisible.value = false
|
||||
}
|
||||
@@ -410,20 +411,21 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="editorVisible" title="编辑箱规尺寸 / 计算规则" width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||
<div class="editor-content">
|
||||
<p class="editor-formula">计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
||||
<el-form ref="editorForm" :model="form" label-position="top">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="editorVisible" :title="customerDialogTitle('编辑箱规尺寸 / 计算规则', { tenantCode: customerCode, tenantName: customerName })" fullscreenable width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving || chargeSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
|
||||
<el-form ref="editorForm" :model="form" label-position="top" class="density-form density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<h3 class="density-dialog-section__title">按箱规尺寸计算</h3>
|
||||
<p class="density-dialog-note">计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
||||
<div class="editor-table-wrap">
|
||||
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
||||
<table class="density-table density-table--editable editor-table" aria-label="箱规尺寸规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>备注</th></tr></thead><tbody>
|
||||
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" placeholder="请输入边长加值" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
<div class="rule-save-row"><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div>
|
||||
<section v-if="canChargeView" class="combined-charge-editor"><h3>箱规按权重系数计算</h3><p class="editor-formula">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="charge-rule-editor"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th>{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
|
||||
<div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div>
|
||||
</section>
|
||||
<section v-if="canChargeView" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">箱规按权重系数计算</h3><p class="density-dialog-note">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="density-table density-table--editable charge-rule-editor" aria-label="权重系数规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th scope="row">{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
|
||||
</el-form>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -433,23 +435,23 @@ onBeforeUnmount(stopInitializationPolling)
|
||||
.rule-heading { align-items: center; }
|
||||
.rule-heading h2 { margin: 0; color: var(--cc-text); }.rule-heading p { margin: var(--admin-space-2, 6px) 0 0; color: var(--cc-muted); }
|
||||
.heading-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
|
||||
.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }.heading-actions :deep(.el-button) { margin-left: 0; }.dialog-actions :deep(.el-button) { margin-left: 0; }
|
||||
.heading-actions :deep(.el-button) { margin-left: 0; }
|
||||
.rule-card { min-width: 0; }.rule-card-heading { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); }.formula-band { display: flex; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--cc-border); background: var(--admin-label-bg, #f8fafc); color: var(--cc-muted); font-size: var(--admin-font-body, 13px); }.formula-band strong { color: #344054; }
|
||||
.rule-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }.rule-title-row :deep(.el-checkbox) { height: 30px; margin-right: 0; padding: 0 var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-radius: 6px; background: var(--admin-surface, #fff); }.rule-title-row :deep(.el-checkbox.is-checked) { border-color: var(--cc-primary); background: var(--cc-primary-soft); }
|
||||
.rule-state { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: var(--admin-space-3, 8px); color: #8797b2; text-align: center; }.rule-state strong { color: #475467; font-size: var(--admin-font-body, 13px); }.rule-state p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.rule-error { display: flex; align-items: center; justify-content: space-between; gap: 18px; background: #fff4f3; }.rule-error strong { color: #7a271a; }.rule-error p { margin: 5px 0; color: #b42318; font-size: var(--admin-font-caption, 12px); }
|
||||
.volume-value { color: #1570ef; }.editor-content { display: grid; }.editor-formula { margin: 0; color: #475467; font-size: var(--admin-font-body, 13px); line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table td { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: var(--admin-font-caption, 12px); }.editor-table tbody th { color: var(--cc-text); font-size: var(--admin-font-body, 13px); }.editor-table :deep(.el-input-number) { width: 140px; }.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }.charge-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }.charge-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }.charge-form :deep(.el-input-number) { width: 100%; }.charge-form :deep(.el-input) { width: 100%; }
|
||||
.charge-form--simple { grid-template-columns: 1fr; }.charge-form--simple :deep(.el-form-item) { grid-column: 1; }
|
||||
.charge-rule-editor-wrap { overflow-x: auto; }.charge-rule-editor { width: 100%; min-width: 920px; border-collapse: collapse; }.charge-rule-editor th { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor td { border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.charge-rule-editor thead th { background: #eef0fa; color: #475467; font-size: var(--admin-font-caption, 12px); }.charge-rule-editor tbody th { color: var(--cc-text); font-size: var(--admin-font-body, 13px); }.charge-rule-editor :deep(.el-input-number) { width: 118px; }.charge-rule-editor :deep(.el-input) { width: 118px; }.charge-rule-editor :deep(.el-input-number .el-input__wrapper) { width: 100%; }
|
||||
.charge-box-cell { min-height: 34px; display: flex; align-items: center; border-bottom: 1px solid var(--cc-border); }.charge-box-cell:last-child { border-bottom: 0; }
|
||||
.rule-save-row { display: flex; justify-content: flex-end; padding: var(--admin-space-5, 12px) 0 var(--admin-space-1, 4px); }
|
||||
.dialog-actions { width: 100%; }
|
||||
.dialog-actions :deep(.el-button) { min-width: 112px; min-height: 46px; font-size: 15px; }
|
||||
.volume-value { color: #1570ef; } .editor-table,.charge-rule-editor { min-width:920px; table-layout:fixed; } .editor-table th:first-child,.charge-rule-editor th:first-child { width:100px; } .editor-table th:last-child,.charge-rule-editor th:last-child { width:20%; }.editor-table-wrap { overflow-x: auto; }.section-hint { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }
|
||||
|
||||
.charge-rule-editor-wrap { overflow-x: auto; }
|
||||
|
||||
.rule-save-row { padding: var(--admin-space-5, 12px) 0 var(--admin-space-1, 4px); }
|
||||
|
||||
|
||||
.charge-rule-card :deep(.el-table) { color: #344054; font-size: var(--admin-font-body, 13px); }
|
||||
.charge-rule-card :deep(.el-table th.el-table__cell) { background: var(--admin-label-bg, #f8fafc); color: #475467; font-size: var(--admin-font-body, 13px); font-weight: 650; }
|
||||
.charge-rule-card :deep(.el-table td.el-table__cell) { color: #344054; font-size: var(--admin-font-body, 13px); }
|
||||
.charge-rule-card :deep(.el-table .cell) { line-height: 1.5; text-align: center; }
|
||||
.charge-rule-card :deep(.el-table .charge-remark-column .cell) { text-align: left; }
|
||||
.charge-rule-card :deep(.el-table strong) { color: #1570ef; font-weight: 650; }
|
||||
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; }.dialog-actions { width: 100%; }.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; } }
|
||||
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -41,21 +41,7 @@
|
||||
.customer-density-dialog.admin-density-dialog .file-picker { min-height: var(--admin-control-height, 32px); }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-file-name { max-width: none; overflow-wrap: anywhere; white-space: normal; }
|
||||
.customer-density-dialog.admin-density-dialog .qualification-preview-action { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-content { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-header { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-radius: 0; background: var(--admin-surface, #fff); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-title strong { white-space: normal; overflow-wrap: anywhere; }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-icon { display: none; }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-grid { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-dialog-section,.customer-density-dialog.admin-density-dialog .review-decision-card { padding: var(--admin-space-4, 10px); border-radius: 0; box-shadow: none; }
|
||||
.customer-density-dialog.admin-density-dialog .review-section-heading { margin-bottom: var(--admin-space-3, 8px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-section-heading span { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-info-grid { grid-template-columns: 1fr; }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-table { border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-header { font-size: var(--admin-font-caption, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .review-document-name { white-space: normal; }
|
||||
.customer-density-dialog.admin-density-dialog .decision-summary { margin-bottom: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .approval-credentials { margin-top: var(--admin-space-5, 12px); border-radius: 0; }
|
||||
.customer-density-dialog.admin-density-dialog .approval-credentials > div { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); }
|
||||
|
||||
.customer-density.admin-density .rule-heading,.customer-density.admin-density .rule-card-heading,.customer-density.admin-density .panel-heading { min-height: 0; padding: var(--admin-space-3, 8px) 0; gap: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .rule-heading h2,.customer-density.admin-density .rule-card-heading h2,.customer-density.admin-density .panel-heading h2 { font-size: var(--admin-font-section, 14px); }
|
||||
@@ -66,18 +52,34 @@
|
||||
.customer-density.admin-density .charge-rule-card { margin-top: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .overseas-fee-panel { margin-top: var(--admin-space-5, 12px); }
|
||||
.customer-density.admin-density .fee-groups { gap: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) 0; }
|
||||
.customer-density.admin-density .fee-groups h3,.customer-density-dialog.admin-density-dialog .edit-groups h3,.customer-density-dialog.admin-density-dialog .groups h3 { font-size: var(--admin-font-section, 14px); }
|
||||
.customer-density.admin-density .fee-groups h3 { font-size: var(--admin-font-section, 14px); }
|
||||
.customer-density.admin-density .fee-groups th,.customer-density.admin-density .fee-groups td { padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); }
|
||||
.customer-density-dialog.admin-density-dialog .editor-content { gap: var(--admin-space-5, 12px); }
|
||||
.customer-density-dialog.admin-density-dialog .editor-formula { padding: var(--admin-space-2, 6px) 0; }
|
||||
.customer-density-dialog.admin-density-dialog .editor-table th,.customer-density-dialog.admin-density-dialog .editor-table td,
|
||||
.customer-density-dialog.admin-density-dialog .charge-rule-editor th,.customer-density-dialog.admin-density-dialog .charge-rule-editor td,
|
||||
.customer-density-dialog.admin-density-dialog .edit-groups th,.customer-density-dialog.admin-density-dialog .edit-groups td { padding: var(--admin-space-1, 4px) var(--admin-space-3, 8px); }
|
||||
.customer-density-dialog.admin-density-dialog .dialog-actions { justify-content: flex-end; }
|
||||
.customer-density-dialog.admin-density-dialog .dialog-actions .el-button { min-height: var(--admin-control-height, 32px); min-width: 64px; font-size: var(--admin-font-body, 13px); }
|
||||
.customer-density-dialog.admin-density-dialog .edit-groups,.customer-density-dialog.admin-density-dialog .groups { gap: var(--admin-space-5, 12px); padding: 0; }
|
||||
|
||||
@media (max-width: 960px) { .customer-density.admin-density .info-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width: 720px) { .customer-density.admin-density .info-grid { grid-template-columns: 1fr; }.customer-density.admin-density .qualification-list button { grid-template-columns: minmax(0,1fr) auto; }.customer-density.admin-density .qualification-list .file-date { white-space: normal; } }
|
||||
|
||||
.customer-density-dialog .review-info-grid { --admin-field-label-width: 96px; }
|
||||
|
||||
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
|
||||
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
|
||||
.customer-editor-dialog .customer-editor-table td { width: 50%; }
|
||||
.customer-review-table th { width: 120px; }
|
||||
.customer-review-documents th:first-child { width: 150px; }
|
||||
.customer-review-documents th:last-child { width: 150px; }
|
||||
.customer-credential-value { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
|
||||
.customer-credential-value code, .customer-preview-filename { overflow-wrap: anywhere; }
|
||||
.customer-qualification-preview .qualification-preview { display: grid; min-height: 240px; place-items: center; }
|
||||
.customer-qualification-preview .qualification-preview img { display: block; max-width: 100%; height: auto; }
|
||||
.customer-qualification-preview .qualification-preview iframe { width: 100%; min-height: min(68vh, 720px); border: 0; }
|
||||
.customer-qualification-preview.is-fullscreen .qualification-preview iframe { height: calc(100vh - 200px); min-height: 0; }
|
||||
|
||||
/* Both overseas fee entry points share geometry; pricing values and actions stay in their own controllers. */
|
||||
.customer-density-dialog .customer-fee-tier-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--admin-space-5); }
|
||||
.customer-density-dialog .customer-fee-tier-groups > section { min-width:0; }
|
||||
.customer-density-dialog .customer-fee-tier-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--admin-space-3); margin-bottom:var(--admin-space-3); }
|
||||
.customer-density-dialog .customer-fee-tier-heading h3 { margin-bottom:0; }
|
||||
.customer-density-dialog .customer-fee-tier-table-wrap { overflow-x:auto; }
|
||||
.customer-density-dialog .customer-fee-tier-table { min-width:420px; table-layout:fixed; }
|
||||
.customer-density-dialog .customer-fee-tier-table th:first-child { width:70px; }
|
||||
.customer-density-dialog .customer-fee-tier-table :is(th, td):first-child { position:sticky; left:0; z-index:1; }
|
||||
.customer-density-dialog .customer-fee-currency .el-select { max-width:180px; }
|
||||
@media (max-width:1000px) { .customer-density-dialog .customer-fee-tier-groups { grid-template-columns:1fr; } }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { customerDialogTitle } from './customerDialogTitle'
|
||||
|
||||
describe('customer operation titles', () => {
|
||||
it('identifies the customer by its trimmed business code', () => {
|
||||
expect(customerDialogTitle('编辑客户', { tenantCode: ' C0008 ', tenantName: '客户公司' })).toBe('编辑客户 · C0008')
|
||||
})
|
||||
it('uses the company name when a business code is unavailable', () => {
|
||||
expect(customerDialogTitle('客户入驻审核', { tenantCode: ' ', tenantName: ' 客户公司 ' })).toBe('客户入驻审核 · 客户公司')
|
||||
})
|
||||
it('does not expose database IDs when both business identifiers are absent', () => {
|
||||
const customer = { id: '987654321', tenantCode: null, tenantName: ' ' }
|
||||
expect(customerDialogTitle('删除客户', customer)).toBe('删除客户')
|
||||
expect(customerDialogTitle('新增客户')).toBe('新增客户')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
interface CustomerDialogIdentity {
|
||||
tenantCode?: string | null
|
||||
tenantName?: string | null
|
||||
}
|
||||
|
||||
export function customerDialogTitle(action: string, customer?: CustomerDialogIdentity | null): string {
|
||||
const identity = customer?.tenantCode?.trim() || customer?.tenantName?.trim()
|
||||
return identity ? `${action} · ${identity}` : action
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import ElementPlus, { ElDialog, ElMessageBox, ElOption } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import CustomerOverseasStorageFeePanel from './CustomerOverseasStorageFeePanel.vue'
|
||||
import OverseasStorageFeesView from './pages/OverseasStorageFeesView.vue'
|
||||
import { getCustomerOverseasStorageFees, listOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig } from './overseasStorageFee'
|
||||
|
||||
vi.mock('./overseasStorageFee', () => ({
|
||||
getCustomerOverseasStorageFees: vi.fn(),
|
||||
listOverseasStorageFees: vi.fn(),
|
||||
updateCustomerOverseasStorageFees: vi.fn(),
|
||||
}))
|
||||
|
||||
const customerId = '9007199254740993'
|
||||
function configuration(): OverseasStorageFeeConfig {
|
||||
return { id: 'fee-1', customerId, customerName: '费用测试客户', currency: 'USD', version: 7, createdAt: null, updatedAt: null, tiers: [
|
||||
{ id: 'storage', feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: null, unitPrice: 0.123456, sortNo: 1 },
|
||||
{ id: 'return', feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 1, endDay: null, unitPrice: 2, sortNo: 1 },
|
||||
] }
|
||||
}
|
||||
function button(text: string) {
|
||||
const found = [...document.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === text)
|
||||
expect(found, text).toBeDefined()
|
||||
return found!
|
||||
}
|
||||
function changeNumber(input: HTMLInputElement, value: string) {
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
async function open(entry: 'detail' | 'list') {
|
||||
const wrapper = entry === 'detail'
|
||||
? mount(CustomerOverseasStorageFeePanel, { attachTo: document.body, props: { customerId, customerName: '费用测试客户' }, global: { plugins: [ElementPlus] } })
|
||||
: mount(OverseasStorageFeesView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
if (entry === 'detail') button('编辑费用').click()
|
||||
else {
|
||||
const action = document.querySelector<HTMLButtonElement>('.el-table__body .row-actions button')
|
||||
expect(action).not.toBeNull()
|
||||
action!.click()
|
||||
}
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => { vi.restoreAllMocks(); ElMessageBox.close() })
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'test-session'
|
||||
auth.permissions = ['platform:customer:list', 'platform:customer:edit', 'platform:overseas-storage-fee:edit']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(getCustomerOverseasStorageFees).mockImplementation(async () => configuration())
|
||||
vi.mocked(listOverseasStorageFees).mockImplementation(async () => ({ records: [configuration()], total: 1, page: 1, size: 20 }))
|
||||
vi.mocked(updateCustomerOverseasStorageFees).mockImplementation(async () => configuration())
|
||||
})
|
||||
|
||||
describe('customer overseas fee dialog entry points', () => {
|
||||
it.each(['detail', 'list'] as const)('retains %s currency choices, values, version and full customer ID on save', async entry => {
|
||||
const wrapper = await open(entry)
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe(`${entry === 'detail' ? '编辑海外仓费用' : '编辑海外仓阶梯费用'} · 费用测试客户`)
|
||||
expect(dialog.props('fullscreenable')).toBe(true)
|
||||
expect(dialog.findAllComponents(ElOption).map(option => option.props('value'))).toEqual(entry === 'detail' ? ['USD', 'CNY', 'HKD', 'EUR', 'GBP'] : ['USD', 'CNY', 'EUR'])
|
||||
const prices = document.querySelectorAll<HTMLInputElement>('.customer-fee-tier-table input[placeholder^="请输入"]')
|
||||
expect(prices).toHaveLength(4)
|
||||
const price = document.querySelector<HTMLInputElement>(entry === 'detail' ? 'input[placeholder="请输入价格"]' : 'input[placeholder="请输入单价"]')!
|
||||
changeNumber(price, '0.654321')
|
||||
await flushPromises()
|
||||
button('新增阶梯').click()
|
||||
await flushPromises()
|
||||
expect(document.querySelectorAll('.customer-fee-tier-table tbody tr')).toHaveLength(3)
|
||||
document.querySelector<HTMLButtonElement>('button[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-density-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(price.value).toBe('0.654321')
|
||||
button(entry === 'detail' ? '保存费用' : '确定').click()
|
||||
await flushPromises()
|
||||
expect(updateCustomerOverseasStorageFees).toHaveBeenCalledWith(customerId, {
|
||||
customerId, customerName: '费用测试客户', currency: 'USD', version: 7, tiers: [
|
||||
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: 1, unitPrice: 0.654321 },
|
||||
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 2, endDay: null, unitPrice: 0.654321 },
|
||||
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 1, endDay: null, unitPrice: 2 },
|
||||
],
|
||||
})
|
||||
expect(dialog.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('applies dirty confirmation to the detail header close and preserves inputs when declined', async () => {
|
||||
const wrapper = await open('detail')
|
||||
const price = document.querySelector<HTMLInputElement>('input[placeholder="请输入价格"]')!
|
||||
changeNumber(price, '9.123456')
|
||||
await flushPromises()
|
||||
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
|
||||
document.querySelector<HTMLButtonElement>('.customer-density-dialog .el-dialog__headerbtn')!.click()
|
||||
await flushPromises()
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('尚未保存'), '放弃编辑 · 费用测试客户', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(price.value).toBe('9.123456')
|
||||
const done = vi.fn()
|
||||
const closing = wrapper.findComponent(AppDialog).props('beforeClose')!(done)
|
||||
await flushPromises()
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('.el-message-box .el-button--primary')).at(-1)!.click()
|
||||
await closing
|
||||
expect(done).toHaveBeenCalledOnce()
|
||||
button('取消').click()
|
||||
await flushPromises()
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('.el-message-box .el-button--primary')).at(-1)!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(updateCustomerOverseasStorageFees).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['detail', 'list'] as const)('keeps %s inputs and context when saving fails', async entry => {
|
||||
vi.mocked(updateCustomerOverseasStorageFees).mockRejectedValueOnce(new Error('费用版本冲突'))
|
||||
const wrapper = await open(entry)
|
||||
button(entry === 'detail' ? '保存费用' : '确定').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.body.textContent).toContain(entry === 'detail' ? '海外仓费用保存失败,请刷新后重试' : '保存失败,请刷新后重试')
|
||||
expect(document.querySelectorAll('.customer-fee-tier-table tbody tr')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each(['detail', 'list'] as const)('locks %s close actions during save', async entry => {
|
||||
let finish!: (value: OverseasStorageFeeConfig) => void
|
||||
vi.mocked(updateCustomerOverseasStorageFees).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = await open(entry)
|
||||
button(entry === 'detail' ? '保存费用' : '确定').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
|
||||
expect(wrapper.findComponent(ElDialog).props('showClose')).toBe(false)
|
||||
expect(wrapper.findComponent(ElDialog).props('closeOnPressEscape')).toBe(false)
|
||||
finish(configuration())
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -25,10 +25,10 @@ describe('customer overseas storage fee api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps overseas fee business actions as semantic buttons at narrow widths', () => {
|
||||
expect(overseasListSource).toContain('border stripe')
|
||||
expect(overseasListSource).toContain('density-table density-table--editable customer-fee-tier-table')
|
||||
expect(overseasListSource).toContain('class="density-action" size="small" plain')
|
||||
expect(overseasListSource).toContain('size="small" plain type="danger"')
|
||||
expect(overseasListSource).toContain('<el-table :data="group" size="small" border stripe>')
|
||||
expect(overseasListSource).toContain('<thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead>')
|
||||
expect(overseasListSource).not.toContain('<Pencil')
|
||||
expect(overseasListSource).not.toContain('<Trash2')
|
||||
expect(overseasListSource).toContain('@media (max-width:720px)')
|
||||
|
||||
@@ -121,6 +121,11 @@ describe('CustomerDetailView', () => {
|
||||
|
||||
expect(downloadTenantQualification).toHaveBeenCalledWith(tenant.id, '/files/license.png')
|
||||
expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull()
|
||||
expect(document.querySelector('.customer-qualification-preview .el-dialog__title')?.textContent).toBe('查看资质文件 · TEN8')
|
||||
expect(document.querySelector('.customer-preview-filename')?.textContent).toBe('license.png')
|
||||
document.querySelector<HTMLButtonElement>('.customer-qualification-preview button[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-qualification-preview.is-fullscreen img')?.getAttribute('src')).toBe('blob:qualification-preview')
|
||||
})
|
||||
|
||||
it('reloads a reused detail view and ignores the previous customer response', async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { customerDialogTitle } from '../customerDialogTitle'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
@@ -163,7 +164,7 @@ async function changeStatus() {
|
||||
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, `${verb}客户`, {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, customerDialogTitle(`${verb}客户`, customer.value), {
|
||||
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
|
||||
})
|
||||
statusChanging.value = true
|
||||
@@ -307,8 +308,9 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||||
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog customer-qualification-preview" v-model="previewOpen" :title="customerDialogTitle('查看资质文件', customer)" fullscreenable width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||||
<p class="density-dialog-note customer-preview-filename">{{ previewName }}</p>
|
||||
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -345,7 +347,7 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:var(--a
|
||||
.member-contact span { display:block; }
|
||||
.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--cc-muted); }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:var(--admin-font-caption, 12px); }
|
||||
.contract-note { padding:var(--admin-space-6, 16px) 18px; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.contract-note strong { color:#344054; font-size:var(--admin-font-caption, 12px); }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.7; }
|
||||
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:var(--admin-surface, #fff); }
|
||||
|
||||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; }.info-grid--three { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } }
|
||||
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('CustomersView', () => {
|
||||
it('matches the frozen prototype containers for customer operations', () => {
|
||||
expect(customersSource).toContain("router.push({ name: 'customer-detail'")
|
||||
expect(customersSource).toContain("const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'")
|
||||
expect(customersSource.match(/class="form-grid three-cols"/g)).toHaveLength(2)
|
||||
expect(customersSource.match(/class="density-table density-table--editable customer-editor-table"/g)).toHaveLength(2)
|
||||
expect(customersSource).toContain('width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog"')
|
||||
expect(customersSource).toContain('width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog"')
|
||||
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
@@ -218,6 +218,18 @@ describe('CustomersView', () => {
|
||||
const email = document.querySelector<HTMLInputElement>('input[placeholder="如:contact@example.com"]')
|
||||
expect(phone?.value).toBe(fullTenant.contactPhone)
|
||||
expect(email?.value).toBe(fullTenant.contactEmail)
|
||||
expect(document.querySelector('.customer-editor-dialog .el-dialog__title')?.textContent).toBe('编辑客户 · TEN8')
|
||||
expect(document.querySelector('input[placeholder="请输入18位大写字母或数字"]')).toBeNull()
|
||||
expect(document.querySelector('.qualification-credit-code')?.textContent).toContain(fullTenant.unifiedSocialCreditCode)
|
||||
phone!.value = '13900139000'
|
||||
phone!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector<HTMLButtonElement>('.customer-editor-dialog button[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-editor-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')?.value).toBe('13900139000')
|
||||
document.querySelector<HTMLButtonElement>('.customer-editor-dialog button[aria-label="退出全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')?.value).toBe('13900139000')
|
||||
})
|
||||
|
||||
it('shows an authenticated image preview for an uploaded qualification', async () => {
|
||||
@@ -607,7 +619,7 @@ describe('CustomersView', () => {
|
||||
temporaryPassword: 'Temporary#2026',
|
||||
})
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('审核')?.click()
|
||||
await flushPromises()
|
||||
@@ -620,6 +632,13 @@ describe('CustomersView', () => {
|
||||
expect(document.body.textContent).toContain('请立即保存以下一次性凭证')
|
||||
expect(document.body.textContent).toContain('merchant_8_owner')
|
||||
expect(document.body.textContent).toContain('Temporary#2026')
|
||||
const credentials = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('审核通过,管理员账号已创建'))!
|
||||
expect(credentials.props()).toMatchObject({ modelValue: true, showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(document.querySelector('.approval-result-dialog .el-dialog__headerbtn')).toBeNull()
|
||||
document.querySelector<HTMLButtonElement>('.approval-result-dialog .el-dialog__footer button')!.click()
|
||||
await flushPromises()
|
||||
expect(credentials.props('modelValue')).toBe(false)
|
||||
expect(document.querySelector('.approval-result-dialog code')).toBeNull()
|
||||
})
|
||||
|
||||
it('loads and previews authenticated qualification files in the review dialog', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import { CheckCircle2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
@@ -42,7 +42,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import { customerDialogTitle } from '../customerDialogTitle'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
@@ -227,7 +227,8 @@ const customerColumns: DataTableColumn[] = [
|
||||
let loadSequence = 0
|
||||
let reviewLoadSequence = 0
|
||||
let editorLoadSequence = 0
|
||||
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
|
||||
const editorCustomer = computed(() => result.value.records.find(row => String(row.id) === editingId.value) || { tenantName: form.value.tenantName })
|
||||
const editorTitle = computed(() => editingId.value ? customerDialogTitle('编辑客户', editorCustomer.value) : '新增客户')
|
||||
const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'
|
||||
const canUploadQualifications = computed(() => !editorStatus.value
|
||||
|| ['PROFILE_REQUIRED', 'REJECTED', 'ACTIVE', 'DISABLED'].includes(editorStatus.value))
|
||||
@@ -641,7 +642,7 @@ async function submitReview() {
|
||||
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
|
||||
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
|
||||
if (missing.length) { ElMessage.warning(`请先上传${missing.map((item) => item.label).join('、')}`); return }
|
||||
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', '提交企业审核', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
|
||||
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', customerDialogTitle('提交企业审核', editorCustomer.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
|
||||
const saved = await submitPlatformTenantReview(editingId.value)
|
||||
editorStatus.value = saved.status; ElMessage.success('审核单已提交')
|
||||
editorOpen.value = false; await loadCustomers()
|
||||
@@ -730,7 +731,7 @@ function handleMoreAction(row: PlatformTenantResource, command: string) {
|
||||
async function toggleStatus(row: PlatformTenantResource) {
|
||||
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, `${verb}客户`, { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, customerDialogTitle(`${verb}客户`, row), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
|
||||
rowActionId.value = String(row.id); await changePlatformTenantStatus(row.id, next)
|
||||
ElMessage.success(`客户已${verb}`); await loadCustomers()
|
||||
} catch (error) {
|
||||
@@ -1081,30 +1082,29 @@ onMounted(() => { void loadCustomers() })
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid three-cols">
|
||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item>
|
||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
|
||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="144px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid three-cols">
|
||||
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /><small class="field-hint">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</small></el-form-item>
|
||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item>
|
||||
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
|
||||
<el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item>
|
||||
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection>
|
||||
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">联系与商务</h3><p class="density-dialog-note">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<FormSection class="qualification-section" title="企业认证资料" description="营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item>
|
||||
<section class="density-dialog-section qualification-section" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料</h3><p class="density-dialog-note">营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
|
||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
|
||||
@@ -1122,96 +1122,90 @@ onMounted(() => { void loadCustomers() })
|
||||
</div>
|
||||
</div>
|
||||
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
|
||||
</FormSection>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||
<div class="qualification-preview">
|
||||
<AppDialog v-model="qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览">
|
||||
<p class="density-dialog-note customer-preview-filename">{{ qualificationPreviewName }}</p>
|
||||
<div class="density-dialog-section qualification-preview" data-tone="source">
|
||||
<img v-if="qualificationPreviewUrl && qualificationPreviewKind === 'image'" :src="qualificationPreviewUrl" :alt="`${qualificationPreviewName}预览`" />
|
||||
<iframe v-else-if="qualificationPreviewUrl && qualificationPreviewKind === 'pdf'" :src="qualificationPreviewUrl" :title="`${qualificationPreviewName} PDF 预览`" />
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog v-model="approvalResultOpen" :title="credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建'" width="min(560px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||
<div v-if="approvalResult" class="approval-result-content">
|
||||
<AppDialog v-model="approvalResultOpen" :title="customerDialogTitle(credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建', credentialSource === 'creation' ? editorCustomer : selectedReview)" width="min(560px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||
<div v-if="approvalResult" class="density-dialog-sections">
|
||||
<div class="approval-result-warning" role="alert"><ShieldCheck :size="20" /><div><strong>请立即保存以下一次性凭证</strong><span>临时密码只在本次{{ credentialSource === 'creation' ? '创建' : '审核' }}响应中返回,关闭后无法再次查看。</span></div></div>
|
||||
<dl class="approval-credentials">
|
||||
<div><dt>登录账号</dt><dd><code>{{ approvalResult.administratorUsername || '未返回' }}</code><CopyButton v-if="approvalResult.administratorUsername" :value="approvalResult.administratorUsername" label="登录账号" size="small" /></dd></div>
|
||||
<div><dt>临时密码</dt><dd><code>{{ approvalResult.temporaryPassword || '未返回' }}</code><CopyButton v-if="approvalResult.temporaryPassword" :value="approvalResult.temporaryPassword" label="临时密码" size="small" /></dd></div>
|
||||
</dl>
|
||||
<section class="density-dialog-section" data-tone="summary"><table class="density-table" aria-label="一次性管理员凭据"><tbody>
|
||||
<tr><th scope="row">登录账号</th><td><div class="customer-credential-value"><code>{{ approvalResult.administratorUsername || '未返回' }}</code><CopyButton v-if="approvalResult.administratorUsername" :value="approvalResult.administratorUsername" label="登录账号" size="small" /></div></td></tr>
|
||||
<tr><th scope="row">临时密码</th><td><div class="customer-credential-value"><code>{{ approvalResult.temporaryPassword || '未返回' }}</code><CopyButton v-if="approvalResult.temporaryPassword" :value="approvalResult.temporaryPassword" label="临时密码" size="small" /></div></td></tr>
|
||||
</tbody></table></section>
|
||||
</div>
|
||||
<template #footer><el-button type="primary" @click="closeApprovalResult">我已保存,关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog v-model="decisionOpen" title="客户入驻审核" width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog" :close-on-click-modal="false">
|
||||
<div v-if="selectedReview" class="review-dialog-content">
|
||||
<header class="review-dialog-header">
|
||||
<div class="review-dialog-title"><span class="review-dialog-icon"><Building2 :size="18" /></span><div><strong>{{ selectedReview.tenantName || '未命名客户' }}</strong><span>客户编号:{{ selectedReview.tenantCode || '未生成' }}</span></div></div>
|
||||
<el-tag type="warning" effect="plain">待审核</el-tag>
|
||||
</header>
|
||||
<div class="review-dialog-grid">
|
||||
<section class="review-dialog-section review-dialog-card">
|
||||
<div class="review-section-heading"><div><h3>企业基本信息</h3><span>核对客户提交的主体资料</span></div><CircleCheck :size="18" /></div>
|
||||
<dl class="review-info-grid density-fields">
|
||||
<div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '未填写' }}</dd></div>
|
||||
<div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '未生成' }}</dd></div>
|
||||
<div><dt>联系人</dt><dd>{{ selectedReview.contactName || '未填写' }}</dd></div>
|
||||
<div><dt>公司联系电话</dt><dd>{{ selectedReview.contactPhone || '未填写' }}</dd></div>
|
||||
<div><dt>联系邮箱</dt><dd>{{ selectedReview.contactEmail || '未填写' }}</dd></div>
|
||||
<div><dt>提交时间</dt><dd>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="review-dialog-section review-dialog-card">
|
||||
<div class="review-section-heading"><div><h3>资质文件</h3><span>营业执照为必填;法人身份证及其他资质为选填。共 {{ reviewQualifications.length }} 份</span></div><FileCheck2 :size="18" /></div>
|
||||
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
|
||||
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
|
||||
<div v-else class="review-document-table" role="table" aria-label="待审核客户资质">
|
||||
<div class="review-document-header" role="row"><span role="columnheader">资质类型</span><span role="columnheader">已上传文件</span><span role="columnheader">操作</span></div>
|
||||
<div v-for="item in reviewQualifications" :key="String(item.id)" class="review-document-row" role="row"><span role="cell" class="review-document-type">{{ qualificationLabel(item.qualificationType) }}</span><span role="cell" class="review-document-name" :title="qualificationName(item)"><FileText :size="15" />{{ qualificationName(item) }}</span><span role="cell" class="review-document-actions"><el-button link type="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)">预览</el-button><el-button link :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)">下载</el-button></span></div>
|
||||
<div v-if="reviewQualifications.length === 0" class="review-document-empty"><FileText :size="18" /><span>暂无资质文件</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="review-decision-card">
|
||||
<div class="review-section-heading"><div><h3>审核决定</h3><span>确认资料无误后提交审核结果</span></div><ShieldCheck :size="18" /></div>
|
||||
<AppDialog v-model="decisionOpen" :title="customerDialogTitle('客户入驻审核', selectedReview)" fullscreenable :confirm-loading="reviewSubmitting" :lock-while-submitting="false" width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog" :close-on-click-modal="false">
|
||||
<div v-if="selectedReview" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">企业基本信息 <el-tag type="warning" effect="plain">待审核</el-tag></h3>
|
||||
<table class="density-table customer-review-table" aria-label="待审核企业资料"><tbody>
|
||||
<tr><th scope="row">客户名称</th><td>{{ selectedReview.tenantName || '未填写' }}</td><th scope="row">客户编号</th><td>{{ selectedReview.tenantCode || '未生成' }}</td></tr>
|
||||
<tr><th scope="row">联系人</th><td>{{ selectedReview.contactName || '未填写' }}</td><th scope="row">公司联系电话</th><td>{{ selectedReview.contactPhone || '未填写' }}</td></tr>
|
||||
<tr><th scope="row">联系邮箱</th><td>{{ selectedReview.contactEmail || '未填写' }}</td><th scope="row">提交时间</th><td>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="source">
|
||||
<h3 class="density-dialog-section__title">资质文件</h3>
|
||||
<p class="density-dialog-note">营业执照为必填;法人身份证及其他资质为选填。共 {{ reviewQualifications.length }} 份</p>
|
||||
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
|
||||
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
|
||||
<table v-else class="density-table customer-review-documents" aria-label="待审核客户资质">
|
||||
<thead><tr><th>资质类型</th><th>已上传文件</th><th>操作</th></tr></thead>
|
||||
<tbody><tr v-for="item in reviewQualifications" :key="String(item.id)"><td>{{ qualificationLabel(item.qualificationType) }}</td><td :title="qualificationName(item)">{{ qualificationName(item) }}</td><td><div class="review-document-actions density-actions"><el-button class="density-action" data-tone="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)">预览</el-button><el-button class="density-action" :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)">下载</el-button></div></td></tr>
|
||||
<tr v-if="reviewQualifications.length === 0"><td colspan="3">暂无资质文件</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<h3 class="density-dialog-section__title">审核决定</h3>
|
||||
<el-radio-group v-if="access.can('platform:customer:approve') && access.can('platform:customer:reject')" v-model="decision.type" class="decision-type" aria-label="审核决定"><el-radio-button label="approve">通过</el-radio-button><el-radio-button label="reject">驳回</el-radio-button></el-radio-group>
|
||||
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top">
|
||||
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top" class="density-form density-form--flush-end">
|
||||
<el-form-item prop="comment" :label="decision.type === 'approve' ? '审核意见' : '驳回原因'" required><el-input v-model="decision.comment" type="textarea" :rows="3" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '可填写审核意见' : '请明确说明需要企业修改的内容'" /></el-form-item>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">取消</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
|
||||
</el-dialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="reviewPreviewOpen" :title="reviewPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
|
||||
<div class="qualification-preview"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">取消</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog customer-qualification-preview" v-model="reviewPreviewOpen" :title="customerDialogTitle('查看审核资质', selectedReview)" fullscreenable width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
|
||||
<p class="density-dialog-note customer-preview-filename">{{ reviewPreviewName }}</p>
|
||||
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="passwordDialogOpen" :title="customerDialogTitle('重置客户密码', passwordTenant)" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top" class="density-form">
|
||||
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
|
||||
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
|
||||
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
|
||||
<p class="dialog-hint">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
|
||||
<p class="density-dialog-note">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
|
||||
</el-dialog>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog class="admin-density-dialog customer-density-dialog" v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="impersonationDialogOpen" :title="customerDialogTitle('跳转登录客户工作台', impersonationTenant)" :confirm-loading="impersonationSubmitting" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
|
||||
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
|
||||
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
|
||||
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="impersonationSubmitting" @click="impersonationDialogOpen = false">取消</el-button><el-button type="primary" :loading="impersonationSubmitting" @click="submitImpersonation">确认跳转</el-button></div></template>
|
||||
</el-dialog>
|
||||
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top" class="density-form"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="impersonationSubmitting" @click="impersonationDialogOpen = false">取消</el-button><el-button type="primary" :loading="impersonationSubmitting" @click="submitImpersonation">确认跳转</el-button></div></template>
|
||||
</AppDialog>
|
||||
|
||||
<el-dialog class="admin-density-dialog customer-density-dialog" v-model="archiveDialogOpen" title="删除客户" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="archiveDialogOpen" :title="customerDialogTitle('删除客户', archiveTenant)" :confirm-loading="archiveSubmitting" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
|
||||
<el-alert type="warning" :closable="false" show-icon>删除是逻辑归档:历史业务数据会保留,客户账号会被停用。是否允许归档由服务端最终判断。</el-alert>
|
||||
<div v-if="archiveTenant" class="archive-confirm"><strong>{{ archiveTenant.tenantName }}</strong><span>请输入客户编号 {{ archiveTenant.tenantCode }} 以确认。</span><el-input v-model="archiveConfirmCode" :disabled="archiveSubmitting" :placeholder="archiveTenant.tenantCode" autocomplete="off" /></div>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
|
||||
</el-dialog>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -1227,7 +1221,7 @@ onMounted(() => { void loadCustomers() })
|
||||
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
|
||||
.qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
|
||||
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
|
||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
|
||||
@@ -1246,51 +1240,17 @@ onMounted(() => { void loadCustomers() })
|
||||
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
|
||||
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
|
||||
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--admin-surface, #fff); }
|
||||
.qualification-preview { display:grid; max-height:min(70vh,760px); min-height:240px; place-items:center; overflow:auto; border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-label-bg, #f8fafc); }
|
||||
.qualification-preview img { display:block; max-width:100%; height:auto; }
|
||||
.qualification-preview iframe { width:100%; min-height:min(68vh,720px); border:0; background:var(--admin-surface, #fff); }
|
||||
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:var(--admin-space-3, 8px); } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-align:left; }
|
||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--admin-label-bg, #f8fafc); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); }
|
||||
.review-dialog-content { display:grid; }
|
||||
.review-dialog-header { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); border:1px solid #dbe4f0; }
|
||||
.review-dialog-title { display:flex; min-width:0; align-items:center; gap:11px; }
|
||||
.review-dialog-title > div { display:grid; min-width:0; gap:3px; }
|
||||
.review-dialog-title strong { overflow:hidden; color:#18243a; font-size:var(--admin-font-title, 16px); text-overflow:ellipsis; }
|
||||
.review-dialog-title span:not(.review-dialog-icon) { overflow:hidden; color:#718096; font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; white-space:nowrap; }
|
||||
.review-dialog-icon { width:36px; height:var(--admin-row-height, 36px); flex:0 0 36px; place-items:center; border-radius:9px; background:#e9efff; color:#526fe8; }
|
||||
.review-dialog-grid { display:grid; grid-template-columns:minmax(270px,.85fr) minmax(390px,1.15fr); }
|
||||
.review-dialog-section { min-width:0; border:1px solid #e5eaf1; background:var(--admin-surface, #fff); }
|
||||
.review-decision-card { min-width:0; border:1px solid #e5eaf1; background:var(--admin-surface, #fff); }
|
||||
.review-section-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-5, 12px); }
|
||||
.review-section-heading > div { min-width:0; }
|
||||
.review-section-heading h3 { margin:0; color:#1a2438; font-size:var(--admin-font-section, 14px); font-weight:700; }
|
||||
.review-section-heading span { display:block; margin-top:var(--admin-space-1, 4px); color:#8a94a6; line-height:1.4; }
|
||||
.review-section-heading > svg { flex:0 0 auto; color:#657bea; }
|
||||
.review-info-grid { margin:0; }
|
||||
.review-document-table { overflow:hidden; border:1px solid #e4e9f1; }
|
||||
.review-document-header { display:grid; grid-template-columns:118px minmax(0,1fr) 138px; }
|
||||
.review-document-row { display:grid; grid-template-columns:118px minmax(0,1fr) 138px; }
|
||||
.review-document-header { background:#f5f7fb; color:#657188; font-weight:700; }
|
||||
.review-document-header > span { min-width:0; padding:9px var(--admin-space-4, 10px); overflow-wrap:anywhere; border-right:1px solid #e4e9f1; }
|
||||
.review-document-row > span { min-width:0; padding:9px var(--admin-space-4, 10px); overflow-wrap:anywhere; border-right:1px solid #e4e9f1; }
|
||||
.review-document-header > span:last-child { border-right:0; }
|
||||
.review-document-row > span:last-child { border-right:0; }
|
||||
.review-document-row { min-height:46px; border-top:1px solid #edf0f4; color:#334155; font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-row > span { display:flex; align-items:center; }
|
||||
.review-document-type { font-weight:650; }
|
||||
.review-document-name { min-width:0; gap:var(--admin-space-2, 6px); overflow:hidden; color:#4c5b72; text-overflow:ellipsis; }
|
||||
.review-document-name svg { flex:0 0 auto; color:#8798cf; }
|
||||
.review-document-actions { justify-content:flex-end; gap:var(--admin-space-2, 6px); }
|
||||
.review-document-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.review-document-state { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-error { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-empty { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.review-document-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-3, 8px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; }
|
||||
.review-document-empty { display:flex; align-items:center; justify-content:center; gap:7px; text-align:center; }.review-document-empty svg { color:#a5afc1; }
|
||||
.review-decision-card :deep(.el-form-item) { margin-bottom:0; }.review-decision-card :deep(.el-form-item__label) { color:#4b5870; font-size:var(--admin-font-caption, 12px); font-weight:650; }.decision-summary > div > span { display:block; margin-top:var(--admin-space-1, 4px); }.decision-type { margin:0 0 14px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:var(--admin-space-3, 8px); }.dialog-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; }.archive-confirm { display:grid; gap:var(--admin-space-3, 8px); margin:var(--admin-space-6, 16px) 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:var(--admin-space-4, 10px); padding:var(--admin-space-5, 12px) 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong { display:block; } .approval-result-warning span { display:block; } .approval-result-warning strong { font-size:var(--admin-font-section, 14px); } .approval-result-warning span { margin-top:var(--admin-space-1, 4px); font-size:var(--admin-font-caption, 12px); line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:var(--admin-space-5, 12px); } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:var(--admin-space-4, 10px); margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:var(--admin-font-section, 14px); font-weight:700; }
|
||||
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-dialog-grid { grid-template-columns:1fr; gap:12px; }.review-info-grid { grid-template-columns:1fr 1fr; }.review-document-header,.review-document-row { grid-template-columns:104px minmax(0,1fr) 124px; }.review-dialog-header { align-items:flex-start; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||
@media (max-width:480px) { :global(.review-decision-dialog .el-dialog__header),:global(.review-decision-dialog .el-dialog__body) { padding-left:16px; padding-right:16px; }.review-info-grid { grid-template-columns:1fr; }.review-document-header { display:none; }.review-document-row { grid-template-columns:1fr; gap:6px; padding:10px; }.review-document-row > span { padding:0; border-right:0; }.review-document-actions { justify-content:flex-start; }.review-dialog-title strong { max-width:190px; } }
|
||||
.decision-summary > div > span { display:block; margin-top:var(--admin-space-1, 4px); }.decision-type { margin:0 0 14px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:var(--admin-space-3, 8px); }.archive-confirm { display:grid; gap:var(--admin-space-3, 8px); margin:var(--admin-space-6, 16px) 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:var(--admin-space-4, 10px); padding:var(--admin-space-5, 12px) 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong { display:block; } .approval-result-warning span { display:block; } .approval-result-warning strong { font-size:var(--admin-font-section, 14px); } .approval-result-warning span { margin-top:var(--admin-space-1, 4px); font-size:var(--admin-font-caption, 12px); line-height:1.5; }
|
||||
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-document-header,.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||
@media (max-width:480px) { :global(.review-decision-dialog .el-dialog__header),:global(.review-decision-dialog .el-dialog__body) { padding-left:16px; padding-right:16px; } }
|
||||
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ElMessage } from 'element-plus'
|
||||
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { customerDialogTitle } from '../customerDialogTitle'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
@@ -70,13 +71,11 @@ onMounted(() => { void load() })
|
||||
<div class="business-page overseas-fees-page admin-density customer-density">
|
||||
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
|
||||
|
||||
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||
<template #toolbar-start><h1>海外仓阶梯费用</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
|
||||
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
|
||||
|
||||
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" class="density-action" size="small" plain @click="openEdit(row as OverseasStorageFeeConfig)">编辑</el-button></div></template>
|
||||
<template #storage="{ row }">
|
||||
<dl class="tier-list" aria-label="仓租阶梯单价,币种见本行币种列">
|
||||
@@ -91,8 +90,18 @@ onMounted(() => { void load() })
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><el-table :data="group" size="small" border stripe><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)">删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></template></el-table-column></el-table></div></div></el-form>
|
||||
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
<div class="customer-fee-tier-groups">
|
||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
|
||||
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
||||
<div class="customer-fee-tier-table-wrap"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
||||
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
</div>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</AdminPage>
|
||||
@@ -105,14 +114,6 @@ onMounted(() => { void load() })
|
||||
.tier-item { display:flex; align-items:baseline; min-width:0; max-width:100%; line-height:24px; }
|
||||
.tier-item dt { flex:0 0 auto; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
|
||||
.tier-item dd { min-width:0; margin:0; color:var(--cc-text); font-size:var(--admin-font-body, 13px); font-weight:600; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.groups > div { min-width:0; }
|
||||
.groups :deep(.el-table) { width:100%; }
|
||||
.groups :deep(.el-input-number) { width:100%; min-width:0; }
|
||||
.groups h3 { margin:0 0 var(--admin-space-4, 10px); }
|
||||
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
|
||||
.group-heading h3 { min-width:0; margin:0; }
|
||||
.group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
@media (max-width:900px) { .groups { grid-template-columns:1fr; } }
|
||||
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } .groups > div { overflow-x:auto; } .groups :deep(.el-table) { min-width:560px; } }
|
||||
|
||||
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin 历史报关模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/customs/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/CustomsCasesView.vue`、`api/platformCustoms.ts`、`types.ts`、`status.ts`:承载历史报关案件、清单、代理提交与报关行回执。
|
||||
- 后端归 `firstleg/customs/`(`ContainerCustomsService.java` 等),文件归 `file/`。
|
||||
- **不要与货柜详情里的“送柜报关”面板混淆**:整柜报关资料在 `../container-loading/components/ContainerCustomsPanel.vue`,本模块是历史案件入口。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 报关案件列表、状态与筛选 | `pages/CustomsCasesView.vue` | `api/platformCustoms.ts`;`firstleg/customs/` |
|
||||
| 报关清单与代理提交 | 案件详情与提交动作 | `api/platformCustoms.ts` |
|
||||
| 报关行回执与完成文件 | 详情附件区 | `file/` 的 `LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` |
|
||||
| 状态与中文文案 | `status.ts`、`types.ts` | 服务端状态机 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 报关源文件、回执与完成文件都是客户作用域,`businessId` 固定为装柜计划 ID;下载走鉴权入口。
|
||||
- 回执与完成文件必须满足整柜完成与可见性条件,前端不自行放宽或绕过。
|
||||
- 提交给报关行后案件进入只读确认流程;打回、补资料等状态以服务端为准,不在前端改状态。
|
||||
- 与货柜页签共用同一后端能力时,只改一处展示,不要复制业务规则。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/customs/<目标测试>`;文件或状态变化同时核对 `firstleg/customs/` 与 `file/` 的报关合同。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -38,5 +38,5 @@ async function submit() { if (!detail.value || !submitForm.customsAgentId || !cu
|
||||
watch([page, pageSize], () => void load()); onMounted(() => { void load(); void loadCustomers(); void loadCustomsAgents() })
|
||||
</script>
|
||||
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" size="small" type="primary" plain @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-alert v-if="customsAgentsError" type="warning" :closable="false" :title="customsAgentsError"><el-button link type="warning" :loading="customsAgentsLoading" @click="loadCustomsAgents">重新加载</el-button></el-alert><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理" required><el-select :model-value="submitForm.customsAgentId" filterable clearable placeholder="请选择报关代理" :loading="customsAgentsLoading" no-data-text="暂无可用报关供应商" style="width:100%" @update:model-value="selectCustomsAgent(String($event || ''))"><el-option v-for="supplier in customsAgents" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></el-form-item></el-col></el-row><el-button type="primary" :disabled="!submitForm.customsAgentId" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>提交批次</h3><el-empty v-if="!detail.submissionBatches.length" description="暂无提交批次" :image-size="56" /><el-timeline v-else><el-timeline-item v-for="batch in detail.submissionBatches" :key="batch.id" :timestamp="batch.createdAt">报关代理:{{ batch.customsAgentName || '服务端未返回名称快照' }}</el-timeline-item></el-timeline><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-alert v-if="customsAgentsError" type="warning" :closable="false" :title="customsAgentsError"><el-button link type="warning" :loading="customsAgentsLoading" @click="loadCustomsAgents">重新加载</el-button></el-alert><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理" required><el-select :model-value="submitForm.customsAgentId" filterable clearable placeholder="请选择报关代理" :loading="customsAgentsLoading" no-data-text="暂无可用报关供应商" style="width:100%" @update:model-value="selectCustomsAgent(String($event || ''))"><el-option v-for="supplier in customsAgents" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" /></el-form-item></el-col></el-row><el-button type="primary" :disabled="!submitForm.customsAgentId" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>提交批次</h3><el-empty v-if="!detail.submissionBatches.length" description="暂无提交批次" :image-size="56" /><el-timeline v-else><el-timeline-item v-for="batch in detail.submissionBatches" :key="batch.id" :timestamp="batch.createdAt">报关代理:{{ batch.customsAgentName || '服务端未返回名称快照' }}</el-timeline-item></el-timeline><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}:global(.el-dialog:has(.submit-form,.el-descriptions)){width:min(900px,calc(100vw - 28px))!important;max-height:90vh;overflow:hidden}:global(.el-dialog:has(.submit-form,.el-descriptions) .el-dialog__body){max-height:calc(90vh - 120px);overflow-y:auto}</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Admin 工作台模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/dashboard/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/DashboardView.vue` 与 `api/dashboard.ts`;指标口径与实时/缓存边界见工作区 `docs/首页数据看板真实化与统计策略规划.md`。
|
||||
- 后端查 `firstleg/` 的 `firstleg/dashboard/PlatformDashboardMetricsController.java`;自营经营总览是另一个入口(`../self-operated/`),不要混。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 首页指标卡片与趋势 | `pages/DashboardView.vue`;根路径默认页 | `api/dashboard.ts`;`firstleg/dashboard/` |
|
||||
| 指标筛选与刷新 | 页面顶部筛选 | `api/dashboard.ts`;服务端统计口径 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 指标只展示服务端结果,前端不二次汇总或补算同比/环比。
|
||||
- 缓存与实时口径按统计策略文档执行,不在页面自行加轮询或本地缓存。
|
||||
- 自营经营总览(领星数据)不进入本模块,走 `../self-operated/AGENTS.md`。
|
||||
- 指标下钻跳转要带明确业务筛选条件,不用指标名称反推业务状态。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/dashboard/<目标测试>`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -48,7 +48,7 @@ describe('DashboardView', () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
|
||||
expect(wrapper.find('.dashboard-context > p').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
|
||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.dashboard-metric p').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数'])
|
||||
@@ -77,7 +77,7 @@ describe('DashboardView', () => {
|
||||
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getTodayNewOrderCount).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
|
||||
expect(wrapper.find('.dashboard-context > p').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('12 票')
|
||||
})
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<template>
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<section class="dashboard-context" aria-label="数据看板说明">
|
||||
<p>{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
|
||||
<section class="dashboard-context" aria-label="数据看板操作">
|
||||
<p v-if="!hasMetrics || metricsError">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
<span v-if="hasMetrics">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
@@ -119,7 +119,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
|
||||
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
|
||||
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||
.dashboard-head-actions { margin-left: auto; display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
|
||||
.dashboard-outline-button { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; padding: 0 13px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
|
||||
.dashboard-outline-button:hover:not(:disabled) { border-color: var(--cc-primary); color: var(--cc-primary); }
|
||||
.dashboard-outline-button:disabled { cursor: wait; opacity: .65; }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 尺寸配置模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/dimension-config/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 本模块**没有页面**,只有 `api.ts`、`types.ts`、`index.ts`:它是客户四档箱规与尺寸系数的数据层,由 `../inbounds/`(测量)和 `../customers/`(客户计费规则)消费。
|
||||
- 后端只查 `firstleg/` 的 `CustomerDimensionConfigService.java`、`CustomerDimensionWeightingService.java`。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户箱规配置与四档尺寸 | `api.ts`、`types.ts` | `firstleg/service/CustomerDimensionConfigService.java` |
|
||||
| 尺寸系数与默认权重算法 | `api.ts` | `firstleg/service/CustomerDimensionWeightingService.java` |
|
||||
| 模块对外导出 | `index.ts` | 供 `../inbounds/`、`../customers/` 引用 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 客户固定四档箱规与默认算法(`ADD/MULTIPLY`)的正式规则见详细索引同名节;本模块只提供数据入口,不在调用方重写算法或补默认档位。
|
||||
- 新建客户时的箱规与权重系数初始化由 IAM 建档流程触发(`iam/` 的初始化服务 + Firstleg 内部接口),本模块只提供查询与保存入口。
|
||||
- 尺寸单位固定为厘米,体积由服务端计算;页面与调用方不自行换算。
|
||||
- 改类型或字段时同步检查 `../inbounds/`、`../customers/` 两个消费方与 Firstleg 合同测试。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/dimension-config/<目标测试>`;字段变化同时核对两个消费方。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 国内整柜模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/domestic-containers/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 页面:`pages/DomesticContainersView.vue`(整柜业务列表)与 `pages/DomesticContainerInventoryDetailView.vue`(库存详情);接口 `api/domesticContainers.ts`。
|
||||
- 后端归 `warehouse/domesticcontainer/`(`PlatformDomesticContainerController.java`、`DomesticContainerService.java`)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 整柜入库、出库与库存列表 | `pages/DomesticContainersView.vue`;`domestic-containers` | `api/domesticContainers.ts`;`warehouse/domesticcontainer/` |
|
||||
| 整柜库存快照与不可变交易记录 | `pages/DomesticContainerInventoryDetailView.vue`;`domestic-containers/inventories/:inventoryId` | `api/domesticContainers.ts` |
|
||||
| 汇总样式 | `pages/domesticContainersSummary.css` | 本地样式,按公共密度规范接入 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 库存数量与交易记录只读展示服务端快照,不做前端加减或重算。
|
||||
- 出入库交易是追加式不可变记录,不提供编辑或删除入口。
|
||||
- 与货柜履约(`../container-loading/`)的柜次是不同概念:本模块是国内仓整柜库存业务,不承担装柜与订舱。
|
||||
- 权限统一 `platform:domestic-container:list`,写操作按独立权限。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/domestic-containers/<目标测试>`;字段变化同时核对 `domestic-container-v1.yaml`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createRequire } from 'node:module'
|
||||
import DomesticContainersView from './DomesticContainersView.vue'
|
||||
import * as api from '../api/domesticContainers'
|
||||
import type { InventoryRecord } from '../types'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
// Use Node's supported require export so the validator retains its constructor interop.
|
||||
const ElementPlus = (createRequire(import.meta.url)('element-plus') as typeof import('element-plus')).default
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/DomesticContainersView.vue'), 'utf8')
|
||||
const summaryStyles = readFileSync(resolve(process.cwd(), 'src/modules/domestic-containers/pages/domesticContainersSummary.css'), 'utf8')
|
||||
describe('DomesticContainersView', () => {
|
||||
@@ -22,3 +29,86 @@ describe('DomesticContainersView', () => {
|
||||
it('matches backend numeric and container-number input boundaries before submit', () => { expect(source).toContain("pattern: /^[A-Za-z0-9-]{4,32}$/"); expect(source).toContain(':max="999999999"'); expect(source).toContain(':max="999999999999.9999"') })
|
||||
it('requires a refreshed inventory version for completed outbound submission', () => { expect(source).toContain("outboundForm.inventoryVersion == null"); expect(source).toContain('库存版本未就绪,请重新选择集装箱') })
|
||||
})
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([{ id: 'warehouse-1', warehouseCode: 'WH-01', warehouseName: '测试仓', warehouseType: 'DOMESTIC', status: 'ACTIVE' }]) }))
|
||||
vi.mock('../api/domesticContainers', () => ({ createDomesticContainerInbound: vi.fn(), createDomesticContainerOutbound: vi.fn(), getDomesticContainerInventory: vi.fn(), listDomesticContainerInbounds: vi.fn(), listDomesticContainerInventories: vi.fn(), listDomesticContainerOutbounds: vi.fn() }))
|
||||
const inventory: InventoryRecord = { id: 'inventory-1', warehouseId: 'warehouse-1', warehouseName: '测试仓', containerNo: 'MSCU1234567', cargoName: '测试货物', availableBoxes: 8, inboundBoxes: 10, outboundBoxes: 2, inboundVolumeCbm: 2.5, inventoryStatus: 'PARTIALLY_OUTBOUND', version: 3, updatedAt: '2026-09-30T10:00:00' }
|
||||
let mounted: VueWrapper | undefined
|
||||
async function openCreate(label: string) {
|
||||
const view = mount(DomesticContainersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
mounted = view
|
||||
await flushPromises()
|
||||
if (label === '新建出库单') await view.findAll('[role="tab"]').find(tab => tab.text() === '整柜出库单')!.trigger('click')
|
||||
await flushPromises()
|
||||
await view.findAll('button').find(button => button.text() === label)!.trigger('click')
|
||||
await flushPromises()
|
||||
const form = view.findComponent({ name: 'ElForm' })
|
||||
return { view, form }
|
||||
}
|
||||
async function submitCreate() {
|
||||
document.querySelector<HTMLButtonElement>('.el-dialog__footer .el-button--primary')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
describe('DomesticContainersView create workflows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
vi.mocked(api.listDomesticContainerInbounds).mockResolvedValue(empty)
|
||||
vi.mocked(api.listDomesticContainerOutbounds).mockResolvedValue(empty)
|
||||
vi.mocked(api.listDomesticContainerInventories).mockResolvedValue({ ...empty, records: [inventory], summary: { inboundBoxes: 10, outboundBoxes: 2, availableBoxes: 8, inboundVolumeCbm: 2.5 } })
|
||||
vi.mocked(api.getDomesticContainerInventory).mockResolvedValue({ inventory: { ...inventory, availableBoxes: 6, version: 9 }, inboundOrderCount: 1, outboundOrderCount: 1, transactions: [] })
|
||||
})
|
||||
afterEach(() => { mounted?.unmount(); mounted = undefined; document.body.innerHTML = '' })
|
||||
|
||||
it('keeps validation, normalized input and the idempotency key when retrying an inbound', async () => {
|
||||
vi.mocked(api.createDomesticContainerInbound).mockRejectedValueOnce(new Error('临时失败')).mockResolvedValueOnce({ id: 'order-internal', orderNo: 'ZGRK-01', replayed: false })
|
||||
const { form } = await openCreate('新建入库单')
|
||||
await submitCreate()
|
||||
expect(api.createDomesticContainerInbound).not.toHaveBeenCalled()
|
||||
const fields = form.findAllComponents({ name: 'ElFormItem' })
|
||||
const warehouse = fields.find(item => item.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' })
|
||||
warehouse.vm.$emit('update:modelValue', 'warehouse-1')
|
||||
await fields.find(item => item.props('prop') === 'cargoName')!.find('input').setValue(' 测试货物 ')
|
||||
await fields.find(item => item.props('prop') === 'containerNo')!.find('input').setValue('mscu1234567')
|
||||
fields.find(item => item.props('prop') === 'boxCount')!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 4)
|
||||
fields.find(item => item.props('prop') === 'volumeCbm')!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1.2345)
|
||||
await flushPromises()
|
||||
await submitCreate()
|
||||
expect(document.body.textContent).toContain('整柜单据创建失败')
|
||||
await submitCreate()
|
||||
const calls = vi.mocked(api.createDomesticContainerInbound).mock.calls
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0][0]).toEqual(calls[1][0])
|
||||
expect(calls[1][0]).toMatchObject({ warehouseId: 'warehouse-1', cargoName: '测试货物', containerNo: 'MSCU1234567', boxCount: 4, volumeCbm: 1.2345, status: 'COMPLETED', idempotencyKey: expect.any(String) })
|
||||
})
|
||||
|
||||
it('shows inventory facts as text and submits the refreshed version only within available stock', async () => {
|
||||
vi.mocked(api.createDomesticContainerOutbound).mockResolvedValue({ id: 'order-internal', orderNo: 'ZGCK-01', replayed: false })
|
||||
const { form } = await openCreate('新建出库单')
|
||||
const fields = form.findAllComponents({ name: 'ElFormItem' })
|
||||
const warehouse = fields.find(item => item.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' })
|
||||
warehouse.vm.$emit('update:modelValue', 'warehouse-1')
|
||||
warehouse.vm.$emit('change', 'warehouse-1')
|
||||
await flushPromises()
|
||||
const selection = fields.find(item => item.props('prop') === 'containerNo')!.findComponent({ name: 'ElSelect' })
|
||||
selection.vm.$emit('update:modelValue', 'inventory-1')
|
||||
selection.vm.$emit('change', 'inventory-1')
|
||||
await flushPromises()
|
||||
const cargo = fields.find(item => item.props('label') === '货物名称')!
|
||||
expect(cargo.text()).toContain('测试货物')
|
||||
expect(cargo.find('input').exists()).toBe(false)
|
||||
expect(fields.find(item => item.props('label') === '可用库存')!.text()).toContain('6 箱')
|
||||
const quantity = fields.find(item => item.props('prop') === 'boxCount')!.findComponent({ name: 'ElInputNumber' })
|
||||
quantity.vm.$emit('update:modelValue', 7)
|
||||
await flushPromises()
|
||||
await submitCreate()
|
||||
expect(api.createDomesticContainerOutbound).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('库存不足,当前可用 6 箱')
|
||||
quantity.vm.$emit('update:modelValue', 5)
|
||||
await flushPromises()
|
||||
await submitCreate()
|
||||
expect(api.createDomesticContainerOutbound).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ warehouseId: 'warehouse-1', containerNo: 'MSCU1234567', boxCount: 5, inventoryVersion: 9, idempotencyKey: expect.any(String) }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,6 +80,45 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
|
||||
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit">
|
||||
<LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
|
||||
<el-form ref="formRef" :model="currentForm" :rules="rules" label-position="left" label-width="100px" class="density-form">
|
||||
<table class="density-table density-table--editable container-entry-table" :aria-label="dialogType === 'inbound' ? '整柜入库资料' : '整柜出库资料'">
|
||||
<tbody>
|
||||
<template v-if="dialogType === 'inbound'">
|
||||
<tr>
|
||||
<td><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></td>
|
||||
<td><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></td>
|
||||
<td><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></td>
|
||||
<td><el-form-item label="入库时间" prop="operationTime"><el-date-picker v-model="inboundForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr>
|
||||
<td><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></td>
|
||||
<td><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="货物名称"><span>{{ outboundForm.cargoName || '—' }}</span></el-form-item></td>
|
||||
<td><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><el-form-item label="可用库存"><span>{{ outboundForm.availableBoxes }} 箱</span></el-form-item></td>
|
||||
<td><el-form-item label="出库时间" prop="operationTime"><el-date-picker v-model="outboundForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></td>
|
||||
</tr>
|
||||
<tr v-if="candidateError"><td colspan="2"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></td></tr>
|
||||
</template>
|
||||
<tr><td colspan="2"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</el-form>
|
||||
</AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.container-entry-table{table-layout:fixed}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 下载中心模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/downloads/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有 `pages/DownloadCenterView.vue`、`useExportTask.ts`、`api/platformExportTasks.ts`;所有业务导出都走这一套。
|
||||
- 后端归 `file/`(`PlatformExportTaskController`、`ExportTaskService`、`ExportStateService`、`ExportDefinitionRegistry`),实际取数由各业务服务的 Provider 分批提供。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 导出任务列表、下载与失败重试 | `pages/DownloadCenterView.vue` | `api/platformExportTasks.ts`;`file/` |
|
||||
| 业务页面创建导出任务 | `useExportTask.ts`(供其他模块调用) | `api/platformExportTasks.ts` |
|
||||
| 自动退避与重试状态 | 任务行 `attemptCount/maxAttempts/nextRetryAt` | `file/` `ExportStateService` |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 业务页面只创建异步任务,不直接拼装文件,也不直连 Provider。
|
||||
- 重试只允许 `FAILED`,或 `PENDING` 且已有失败尝试、正在等待自动退避的任务;`RUNNING`、初次等待、已成功或已过期任务拒绝重试。
|
||||
- 导出类型必须在 `ExportDefinitionRegistry` 登记;新增类型同步检查业务 Provider、权限、页面标签与重试映射。
|
||||
- 下载链接走鉴权接口,不缓存为公共地址;导出文件不进入浏览器本地持久化。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/downloads/<目标测试>`;导出类型变化同时核对 `file/` 的 Provider 契约与业务服务 Provider。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElDialog } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import DownloadCenterView from './DownloadCenterView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
|
||||
vi.mock('@/modules/downloads/api/platformExportTasks', () => ({
|
||||
downloadPlatformExportTask: vi.fn(),
|
||||
@@ -58,6 +60,21 @@ describe('DownloadCenterView', () => {
|
||||
vi.mocked(retryPlatformExportTask).mockResolvedValue({ ...task, status: 'PENDING' })
|
||||
})
|
||||
|
||||
const dialogButton = (label: string) => {
|
||||
const found = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === label)
|
||||
expect(found, label).toBeDefined(); return found!
|
||||
}
|
||||
const factValue = (label: string) => {
|
||||
const term = Array.from(document.querySelectorAll('[aria-label="下载任务资料"] dt')).find(node => node.textContent === label)
|
||||
expect(term, label).toBeDefined(); return term!.nextElementSibling!.textContent
|
||||
}
|
||||
async function openTask(value: PlatformExportTask = task) {
|
||||
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [value], total: 1, page: 1, size: 20, pages: 1 })
|
||||
const wrapper = mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
const open = () => Array.from(document.querySelectorAll<HTMLButtonElement>('.el-table__body .table-actions button')).find(button => button.textContent?.trim() === '详情')!.click()
|
||||
open(); await flushPromises(); return { wrapper, open }
|
||||
}
|
||||
|
||||
it('lists completed tasks and downloads the backend-generated artifact', async () => {
|
||||
const pinia = setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
@@ -290,4 +307,99 @@ describe('DownloadCenterView', () => {
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('12 / 12 行')
|
||||
})
|
||||
|
||||
it('keeps long facts and zero values complete through fullscreen and never uses a requester ID fallback', async () => {
|
||||
const value: PlatformExportTask = { ...task, id: '9223372036854775807', requestedBy: '9223372036854775806', requestedUsername: null, taskNo: 'EXP-FULL-001', taskName: '完整导出任务名称'.repeat(25), fileName: '完整导出文件名称'.repeat(25) + '.xlsx', processedRows: 0, totalRows: 0, sizeBytes: 0 }
|
||||
vi.mocked(getPlatformExportTask).mockResolvedValue(value)
|
||||
const { wrapper } = await openTask(value)
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props()).toMatchObject({ title: '任务详情 · EXP-FULL-001', width: 'min(720px, calc(100vw - 28px))', fullscreenable: true, showConfirm: false })
|
||||
expect(document.querySelector('[aria-label="下载任务资料"]')?.textContent).toContain(value.taskName)
|
||||
expect(factValue('文件名称')).toBe(value.fileName)
|
||||
expect(factValue('申请人')).toBe('—')
|
||||
expect(factValue('处理数据')).toBe('0 / 0 行')
|
||||
expect(factValue('文件大小')).toBe('0 B')
|
||||
expect(document.body.textContent).not.toContain(value.requestedBy)
|
||||
expect(document.body.textContent).not.toContain(value.id)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
|
||||
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
|
||||
expect(factValue('文件名称')).toBe(value.fileName)
|
||||
dialogButton('下载文件').click(); await flushPromises()
|
||||
expect(downloadPlatformExportTask).toHaveBeenCalledWith(value.id)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['RUNNING', '处理中', false], ['PENDING', '等待重试', true], ['EXPIRED', '已过期', false],
|
||||
] as const)('shows %s facts and original retry/download eligibility', async (status,label,retry) => {
|
||||
const value: PlatformExportTask = { ...task, status, attemptCount: 2, processedRows: 3, totalRows: null, sizeBytes: null, fileName: null, nextRetryAt: '2026-10-20T12:00:00' }
|
||||
vi.mocked(getPlatformExportTask).mockResolvedValue(value)
|
||||
await openTask(value)
|
||||
expect(document.querySelector('.task-summary-heading')?.textContent).toContain(label)
|
||||
expect(factValue('处理数据')).toBe('3 行'); expect(factValue('文件大小')).toBe('—'); expect(factValue('文件名称')).toBe('—')
|
||||
expect(dialogButton('下载文件').disabled).toBe(true)
|
||||
if (retry) {
|
||||
expect(factValue('下次重试')).not.toBe('—')
|
||||
dialogButton('立即重试').click(); await flushPromises()
|
||||
expect(retryPlatformExportTask).toHaveBeenCalledWith(value.id)
|
||||
} else {
|
||||
expect(document.querySelector('.el-dialog__footer')?.textContent).not.toContain('重试')
|
||||
expect(factValue('下次重试')).toBe('—')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the full failure reason when retry fails and refreshes the same task after retry succeeds', async () => {
|
||||
const failed: PlatformExportTask = { ...task, status: 'FAILED', failureMessage: '完整失败原因'.repeat(90) }
|
||||
vi.mocked(getPlatformExportTask).mockResolvedValue(failed)
|
||||
vi.mocked(retryPlatformExportTask).mockRejectedValueOnce(new Error('重试失败')).mockResolvedValueOnce({ ...failed, status: 'PENDING' })
|
||||
await openTask(failed)
|
||||
expect(document.querySelector('.task-failure')?.textContent).toContain(failed.failureMessage)
|
||||
dialogButton('重试').click(); await flushPromises()
|
||||
expect(document.querySelector('.task-failure')?.textContent).toContain(failed.failureMessage)
|
||||
expect(dialogButton('重试').disabled).toBe(false)
|
||||
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [{ ...failed, status: 'PENDING', attemptCount: 0, failureMessage: null }], total: 1, page: 1, size: 20, pages: 1 })
|
||||
dialogButton('重试').click(); await flushPromises()
|
||||
expect(retryPlatformExportTask).toHaveBeenCalledTimes(2)
|
||||
expect(document.querySelector('.task-summary-heading')?.textContent).toContain('等待处理')
|
||||
expect(document.querySelector('.task-failure')).toBeNull()
|
||||
expect(dialogButton('下载文件').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a compact detail error with diagnostics and restores actions only after reloading', async () => {
|
||||
vi.mocked(getPlatformExportTask).mockRejectedValueOnce(new ApiResponseError('详情读取失败', 'A0500', 500, null, 'req-download-detail')).mockResolvedValueOnce(task)
|
||||
await openTask()
|
||||
expect(document.querySelector('.el-dialog .load-error-state')?.textContent).toContain('req-download-detail')
|
||||
expect(document.querySelector('.el-dialog .load-error-state')?.classList.contains('is-compact')).toBe(true)
|
||||
expect(dialogButton('下载文件').disabled).toBe(true)
|
||||
const reload = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog button')).find(button => button.textContent?.trim() === '重新加载')!
|
||||
reload.click(); await flushPromises()
|
||||
expect(getPlatformExportTask).toHaveBeenCalledTimes(2)
|
||||
expect(document.querySelector('.el-dialog .load-error-state')).toBeNull()
|
||||
expect(dialogButton('下载文件').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps actions disabled while detail is loading and ignores an old response after close and reopen', async () => {
|
||||
let release!: (value: PlatformExportTask) => void
|
||||
vi.mocked(getPlatformExportTask).mockImplementationOnce(() => new Promise(resolve => { release = resolve })).mockResolvedValueOnce({ ...task, taskNo: 'EXP-NEW', taskName: '最新任务详情' })
|
||||
const { wrapper, open } = await openTask()
|
||||
expect(wrapper.findComponent(AppDialog).props('loading')).toBe(true)
|
||||
expect(dialogButton('下载文件').disabled).toBe(true)
|
||||
dialogButton('关闭').click(); await flushPromises()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('closed'); await flushPromises()
|
||||
open(); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('任务详情 · EXP-NEW')
|
||||
release({ ...task, taskName: '过期响应' }); await flushPromises()
|
||||
expect(document.querySelector('.task-summary-heading')?.textContent).toContain('最新任务详情')
|
||||
expect(document.querySelector('.task-summary-heading')?.textContent).not.toContain('过期响应')
|
||||
expect(wrapper.findComponent(AppDialog).props('loading')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows a missing business number as — without exposing the internal task ID', async () => {
|
||||
const value: PlatformExportTask = { ...task, id: '9223372036854775807', taskNo: '' }
|
||||
vi.mocked(getPlatformExportTask).mockResolvedValue(value)
|
||||
const { wrapper } = await openTask(value)
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('任务详情')
|
||||
expect(factValue('任务编号')).toBe('—')
|
||||
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(value.id)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
@@ -306,7 +307,7 @@ onBeforeUnmount(() => {
|
||||
<template #type="{ row }">{{ typeLabel((row as PlatformExportTask).exportType) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="taskStatusMeta(row as PlatformExportTask).type" effect="light">{{ taskStatusMeta(row as PlatformExportTask).label }}</el-tag></template>
|
||||
<template #progress="{ row }"><span>{{ (row as PlatformExportTask).processedRows }}<template v-if="(row as PlatformExportTask).totalRows !== null"> / {{ (row as PlatformExportTask).totalRows }}</template></span><small v-if="(row as PlatformExportTask).sizeBytes !== null">{{ formatSize((row as PlatformExportTask).sizeBytes) }}</small></template>
|
||||
<template #requester="{ row }">{{ (row as PlatformExportTask).requestedUsername || (row as PlatformExportTask).requestedBy }}</template>
|
||||
<template #requester="{ row }">{{ (row as PlatformExportTask).requestedUsername || '—' }}</template>
|
||||
<template #requestedAt="{ row }">{{ formatDate((row as PlatformExportTask).requestedAt) }}</template>
|
||||
<template #completedAt="{ row }">{{ formatDate((row as PlatformExportTask).completedAt) }}</template>
|
||||
<template #expiresAt="{ row }">{{ formatDate((row as PlatformExportTask).expiresAt) }}</template>
|
||||
@@ -315,30 +316,24 @@ onBeforeUnmount(() => {
|
||||
|
||||
<AppDialog class="admin-density-dialog"
|
||||
v-model="detailOpen"
|
||||
:title="detailTask ? `任务详情 · ${detailTask.taskNo}` : '任务详情'"
|
||||
:title="detailTask?.taskNo?.trim() ? `任务详情 · ${detailTask.taskNo.trim()}` : '任务详情'"
|
||||
width="min(720px, calc(100vw - 28px))"
|
||||
fullscreenable
|
||||
:loading="detailLoading"
|
||||
:show-confirm="false"
|
||||
destroy-on-close
|
||||
@closed="closeDetail"
|
||||
>
|
||||
<section v-if="detailError" class="detail-load-error" role="alert">
|
||||
<strong>任务详情暂时无法加载</strong>
|
||||
<p>{{ detailError.message }}</p>
|
||||
<ApiErrorMeta :details="detailError" />
|
||||
<el-button type="primary" @click="detailTask && openDetail(detailTask)">重新加载</el-button>
|
||||
</section>
|
||||
<div v-else-if="detailTask" class="task-detail">
|
||||
<div class="task-detail-summary">
|
||||
<div>
|
||||
<h3>{{ detailTask.taskName }}</h3>
|
||||
<p>{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
|
||||
</div>
|
||||
<el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag>
|
||||
</div>
|
||||
<LoadErrorState v-if="detailError" compact title="任务详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="detailTask && openDetail(detailTask)">
|
||||
<template #details><ApiErrorMeta :details="detailError" /></template>
|
||||
</LoadErrorState>
|
||||
<div v-else-if="detailTask" class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="下载任务资料">
|
||||
<h3 class="density-dialog-section__title task-summary-heading"><span>{{ detailTask.taskName || '—' }}</span><el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag></h3>
|
||||
<p class="density-dialog-note">{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
|
||||
<dl class="task-detail-grid density-fields">
|
||||
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo }}</dd></div>
|
||||
<div><dt>申请人</dt><dd>{{ detailTask.requestedUsername || detailTask.requestedBy }}</dd></div>
|
||||
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo || '—' }}</dd></div>
|
||||
<div><dt>申请人</dt><dd>{{ detailTask.requestedUsername || '—' }}</dd></div>
|
||||
<div><dt>申请时间</dt><dd>{{ formatDate(detailTask.requestedAt) }}</dd></div>
|
||||
<div><dt>开始时间</dt><dd>{{ formatDate(detailTask.startedAt) }}</dd></div>
|
||||
<div><dt>完成时间</dt><dd>{{ formatDate(detailTask.completedAt) }}</dd></div>
|
||||
@@ -349,23 +344,21 @@ onBeforeUnmount(() => {
|
||||
<div><dt>文件大小</dt><dd>{{ formatSize(detailTask.sizeBytes) || '—' }}</dd></div>
|
||||
<div class="is-wide"><dt>文件名称</dt><dd>{{ detailTask.fileName || '—' }}</dd></div>
|
||||
</dl>
|
||||
<section v-if="detailTask.failureMessage" class="task-failure" role="status">
|
||||
<strong>失败原因</strong>
|
||||
<p>{{ detailTask.failureMessage }}</p>
|
||||
</section>
|
||||
<el-alert v-if="detailTask.failureMessage" class="task-failure" role="status" title="失败原因" :description="detailTask.failureMessage" type="error" :closable="false" show-icon />
|
||||
</div>
|
||||
<template #footer="{ close }">
|
||||
<div class="app-dialog__footer">
|
||||
<div class="density-actions density-actions--footer">
|
||||
<el-button @click="close">关闭</el-button>
|
||||
<el-button
|
||||
v-if="detailTask && canRetry(detailTask)"
|
||||
v-if="!detailLoading && !detailError && detailTask && canRetry(detailTask)"
|
||||
:loading="retryingId === detailTask.id"
|
||||
@click="retryTask(detailTask)"
|
||||
>{{ retryLabel(detailTask) }}</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="downloadingId === detailTask?.id"
|
||||
:disabled="detailTask?.status !== 'SUCCEEDED'"
|
||||
:disabled="detailLoading || Boolean(detailError) || detailTask?.status !== 'SUCCEEDED'"
|
||||
@click="downloadDetailTask"
|
||||
>下载文件</el-button>
|
||||
</div>
|
||||
@@ -378,6 +371,8 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.task-detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.task-detail-grid .is-wide { grid-column: 1 / -1; }
|
||||
.task-summary-heading { justify-content: space-between; }
|
||||
.task-summary-heading > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.download-center-page { min-width: 0; }
|
||||
.download-error { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
|
||||
.download-error { padding: var(--admin-space-6); }
|
||||
@@ -386,17 +381,6 @@ onBeforeUnmount(() => {
|
||||
.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: var(--admin-font-caption); }
|
||||
.failure-cell { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.failure-message { color: var(--cc-danger); }
|
||||
.detail-load-error { display: flex; min-height: 220px; align-items: flex-start; justify-content: center; flex-direction: column; }
|
||||
.detail-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-section); }
|
||||
.detail-load-error p { margin: var(--admin-space-2) 0; color: var(--cc-text); font-size: var(--admin-font-body); }
|
||||
.detail-load-error :deep(.el-button) { margin-top: var(--admin-space-6); }
|
||||
.task-detail { min-width: 0; }
|
||||
.task-detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-6); padding-bottom: var(--admin-space-6); border-bottom: 1px solid var(--cc-border); }
|
||||
.task-detail-summary h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-section); }
|
||||
.task-detail-summary p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.task-failure { margin-top: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: var(--admin-radius); background: color-mix(in srgb, var(--cc-danger) 5%, #fff); }
|
||||
.task-failure strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
|
||||
.task-failure p { margin: var(--admin-space-2) 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-body); line-height: 1.6; }
|
||||
small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
@media (max-width: 720px) {
|
||||
.download-error, .download-sync-warning { align-items: stretch; flex-direction: column; }
|
||||
@@ -404,9 +388,7 @@ small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: var(
|
||||
.table-actions :deep(.el-button) { padding-right: 8px; padding-left: 8px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.task-detail-summary { flex-direction: column; }
|
||||
.task-detail-grid { grid-template-columns: 1fr; }
|
||||
.task-detail-grid > div:nth-child(odd):not(.is-wide) { border-right: 0; }
|
||||
.task-detail-grid .is-wide { grid-column: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin FBA 价格簿模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/fba-pricing/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 子域:`carrier-rates/`(客户专属价格簿、柜级阶梯运费、上传与确认)、`customer-rules/`(客户计费规则);各自有页面与 API。
|
||||
- 后端归 `firstleg/`(`FbaPricingService.java`、`PlatformPriceBookService.java`、`firstleg/pricing/ContainerTierAmountAllocator.java`);计费消费方见 `../inbounds/`。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 客户专属价格簿列表、上传与确认 | `carrier-rates/pages/CarrierRatesView.vue` | `carrier-rates/api/platformPriceBooks.ts`;`firstleg/PlatformPriceBookService` |
|
||||
| 价格版本详情、仓库映射与只读价格行 | `carrier-rates/pages/CarrierRateDetailView.vue` | 同上 |
|
||||
| 客户计费规则 | `customer-rules/api.ts` | `firstleg/` 箱规与计价主数据 |
|
||||
| 对外导出 | `public.ts` | — |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 价格簿页面必须显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不混版本。
|
||||
- 计价优先客户专属 ACTIVE 版本,无专属才回退公共 ACTIVE;柜级阶梯金额与分摊只展示服务端结果。
|
||||
- 调整计费量只能在服务端允许区间内,并提交当前费用版本、原因与二次确认。
|
||||
- 上传后按 `versionId` 进入独立详情页,价格行只读;确认动作不可逆,需二次确认。
|
||||
- 私人仓尾程派送费使用稳定代码,只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/fba-pricing/<目标测试>`;价格字段变化同时核对 `fba-pricing-v1.yaml` 与 `../inbounds/` 计费消费。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -384,7 +384,7 @@ async function confirmPriceBook() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
|
||||
'确认并生效船期价格表',
|
||||
`确认并生效船期价格表 · V${detail.value.version.versionNo} · ${priceBookScopeLabel(detail.value.version.customerId)}`,
|
||||
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
@@ -267,7 +267,7 @@ async function voidPending(row: PlatformPriceBookVersion) {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(
|
||||
`作废后,版本 V${row.versionNo} 将不能再确认生效;版本记录和原始文件会继续保留。`,
|
||||
'作废待确认价格表',
|
||||
`作废待确认价格表 · V${row.versionNo} · ${priceBookScopeLabel(row.customerId)}`,
|
||||
{
|
||||
customClass: 'admin-density-confirm',
|
||||
type: 'warning',
|
||||
@@ -396,19 +396,19 @@ onMounted(() => {
|
||||
confirm-text="上传并解析"
|
||||
@confirm="submitUpload"
|
||||
>
|
||||
<div class="upload-content">
|
||||
<el-alert title="上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。" type="info" :closable="false" show-icon />
|
||||
<label class="upload-scope-field"><span>报价范围</span><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></label>
|
||||
<input ref="uploadInput" class="visually-hidden-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria-label="选择 XLSX 船期价格表" :disabled="uploading" @change="selectUploadFile" />
|
||||
<div class="upload-file-control">
|
||||
<el-button :disabled="uploading" @click="uploadInput?.click()"><FileSpreadsheet :size="16" />选择 XLSX 文件</el-button>
|
||||
<div class="upload-file-name" :class="{ 'is-empty': !uploadFile }">
|
||||
<strong>{{ uploadFile?.name || '尚未选择文件' }}</strong>
|
||||
<span v-if="uploadFile">{{ (uploadFile.size / 1024 / 1024).toFixed(2) }} MB</span>
|
||||
<span v-else>最大 10MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="uploadFileError" class="upload-file-error" role="alert">{{ uploadFileError }}</p>
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert title="上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。" type="info" :closable="false" show-icon />
|
||||
<el-form class="density-form" label-position="left" label-width="90px" :disabled="uploading">
|
||||
<table class="density-table density-table--editable" aria-label="上传价格表资料"><tbody>
|
||||
<tr><td><el-form-item label="报价范围"><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="价格表文件">
|
||||
<input ref="uploadInput" class="visually-hidden-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria-label="选择 XLSX 船期价格表" :disabled="uploading" @change="selectUploadFile" />
|
||||
<div class="upload-file-control"><el-button :disabled="uploading" @click="uploadInput?.click()"><FileSpreadsheet :size="16" />选择 XLSX 文件</el-button><span class="upload-file-name">{{ uploadFile?.name || '尚未选择文件' }}</span></div>
|
||||
</el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
<p class="density-dialog-note">{{ uploadFile ? (uploadFile.size / 1024 / 1024).toFixed(2) + ' MB' : '最大 10MB' }}</p>
|
||||
<el-alert v-if="uploadFileError" :title="uploadFileError" type="error" :closable="false" show-icon />
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -424,16 +424,9 @@ onMounted(() => {
|
||||
.metric-cell strong { color: var(--cc-text); font-size: var(--admin-font-body); }
|
||||
.issue-counts { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
|
||||
.upload-content { display: grid; gap: var(--admin-space-6); }
|
||||
.upload-scope-field { display: grid; gap: var(--admin-space-2); color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.upload-scope-field :deep(.el-select) { width: 100%; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
|
||||
.upload-file-control { display: flex; align-items: center; gap: var(--admin-space-5); }
|
||||
.upload-file-name { display: grid; min-width: 0; gap: 3px; color: var(--cc-text); font-size: var(--admin-font-body); }
|
||||
.upload-file-name strong { overflow-wrap: anywhere; }
|
||||
.upload-file-name span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.upload-file-name.is-empty strong { color: var(--cc-muted); font-weight: 400; }
|
||||
.upload-file-error { margin: calc(-1 * var(--admin-space-3)) 0 0; color: var(--el-color-danger); font-size: var(--admin-font-caption); }
|
||||
.upload-file-name { min-width: 0; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.table-actions :deep(.el-button) { min-height: 40px; }
|
||||
.upload-file-control { align-items: stretch; flex-direction: column; }
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import ElementPlus, { ElDialog, ElSelect, ElMessageBox } from 'element-plus'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import CarrierRatesView from './CarrierRatesView.vue'
|
||||
import CarrierRateDetailView from './CarrierRateDetailView.vue'
|
||||
import * as api from '../api/platformPriceBooks'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
|
||||
const { push } = vi.hoisted(() => ({ push: vi.fn() }))
|
||||
const privateId = '9007199254740993123'
|
||||
const customerId = '9007199254740993124'
|
||||
const domesticId = '9007199254740993125'
|
||||
const overseasId = '9007199254740993126'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ params: { versionId: '9007199254740993123' } }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformPriceBooks', () => ({ downloadPlatformPriceBookSource: vi.fn(), listPlatformPriceBooks: vi.fn(), uploadPlatformPriceBook: vi.fn(), voidPlatformPriceBook: vi.fn(), getPlatformPriceBook: vi.fn(), publishPlatformPriceBook: vi.fn() }))
|
||||
let detail: api.PlatformPriceBookDetail
|
||||
const customer: PlatformTenantResource = { id: customerId, tenantCode: 'C-TEST', tenantName: '测试客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null }
|
||||
const warehouse = (id: string, warehouseCode: string, warehouseType: 'DOMESTIC' | 'OVERSEAS'): PlatformWarehouseResource => ({ id, tenantId: '0', warehouseCode, internalCode: null, warehouseName: warehouseCode, warehouseType, countryCode: warehouseType === 'DOMESTIC' ? 'CN' : 'US', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null })
|
||||
const table = { props: ['data'], template: '<div><slot name="toolbar-actions"/><div class="table-row"><slot v-if="data?.length" name="actions" :row="data[0]"/></div></div>' }
|
||||
const plugins = [ElementPlus]
|
||||
function button(label: string, selector = 'button') {
|
||||
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
|
||||
expect(found, label).toBeDefined(); return found!
|
||||
}
|
||||
async function list() {
|
||||
const wrapper = mount(CarrierRatesView, { attachTo: document.body, global: { plugins, stubs: { DataTable: table, QueryToolbar: true } } }); await flushPromises(); return wrapper
|
||||
}
|
||||
async function upload() {
|
||||
const wrapper = await list(); button('上传价格表').click(); await flushPromises(); return wrapper
|
||||
}
|
||||
async function choose(wrapper: Awaited<ReturnType<typeof upload>>, file: File) {
|
||||
Object.defineProperty(wrapper.get('input[type="file"]').element, 'files', { configurable: true, value: [file] })
|
||||
await wrapper.get('input[type="file"]').trigger('change'); await flushPromises()
|
||||
}
|
||||
async function detailView() {
|
||||
const wrapper = mount(CarrierRateDetailView, { attachTo: document.body, global: { plugins } }); await flushPromises(); return wrapper
|
||||
}
|
||||
function setReason(value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>('.el-message-box input')!
|
||||
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => ElMessageBox.close())
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); document.body.innerHTML = ''; setActivePinia(createPinia())
|
||||
const auth = useAuthStore(); auth.token = 'test-session'; auth.permissions = ['firstleg:fba-pricing:rate:view','firstleg:fba-pricing:rate:edit','platform:warehouse:list']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
detail = { version: { id: privateId, customerId: '0', versionNo: 7, originalFileName: '测试价格表.xlsx', sourceFileId: 'private-file', sourceSha256: null, status: 'PENDING_CONFIRMATION', channelCount: 0, rateCount: 1, blockingErrorCount: 0, warningCount: 0, parseIssuesJson: null, createdBy: null, createdAt: null, publishedBy: null, publishedAt: null, supersededAt: null, voidedBy: null, voidedAt: null, voidReason: null }, channels: [], issues: [], rates: [{ id: null, channelCode: 'CHANNEL', priceModel: 'FBA_WAREHOUSE_RATE', originWarehouseId: null, originWarehouseCode: 'CN-TEST', originWarehouseName: '国内仓', destinationType: 'OVERSEAS', destinationCode: 'US-TEST', destinationWarehouseId: null, destinationName: '海外测试仓', regionCode: null, pricingTaxMode: 'TAX_INCLUDED', chargeUnit: 'KG', weightFromKg: null, unitPrice: 12.5, currency: 'CNY', sourceSheetName: 'Sheet1', sourceRowNumber: 8, sourceDestinationText: '海外测试仓', remark: null }] }
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], total: 1, page: 1, size: 200, pages: 1 })
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse(domesticId, 'CN-TEST', 'DOMESTIC'), warehouse(overseasId, 'US-TEST', 'OVERSEAS')])
|
||||
vi.mocked(api.listPlatformPriceBooks).mockImplementation(async () => [detail.version])
|
||||
vi.mocked(api.getPlatformPriceBook).mockImplementation(async () => detail)
|
||||
vi.mocked(api.uploadPlatformPriceBook).mockImplementation(async () => detail)
|
||||
vi.mocked(api.voidPlatformPriceBook).mockImplementation(async () => ({ ...detail, version: { ...detail.version, status: 'VOIDED' } }))
|
||||
vi.mocked(api.publishPlatformPriceBook).mockImplementation(async () => ({ ...detail, version: { ...detail.version, status: 'ACTIVE' } }))
|
||||
})
|
||||
|
||||
describe('ship price book dialogs', () => {
|
||||
it.each(['0', customerId])('uploads the original XLSX in scope %s then navigates to formal detail', async scope => {
|
||||
const wrapper = await upload()
|
||||
expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '上传船期价格表', width: 'min(560px, calc(100vw - 28px))' })
|
||||
expect(document.querySelector('[aria-label="上传价格表资料"]')?.textContent).toContain('报价范围')
|
||||
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', scope); await flushPromises()
|
||||
const file = new File(['xlsx'], '完整价格表名称'.repeat(15) + '.xlsx')
|
||||
await choose(wrapper, file)
|
||||
expect(document.querySelector('.upload-file-name')?.textContent).toBe(file.name)
|
||||
expect(button('上传并解析', '.el-dialog__footer button').disabled).toBe(false)
|
||||
button('上传并解析', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.uploadPlatformPriceBook).toHaveBeenCalledWith(file, scope)
|
||||
expect(push).toHaveBeenCalledWith({ name: 'fba-carrier-rate-detail', params: { versionId: privateId } })
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['extension','size'] as const)('invalid %s replacement clears the selected file and disables upload', async invalid => {
|
||||
const wrapper = await upload(); await choose(wrapper, new File(['xlsx'], 'valid.xlsx'))
|
||||
const file = new File(['bad'], invalid === 'extension' ? 'invalid.csv' : 'large.xlsx')
|
||||
if (invalid === 'size') Object.defineProperty(file, 'size', { value: 10 * 1024 * 1024 + 1 })
|
||||
await choose(wrapper, file)
|
||||
expect(document.querySelector('.el-dialog .el-alert--error')?.textContent).toContain(invalid === 'extension' ? 'XLSX' : '10MB')
|
||||
expect(button('上传并解析', '.el-dialog__footer button').disabled).toBe(true)
|
||||
expect(document.querySelector('.upload-file-name')?.textContent).toBe('尚未选择文件')
|
||||
expect(api.uploadPlatformPriceBook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('locks upload controls and close paths while processing and retains context on failure', async () => {
|
||||
let rejectUpload!: (error: Error) => void
|
||||
vi.mocked(api.uploadPlatformPriceBook).mockImplementation(() => new Promise((_resolve,reject) => { rejectUpload = reject }))
|
||||
const wrapper = await upload(); wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', customerId); await flushPromises()
|
||||
const file = new File(['xlsx'], 'retry.xlsx'); await choose(wrapper, file)
|
||||
button('上传并解析', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
expect(wrapper.findComponent(ElSelect).props('disabled')).toBe(true)
|
||||
expect(wrapper.get('input[type="file"]').attributes('disabled')).toBeDefined()
|
||||
rejectUpload(new Error('解析失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(wrapper.findComponent(ElSelect).props('modelValue')).toBe(customerId)
|
||||
expect(document.querySelector('.upload-file-name')?.textContent).toBe(file.name)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
vi.mocked(api.uploadPlatformPriceBook).mockResolvedValue(detail)
|
||||
button('上传并解析', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.uploadPlatformPriceBook).toHaveBeenLastCalledWith(file, customerId)
|
||||
})
|
||||
|
||||
it('identifies void scope/version and validates the original required reason before writing', async () => {
|
||||
detail.version.customerId = customerId
|
||||
await list(); button('作废', '.table-row button').click(); await flushPromises()
|
||||
expect(document.querySelector('.el-message-box__title')?.textContent).toBe('作废待确认价格表 · V7 · 客户专属 · 测试客户')
|
||||
expect(document.querySelector('.el-message-box')?.classList.contains('admin-density-confirm')).toBe(true)
|
||||
button('确认作废', '.el-message-box button').click(); await flushPromises()
|
||||
expect(api.voidPlatformPriceBook).not.toHaveBeenCalled()
|
||||
setReason('x'.repeat(501)); await flushPromises(); button('确认作废', '.el-message-box button').click(); await flushPromises()
|
||||
expect(api.voidPlatformPriceBook).not.toHaveBeenCalled()
|
||||
setReason(' 测试作废原因 '); await flushPromises(); button('确认作废', '.el-message-box button').click(); await flushPromises()
|
||||
expect(api.voidPlatformPriceBook).toHaveBeenCalledWith(privateId, '测试作废原因')
|
||||
})
|
||||
|
||||
it('cancels void without sending a write', async () => {
|
||||
await list(); button('作废', '.table-row button').click(); await flushPromises()
|
||||
button('取消', '.el-message-box button').click(); await flushPromises()
|
||||
expect(api.voidPlatformPriceBook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['0', customerId])('identifies publish scope %s and submits the original complete warehouse mapping after confirmation', async scope => {
|
||||
detail.version.customerId = scope
|
||||
await detailView()
|
||||
expect(button('确认并立即生效').disabled).toBe(false)
|
||||
button('确认并立即生效').click(); await flushPromises()
|
||||
const scopeText = scope === '0' ? '平台公共报价' : '客户专属 · 测试客户'
|
||||
const box = document.querySelector('.el-message-box')!
|
||||
expect(box.querySelector('.el-message-box__title')?.textContent).toBe(`确认并生效船期价格表 · V7 · ${scopeText}`)
|
||||
expect(box.textContent).toContain('同一报价范围'); expect(box.textContent).not.toContain(privateId); expect(box.textContent).not.toContain(customerId)
|
||||
expect(api.publishPlatformPriceBook).not.toHaveBeenCalled()
|
||||
button('确认并立即生效', '.el-message-box button').click(); await flushPromises()
|
||||
expect(api.publishPlatformPriceBook).toHaveBeenCalledWith(privateId, { originMappings: [{ originWarehouseCode: 'CN-TEST', warehouseId: domesticId }], overseasMappings: [{ sourceDestinationName: '海外测试仓', warehouseId: overseasId }] })
|
||||
expect(document.querySelector('.density-detail-title')?.textContent).toBe('船期价格表 · V7')
|
||||
expect(document.querySelectorAll('.el-dialog')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps publish disabled for a parsing blocker and never opens confirmation', async () => {
|
||||
detail.issues = [{ severity: 'BLOCKING', issueCode: 'INVALID_PRICE', message: '原文件价格无效', sourceSheetName: 'Sheet1', sourceRowNumber: 8, sourceCell: 'C8' }]
|
||||
await detailView()
|
||||
expect(button('确认并立即生效').disabled).toBe(true)
|
||||
expect(document.querySelector('.el-message-box')).toBeNull()
|
||||
expect(api.publishPlatformPriceBook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides upload and void for a list-only account', async () => {
|
||||
useAuthStore().permissions = ['firstleg:fba-pricing:rate:view']; useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = await list()
|
||||
expect(wrapper.findAll('button').map(node => node.text())).toEqual(['查看详情','原文件'])
|
||||
expect(api.uploadPlatformPriceBook).not.toHaveBeenCalled(); expect(api.voidPlatformPriceBook).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 费用项目模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/fee-items/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 只有一个页面 `pages/FeeItemsView.vue`(路由 `master-data/fee-items`,挂在系统主数据下)和一个接口文件 `api/platformFeeItems.ts`;弹框实现与页面同目录。
|
||||
- 后端只查 `firstleg/controller/PlatformFeeItemController.java` 与 `openapi/fee-item-v1.yaml`;客户专属价格簿归 `../fba-pricing/`,两者不是同一概念。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 费用项目列表、启停与排序 | `pages/FeeItemsView.vue`;`master-data/fee-items` | `api/platformFeeItems.ts`;`firstleg/controller/PlatformFeeItemController.java` |
|
||||
| 新增/编辑费用项目弹框 | `pages/FeeItemsView.vue` 内弹框 | `api/platformFeeItems.ts` 的 `SavePlatformFeeItemRequest`;当前表单不提交 `feeCode` |
|
||||
| 默认价格(CNY,可空)与类别/贸易范围 | 列表与新增/编辑弹框 | `api/platformFeeItems.ts`;带价与校验规则见详细索引 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 服务端支持修改 `feeCode`,省略时保留原编码;当前 Admin 表单未暴露该字段,不因本指引扩展界面。编码格式、长度与唯一性以服务端契约和校验为准。
|
||||
- `defaultPrice` 只是带入初值:仅当用户主动选择该项目时带入(入库同时带单价与金额、柜次带金额),**均允许人工修改**;为空表示不自动带价,`0` 表示明确的零元。修改或清空默认价不追改已有费用;打开已有费用、切换供应商或保存业务费用时不重新套用默认价,也不改变审核、发布与结算流程。
|
||||
- 停用费用项目不回溯已出账费用;历史账单继续显示原项目名称快照。
|
||||
- 供应商类别仅对需要供应商的费用类型有效,留空表示任意启用供应商,不由前端补默认值。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/fee-items/<目标测试>`;字段或权限变化同时运行 `pnpm typecheck`。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FeeItemsView from './FeeItemsView.vue'
|
||||
import * as api from '../api/platformFeeItems'
|
||||
|
||||
// Preserve the validator constructor interop through Element Plus's supported Node export.
|
||||
const { default: ElementPlus, ElCheckbox, ElDrawer, ElInputNumber, ElSelect } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
|
||||
vi.mock('../api/platformFeeItems', () => ({ listPlatformFeeItems: vi.fn(), getPlatformFeeItem: vi.fn(), createPlatformFeeItem: vi.fn(), updatePlatformFeeItem: vi.fn(), changePlatformFeeItemStatus: vi.fn(), deletePlatformFeeItem: vi.fn() }))
|
||||
let record: api.PlatformFeeItemResource
|
||||
const privateId = '9223372036854775807'
|
||||
const table = { props: ['data'], template: '<div><slot name="toolbar-actions"/><div class="real-row"><slot v-if="data?.length" name="actions" :row="data[0]"/></div></div>' }
|
||||
function button(label: string, selector = 'button') {
|
||||
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
|
||||
expect(found, label).toBeDefined(); return found!
|
||||
}
|
||||
async function openView() {
|
||||
const wrapper = mount(FeeItemsView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { DataTable: table, QueryToolbar: true } } }); await flushPromises(); return wrapper
|
||||
}
|
||||
async function openEditor(create = false) {
|
||||
const wrapper = await openView(); button(create ? '新增费用项目' : '编辑').click(); await flushPromises(); return wrapper
|
||||
}
|
||||
async function setText(placeholder: string, value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`.el-drawer [placeholder="${placeholder}"]`)!
|
||||
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
}
|
||||
async function check(wrapper: Awaited<ReturnType<typeof openView>>, label: string, value: boolean) {
|
||||
const checkbox = wrapper.findAllComponents(ElCheckbox).find(node => node.props('ariaLabel') === label)!
|
||||
expect(checkbox, label).toBeDefined(); await checkbox.get('input').setValue(value); await flushPromises()
|
||||
}
|
||||
const currentDialog = (wrapper: Awaited<ReturnType<typeof openView>>) => wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('modelValue'))!
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); document.body.innerHTML = ''; setActivePinia(createPinia())
|
||||
const auth = useAuthStore(); auth.token = 'test-session'; auth.permissions = ['firstleg:fee-item:view','firstleg:fee-item:create','firstleg:fee-item:edit','firstleg:fee-item:delete']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
record = { id: privateId, category: '操作费', tradeScope: 'DOMESTIC', feeCode: 'FEE-TEST', supplierCategory: null, feeTypeName: '测试操作费', defaultPrice: 0, pickupPayable: false, pickupReceivable: true, warehousePayable: false, warehouseReceivable: true, allocationFee: false, description: '适用说明', status: 'ACTIVE' }
|
||||
vi.mocked(api.listPlatformFeeItems).mockImplementation(async () => ({ records: [record], page: 1, size: 20, total: 1, pages: 1 }))
|
||||
vi.mocked(api.getPlatformFeeItem).mockImplementation(async () => record)
|
||||
vi.mocked(api.createPlatformFeeItem).mockImplementation(async () => record)
|
||||
vi.mocked(api.updatePlatformFeeItem).mockImplementation(async () => record)
|
||||
vi.mocked(api.changePlatformFeeItemStatus).mockResolvedValue(null)
|
||||
vi.mocked(api.deletePlatformFeeItem).mockResolvedValue(null)
|
||||
})
|
||||
|
||||
describe('fee item editing and confirmations', () => {
|
||||
it('creates from the original compact drawer with a zero default and all direction flags', async () => {
|
||||
const wrapper = await openEditor(true)
|
||||
expect(wrapper.findComponent(AppDrawer).props()).toMatchObject({ title: '新增费用项目', size: 'min(620px, 94vw)' })
|
||||
expect(document.querySelector('.el-drawer')?.classList.contains('admin-density-drawer--editor')).toBe(true)
|
||||
expect(document.querySelectorAll('.el-drawer .density-dialog-section')).toHaveLength(3)
|
||||
expect(document.querySelector('.el-dialog')).toBeNull()
|
||||
await setText('请输入费用名称', ' 新增操作费 '); await setText('例如 仓储成本', ' 操作费 ')
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 0)
|
||||
await check(wrapper, '提货环节应付', true); await check(wrapper, '提货环节应收', true)
|
||||
await check(wrapper, '仓库环节应付', true); await check(wrapper, '仓库环节应收', true)
|
||||
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'TRUCKING')
|
||||
await wrapper.findAllComponents(ElCheckbox).find(node => node.text() === '作为分摊费用')!.get('input').setValue(true)
|
||||
await setText('请输入适用场景或核算说明', ' 新说明 ')
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).toHaveBeenCalledWith({ category: '操作费', tradeScope: 'DOMESTIC', feeTypeName: '新增操作费', defaultPrice: 0, supplierCategory: 'TRUCKING', pickupPayable: true, pickupReceivable: true, warehousePayable: true, warehouseReceivable: true, allocationFee: true, description: '新说明' })
|
||||
expect(useUnsavedWorkStore().entries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('validates required fields and payable supplier category without losing entered values', async () => {
|
||||
const wrapper = await openEditor(true)
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-drawer')?.textContent).toContain('请输入费用名称'))
|
||||
await setText('请输入费用名称', '新费用'); await setText('例如 仓储成本', '操作费')
|
||||
await check(wrapper, '提货环节应付', true)
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformFeeItem).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('新费用')
|
||||
})
|
||||
|
||||
it('edits the same long ID and clears the supplier payload when payable directions are removed', async () => {
|
||||
record = { ...record, pickupPayable: true, supplierCategory: 'TRUCKING', defaultPrice: 25 }
|
||||
const wrapper = await openEditor()
|
||||
expect(wrapper.findComponent(AppDrawer).props('title')).toBe('编辑费用项目 · 测试操作费')
|
||||
expect(document.querySelector('.el-drawer')?.textContent).not.toContain(privateId)
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', null)
|
||||
await check(wrapper, '提货环节应付', false)
|
||||
expect(document.querySelector('.el-drawer [placeholder="请选择供应商类别"]')).toBeNull()
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformFeeItem).toHaveBeenCalledWith(privateId, expect.objectContaining({ defaultPrice: null, supplierCategory: null, pickupPayable: false }))
|
||||
})
|
||||
|
||||
it('locks every close path during save and preserves the dirty draft after rejection', async () => {
|
||||
let rejectSave!: (error: Error) => void
|
||||
vi.mocked(api.updatePlatformFeeItem).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
|
||||
const wrapper = await openEditor(); await setText('请输入费用名称', '修改后费用')
|
||||
expect(useUnsavedWorkStore().entries[0]).toMatchObject({ key: 'fee-item-editor', dirty: true, requiredPermission: 'firstleg:fee-item:edit' })
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDrawer).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-drawer__footer button').disabled).toBe(true)
|
||||
rejectSave(new Error('保存失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('.el-drawer [placeholder="请输入费用名称"]')!.value).toBe('修改后费用')
|
||||
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
|
||||
vi.mocked(api.updatePlatformFeeItem).mockResolvedValue(record)
|
||||
button('保存费用项目', '.el-drawer__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformFeeItem).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unsaved registration and the existing permission-change closure', async () => {
|
||||
const wrapper = await openEditor()
|
||||
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(false)
|
||||
await setText('请输入费用名称', '权限变化前的修改')
|
||||
expect(useUnsavedWorkStore().entries[0]?.dirty).toBe(true)
|
||||
useAuthStore().permissions = ['firstleg:fee-item:view']; useAccessPreviewStore().initializeAfterLogin(); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDrawer).props('modelValue')).toBe(false)
|
||||
expect(useUnsavedWorkStore().entries).toHaveLength(0)
|
||||
expect(api.updatePlatformFeeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['ACTIVE','DISABLED'] as const)('identifies the %s status confirmation and retains original status requests', async status => {
|
||||
record.status = status
|
||||
const verb = status === 'ACTIVE' ? '停用' : '启用'
|
||||
const wrapper = await openView(); button(verb, '.real-row button').click(); await flushPromises()
|
||||
expect(currentDialog(wrapper).props('title')).toBe(`${verb}费用项目 · 测试操作费`)
|
||||
expect(currentDialog(wrapper).props('confirmType')).toBe(status === 'ACTIVE' ? 'warning' : 'success')
|
||||
expect(api.changePlatformFeeItemStatus).not.toHaveBeenCalled()
|
||||
button(`确认${verb}`, '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.changePlatformFeeItemStatus).toHaveBeenCalledWith(privateId, status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE')
|
||||
})
|
||||
|
||||
it('keeps a server-rejected delete confirmation open with its original target', async () => {
|
||||
vi.mocked(api.deletePlatformFeeItem).mockRejectedValueOnce(new Error('项目已使用')).mockResolvedValueOnce(null)
|
||||
const wrapper = await openView(); button('删除', '.real-row button').click(); await flushPromises()
|
||||
expect(currentDialog(wrapper).props()).toMatchObject({ title: '删除费用项目 · 测试操作费', confirmType: 'danger' })
|
||||
expect(document.querySelector('.el-dialog')?.textContent).toContain('已被业务单据使用')
|
||||
button('确认删除', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(currentDialog(wrapper).props('modelValue')).toBe(true)
|
||||
button('确认删除', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.deletePlatformFeeItem).toHaveBeenNthCalledWith(2, privateId)
|
||||
expect(wrapper.findAllComponents(AppDialog).every(dialog => !dialog.props('modelValue'))).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels a confirmation without changing status or deleting data', async () => {
|
||||
const wrapper = await openView(); button('停用', '.real-row button').click(); await flushPromises()
|
||||
button('取消', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findAllComponents(AppDialog).every(dialog => !dialog.props('modelValue'))).toBe(true)
|
||||
expect(api.changePlatformFeeItemStatus).not.toHaveBeenCalled(); expect(api.deletePlatformFeeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -132,7 +132,8 @@ describe('FeeItemsView public master data and access', () => {
|
||||
expect(source).toContain('appearance="integrated" :stripe="false"')
|
||||
expect(source).toContain(':page-sizes="[20, 50, 100]"')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('.direction-grid, .form-grid.two-cols { grid-template-columns: 1fr; }')
|
||||
expect(source).toContain('.fee-item-editor-table tr { display: grid; }')
|
||||
expect(source).toContain('class="density-table density-table--editable fee-item-editor-table"')
|
||||
expect(source).not.toContain('pricingMode')
|
||||
expect(source).not.toContain('计价模式')
|
||||
expect(source).not.toContain('固定费用')
|
||||
|
||||
@@ -24,7 +24,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.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'
|
||||
@@ -92,7 +92,7 @@ const rules: FormRules<FeeItemForm> = {
|
||||
const hasPayableDirection = computed(() => form.pickupPayable || form.warehousePayable)
|
||||
|
||||
const canView = computed(() => access.can('firstleg:fee-item:view'))
|
||||
const editorTitle = computed(() => form.id ? '编辑费用项目' : '新增费用项目')
|
||||
const editorTitle = computed(() => form.id && form.feeTypeName.trim() ? `编辑费用项目 · ${form.feeTypeName.trim()}` : form.id ? '编辑费用项目' : '新增费用项目')
|
||||
const editorPermission = computed(() => form.id ? 'firstleg:fee-item:edit' : 'firstleg:fee-item:create')
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
@@ -429,46 +429,38 @@ onMounted(() => {
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer class="admin-density admin-density-drawer"
|
||||
<AppDrawer class="admin-density admin-density-drawer admin-density-drawer--editor"
|
||||
v-model="editorOpen"
|
||||
:title="editorTitle"
|
||||
size="min(620px, 94vw)"
|
||||
:loading="editorLoading"
|
||||
:confirm-loading="saving"
|
||||
>
|
||||
<el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
|
||||
<FormSection class="density-form-section" title="基本信息" description="费用项目为平台公共主数据,不按客户单独维护。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item>
|
||||
<el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="贸易范围" prop="tradeScope" required>
|
||||
<el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认价格(CNY)" prop="defaultPrice">
|
||||
<el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" />
|
||||
</el-form-item>
|
||||
<p class="muted-value">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</p>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="费用方向" description="选择费用在提货和仓库环节的应收、应付属性。">
|
||||
<div class="direction-grid">
|
||||
<fieldset><legend>提货环节</legend><el-checkbox v-model="form.pickupPayable">应付</el-checkbox><el-checkbox v-model="form.pickupReceivable">应收</el-checkbox></fieldset>
|
||||
<fieldset><legend>仓库环节</legend><el-checkbox v-model="form.warehousePayable">应付</el-checkbox><el-checkbox v-model="form.warehouseReceivable">应收</el-checkbox></fieldset>
|
||||
</div>
|
||||
<el-checkbox v-model="form.allocationFee">作为分摊费用</el-checkbox>
|
||||
<el-form-item v-if="hasPayableDirection" label="供应商类别" required>
|
||||
<el-select v-model="form.supplierCategory" placeholder="请选择 Warehouse 供应商类别">
|
||||
<el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</FormSection>
|
||||
<FormSection class="density-form-section" title="适用说明" :bordered="false">
|
||||
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item>
|
||||
</FormSection>
|
||||
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="rules" label-position="left" label-width="100px" :disabled="saving || editorLoading">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="费用基本信息">
|
||||
<h3 class="density-dialog-section__title">基本信息 <InlineHelp title="默认价格说明">选择费用项目时自动带入,使用时仍可修改;清空表示不设置默认价格。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable fee-item-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item></td><td><el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="贸易范围" prop="tradeScope" required><el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" /></el-form-item></td><td><el-form-item label="默认价格(CNY)" prop="defaultPrice"><el-input-number v-model="form.defaultPrice" :min="0" :max="9999999999.99" :precision="2" controls-position="right" placeholder="不填则由使用时手工录入" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="费用方向与供应商">
|
||||
<h3 class="density-dialog-section__title">费用方向 <InlineHelp title="费用方向说明">选择费用在提货和仓库环节的应收、应付属性。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable fee-direction-table"><thead><tr><th scope="col">环节</th><th scope="col">应付</th><th scope="col">应收</th></tr></thead><tbody>
|
||||
<tr><th scope="row">提货环节</th><td><el-checkbox v-model="form.pickupPayable" aria-label="提货环节应付" /></td><td><el-checkbox v-model="form.pickupReceivable" aria-label="提货环节应收" /></td></tr>
|
||||
<tr><th scope="row">仓库环节</th><td><el-checkbox v-model="form.warehousePayable" aria-label="仓库环节应付" /></td><td><el-checkbox v-model="form.warehouseReceivable" aria-label="仓库环节应收" /></td></tr>
|
||||
<tr><td colspan="3"><el-checkbox v-model="form.allocationFee">作为分摊费用</el-checkbox></td></tr>
|
||||
<tr v-if="hasPayableDirection"><td colspan="3"><el-form-item label="供应商类别" required><el-select v-model="form.supplierCategory" placeholder="请选择供应商类别"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="费用适用说明">
|
||||
<h3 class="density-dialog-section__title">适用说明</h3>
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item></td></tr></tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button :disabled="saving" @click="editorOpen = false">取消</el-button>
|
||||
<template #footer="{close}">
|
||||
<div class="density-actions density-actions--footer">
|
||||
<el-button :disabled="saving" @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="editorLoading" @click="saveFeeItem"><Save :size="16" />保存费用项目</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -476,13 +468,13 @@ onMounted(() => {
|
||||
|
||||
<AppDialog class="admin-density-dialog"
|
||||
v-model="statusOpen"
|
||||
:title="statusTarget?.status === 'ACTIVE' ? '停用费用项目' : '启用费用项目'"
|
||||
:title="`${statusTarget?.status === 'ACTIVE' ? '停用' : '启用'}费用项目${statusTarget?.feeTypeName?.trim() ? ' · ' + statusTarget.feeTypeName.trim() : ''}`"
|
||||
:confirm-text="statusTarget?.status === 'ACTIVE' ? '确认停用' : '确认启用'"
|
||||
:confirm-type="statusTarget?.status === 'ACTIVE' ? 'warning' : 'success'"
|
||||
:confirm-loading="statusSaving"
|
||||
@confirm="confirmStatusChange"
|
||||
>
|
||||
<p class="delete-message">
|
||||
<p class="density-dialog-note">
|
||||
确定{{ statusTarget?.status === 'ACTIVE' ? '停用' : '启用' }}“<strong>{{ statusTarget?.feeTypeName }}</strong>”吗?
|
||||
<template v-if="statusTarget?.status === 'ACTIVE'">停用后该项目不能再用于新的费用记录。</template>
|
||||
</p>
|
||||
@@ -490,29 +482,28 @@ onMounted(() => {
|
||||
|
||||
<AppDialog class="admin-density-dialog"
|
||||
v-model="deleteOpen"
|
||||
title="删除费用项目"
|
||||
:title="deleteTarget?.feeTypeName?.trim() ? `删除费用项目 · ${deleteTarget.feeTypeName.trim()}` : '删除费用项目'"
|
||||
confirm-text="确认删除"
|
||||
confirm-type="danger"
|
||||
:confirm-loading="deleting"
|
||||
@confirm="confirmDelete"
|
||||
>
|
||||
<p class="delete-message">确定删除“<strong>{{ deleteTarget?.feeTypeName }}</strong>”吗?删除后无法恢复;已被业务单据使用的项目将由服务端拒绝删除。</p>
|
||||
<p class="density-dialog-note">确定删除“<strong>{{ deleteTarget?.feeTypeName }}</strong>”吗?删除后无法恢复;已被业务单据使用的项目将由服务端拒绝删除。</p>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fee-items-page { min-width: 0; }
|
||||
.fee-item-editor-table { table-layout: fixed; }
|
||||
.fee-item-editor-table td { width: 50%; }
|
||||
.fee-item-name { display: flex; min-width: 0; flex-direction: column; gap: var(--admin-space-1); }
|
||||
.fee-item-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fee-item-name small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.description-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.direction-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
|
||||
.direction-grid fieldset { min-width: 0; margin: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); }
|
||||
.direction-grid legend { padding: 0 5px; color: var(--el-text-color-secondary); font-size: var(--admin-font-body); }
|
||||
.delete-message { margin: 0; color: var(--el-text-color-regular); line-height: 1.7; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.direction-grid, .form-grid.two-cols { grid-template-columns: 1fr; }
|
||||
.fee-item-editor-table tr { display: grid; }
|
||||
.fee-item-editor-table td { width: auto; }
|
||||
.table-actions { flex-wrap: wrap; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import * as api from '../api/finance'
|
||||
import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types'
|
||||
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
|
||||
|
||||
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(),
|
||||
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(),
|
||||
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
|
||||
}))
|
||||
const account: FinanceAccountSummary = {
|
||||
customerId: '9223372036854775001', customerCode: 'C-TEST', customerName: '测试客户', contactName: '', contactPhone: '',
|
||||
outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0,
|
||||
actualBalance: '-20.00', frozenBalance: '0.00', availableBalance: '80.00', creditLimit: '100.00', creditUsed: '0.00', availableCredit: '100.00',
|
||||
creditExpiresOn: '2099-12-31', creditActive: true, accountVersion: 7, pendingRechargeCount: 1, status: 'ACTIVE', lastChangedAt: '',
|
||||
}
|
||||
const recharge: FinanceRechargeRequest = {
|
||||
requestId: '9223372036854775002', requestNo: 'RC202610200001', amount: '500.00', voucherFileId: '9223372036854775003', voucherFileName: '付款凭证.png',
|
||||
status: 'PENDING', version: 8, submittedByName: '测试客户', reviewedByName: '', reviewRemark: '', createdAt: '2026-10-20T01:00:00Z', reviewedAt: '',
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const failure = () => new ApiResponseError('测试操作未成功', 'TEST_REJECTED', 409, null, 'finance-test-request')
|
||||
async function mountPage(detail = true) {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView },
|
||||
{ path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView },
|
||||
] })
|
||||
await router.push(detail ? '/finance/customer-accounts/' + account.customerId : '/finance/customer-accounts')
|
||||
await router.isReady()
|
||||
const wrapper = mount(FinanceCustomerAccountsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
function activeDialog(wrapper: VueWrapper) {
|
||||
const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue'))
|
||||
expect(dialog).toBeDefined()
|
||||
return dialog!
|
||||
}
|
||||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||||
expect(button, text).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
async function openAdjustment(type: 'RECHARGE' | 'DEDUCTION', detail = true) {
|
||||
const wrapper = await mountPage(detail)
|
||||
await click(wrapper, detail ? (type === 'RECHARGE' ? '手工充值' : '手工扣款') : (type === 'RECHARGE' ? '充值' : '扣款'), detail ? 'button' : '.el-table__body-wrapper button')
|
||||
return wrapper
|
||||
}
|
||||
async function openCredit() {
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, '设置授信')
|
||||
return wrapper
|
||||
}
|
||||
async function openReview() {
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, '审核', '.recharge-table button')
|
||||
return wrapper
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.stubGlobal('URL', class extends URL {
|
||||
static createObjectURL = vi.fn(() => 'blob:finance-current')
|
||||
static revokeObjectURL = vi.fn()
|
||||
})
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(api.listFinanceAccounts).mockResolvedValue({ records: [account], total: 1, pages: 1 })
|
||||
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [], total: 0, pages: 1 })
|
||||
vi.mocked(api.listFinanceRechargeRequests).mockResolvedValue({ records: [recharge], total: 1, pages: 1 })
|
||||
vi.mocked(api.adjustFinanceAccount).mockResolvedValue({})
|
||||
vi.mocked(api.updateFinanceCreditLimit).mockResolvedValue({})
|
||||
vi.mocked(api.reviewFinanceRechargeRequest).mockResolvedValue({ ...recharge, status: 'APPROVED', version: 9 })
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('customer wallet native dialogs', () => {
|
||||
it.each([true, false])('opens the same recharge dialog from detail=%s with plain authoritative balances', async detail => {
|
||||
const wrapper = await openAdjustment('RECHARGE', detail)
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect((wrapper.vm as unknown as { adjustmentAccount: FinanceAccountSummary }).adjustmentAccount).toMatchObject(account)
|
||||
expect(dialog.props('width')).toContain('720px')
|
||||
expect(dialog.props('title')).toBe('手工充值 · 测试客户')
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥-20.00')
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
expect(dialog.find('[data-tone="work"] .density-table--editable').exists()).toBe(true)
|
||||
})
|
||||
it.each(['RECHARGE', 'DEDUCTION'] as const)('preserves %s fields, account version and refresh after fullscreen editing', async type => {
|
||||
const wrapper = await openAdjustment(type)
|
||||
const dialog = activeDialog(wrapper)
|
||||
if (type === 'DEDUCTION') {
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥80.00')
|
||||
expect(dialog.get('.el-alert').text()).toContain('不足部分再使用可用授信')
|
||||
}
|
||||
await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue('12.3')
|
||||
await dialog.get('input[placeholder="请输入关联业务单号(选填)"]').setValue(' RC-TEST ')
|
||||
await dialog.get('textarea').setValue(' 测试备注 ')
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
||||
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.props('fullscreen')).toBe(true)
|
||||
expect((dialog.get('input[placeholder="请输入大于 0 的金额"]').element as HTMLInputElement).value).toBe('12.3')
|
||||
await click(dialog, type === 'RECHARGE' ? '确认充值' : '确认扣款')
|
||||
expect(api.adjustFinanceAccount).toHaveBeenCalledWith(account.customerId, { type, amount: '12.30', occurredDate: '2026-10-20', paymentMethod: 'CASH', referenceNo: 'RC-TEST', remark: '测试备注', expectedAccountVersion: 7 })
|
||||
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2)
|
||||
expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it.each(['', '0', '-1', 'invalid'])('keeps invalid adjustment %s from reaching the API', async amount => {
|
||||
const wrapper = await openAdjustment('RECHARGE')
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue(amount)
|
||||
await click(dialog, '确认充值')
|
||||
expect(api.adjustFinanceAccount).not.toHaveBeenCalled()
|
||||
})
|
||||
it('locks adjustment controls and close paths, then keeps the rejected draft', async () => {
|
||||
const pending = deferred<Awaited<ReturnType<typeof api.adjustFinanceAccount>>>()
|
||||
vi.mocked(api.adjustFinanceAccount).mockReturnValue(pending.promise)
|
||||
const error = vi.spyOn(ElMessage, 'error')
|
||||
const wrapper = await openAdjustment('DEDUCTION')
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue('10')
|
||||
await dialog.get('textarea').setValue('保留备注')
|
||||
await click(dialog, '确认扣款')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
await click(dialog, '取消')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(error).toHaveBeenCalledWith('测试操作未成功')
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留备注')
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeUndefined()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('submits zero credit with a far-future expiry and the original account version', async () => {
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(dialog.props('width')).toContain('560px')
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥0.00')
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
expect(dialog.get('.el-alert').text()).toContain('次日授信失效')
|
||||
const date = dialog.getComponent({ name: 'ElDatePicker' })
|
||||
const disabledDate = date.props('disabledDate') as (value: Date) => boolean
|
||||
expect(disabledDate(new Date('2099-12-31T00:00:00'))).toBe(false)
|
||||
await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue('0')
|
||||
await dialog.get('textarea').setValue(' 清零额度 ')
|
||||
date.vm.$emit('update:modelValue', '2099-12-31')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(api.updateFinanceCreditLimit).toHaveBeenCalledWith(account.customerId, { creditLimit: '0.00', creditExpiresOn: '2099-12-31', remark: '清零额度', expectedAccountVersion: 7 })
|
||||
})
|
||||
it.each(['-1', '1.234', ''])('preserves credit amount validation for %s', async amount => {
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue(amount)
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(api.updateFinanceCreditLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects a past expiry without imposing a maximum future date', async () => {
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2000-01-01')
|
||||
await flushPromises()
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(api.updateFinanceCreditLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retains credit input and restores close controls after a rejected request', async () => {
|
||||
const pending = deferred<Awaited<ReturnType<typeof api.updateFinanceCreditLimit>>>()
|
||||
vi.mocked(api.updateFinanceCreditLimit).mockReturnValue(pending.promise)
|
||||
const wrapper = await openCredit()
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue('321.09')
|
||||
await click(dialog, '保存授信额度')
|
||||
expect(dialog.props('showClose')).toBe(false)
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(dialog.props('showClose')).toBe(true)
|
||||
expect((dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').element as HTMLInputElement).value).toBe('321.09')
|
||||
})
|
||||
it.each(['APPROVE', 'REJECT'] as const)('reviews %s with plain request details and the original request version', async decision => {
|
||||
const wrapper = await openReview()
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(dialog.props('title')).toBe('审核充值申请 · ' + recharge.requestNo)
|
||||
expect(dialog.props('width')).toContain('620px')
|
||||
expect(dialog.get('[data-tone="source"]').text()).toContain('¥500.00')
|
||||
expect(dialog.find('[data-tone="source"] input').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain(recharge.requestId)
|
||||
dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', decision)
|
||||
await dialog.get('textarea').setValue(' 测试审核 ')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确认审核')
|
||||
expect(api.reviewFinanceRechargeRequest).toHaveBeenCalledWith(recharge.requestId, { decision, remark: '测试审核', expectedVersion: 8 })
|
||||
})
|
||||
it('locks review controls during submission and keeps the failed decision and remark', async () => {
|
||||
const pending = deferred<FinanceRechargeRequest>()
|
||||
vi.mocked(api.reviewFinanceRechargeRequest).mockReturnValue(pending.promise)
|
||||
const wrapper = await openReview()
|
||||
const dialog = activeDialog(wrapper)
|
||||
dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT')
|
||||
await dialog.get('textarea').setValue('保留审核意见')
|
||||
await click(dialog, '确认审核')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('REJECT')
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留审核意见')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('keeps independent adjustment and review permissions on the account detail', async () => {
|
||||
can.mockImplementation(permission => permission === 'platform:finance:customer-account:list')
|
||||
const wrapper = await mountPage()
|
||||
const labels = wrapper.findAll('button').map(button => button.text().trim())
|
||||
for (const label of ['手工充值', '手工扣款', '设置授信', '审核']) expect(labels).not.toContain(label)
|
||||
expect(labels).toContain(recharge.voucherFileName)
|
||||
})
|
||||
it('previews the authenticated voucher, preserves its URL through fullscreen and revokes on close', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(api.downloadFinanceRechargeVoucher).toHaveBeenCalledWith(recharge.requestId)
|
||||
expect(dialog.props('width')).toContain('920px')
|
||||
expect(dialog.get('.voucher-preview__image').attributes('src')).toBe('blob:finance-current')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.props('fullscreen')).toBe(true)
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
dialog.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:finance-current')
|
||||
})
|
||||
it('does not create an image URL for a voucher response arriving after close', async () => {
|
||||
const pending = deferred<Blob>()
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockReturnValue(pending.promise)
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
activeDialog(wrapper).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
pending.resolve(new Blob(['late'], { type: 'image/png' }))
|
||||
await flushPromises()
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps the current long-named voucher when an older request finishes after reopen', async () => {
|
||||
const pending = deferred<Blob>()
|
||||
const filename = '第二份很长的付款凭证名称'.repeat(20) + '.png'
|
||||
vi.mocked(api.listFinanceRechargeRequests).mockResolvedValue({ records: [recharge, { ...recharge, requestId: '9223372036854775004', requestNo: 'RC202610200002', voucherFileName: filename }], total: 2, pages: 1 })
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockReturnValueOnce(pending.promise)
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
activeDialog(wrapper).vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
await click(wrapper, filename, '.recharge-table button')
|
||||
expect(activeDialog(wrapper).props('title')).toBe(filename)
|
||||
pending.resolve(new Blob(['old'], { type: 'image/png' }))
|
||||
await flushPromises()
|
||||
expect(activeDialog(wrapper).get('.voucher-preview__image').attributes('src')).toBe('blob:finance-current')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('reports voucher download failure and allows reopening the same request', async () => {
|
||||
vi.mocked(api.downloadFinanceRechargeVoucher).mockRejectedValueOnce(failure())
|
||||
const error = vi.spyOn(ElMessage, 'error')
|
||||
const wrapper = await mountPage()
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
expect(error).toHaveBeenCalledWith('测试操作未成功')
|
||||
expect(wrapper.findAllComponents(ElDialog).some(item => item.props('modelValue'))).toBe(false)
|
||||
await click(wrapper, recharge.voucherFileName, '.recharge-table button')
|
||||
expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,334 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import * as api from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
|
||||
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
||||
|
||||
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
listFinanceFeeSources: vi.fn(), getFinanceFeeSource: vi.fn(), reviewFinanceFeeSource: vi.fn(),
|
||||
editFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(),
|
||||
listFinanceStatements: vi.fn(), getReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(), actionReconciliationStatement: vi.fn(),
|
||||
}))
|
||||
const customer: PlatformTenantResource = {
|
||||
id: '9223372036854775001', tenantCode: 'C-TEST', tenantName: '测试客户', status: 'ACTIVE', legalName: null, legalRepresentative: null,
|
||||
unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null,
|
||||
paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
||||
internalRemark: null, createdAt: null, approvedAt: null,
|
||||
}
|
||||
const fee: FinanceFeeSourceDetail = {
|
||||
poolItemId: '9223372036854775002', feeSourceId: '9223372036854775003', direction: 'RECEIVABLE', sourceType: 'MANUAL', sourceId: '9223372036854775004',
|
||||
sourceFeeId: '9223372036854775004', sourceVersion: 3, sourceSystem: 'FINANCE', sourceBusinessId: 'IN-TEST-1',
|
||||
customerId: String(customer.id), customerName: customer.tenantName, supplierId: null, supplierName: '', orderId: null,
|
||||
inboundId: '9223372036854775005', inboundCode: 'IN-TEST-1', containerId: null, containerNo: '', businessDate: '2026-10-01', feeItemName: '运费',
|
||||
quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'AVAILABLE', status: 'AVAILABLE',
|
||||
statementGenerated: false, operationVersion: 9, createdAt: '2026-10-01', operationLogs: [],
|
||||
}
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: '9223372036854775010', customerId: String(customer.id), billNo: 'REC202610200001', customerName: customer.tenantName,
|
||||
billDate: '2026-10-20', dueDate: '', periodStart: '2026-10-01', periodEnd: '2026-10-20', creditDays: 30, lineCount: 1, sourceCount: 1,
|
||||
totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
}
|
||||
const detail: FinanceReconciliationDetail = {
|
||||
...statement, paidAmount: 0, periodStart: '2026-10-01', periodEnd: '2026-10-20', creditDays: 30, settled: false, remark: '', version: 8,
|
||||
lines: [{ statementLineId: '9223372036854775011', sourceFeeId: fee.sourceFeeId, sourceFeeVersion: 3, sourceHash: 'test-hash', direction: 'RECEIVABLE',
|
||||
sourceType: 'MANUAL', sourceNo: 'IN-TEST-1', orderId: null, inboundId: fee.inboundId, inboundCode: 'IN-TEST-1', containerId: null, containerNo: '',
|
||||
businessDate: '2026-10-01', quantity: 2, unitPrice: 50, feeItemName: '运费', sourceAmount: 100, amount: 100 }],
|
||||
paymentHistory: [], revisions: [],
|
||||
}
|
||||
const failure = (message = '测试拒绝操作', code = 'TEST_REJECTED') => new ApiResponseError(message, code, 409, null, 'finance-dialog-test')
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function setFee(overrides: Partial<FinanceFeeSourceDetail> = {}) {
|
||||
const current = { ...fee, ...overrides }
|
||||
vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 })
|
||||
vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current)
|
||||
}
|
||||
async function mountPage(kind: 'arap' | 'statements') {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
|
||||
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
|
||||
{ path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView },
|
||||
{ path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView },
|
||||
] })
|
||||
await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements')
|
||||
await router.isReady()
|
||||
const wrapper = mount(kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, router }
|
||||
}
|
||||
function activeDialog(wrapper: VueWrapper) {
|
||||
const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue'))
|
||||
expect(dialog).toBeDefined()
|
||||
return dialog!
|
||||
}
|
||||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||||
expect(button, text).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
async function rowClick(wrapper: VueWrapper, label: string) { await click(wrapper, label, '.el-table__body-wrapper button') }
|
||||
async function openCreate() {
|
||||
const { wrapper, router } = await mountPage('statements')
|
||||
await click(wrapper, '生成对账单')
|
||||
return { wrapper, router, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function fillCreation(dialog: VueWrapper, selected = false) {
|
||||
dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', selected ? 'SELECTED' : 'BY_CUTOFF')
|
||||
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
|
||||
dates[0]!.vm.$emit('update:modelValue', '2026-10-01')
|
||||
dates[1]!.vm.$emit('update:modelValue', '2026-10-20')
|
||||
await flushPromises()
|
||||
await dialog.get('textarea').setValue('测试备注')
|
||||
}
|
||||
async function openAdjustment() {
|
||||
const { wrapper, router } = await mountPage('statements')
|
||||
await rowClick(wrapper, '调整')
|
||||
return { wrapper, router, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function fillAdjustment(dialog: VueWrapper) {
|
||||
await dialog.get('textarea[placeholder="请输入整体调整原因"]').setValue(' 整体调整 ')
|
||||
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
|
||||
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 88.12)
|
||||
await dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').setValue(' 行说明 ')
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
setFee()
|
||||
for (const method of [api.reviewFinanceFeeSource, api.editFinanceFeeSource, api.adjustFinanceFeeSource, api.submitFinanceFeeSource, api.voidFinanceFeeSource]) vi.mocked(method).mockResolvedValue(fee)
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([statement])
|
||||
vi.mocked(api.getReconciliationStatement).mockResolvedValue(detail)
|
||||
vi.mocked(api.createReconciliationStatement).mockResolvedValue({ ...detail, id: '9223372036854775012', billNo: 'REC202610200002', status: 'DRAFT' })
|
||||
vi.mocked(api.actionReconciliationStatement).mockResolvedValue(detail)
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('fee source and statement native dialogs', () => {
|
||||
it.each(['APPROVE', 'REJECT'] as const)('reviews %s with plain fee facts and the loaded operation version', async action => {
|
||||
setFee({ reviewStatus: 'PENDING' })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '审核')
|
||||
const dialog = activeDialog(wrapper)
|
||||
expect(dialog.props('title')).toContain('应收费用 · 运费')
|
||||
expect(dialog.get('[data-tone="source"]').text()).toContain('IN-TEST-1')
|
||||
expect(dialog.find('[data-tone="source"] input').exists()).toBe(false)
|
||||
expect(dialog.text()).not.toContain(fee.feeSourceId)
|
||||
dialog.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', action)
|
||||
await dialog.get('textarea').setValue(' 审核说明 ')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.props('fullscreen')).toBe(true)
|
||||
await click(dialog, '提交审核')
|
||||
expect(api.reviewFinanceFeeSource).toHaveBeenCalledWith(fee.feeSourceId, { action, reason: '审核说明', expectedVersion: 9 })
|
||||
})
|
||||
it('retains required rejection reasons', async () => {
|
||||
setFee({ reviewStatus: 'PENDING' })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '审核')
|
||||
const dialog = activeDialog(wrapper)
|
||||
dialog.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', 'REJECT')
|
||||
await flushPromises()
|
||||
await click(dialog, '提交审核')
|
||||
expect(api.reviewFinanceFeeSource).not.toHaveBeenCalled()
|
||||
expect(dialog.get('textarea').attributes('placeholder')).toBe('请填写驳回原因')
|
||||
})
|
||||
it.each([
|
||||
['编辑', 'DRAFT', 'editFinanceFeeSource'], ['调整', 'APPROVED', 'adjustFinanceFeeSource'],
|
||||
['提交审核', 'DRAFT', 'submitFinanceFeeSource'], ['作废', 'APPROVED', 'voidFinanceFeeSource'],
|
||||
] as const)('keeps %s command, reason, amount applicability and version', async (label, status, method) => {
|
||||
setFee({ reviewStatus: status })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, label)
|
||||
const dialog = activeDialog(wrapper)
|
||||
const hasAmount = label === '编辑' || label === '调整'
|
||||
expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(hasAmount)
|
||||
await dialog.get('textarea').setValue(' 操作说明 ')
|
||||
if (hasAmount) dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 123.45)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, label === '作废' ? '确认作废' : '确认')
|
||||
expect(api[method]).toHaveBeenCalledWith(fee.feeSourceId, { reason: '操作说明', expectedVersion: 9, ...(hasAmount ? { amount: 123.45 } : {}) })
|
||||
})
|
||||
it('keeps operation reasons mandatory before editing', async () => {
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '编辑')
|
||||
await click(activeDialog(wrapper), '确认')
|
||||
expect(api.editFinanceFeeSource).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['review', 'edit'] as const)('locks %s forms and keeps failed drafts and diagnostics', async mode => {
|
||||
const pending = deferred<FinanceFeeSourceDetail>()
|
||||
if (mode === 'review') { setFee({ reviewStatus: 'PENDING' }); vi.mocked(api.reviewFinanceFeeSource).mockReturnValue(pending.promise) }
|
||||
else vi.mocked(api.editFinanceFeeSource).mockReturnValue(pending.promise)
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, mode === 'review' ? '审核' : '编辑')
|
||||
const dialog = activeDialog(wrapper)
|
||||
await dialog.get('textarea').setValue('保留操作说明')
|
||||
await click(dialog, mode === 'review' ? '提交审核' : '确认')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'finance-dialog-test' })
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留操作说明')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('shows the current mutation failure after a previous review failed', async () => {
|
||||
setFee({ reviewStatus: 'PENDING' })
|
||||
vi.mocked(api.reviewFinanceFeeSource).mockRejectedValueOnce(failure('审核失败'))
|
||||
vi.mocked(api.voidFinanceFeeSource).mockRejectedValueOnce(failure('作废失败'))
|
||||
const { wrapper } = await mountPage('arap')
|
||||
await rowClick(wrapper, '审核')
|
||||
await click(activeDialog(wrapper), '提交审核')
|
||||
await click(activeDialog(wrapper), '取消')
|
||||
await rowClick(wrapper, '作废')
|
||||
await activeDialog(wrapper).get('textarea').setValue('测试作废')
|
||||
await click(activeDialog(wrapper), '确认作废')
|
||||
expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '作废失败' })
|
||||
})
|
||||
it.each(['FIRSTLEG', 'LASTLEG'])('keeps %s upstream adjustment and void actions unavailable', async system => {
|
||||
setFee({ sourceSystem: system, reviewStatus: 'APPROVED' })
|
||||
const { wrapper } = await mountPage('arap')
|
||||
expect(wrapper.get('.el-table__body-wrapper .density-actions').findAll('button').map(button => button.text())).toEqual(['详情'])
|
||||
})
|
||||
|
||||
it('creates a cutoff statement with the original customer, CNY and period payload', async () => {
|
||||
const { dialog, router } = await openCreate()
|
||||
expect(dialog.props('width')).toContain('680px')
|
||||
await fillCreation(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).toHaveBeenCalledWith({ customerId: String(customer.id), currency: 'CNY', generationMode: 'BY_CUTOFF', feeSourceIds: undefined, periodStart: '2026-10-01', periodEnd: '2026-10-20', remark: '测试备注' })
|
||||
expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail')
|
||||
})
|
||||
it('keeps selected-source filtering, summary and complete long IDs through fullscreen', async () => {
|
||||
setFee({ reviewStatus: 'APPROVED' })
|
||||
const { dialog } = await openCreate()
|
||||
await fillCreation(dialog, true)
|
||||
expect(api.listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: String(customer.id), direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false }))
|
||||
const select = dialog.findAllComponents({ name: 'ElSelect' }).find(item => item.props('multiple'))!
|
||||
select.vm.$emit('update:modelValue', [fee.feeSourceId])
|
||||
await flushPromises()
|
||||
expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] }))
|
||||
})
|
||||
it('preserves customer and period validation before creating', async () => {
|
||||
const { dialog } = await openCreate()
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
|
||||
await fillCreation(dialog)
|
||||
dialog.findAllComponents({ name: 'ElDatePicker' })[0]!.vm.$emit('update:modelValue', '2026-11-01')
|
||||
await flushPromises()
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReconciliationStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps selected-source mode disabled without its independent read permission', async () => {
|
||||
can.mockImplementation(permission => permission !== 'platform:finance:customer-arap:list')
|
||||
const { dialog } = await openCreate()
|
||||
expect(dialog.findAllComponents({ name: 'ElRadioButton' })[1]!.props('disabled')).toBe(true)
|
||||
})
|
||||
it('keeps eligible-source loading errors visible and retries the same selection context', async () => {
|
||||
vi.mocked(api.listFinanceFeeSources).mockRejectedValueOnce(failure('费用加载失败'))
|
||||
const { dialog } = await openCreate()
|
||||
await fillCreation(dialog, true)
|
||||
expect(dialog.get('.el-alert').text()).toContain('可生成费用加载失败')
|
||||
await click(dialog, '重试')
|
||||
expect(dialog.find('.el-alert').exists()).toBe(false)
|
||||
expect(api.listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: String(customer.id), businessDateFrom: '2026-10-01', businessDateTo: '2026-10-20' }))
|
||||
})
|
||||
it('locks creation then retains the draft and no-eligible-source feedback after failure', async () => {
|
||||
const pending = deferred<FinanceReconciliationDetail>()
|
||||
vi.mocked(api.createReconciliationStatement).mockReturnValue(pending.promise)
|
||||
const { dialog } = await openCreate()
|
||||
await fillCreation(dialog)
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure('没有可生成的费用', 'FIN_409'))
|
||||
await flushPromises()
|
||||
expect(dialog.get('.el-alert').text()).toContain('所选账期内没有可生成的应收费用')
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => {
|
||||
vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) })
|
||||
const { dialog } = await openAdjustment()
|
||||
expect(dialog.props('width')).toContain('900px')
|
||||
expect(dialog.props('title')).toContain(statement.billNo)
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
|
||||
expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13)
|
||||
expect(dialog.get('.el-table__body-wrapper').text()).toContain('费用13')
|
||||
})
|
||||
it('saves only checked lines with full IDs and the freshly loaded version', async () => {
|
||||
const { dialog } = await openAdjustment()
|
||||
await fillAdjustment(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action: 'ADJUST', reason: '整体调整', expectedVersion: 8, adjustmentLines: [{ statementLineId: '9223372036854775011', adjustedAmount: 88.12, reason: '行说明' }] })
|
||||
})
|
||||
it('requires an overall reason, selection and each selected line reason', async () => {
|
||||
const { dialog } = await openAdjustment()
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
await dialog.get('textarea').setValue('整体调整')
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true)
|
||||
await click(dialog, '确定')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
it('locks adjustment rows and retains their state with visible failure diagnostics', async () => {
|
||||
const pending = deferred<FinanceReconciliationDetail>()
|
||||
vi.mocked(api.actionReconciliationStatement).mockReturnValue(pending.promise)
|
||||
const { dialog } = await openAdjustment()
|
||||
await fillAdjustment(dialog)
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props('showClose')).toBe(false)
|
||||
expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(dialog.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝操作', requestId: 'finance-dialog-test' })
|
||||
expect((dialog.get('.el-table__body-wrapper input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').element as HTMLInputElement).value).toBe(' 行说明 ')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it.each([
|
||||
['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'],
|
||||
] as const)('identifies %s reason prompts without changing the state command', async (label, status, action) => {
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }])
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试原因', action: 'confirm' as const }))
|
||||
const { wrapper } = await mountPage('statements')
|
||||
await rowClick(wrapper, label)
|
||||
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(statement.billNo), expect.objectContaining({ customClass: 'admin-density-confirm', inputPattern: /\S+/ }))
|
||||
expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action, reason: '测试原因', expectedVersion: 3 })
|
||||
})
|
||||
it('does not change statement state when its reason prompt is cancelled', async () => {
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status: 'REJECTED' }])
|
||||
vi.spyOn(ElMessageBox, 'prompt').mockRejectedValue('cancel')
|
||||
const { wrapper } = await mountPage('statements')
|
||||
await rowClick(wrapper, '重开')
|
||||
expect(api.actionReconciliationStatement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -11,6 +11,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -70,6 +71,8 @@ const voucherPreviewOpen = ref(false)
|
||||
const voucherPreviewLoading = ref(false)
|
||||
const voucherPreviewUrl = ref('')
|
||||
const voucherPreviewName = ref('付款凭证')
|
||||
let voucherPreviewRequest = 0
|
||||
watch(voucherPreviewOpen, value => { if (!value) clearVoucherPreview() })
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:customer-account:list'))
|
||||
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
|
||||
@@ -241,7 +244,7 @@ function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDU
|
||||
adjustmentOpen.value = true
|
||||
}
|
||||
async function submitAdjustment() {
|
||||
if (!adjustmentAccount.value) return
|
||||
if (adjustmentSaving.value || !adjustmentAccount.value) return
|
||||
const amount = amountInput(adjustmentForm.amount)
|
||||
if (!amount || !adjustmentForm.occurredDate || !adjustmentForm.paymentMethod) { ElMessage.warning('请填写有效金额、发生日期和付款方式'); return }
|
||||
adjustmentSaving.value = true
|
||||
@@ -287,7 +290,7 @@ function handleAccountAction(command: string, account: FinanceAccountSummary) {
|
||||
else handleAccountMore(command, account)
|
||||
}
|
||||
async function submitCreditLimit() {
|
||||
if (!adjustmentAccount.value) return
|
||||
if (creditLimitSaving.value || !adjustmentAccount.value) return
|
||||
const raw = creditLimitForm.amount.trim()
|
||||
const amount = Number(raw)
|
||||
if (!Number.isFinite(amount) || amount < 0 || !/^\d+(?:\.\d{1,2})?$/.test(raw)) {
|
||||
@@ -318,7 +321,7 @@ function openReview(request: FinanceRechargeRequest) {
|
||||
reviewOpen.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!reviewRequest.value) return
|
||||
if (reviewSaving.value || !reviewRequest.value) return
|
||||
reviewSaving.value = true
|
||||
try {
|
||||
await reviewFinanceRechargeRequest(reviewRequest.value.requestId, {
|
||||
@@ -331,21 +334,25 @@ async function submitReview() {
|
||||
finally { reviewSaving.value = false }
|
||||
}
|
||||
function clearVoucherPreview() {
|
||||
voucherPreviewRequest++
|
||||
if (voucherPreviewUrl.value) URL.revokeObjectURL(voucherPreviewUrl.value)
|
||||
voucherPreviewUrl.value = ''
|
||||
}
|
||||
async function previewVoucher(request: FinanceRechargeRequest) {
|
||||
clearVoucherPreview()
|
||||
const requestSequence = ++voucherPreviewRequest
|
||||
voucherPreviewName.value = request.voucherFileName || '付款凭证'
|
||||
voucherPreviewOpen.value = true
|
||||
voucherPreviewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadFinanceRechargeVoucher(request.requestId)
|
||||
if (requestSequence !== voucherPreviewRequest || !voucherPreviewOpen.value) return
|
||||
voucherPreviewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
if (requestSequence !== voucherPreviewRequest || !voucherPreviewOpen.value) return
|
||||
voucherPreviewOpen.value = false
|
||||
ElMessage.error(getApiErrorMessage(error, '付款凭证加载失败'))
|
||||
} finally { voucherPreviewLoading.value = false }
|
||||
} finally { if (requestSequence === voucherPreviewRequest) voucherPreviewLoading.value = false }
|
||||
}
|
||||
function customerName(customerId: string, snapshot?: string) {
|
||||
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
|
||||
@@ -483,20 +490,54 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</AdminPage>
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustmentOpen" :title="adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'" width="min(720px, calc(100vw - 28px))" :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
|
||||
<el-alert :title="adjustmentForm.type === 'RECHARGE' ? '充值会先归还已用授信,剩余金额计入实际余额。' : '扣款先使用未冻结的实际余额,不足部分再使用可用授信。'" :type="adjustmentForm.type === 'RECHARGE' ? 'info' : 'warning'" :closable="false" />
|
||||
<el-form label-position="top" class="wallet-form density-form"><div class="wallet-form__grid"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前实际余额"><el-input :model-value="money(adjustmentAccount?.actualBalance)" readonly /></el-form-item><el-form-item v-if="adjustmentForm.type === 'DEDUCTION'" label="当前可用余额"><el-input :model-value="money(adjustmentAccount?.availableBalance)" readonly /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></div><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="当前账户资料">
|
||||
<h3 class="density-dialog-section__title">当前账户(CNY) <InlineHelp v-if="adjustmentForm.type === 'RECHARGE'" title="充值说明">充值会先归还已用授信,剩余金额计入实际余额。</InlineHelp></h3>
|
||||
<table class="density-table wallet-summary"><tbody>
|
||||
<tr><th scope="row">客户名称</th><td colspan="3">{{ adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—' }}</td></tr>
|
||||
<tr><th scope="row">当前实际余额</th><td :colspan="adjustmentForm.type === 'DEDUCTION' ? 1 : 3">{{ money(adjustmentAccount?.actualBalance) }}</td><template v-if="adjustmentForm.type === 'DEDUCTION'"><th scope="row">当前可用余额</th><td>{{ money(adjustmentAccount?.availableBalance) }}</td></template></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<el-alert v-if="adjustmentForm.type === 'DEDUCTION'" title="扣款先使用未冻结的实际余额,不足部分再使用可用授信。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="账户变动登记">
|
||||
<h3 class="density-dialog-section__title">{{ adjustmentForm.type === 'RECHARGE' ? '充值登记' : '扣款登记' }}</h3>
|
||||
<el-form label-position="left" label-width="100px" class="density-form" :disabled="adjustmentSaving">
|
||||
<table class="density-table density-table--editable wallet-adjustment-table"><tbody>
|
||||
<tr><td><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item></td><td><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="creditLimitOpen" title="设置授信额度" width="min(560px, calc(100vw - 28px))" confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
|
||||
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
|
||||
<el-form label-position="top" class="wallet-form density-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="creditLimitOpen" :title="`设置授信额度 · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
|
||||
<div class="density-dialog-sections">
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="当前授信资料"><h3 class="density-dialog-section__title">当前授信(CNY)</h3><table class="density-table wallet-summary"><tbody><tr><th scope="row">客户名称</th><td>{{ adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—' }}</td></tr><tr><th scope="row">当前已用授信</th><td>{{ money(adjustmentAccount?.creditUsed) }}</td></tr></tbody></table></section>
|
||||
<el-alert title="授信到期日当天仍有效,次日授信失效;届时负数实际余额将阻止客户提交新订单。" type="warning" :closable="false" />
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="授信额度设置"><h3 class="density-dialog-section__title">授信设置</h3>
|
||||
<el-form label-position="left" label-width="100px" class="density-form" :disabled="creditLimitSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
|
||||
<el-form label-position="top" class="review-form density-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" :title="`审核充值申请 · ${reviewRequest?.requestNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="reviewRequest" class="density-dialog-section" data-tone="source" aria-label="充值申请资料"><h3 class="density-dialog-section__title">充值申请</h3><table class="density-table wallet-summary"><tbody><tr><th scope="row">申请单号</th><td>{{ reviewRequest.requestNo || '—' }}</td></tr><tr><th scope="row">充值金额(CNY)</th><td>{{ money(reviewRequest.amount) }}</td></tr><tr><th scope="row">付款凭证</th><td>{{ reviewRequest.voucherFileName || '—' }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="充值审核登记"><h3 class="density-dialog-section__title">审核登记</h3>
|
||||
<el-form label-position="left" label-width="100px" class="density-form" :disabled="reviewSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
|
||||
<div v-loading="voucherPreviewLoading" class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div>
|
||||
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" fullscreenable :loading="voucherPreviewLoading" :show-footer="false">
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="付款凭证"><div class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div></section>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -628,21 +669,10 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
|
||||
.wallet-form,
|
||||
.review-form {
|
||||
margin-top: var(--admin-space-5);
|
||||
}
|
||||
|
||||
.wallet-form__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 var(--admin-space-5);
|
||||
}
|
||||
|
||||
.wallet-form :deep(.el-date-editor),
|
||||
.wallet-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
.wallet-summary { table-layout: fixed; }
|
||||
.wallet-summary th { width: var(--admin-label-width); }
|
||||
.wallet-adjustment-table { table-layout: fixed; }
|
||||
.wallet-adjustment-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
.detail-footer {
|
||||
width: 100%;
|
||||
@@ -675,12 +705,6 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.wallet-form__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -182,7 +182,7 @@ function sourceSummary(sourceType: unknown, orderId: unknown, inboundId: unknown
|
||||
const type = sourceTypeDisplay(sourceType)
|
||||
return relations.length ? `${type} · ${relations.join('、')}` : type
|
||||
}
|
||||
function businessReference(row: ArapRow) {
|
||||
function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 'sourceBusinessId'>) {
|
||||
const references = [row.inboundCode ? `入仓码 ${row.inboundCode}` : '', row.containerNo ? `柜次 ${row.containerNo}` : '']
|
||||
const businessId = row.sourceBusinessId.trim()
|
||||
if (businessId && ![row.inboundCode, row.containerNo].includes(businessId)) references.push(`业务单号 ${businessId}`)
|
||||
@@ -255,10 +255,10 @@ async function openReview(row: ArapRow) {
|
||||
selected.value = row
|
||||
const loaded = await loadDetail(row)
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; mutationError.value = null; reviewOpen.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!detail.value) return
|
||||
if (reviewSaving.value || !detail.value) return
|
||||
if (reviewForm.action === 'REJECT' && !reviewForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
reviewSaving.value = true; reviewError.value = null
|
||||
try {
|
||||
@@ -277,7 +277,7 @@ async function openMutation(row: ArapRow, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | '
|
||||
}
|
||||
const loaded = await loadDetail(row)
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
|
||||
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; reviewError.value = null; mutationOpen.value = true
|
||||
}
|
||||
function sourceActions(row: ArapRow): RowActionItem[] {
|
||||
const available = row.status === 'AVAILABLE'
|
||||
@@ -301,7 +301,7 @@ function handleSourceAction(command: string, row: ArapRow) {
|
||||
else if (command === 'void') void openMutation(row, 'VOID')
|
||||
}
|
||||
async function submitMutation() {
|
||||
if (!detail.value) return
|
||||
if (mutationSaving.value || !detail.value) return
|
||||
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
|
||||
if ((mutationMode.value === 'EDIT' || mutationMode.value === 'ADJUST') && mutationForm.amount <= 0) { ElMessage.warning('金额必须大于 0'); return }
|
||||
mutationSaving.value = true; mutationError.value = null
|
||||
@@ -423,20 +423,54 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
|
||||
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</AdminPage>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" :title="`费用来源审核 · ${selected?.documentNo || '费用来源'}`" fullscreenable confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section" data-tone="source" aria-label="费用来源资料">
|
||||
<h3 class="density-dialog-section__title">费用来源</h3>
|
||||
<table class="density-table fee-dialog-facts"><tbody>
|
||||
<tr><th scope="row">客户</th><td colspan="3">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</td></tr>
|
||||
<tr><th scope="row">费用项目</th><td>{{ displayFeeName(detail.feeItemName) || '—' }}</td><th scope="row">金额(CNY)</th><td>{{ money(detail.amount) }}</td></tr>
|
||||
<tr><th scope="row">关联业务</th><td colspan="3">{{ businessReference(detail) }}</td></tr>
|
||||
<tr><th scope="row">收付方向</th><td>{{ financeDirectionLabel(detail.direction) }}</td><th scope="row">审核状态</th><td>{{ statusLabel(detail.reviewStatus) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="费用审核登记"><h3 class="density-dialog-section__title">审核登记</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="reviewSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'" :required="reviewForm.action === 'REJECT'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
|
||||
<el-form class="density-form" label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" placeholder="请输入调整金额" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="mutationOpen" :title="`${mutationTitle} · ${selected?.documentNo || '费用来源'}`" fullscreenable :confirm-type="mutationMode === 'VOID' ? 'danger' : 'primary'" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section" data-tone="source" aria-label="费用来源资料">
|
||||
<h3 class="density-dialog-section__title">费用来源</h3>
|
||||
<table class="density-table fee-dialog-facts"><tbody>
|
||||
<tr><th scope="row">客户</th><td colspan="3">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</td></tr>
|
||||
<tr><th scope="row">费用项目</th><td>{{ displayFeeName(detail.feeItemName) || '—' }}</td><th scope="row">金额(CNY)</th><td>{{ money(detail.amount) }}</td></tr>
|
||||
<tr><th scope="row">关联业务</th><td colspan="3">{{ businessReference(detail) }}</td></tr>
|
||||
<tr><th scope="row">收付方向</th><td>{{ financeDirectionLabel(detail.direction) }}</td><th scope="row">审核状态</th><td>{{ statusLabel(detail.reviewStatus) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="费用操作登记"><h3 class="density-dialog-section__title">{{ mutationTitle }}</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="110px" :disabled="mutationSaving"><table class="density-table density-table--editable"><tbody>
|
||||
<tr v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'"><td><el-form-item label="金额(CNY)" required><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" placeholder="请输入调整金额" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="操作原因" required><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fee-dialog-facts { table-layout: fixed; }
|
||||
.fee-dialog-facts th { width: var(--admin-label-width); }
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
.finance-arap-page {
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,6 +13,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
@@ -83,6 +85,7 @@ const createForm = ref({
|
||||
let createSourceLoadSequence = 0
|
||||
const adjustOpen = ref(false)
|
||||
const adjustBusy = ref(false)
|
||||
const adjustError = ref<ApiErrorDetails | null>(null)
|
||||
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
|
||||
const adjustReason = ref('')
|
||||
const adjustLines = ref<AdjustmentRow[]>([])
|
||||
@@ -479,6 +482,7 @@ async function openSelectedCreateFromRoute() {
|
||||
}
|
||||
|
||||
async function createStatement() {
|
||||
if (createBusy.value) return
|
||||
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
|
||||
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
|
||||
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
|
||||
@@ -508,8 +512,9 @@ async function createStatement() {
|
||||
}
|
||||
|
||||
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
|
||||
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单', REOPEN: '重开对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
|
||||
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
|
||||
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
|
||||
? await ElMessageBox.prompt('请输入操作原因', `${title} · ${row.billNo || '—'}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
|
||||
: undefined
|
||||
if (reason === null) return
|
||||
actionBusy.value = true
|
||||
@@ -522,6 +527,7 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
|
||||
}
|
||||
|
||||
async function openAdjustment(row: FinanceStatementReadModel) {
|
||||
adjustError.value = null
|
||||
adjustBusy.value = true
|
||||
try {
|
||||
const current = await fetchDetail(row.id, true)
|
||||
@@ -557,11 +563,13 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel)
|
||||
}
|
||||
|
||||
async function submitAdjustment() {
|
||||
if (adjustBusy.value) return
|
||||
const statement = adjustStatement.value
|
||||
const selected = adjustLines.value.filter((line) => line.selected)
|
||||
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
|
||||
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
|
||||
adjustBusy.value = true
|
||||
adjustError.value = null
|
||||
try {
|
||||
await actionReconciliationStatement(statement.id, {
|
||||
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
|
||||
@@ -571,7 +579,7 @@ async function submitAdjustment() {
|
||||
ElMessage.success('对账单调整已保存')
|
||||
await load(appliedFilters.value)
|
||||
await openDetail(statement)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
||||
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
|
||||
finally { adjustBusy.value = false }
|
||||
}
|
||||
|
||||
@@ -700,34 +708,33 @@ watch(detailRouteId, (statementId) => {
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement">
|
||||
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-form-item label="生成方式">
|
||||
<el-radio-group v-model="createForm.generationMode">
|
||||
<el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button>
|
||||
<el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<div class="create-period-row"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></div>
|
||||
<template v-if="createForm.generationMode === 'SELECTED'">
|
||||
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
|
||||
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
|
||||
<el-form-item label="应收费用">
|
||||
<el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用">
|
||||
<el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" />
|
||||
</el-select>
|
||||
<div class="selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="对账单生成设置">
|
||||
<h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3>
|
||||
<el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody>
|
||||
<tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr v-if="createForm.generationMode === 'SELECTED'"><td colspan="2">
|
||||
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
|
||||
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
|
||||
<el-form-item label="应收费用"><el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用"><el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" /></el-select><div class="density-dialog-note selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div></el-form-item>
|
||||
</td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||||
<el-form class="density-form" label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></el-form>
|
||||
<el-table :data="adjustLines" size="small" max-height="420" border stripe><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table>
|
||||
<AppDialog class="admin-density-dialog" v-model="adjustOpen" :title="`调整客户对账单 · ${adjustStatement?.billNo || '—'}`" width="min(900px, 96vw)" fullscreenable :confirm-loading="adjustBusy" @confirm="submitAdjustment">
|
||||
<OperationErrorToast :details="adjustError" />
|
||||
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="adjustBusy">
|
||||
<section v-if="adjustStatement" class="density-dialog-section" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
|
||||
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table>
|
||||
<el-table :data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table>
|
||||
</div></section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -735,8 +742,11 @@ watch(detailRouteId, (statementId) => {
|
||||
<style scoped>
|
||||
.finance-statements-page { min-width: 0; }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
.create-period-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-6); }
|
||||
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); color: var(--cc-text-secondary); font-size: var(--admin-font-caption); text-align: right; }
|
||||
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; }
|
||||
.statement-create-table, .statement-dialog-facts { table-layout: fixed; }
|
||||
.statement-create-table td:not([colspan]) { width: 50%; }
|
||||
.statement-dialog-facts th { width: var(--admin-label-width); }
|
||||
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
|
||||
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
|
||||
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--cc-border-subtle); }
|
||||
.statement-outstanding { color: var(--el-color-warning-dark-2); }
|
||||
|
||||
@@ -64,8 +64,9 @@ describe('Finance formal settlement desktop presentation', () => {
|
||||
|
||||
it('keeps receipt and payment dialogs readable at desktop width', () => {
|
||||
expect(source.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(source.match(/class="density-detail-overview partial-summary"/g)).toHaveLength(2)
|
||||
expect(source).toContain('.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(source.match(/class="density-dialog-section partial-summary"/g)).toHaveLength(2)
|
||||
expect(source).toContain('density-table density-table--editable settlement-operation-table')
|
||||
expect(source).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
|
||||
expect(source).toContain('<OperationErrorToast :message="receiptValidationError" />')
|
||||
expect(source).toContain('<OperationErrorToast :details="receiptError" />')
|
||||
expect(source).not.toContain('<el-alert v-if="receiptValidationError"')
|
||||
|
||||
@@ -79,6 +79,7 @@ const createForm = ref({
|
||||
const paymentOpen = ref(false)
|
||||
const paymentBusy = ref(false)
|
||||
const paymentFile = ref<File | null>(null)
|
||||
const paymentUploadKey = ref(0)
|
||||
const paymentForm = ref({ transactionDate: new Date().toISOString().slice(0, 10), amount: 0, method: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', referenceNo: '', remark: '' })
|
||||
const receiptOpen = ref(false)
|
||||
const receiptBusy = ref(false)
|
||||
@@ -288,6 +289,10 @@ async function openCreate() {
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '结算来源加载失败') }
|
||||
}
|
||||
|
||||
function reportCreateSourceError(reason: unknown) {
|
||||
if (createOpen.value) error.value = getApiErrorDetails(reason, '结算来源加载失败')
|
||||
}
|
||||
|
||||
function resetCreateDependentFields() {
|
||||
createForm.value.reconciliationStatementId = ''
|
||||
createForm.value.supplierId = ''
|
||||
@@ -329,21 +334,23 @@ async function refreshCreateDependencies(customerId?: string) {
|
||||
}
|
||||
|
||||
watch(() => createForm.value.customerId, (customerId) => {
|
||||
if (createOpen.value) void refreshCreateDependencies(customerId)
|
||||
if (createOpen.value) void refreshCreateDependencies(customerId).catch(reportCreateSourceError)
|
||||
})
|
||||
watch(() => createForm.value.supplierId, () => {
|
||||
if (!createOpen.value) return
|
||||
createForm.value.feeSourceIds = []
|
||||
feeOptions.value = []
|
||||
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId)
|
||||
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId).catch(reportCreateSourceError)
|
||||
})
|
||||
|
||||
async function submitCreate() {
|
||||
if (createBusy.value) return
|
||||
const customerId = String(createForm.value.customerId || '')
|
||||
if (!customerId) return ElMessage.warning('请先选择客户')
|
||||
if (props.kind === 'receivable' && !createForm.value.reconciliationStatementId) return ElMessage.warning('请选择已出账对账单')
|
||||
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
|
||||
createBusy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
|
||||
else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
|
||||
@@ -359,6 +366,8 @@ function openPayment(row: unknown) {
|
||||
selectedRow.value = settlement
|
||||
detail.value = { id: settlement.id, settlementNo: settlement.settlementNo, customerId: settlement.customerId, direction: 'PAYABLE', amount: settlement.payableAmount, paidAmount: settlement.paidAmount, outstandingAmount: settlement.outstandingAmount, currency: 'CNY', status: settlement.status, version: settlement.version, lines: [], transactions: [], revisions: [] }
|
||||
paymentFile.value = null
|
||||
paymentUploadKey.value += 1
|
||||
error.value = null
|
||||
paymentForm.value = { transactionDate: new Date().toISOString().slice(0, 10), amount: settlement.outstandingAmount, method: 'BANK_TRANSFER', referenceNo: '', remark: '' }
|
||||
paymentOpen.value = true
|
||||
}
|
||||
@@ -381,6 +390,7 @@ async function openReceipt(row: unknown) {
|
||||
if (!current.reconciliationStatementId) throw new Error('应收结算单缺少对账单关联')
|
||||
detail.value = current
|
||||
receiptFile.value = null
|
||||
receiptUploadKey.value += 1
|
||||
receiptForm.value = { receivedDate: new Date().toISOString().slice(0, 10), amount: current.outstandingAmount, paymentMethod: 'BANK_TRANSFER', remark: '' }
|
||||
receiptOpen.value = true
|
||||
} catch (cause) { receiptError.value = getApiErrorDetails(cause, '应收结算单加载失败,无法登记收款') }
|
||||
@@ -406,6 +416,7 @@ function selectReceiptFile(file: UploadFile) {
|
||||
}
|
||||
|
||||
async function submitReceipt() {
|
||||
if (receiptBusy.value) return
|
||||
const current = detail.value
|
||||
if (!current?.reconciliationStatementId || !receiptFile.value || receiptForm.value.amount <= 0) {
|
||||
receiptValidationError.value = '请填写收款金额并选择收款凭证'
|
||||
@@ -425,8 +436,10 @@ async function submitReceipt() {
|
||||
}
|
||||
|
||||
async function submitPayment() {
|
||||
if (paymentBusy.value) return
|
||||
if (!detail.value || !paymentFile.value || paymentForm.value.amount <= 0) return ElMessage.warning('请填写付款金额并选择付款凭证')
|
||||
paymentBusy.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const voucher = await uploadFinanceVoucher(detail.value.customerId, paymentFile.value)
|
||||
await createFinanceSettlementPayment(detail.value.id, { ...paymentForm.value, currency: 'CNY', voucherFileIds: [voucher.fileId], expectedVersion: detail.value.version })
|
||||
@@ -439,7 +452,7 @@ async function submitPayment() {
|
||||
}
|
||||
|
||||
async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
|
||||
const reason = await ElMessageBox.prompt('请输入操作原因', action === 'VOID' ? `作废${title.value}` : `重开${title.value}`, {
|
||||
const reason = await ElMessageBox.prompt('请输入操作原因', `${action === 'VOID' ? '作废' : '重开'}${title.value} · ${row.settlementNo || '—'}`, {
|
||||
customClass: 'admin-density-confirm',
|
||||
inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消',
|
||||
}).then((result) => result.value.trim()).catch(() => null)
|
||||
@@ -507,6 +520,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-settlement-page admin-density" spacing="normal">
|
||||
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
|
||||
<OperationErrorToast :message="receiptValidationError" />
|
||||
<OperationErrorToast :details="receiptError" />
|
||||
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
|
||||
@@ -640,25 +654,63 @@ onMounted(() => {
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
|
||||
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.name} · ${categoryLabels[item.category]}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
|
||||
</el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
|
||||
<el-form class="density-form" label-position="left" label-width="120px" :disabled="createBusy"><table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
|
||||
<tr v-if="kind === 'receivable'"><td><el-form-item label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item></td></tr>
|
||||
<template v-else>
|
||||
<tr><td><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.name} · ${categoryLabels[item.category]}`" :value="String(item.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></td></tr>
|
||||
</template>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="paymentOpen" title="登记付款" width="min(760px, 94vw)" :confirm-loading="paymentBusy" @confirm="submitPayment">
|
||||
<dl v-if="detail" class="density-detail-overview partial-summary"><div><dt>结算单号</dt><dd>{{ detail.settlementNo }}</dd></div><div><dt>供应商</dt><dd>{{ detailPartyName() }}</dd></div><div><dt>应付金额</dt><dd>{{ money(detail.amount) }}</dd></div><div><dt>已付金额</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div><div><dt>当前余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div></dl>
|
||||
<el-form class="settlement-operation-form density-form" label-position="top"><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入付款金额" /></el-form-item><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" placeholder="请输入付款流水号" /></el-form-item><el-form-item label="付款凭证"><el-upload :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" placeholder="请输入付款备注(选填)" /></el-form-item></el-form>
|
||||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(paymentForm.amount) }}</strong></div>
|
||||
<AppDialog class="admin-density-dialog" v-model="paymentOpen" :title="`登记付款 · ${detail?.settlementNo || '—'}`" width="min(760px, 94vw)" fullscreenable :confirm-loading="paymentBusy" @confirm="submitPayment">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section partial-summary" data-tone="summary" aria-label="当前结算资料"><h3 class="density-dialog-section__title">当前结算(CNY)</h3>
|
||||
<table class="density-table settlement-dialog-facts"><tbody>
|
||||
<tr><th scope="row">结算单号</th><td>{{ detail.settlementNo || '—' }}</td><th scope="row">供应商</th><td>{{ detailPartyName() }}</td></tr>
|
||||
<tr><th scope="row">应付金额</th><td>{{ money(detail.amount) }}</td><th scope="row">已付金额</th><td class="is-success">{{ money(detail.paidAmount) }}</td></tr>
|
||||
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="付款登记"><h3 class="density-dialog-section__title">付款登记</h3>
|
||||
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="paymentBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
|
||||
<tr><td><el-form-item label="付款日期"><el-date-picker v-model="paymentForm.transactionDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次付款金额(CNY)"><el-input-number v-model="paymentForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入付款金额" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="付款方式"><el-select v-model="paymentForm.method"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td><td><el-form-item label="银行流水号/凭证号"><el-input v-model="paymentForm.referenceNo" placeholder="请输入付款流水号" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="付款凭证"><el-upload :key="paymentUploadKey" :disabled="paymentBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectPaymentFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="paymentForm.remark" type="textarea" placeholder="请输入付款备注(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="登记后剩余余额"><strong>{{ remainingAfter(paymentForm.amount) }}</strong></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="receiptOpen" title="登记收款" width="min(760px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
|
||||
<dl v-if="detail" class="density-detail-overview partial-summary"><div><dt>结算单号</dt><dd>{{ detail.settlementNo }}</dd></div><div><dt>客户公司</dt><dd>{{ detailPartyName() }}</dd></div><div><dt>应收金额</dt><dd>{{ money(detail.amount) }}</dd></div><div><dt>已收金额</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div><div><dt>当前余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div></dl>
|
||||
<el-form class="settlement-operation-form density-form" label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" placeholder="请输入收款备注(选填)" /></el-form-item></el-form>
|
||||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
|
||||
<AppDialog class="admin-density-dialog" v-model="receiptOpen" :title="`登记收款 · ${detail?.settlementNo || '—'}`" width="min(760px, 94vw)" fullscreenable :confirm-loading="receiptBusy" @confirm="submitReceipt">
|
||||
<div class="density-dialog-sections">
|
||||
<section v-if="detail" class="density-dialog-section partial-summary" data-tone="summary" aria-label="当前结算资料"><h3 class="density-dialog-section__title">当前结算(CNY)</h3>
|
||||
<table class="density-table settlement-dialog-facts"><tbody>
|
||||
<tr><th scope="row">结算单号</th><td>{{ detail.settlementNo || '—' }}</td><th scope="row">客户公司</th><td>{{ detailPartyName() }}</td></tr>
|
||||
<tr><th scope="row">应收金额</th><td>{{ money(detail.amount) }}</td><th scope="row">已收金额</th><td class="is-success">{{ money(detail.paidAmount) }}</td></tr>
|
||||
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
|
||||
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
|
||||
<tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" placeholder="请输入收款备注(选填)" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="登记后剩余余额"><strong>{{ remainingAfter(receiptForm.amount) }}</strong></el-form-item></td></tr>
|
||||
</tbody></table></el-form>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -677,11 +729,9 @@ onMounted(() => {
|
||||
:deep(.settlement-detail-info .el-descriptions__table) { table-layout: fixed; }
|
||||
:deep(.settlement-detail-info .el-descriptions__content) { min-width: 0; overflow-wrap: anywhere; }
|
||||
:deep(.detail-table-scroll .el-table) { min-width: 920px; }
|
||||
.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: 0; }
|
||||
.settlement-operation-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.settlement-operation-form :deep(.el-input-number) { width: 100%; }
|
||||
.remaining-preview { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); padding: var(--admin-space-3) var(--admin-space-5); border-radius: var(--admin-radius); color: var(--cc-text-secondary, #64748b); background: var(--el-fill-color-light); }
|
||||
.remaining-preview strong { color: #b45309; font-size: var(--admin-font-section); }
|
||||
.settlement-dialog-facts, .settlement-operation-table { table-layout: fixed; }
|
||||
.settlement-dialog-facts th { width: var(--admin-label-width); }
|
||||
.settlement-operation-table td:not([colspan]) { width: 50%; }
|
||||
.operation-error { margin-bottom: var(--admin-space-5); }
|
||||
.operation-error-meta { margin: -var(--admin-space-2) 0 var(--admin-space-5); }
|
||||
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
|
||||
@@ -707,18 +757,13 @@ onMounted(() => {
|
||||
margin-bottom: var(--admin-space-3);
|
||||
}
|
||||
|
||||
.settlement-operation-form {
|
||||
column-gap: var(--admin-space-5);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.settlement-detail-summary, .partial-summary, .settlement-operation-form { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary { grid-template-columns: 1fr; }
|
||||
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
|
||||
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
|
||||
.settlement-detail-content { padding: 0 16px 14px; }
|
||||
.settlement-detail-state { margin: 14px; }
|
||||
.partial-summary { gap: 10px; padding: 12px 10px; }
|
||||
.partial-summary div { padding: 0; border-left: 0; }
|
||||
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
.settlement-list-actions { width: 100%; justify-content: flex-start; }
|
||||
|
||||
@@ -43,8 +43,8 @@ describe('finance presentation governance', () => {
|
||||
expect(settlement).not.toContain('class="detail-section surface-panel"')
|
||||
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2)
|
||||
expect(settlement).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(settlement).toContain('column-gap: var(--admin-space-5);')
|
||||
expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
|
||||
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
|
||||
})
|
||||
|
||||
it('keeps settlement totals and list actions in the table toolbar', () => {
|
||||
@@ -128,10 +128,11 @@ describe('finance presentation governance', () => {
|
||||
expect(statements).not.toContain('disabled title="接口尚未提供')
|
||||
})
|
||||
|
||||
it('uses bordered striped tables for statement business details', () => {
|
||||
it('keeps bordered statement details and lets the adjustment editor scroll with its dialog', () => {
|
||||
expect(statements).toContain(':data="detail.lines" size="small" border stripe')
|
||||
expect(statements).toContain(':data="detail.revisions" size="small" border stripe')
|
||||
expect(statements).toContain(':data="adjustLines" size="small" max-height="420" border stripe')
|
||||
expect(statements).toContain(':data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"')
|
||||
expect(statements).not.toContain('max-height="420"')
|
||||
})
|
||||
|
||||
it('opens statement details on a compact dedicated route instead of below the list', () => {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import * as api from '../api/finance'
|
||||
import type { FinanceFeePoolItem, FinancePayableSettlement, FinanceReceivableSettlement, FinanceSettlementDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
|
||||
|
||||
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn(), categoryLabels: { CERTIFICATE: '证书服务' } }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
actionFinanceSettlement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
|
||||
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), getFinanceSettlement: vi.fn(),
|
||||
listFinanceFeePool: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
|
||||
listFinanceStatements: vi.fn(), uploadFinanceVoucher: vi.fn(),
|
||||
}))
|
||||
const customer: PlatformTenantResource = {
|
||||
id: '9223372036854775001', tenantCode: 'C-TEST', tenantName: '测试客户', status: 'ACTIVE', legalName: null, legalRepresentative: null,
|
||||
unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null,
|
||||
paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
||||
internalRemark: null, createdAt: null, approvedAt: null,
|
||||
}
|
||||
const supplier: Supplier = {
|
||||
id: '9223372036854775002', tenantId: '0', name: '测试供应商', category: 'CERTIFICATE', truckingScope: null, carrierCodes: [], region: '',
|
||||
legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE',
|
||||
settlementPeriod: 'PER_TRANSACTION', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1,
|
||||
}
|
||||
const ar: FinanceReceivableSettlement = {
|
||||
id: '9223372036854775010', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AR202610200001',
|
||||
reconciliationStatementId: '9223372036854775020', reconciliationStatementNo: 'REC202610200001', billDate: '2026-10-20', dueDate: '2026-11-20',
|
||||
receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
}
|
||||
const ap: FinancePayableSettlement = {
|
||||
id: '9223372036854775011', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AP202610200001',
|
||||
supplierId: supplier.id, supplierName: supplier.name, costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null, feeItemName: '操作费',
|
||||
payableAmount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', createdAt: '', version: 4,
|
||||
}
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: ar.reconciliationStatementId!, customerId: String(customer.id), customerName: customer.tenantName, billNo: 'REC202610200001',
|
||||
billDate: '2026-10-20', dueDate: '', totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 6,
|
||||
}
|
||||
const fee: FinanceFeePoolItem = {
|
||||
poolItemId: '9223372036854775030', feeSourceId: '9223372036854775031', customerId: String(customer.id), customerName: customer.tenantName,
|
||||
direction: 'PAYABLE', sourceType: 'MANUAL', sourceId: '9223372036854775032', sourceVersion: 1, orderId: null, inboundId: null,
|
||||
containerId: null, feeItemName: '操作费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '',
|
||||
}
|
||||
function settlement(kind: 'receivable' | 'payable'): FinanceSettlementDetail {
|
||||
return { id: kind === 'receivable' ? ar.id : ap.id, settlementNo: kind === 'receivable' ? ar.settlementNo : ap.settlementNo,
|
||||
customerId: String(customer.id), customerName: customer.tenantName, supplierId: supplier.id, supplierName: supplier.name,
|
||||
reconciliationStatementId: statement.id, reconciliationStatementNo: statement.billNo, direction: kind === 'receivable' ? 'RECEIVABLE' : 'PAYABLE',
|
||||
amount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 8, lines: [], transactions: [], revisions: [] }
|
||||
}
|
||||
const failure = () => new ApiResponseError('测试操作失败', 'TEST_REJECTED', 409, null, 'settlement-test-request')
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function mountPage(kind: 'receivable' | 'payable', detail = false) {
|
||||
vi.mocked(api.getFinanceSettlement).mockResolvedValue(settlement(kind))
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind, ...(detail ? { settlementId: kind === 'receivable' ? ar.id : ap.id } : {}) }, attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
function activeDialog(wrapper: VueWrapper) {
|
||||
const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue'))
|
||||
expect(dialog).toBeDefined()
|
||||
return dialog!
|
||||
}
|
||||
async function click(wrapper: VueWrapper, text: string, selector = 'button') {
|
||||
const button = wrapper.findAll(selector).find(item => item.text().trim() === text)
|
||||
expect(button, text).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
async function openCreate(kind: 'receivable' | 'payable') {
|
||||
const wrapper = await mountPage(kind)
|
||||
await click(wrapper, kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
|
||||
return { wrapper, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function openCashier(kind: 'receivable' | 'payable', detail = false) {
|
||||
const wrapper = await mountPage(kind, detail)
|
||||
const label = kind === 'receivable' ? '收款' : '付款'
|
||||
await click(wrapper, detail ? '部分' + label : label, detail ? 'button' : '.el-table__body-wrapper button')
|
||||
return { wrapper, dialog: activeDialog(wrapper) }
|
||||
}
|
||||
async function chooseFile(dialog: VueWrapper, name = '测试凭证.png', type = 'image/png') {
|
||||
const file = Object.assign(new File(['test image'], name, { type }), { uid: 100 })
|
||||
const input = dialog.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
return file
|
||||
}
|
||||
function errorDetails(wrapper: VueWrapper) {
|
||||
return wrapper.findAllComponents(OperationErrorToast).find(toast => toast.props('details'))?.props('details')
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
can.mockReturnValue(true)
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [supplier], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([ar])
|
||||
vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([ap])
|
||||
vi.mocked(api.listFinanceStatements).mockResolvedValue([statement, { ...statement, id: 'draft-statement', status: 'DRAFT' }])
|
||||
vi.mocked(api.listFinanceFeePool).mockResolvedValue([fee, { ...fee, feeSourceId: 'direct-paid', settlementMode: 'DIRECT_PAID' }, { ...fee, feeSourceId: 'locked-fee', status: 'LOCKED' }])
|
||||
vi.mocked(api.createReceivableSettlement).mockResolvedValue(settlement('receivable'))
|
||||
vi.mocked(api.createPayableSettlement).mockResolvedValue(settlement('payable'))
|
||||
vi.mocked(api.createFinanceSettlementPayment).mockResolvedValue(settlement('payable'))
|
||||
vi.mocked(api.actionFinanceSettlement).mockResolvedValue(settlement('receivable'))
|
||||
vi.mocked(api.uploadFinanceVoucher).mockResolvedValue({ fileId: '9223372036854775040', customerId: String(customer.id), fileName: '测试凭证.png', contentType: 'image/png', sizeBytes: 10 })
|
||||
vi.mocked(api.createFinanceReceipt).mockResolvedValue({ id: '9223372036854775041', customerId: String(customer.id), customerName: customer.tenantName, receivedDate: '2026-10-20', amount: 50, currency: 'CNY', paymentMethod: 'CASH', voucherFileIds: ['9223372036854775040'], allocationCount: 1 })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('formal settlement native dialogs', () => {
|
||||
it('creates receivables from issued statements with original long IDs and CNY', async () => {
|
||||
const { dialog } = await openCreate('receivable')
|
||||
expect(dialog.props('width')).toContain('600px')
|
||||
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
await flushPromises()
|
||||
expect(api.listFinanceStatements).toHaveBeenCalledWith(String(customer.id))
|
||||
expect(selects[1]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([statement.id])
|
||||
selects[1]!.vm.$emit('update:modelValue', statement.id)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' })
|
||||
})
|
||||
it('keeps active suppliers and excludes directly paid or locked payable sources', async () => {
|
||||
const { dialog } = await openCreate('payable')
|
||||
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
selects[1]!.vm.$emit('update:modelValue', supplier.id)
|
||||
await flushPromises()
|
||||
expect(api.listFinanceFeePool).toHaveBeenLastCalledWith(String(customer.id), 'PAYABLE', supplier.id)
|
||||
expect(selects[2]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([fee.feeSourceId])
|
||||
selects[2]!.vm.$emit('update:modelValue', [fee.feeSourceId])
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2099-12-31')
|
||||
await click(dialog, '确定')
|
||||
expect(api.createPayableSettlement).toHaveBeenCalledWith({ customerId: String(customer.id), supplierId: supplier.id, feeSourceIds: [fee.feeSourceId], dueDate: '2099-12-31', currency: 'CNY' })
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('keeps %s creation validation before mutation', async kind => {
|
||||
const { dialog } = await openCreate(kind)
|
||||
await click(dialog, '确定')
|
||||
expect(api.createReceivableSettlement).not.toHaveBeenCalled()
|
||||
expect(api.createPayableSettlement).not.toHaveBeenCalled()
|
||||
})
|
||||
it('shows dependency load errors without an unhandled customer-selection request', async () => {
|
||||
vi.mocked(api.listFinanceStatements).mockRejectedValueOnce(failure())
|
||||
const { wrapper, dialog } = await openCreate('receivable')
|
||||
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
await flushPromises()
|
||||
expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', requestId: 'settlement-test-request' })
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('locks creation controls and retains a rejected selection', async () => {
|
||||
const pending = deferred<FinanceSettlementDetail>()
|
||||
vi.mocked(api.createReceivableSettlement).mockReturnValue(pending.promise)
|
||||
const { wrapper, dialog } = await openCreate('receivable')
|
||||
const selects = dialog.findAllComponents({ name: 'ElSelect' })
|
||||
selects[0]!.vm.$emit('update:modelValue', String(customer.id))
|
||||
await flushPromises()
|
||||
selects[1]!.vm.$emit('update:modelValue', statement.id)
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true)
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' })
|
||||
expect(selects[1]!.props('modelValue')).toBe(statement.id)
|
||||
})
|
||||
it.each([false, true])('keeps payable balances plain and posts the expected version from detail=%s', async detail => {
|
||||
const { dialog } = await openCashier('payable', detail)
|
||||
expect(dialog.props('title')).toBe('登记付款 · ' + ap.settlementNo)
|
||||
expect(dialog.props('width')).toContain('760px')
|
||||
expect(dialog.get('[data-tone="summary"]').text()).toContain('¥100.00')
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
const amount = dialog.getComponent({ name: 'ElInputNumber' })
|
||||
expect(amount.props('max')).toBe(200)
|
||||
amount.vm.$emit('update:modelValue', 50.25)
|
||||
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
||||
await dialog.get('input[placeholder="请输入付款流水号"]').setValue('BANK-TEST')
|
||||
await dialog.get('textarea').setValue('付款备注')
|
||||
const file = await chooseFile(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.text()).toContain('¥149.75')
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file)
|
||||
expect(api.createFinanceSettlementPayment).toHaveBeenCalledWith(ap.id, { transactionDate: '2026-10-20', amount: 50.25, method: 'CASH', referenceNo: 'BANK-TEST', remark: '付款备注', currency: 'CNY', voucherFileIds: ['9223372036854775040'], expectedVersion: detail ? 8 : 4 })
|
||||
})
|
||||
it.each([false, true])('allocates a real receipt to its loaded statement from detail=%s', async detail => {
|
||||
const { dialog } = await openCashier('receivable', detail)
|
||||
expect(dialog.props('title')).toBe('登记收款 · ' + ar.settlementNo)
|
||||
expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false)
|
||||
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 50)
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20')
|
||||
dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH')
|
||||
await dialog.get('textarea').setValue('收款备注')
|
||||
const file = await chooseFile(dialog)
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.text()).toContain('¥150.00')
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file)
|
||||
expect(api.createFinanceReceipt).toHaveBeenCalledWith({ customerId: String(customer.id), receivedDate: '2026-10-20', amount: 50, paymentMethod: 'CASH', remark: '收款备注', allocations: [{ billId: statement.id, amount: 50 }] }, ['9223372036854775040'])
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('requires a selected voucher for %s before uploading', async kind => {
|
||||
const { dialog } = await openCashier(kind)
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it('retains non-image receipt rejection and clears the invalid file view', async () => {
|
||||
const { dialog } = await openCashier('receivable')
|
||||
await chooseFile(dialog, 'invalid.pdf', 'application/pdf')
|
||||
await click(dialog, '确定')
|
||||
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
||||
expect(dialog.text()).not.toContain('invalid.pdf')
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('locks %s upload and form controls then retains rejected input', async kind => {
|
||||
const pending = deferred<Awaited<ReturnType<typeof api.uploadFinanceVoucher>>>()
|
||||
vi.mocked(api.uploadFinanceVoucher).mockReturnValue(pending.promise)
|
||||
const { wrapper, dialog } = await openCashier(kind)
|
||||
const filename = '很长的凭证名称'.repeat(12) + '.png'
|
||||
await chooseFile(dialog, filename)
|
||||
await dialog.get('textarea').setValue('保留备注')
|
||||
await click(dialog, '确定')
|
||||
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
|
||||
expect(dialog.getComponent({ name: 'ElUpload' }).props('disabled')).toBe(true)
|
||||
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
|
||||
pending.reject(failure())
|
||||
await flushPromises()
|
||||
expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' })
|
||||
expect(dialog.text()).toContain(filename)
|
||||
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留备注')
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('clears the previous %s voucher filename when reopened', async kind => {
|
||||
const { wrapper, dialog } = await openCashier(kind)
|
||||
await chooseFile(dialog, 'old-voucher.png')
|
||||
expect(dialog.text()).toContain('old-voucher.png')
|
||||
await click(dialog, '取消')
|
||||
await click(wrapper, kind === 'receivable' ? '收款' : '付款', '.el-table__body-wrapper button')
|
||||
expect(activeDialog(wrapper).text()).not.toContain('old-voucher.png')
|
||||
await click(activeDialog(wrapper), '确定')
|
||||
expect(api.uploadFinanceVoucher).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['receivable', 'payable'] as const)('keeps %s cashier actions behind their independent permission', async kind => {
|
||||
can.mockImplementation(permission => permission.endsWith(':list'))
|
||||
const wrapper = await mountPage(kind)
|
||||
const labels = wrapper.get('.el-table__body-wrapper').findAll('button').map(button => button.text().trim())
|
||||
expect(labels).not.toContain(kind === 'receivable' ? '收款' : '付款')
|
||||
})
|
||||
it.each([
|
||||
['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'],
|
||||
['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'],
|
||||
] as const)('identifies %s %s prompts with its settlement number and original version', async (kind, action, status) => {
|
||||
if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }])
|
||||
else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }])
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: ' 测试原因 ', action: 'confirm' as const }))
|
||||
const wrapper = await mountPage(kind)
|
||||
await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button')
|
||||
const current = kind === 'receivable' ? ar : ap
|
||||
expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm', inputPattern: /\S+/ }))
|
||||
expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
# Admin 头程订单接口模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/first-leg-orders/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`。
|
||||
- 本模块**没有页面**,只有 `index.ts` 与 `api/platformFirstLegOrders.ts`,提供旧版头程订单列表与客户订单统计接口的封装;新页面统一在 `../unified-orders/`。
|
||||
- 后端查 `order/`(`/platform/v1/orders/first-leg`、客户订单统计)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 旧版头程订单分页查询 | `api/platformFirstLegOrders.ts` 的 `listPlatformFirstLegOrders` | `order/` |
|
||||
| 客户维度订单统计 | `api/platformFirstLegOrders.ts` 的 `getPlatformTenantOrderStatistics` | `order/` |
|
||||
| 对外导出 | `index.ts` | — |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 新增订单页面与交互一律进 `../unified-orders/`,不要在本模块长页面。
|
||||
- 本模块只做请求封装,不承载业务规则、状态判断或权限逻辑。
|
||||
- 客户统计的客户范围由服务端认证上下文决定,前端只传客户 ID。
|
||||
- 若确认无调用方,按当前无兼容原则直接删除,不保留别名或兼容分支。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/first-leg-orders/<目标测试>`;改签名前用 `rg` 确认全部调用方。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 平台用户与权限模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/iam/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,权限语义见 `../../../docs/平台客户层级与权限契约.md`。
|
||||
- 本模块是**平台侧**用户、角色与权限资源维护入口;客户侧成员与角色归 `backend iam/`,账号与登录标识归 `backend auth/`,前端守卫在 `../core/access/`。
|
||||
- 后端只查 `iam/`(平台 RBAC、菜单资源、客户与成员)与 `auth/`(账号与会话撤销)。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/路由 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 平台用户列表、PDA 人员与海外仓绑定 | `pages/InternalUsersView.vue`;`system/users` | `api/platformIam.ts`;`iam/`、`auth/` |
|
||||
| 角色与角色成员 | `pages/RolesView.vue`;`system/roles` | `api/platformIam.ts`;`iam/` |
|
||||
| 权限资源与菜单 | `pages/PermissionsView.vue`、`utils/permissionTree.ts`;`system/permissions` | `api/platformIam.ts`;`iam/` 菜单资源 |
|
||||
| 客户作用域选择器 | `components/PlatformTenantSelector.vue` | `iam/` 客户主档 |
|
||||
| 权限判定与展示 | `utils/platformPermission.ts`、`../core/access/` | 服务端权限快照;前端不替代鉴权 |
|
||||
| 模块密度样式 | `iamDensity.css` | 按公共密度规范接入 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓;其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库,长整型 ID 按字符串透传。
|
||||
- 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓。
|
||||
- 列表权限 `*:list` 同时授权列表与详情只读;`create`、`edit`、`delete`、`status`、`approve` 等保持独立,不保留旧 `*:view`。
|
||||
- 供应商固定使用 `platform:supplier:list`;`platform:supplier:view` 不得重新启用以兼容旧调用。
|
||||
- 菜单、路由与权限资源三处必须同步:改权限码时同时核对 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL 与菜单资源。
|
||||
- 前端守卫只改善体验,服务端必须重复校验租户、角色与数据范围。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/iam/<目标测试>`;权限码或菜单资源变更同时核对 `iam` 菜单 SQL 与权限契约测试。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InternalUsersView from './InternalUsersView.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/platformIam'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
const { default: ElementPlus, ElDialog, ElSelect, ElCheckbox, ElMessageBox } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
vi.mock('../api/platformIam', () => ({ listPlatformManagedUsers: vi.fn(), getPlatformUserOptions: vi.fn(), listSharedReceivingOverseasWarehouseOptions: vi.fn(), createPlatformManagedUser: vi.fn(), updatePlatformManagedUser: vi.fn(), changePlatformManagedUserStatus: vi.fn(), resetPlatformManagedUserPassword: vi.fn() }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
const memberId = '9223372036854775807', warehouseId = '9223372036854775806'
|
||||
let record: api.PlatformManagedUser
|
||||
const table = { props: ['data'], template: '<div><slot name="toolbar-actions"/><div class="real-row"><slot v-if="data?.length" name="actions" :row="data[0]"/></div></div>' }
|
||||
function button(label: string, selector = 'button') {
|
||||
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
|
||||
expect(found, label).toBeDefined(); return found!
|
||||
}
|
||||
async function view() {
|
||||
const wrapper = mount(InternalUsersView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { DataTable: table, QueryToolbar: true } } }); await flushPromises(); return wrapper
|
||||
}
|
||||
async function editor(create = false) {
|
||||
const wrapper = await view(); button(create ? '新增用户' : '编辑').click(); await flushPromises(); return wrapper
|
||||
}
|
||||
async function text(placeholder: string, value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
|
||||
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
}
|
||||
function select(wrapper: Awaited<ReturnType<typeof view>>, placeholder: string) {
|
||||
const found = wrapper.findAllComponents(ElSelect).find(node => node.props('placeholder') === placeholder)
|
||||
expect(found, placeholder).toBeDefined(); return found!
|
||||
}
|
||||
async function fillCreate(wrapper: Awaited<ReturnType<typeof view>>, roles = ['ROLE-OP']) {
|
||||
await text('登录用户名', 'new_operator'); await text('真实姓名', '新操作员'); await text('手机号码', '13800000000'); await text('邮箱地址', 'worker@example.test')
|
||||
select(wrapper, '选择角色').vm.$emit('update:modelValue', roles)
|
||||
await text('8-64 位,包含字母和数字', 'TestPassword123'); await text('再次输入密码', 'TestPassword123'); await flushPromises()
|
||||
}
|
||||
function permissions(values: string[]) { useAuthStore().permissions = ['platform:user:list', ...values]; useAccessPreviewStore().initializeAfterLogin() }
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => ElMessageBox.close())
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); document.body.innerHTML = ''; setActivePinia(createPinia())
|
||||
useAuthStore().token = 'test-session'
|
||||
permissions(['platform:user:create','platform:user:edit','platform:user:status','platform:user:reset-password','platform:user:grant-role'])
|
||||
record = { membershipId: memberId, userId: '9223372036854775805', authUserId: '9223372036854775804', username: 'test_operator', displayName: '测试操作员', phone: '13800000000', email: 'worker@example.test', tenantId: '0', tenantName: '平台', memberType: 'INTERNAL', memberNo: 'EMP-TEST', status: 'ACTIVE', roleIds: ['ROLE-OP'], roleNames: ['运营角色'], lastLoginAt: '2026-10-20T10:30:00', protectedAccount: false, overseasWarehouseId: null }
|
||||
vi.mocked(api.listPlatformManagedUsers).mockImplementation(async () => ({ records: [record], total: 1, page: 1, size: 10, pages: 1 }))
|
||||
vi.mocked(api.getPlatformUserOptions).mockResolvedValue({ tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }], roles: [
|
||||
{ id: 'ROLE-OP', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营角色', status: 'ACTIVE' },
|
||||
{ id: 'ROLE-OVER', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外PDA角色', status: 'ACTIVE' },
|
||||
{ id: 'ROLE-DOM', tenantId: '0', roleCode: 'CN_FIRSTLEG_PDA_OPERATOR', name: '国内PDA角色', status: 'ACTIVE' },
|
||||
] })
|
||||
vi.mocked(api.listSharedReceivingOverseasWarehouseOptions).mockResolvedValue([{ id: warehouseId, warehouseCode: 'US-TEST', warehouseName: '海外收货仓', countryCode: 'US' }])
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: warehouseId, tenantId: '0', warehouseCode: 'CN-TEST', internalCode: null, warehouseName: '国内作业仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }])
|
||||
vi.mocked(api.createPlatformManagedUser).mockImplementation(async () => record)
|
||||
vi.mocked(api.updatePlatformManagedUser).mockImplementation(async () => record)
|
||||
vi.mocked(api.changePlatformManagedUserStatus).mockImplementation(async (_id,status) => ({ ...record, status }))
|
||||
vi.mocked(api.resetPlatformManagedUserPassword).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('internal user unified dialogs', () => {
|
||||
it('creates a platform account and preserves draft through native fullscreen', async () => {
|
||||
const wrapper = await editor(true); await fillCreate(wrapper)
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props()).toMatchObject({ width: 'min(650px, calc(100vw - 28px))', fullscreenable: true, title: '新增用户' })
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
|
||||
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="登录用户名"]')!.value).toBe('new_operator')
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).toHaveBeenCalledWith({ username: 'new_operator', displayName: '新操作员', phone: '13800000000', email: 'worker@example.test', tenantId: '0', roleIds: ['ROLE-OP'], overseasWarehouseId: null, password: 'TestPassword123', status: 'ACTIVE' })
|
||||
})
|
||||
|
||||
it('enforces required fields and matching initial passwords before creating', async () => {
|
||||
const wrapper = await editor(true)
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('请输入用户名'))
|
||||
await fillCreate(wrapper); await text('再次输入密码', 'Mismatch123')
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('两次输入的密码不一致'))
|
||||
})
|
||||
|
||||
it('edits the same membership with readonly login name and timestamp', async () => {
|
||||
const wrapper = await editor()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑用户 · 测试操作员')
|
||||
expect(document.querySelector('.el-dialog [placeholder="登录用户名"]')).toBeNull()
|
||||
expect(document.querySelector('.el-dialog')?.textContent).toContain('test_operator')
|
||||
expect(document.querySelector('.el-dialog')?.textContent).toContain('2026-10-20 10:30')
|
||||
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(memberId)
|
||||
await text('真实姓名', '更名操作员')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformManagedUser).toHaveBeenCalledWith(memberId, { displayName: '更名操作员', phone: record.phone, email: record.email, tenantId: '0', roleIds: ['ROLE-OP'], overseasWarehouseId: null })
|
||||
expect(api.resetPlatformManagedUserPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['ROLE-OVER','ROLE-DOM'] as const)('requires and submits the original %s long warehouse binding', async role => {
|
||||
const wrapper = await editor(true); await fillCreate(wrapper, [role])
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
const placeholder = role === 'ROLE-OVER' ? '请选择平台共享海外收货仓' : '请选择国内作业仓'
|
||||
select(wrapper, placeholder).vm.$emit('update:modelValue', warehouseId); await flushPromises()
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).toHaveBeenCalledWith(expect.objectContaining(role === 'ROLE-OVER' ? { overseasWarehouseId: warehouseId } : { overseasWarehouseId: null, domesticWarehouseId: warehouseId }))
|
||||
if (role === 'ROLE-DOM') expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
|
||||
})
|
||||
|
||||
it('recovers a shared warehouse load failure without discarding the user draft', async () => {
|
||||
vi.mocked(api.listSharedReceivingOverseasWarehouseOptions).mockRejectedValueOnce(new ApiResponseError('目录暂不可用', 'A0500', 500, null, 'req-pda-options'))
|
||||
const wrapper = await editor(true); await fillCreate(wrapper, ['ROLE-OVER'])
|
||||
expect(document.querySelector('.el-dialog .load-error-state')?.textContent).toContain('目录暂不可用')
|
||||
expect(document.querySelector('.el-dialog .load-error-state')?.textContent).toContain('req-pda-options')
|
||||
button('重新加载', '.el-dialog button').click(); await flushPromises()
|
||||
expect(api.listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledTimes(2)
|
||||
expect(document.querySelector('.el-dialog .load-error-state')).toBeNull()
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="登录用户名"]')!.value).toBe('new_operator')
|
||||
})
|
||||
|
||||
it('keeps protected role and status as readonly facts and disables the row status action', async () => {
|
||||
record.protectedAccount = true
|
||||
const wrapper = await editor()
|
||||
expect(button('禁用', '.real-row button').disabled).toBe(true)
|
||||
expect(wrapper.findAllComponents(ElSelect)).toHaveLength(0)
|
||||
expect(document.querySelector('[aria-label="用户基本资料"]')?.textContent).toContain('运营角色')
|
||||
expect(document.querySelector('[aria-label="用户基本资料"]')?.textContent).toContain('正常')
|
||||
expect(api.changePlatformManagedUserStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows only the original status command when editing with status permission alone', async () => {
|
||||
permissions(['platform:user:status'])
|
||||
const wrapper = await editor()
|
||||
expect(document.querySelector('.el-dialog [placeholder="真实姓名"]')).toBeNull()
|
||||
expect(document.querySelector('.el-dialog [placeholder="手机号码"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="用户登录密码"]')).toBeNull()
|
||||
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'DISABLED'); await flushPromises()
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.changePlatformManagedUserStatus).toHaveBeenCalledWith(memberId, 'DISABLED')
|
||||
expect(api.updatePlatformManagedUser).not.toHaveBeenCalled(); expect(api.resetPlatformManagedUserPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the separate reset command without changing profile for reset-only permission', async () => {
|
||||
permissions(['platform:user:reset-password'])
|
||||
const wrapper = await editor()
|
||||
expect(document.querySelector('.el-dialog [placeholder="真实姓名"]')).toBeNull()
|
||||
await wrapper.findComponent(ElCheckbox).get('input').setValue(true); await flushPromises()
|
||||
await text('8-64 位,包含字母和数字', 'ResetPassword123'); await text('再次输入密码', 'ResetPassword123')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.resetPlatformManagedUserPassword).toHaveBeenCalledWith(memberId, 'ResetPassword123')
|
||||
expect(api.updatePlatformManagedUser).not.toHaveBeenCalled(); expect(api.changePlatformManagedUserStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('locks all close paths during saving and retains editable values on failure', async () => {
|
||||
let rejectSave!: (error: Error) => void
|
||||
vi.mocked(api.updatePlatformManagedUser).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
|
||||
const wrapper = await editor(); await text('真实姓名', '失败草稿')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="真实姓名"]')!.disabled).toBe(true)
|
||||
rejectSave(new Error('更新失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="真实姓名"]')!.value).toBe('失败草稿')
|
||||
})
|
||||
|
||||
it.each(['ACTIVE','DISABLED'] as const)('identifies %s status confirmation without exposing account IDs', async status => {
|
||||
record.status = status
|
||||
const verb = status === 'ACTIVE' ? '禁用' : '启用'
|
||||
await view(); button(verb, '.real-row button').click(); await flushPromises()
|
||||
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}用户 · 测试操作员`)
|
||||
expect(document.querySelector('.el-message-box')?.textContent).not.toContain(memberId)
|
||||
expect(api.changePlatformManagedUserStatus).not.toHaveBeenCalled()
|
||||
button(`确认${verb}`, '.el-message-box button').click(); await flushPromises()
|
||||
expect(api.changePlatformManagedUserStatus).toHaveBeenCalledWith(memberId, status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE')
|
||||
})
|
||||
|
||||
it('keeps creation hidden without grant-role permission', async () => {
|
||||
permissions(['platform:user:create'])
|
||||
const wrapper = await view()
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '新增用户')).toBe(false)
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -89,10 +89,11 @@ describe('InternalUsersView platform internal user management', () => {
|
||||
|
||||
it('uses the prototype-sized centered editor with a scrollable body and mobile single column', () => {
|
||||
expect(internalUsersSource).toContain('width="min(650px, calc(100vw - 28px))"')
|
||||
expect(internalUsersSource).toContain(':global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
expect(internalUsersSource).toContain(':global(.internal-user-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }')
|
||||
expect(internalUsersSource).toContain('fullscreenable destroy-on-close')
|
||||
expect(internalUsersSource).toContain(':global(.internal-user-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }')
|
||||
expect(internalUsersSource).not.toContain(':global(.internal-user-editor-dialog .el-dialog__body)')
|
||||
expect(internalUsersSource).toContain('@media (max-width: 720px)')
|
||||
expect(internalUsersSource).toContain('.form-grid.two-cols { grid-template-columns: 1fr; }')
|
||||
expect(internalUsersSource).toContain('.user-editor-table tr { display: grid; }')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
|
||||
@@ -23,7 +23,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
@@ -98,7 +98,7 @@ const canEdit = computed(() => access.can('platform:user:edit'))
|
||||
const canGrantRoles = computed(() => access.can('platform:user:grant-role'))
|
||||
const canChangeStatus = computed(() => access.can('platform:user:status'))
|
||||
const canResetPassword = computed(() => access.can('platform:user:reset-password'))
|
||||
const editorLabel = computed(() => editingUser.value ? `编辑用户 - ${editingUser.value.displayName}` : '新增用户')
|
||||
const editorLabel = computed(() => editingUser.value ? `编辑用户 · ${editingUser.value.displayName?.trim() || editingUser.value.username?.trim() || '—'}` : '新增用户')
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: 'platform-user-editor',
|
||||
@@ -120,6 +120,9 @@ const domesticPdaRoleIds = computed(() => new Set(
|
||||
availableRoles.value.filter((role) => role.roleCode === 'CN_FIRSTLEG_PDA_OPERATOR').map((role) => String(role.id)),
|
||||
))
|
||||
const hasDomesticPdaRole = computed(() => form.roleIds.some((roleId) => domesticPdaRoleIds.value.has(roleId)))
|
||||
const selectedRoleNames = computed(() => form.roleIds.map((roleId) => availableRoles.value.find((role) => String(role.id) === roleId)?.name || '—').join('、') || '—')
|
||||
const selectedOverseasWarehouse = computed(() => overseasWarehouses.value.find((warehouse) => warehouse.id === form.overseasWarehouseId)?.warehouseName || '—')
|
||||
const selectedDomesticWarehouse = computed(() => domesticWarehouses.value.find((warehouse) => String(warehouse.id) === form.domesticWarehouseId)?.warehouseName || '—')
|
||||
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: editingUser.value ? [] : [
|
||||
@@ -331,7 +334,7 @@ async function toggleStatus(row: PlatformManagedUser) {
|
||||
const targetStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const targetLabel = targetStatus === 'ACTIVE' ? '启用' : '禁用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户`, { customClass: 'admin-density-confirm',
|
||||
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户 · ${row.displayName?.trim() || row.username?.trim() || '—'}`, { customClass: 'admin-density-confirm',
|
||||
confirmButtonText: `确认${targetLabel}`,
|
||||
cancelButtonText: '取消',
|
||||
type: targetStatus === 'DISABLED' ? 'warning' : 'info',
|
||||
@@ -429,93 +432,47 @@ onMounted(loadInitialData)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<el-form class="density-form" ref="formRef" :model="form" :rules="formRules" label-position="top">
|
||||
<FormSection class="density-form-section" title="用户资料" description="维护登录身份、联系方式与平台角色。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="用户名" prop="username" required>
|
||||
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="displayName" required>
|
||||
<el-input v-model="form.displayName" :disabled="Boolean(editingUser) && !canEdit" placeholder="真实姓名" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone" required>
|
||||
<el-input v-model="form.phone" :disabled="Boolean(editingUser) && !canEdit" placeholder="手机号码" inputmode="tel" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" :disabled="Boolean(editingUser) && !canEdit" placeholder="邮箱地址" inputmode="email" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="roleIds" required>
|
||||
<el-select v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip
|
||||
:disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择角色">
|
||||
<el-option v-for="role in availableRoles" :key="role.id" :label="role.name" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" :disabled="Boolean(editingUser) && (!canChangeStatus || cannotDisable(editingUser))">
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="禁用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasOverseasPdaRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
|
||||
<el-select v-model="form.overseasWarehouseId" filterable
|
||||
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||
placeholder="请选择平台共享海外收货仓">
|
||||
<el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode} · ${warehouse.countryCode})`"
|
||||
:value="warehouse.id" />
|
||||
</el-select>
|
||||
<p v-if="overseasWarehousesError" class="field-error">
|
||||
{{ overseasWarehousesError.message }}
|
||||
<el-button link type="primary" :loading="overseasWarehousesLoading" @click="loadOverseasWarehouses">重新加载</el-button>
|
||||
</p>
|
||||
<p v-else class="field-help">仓库将写入登录 Token,海外 PDA 不提供仓库切换。</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasDomesticPdaRole" label="国内作业仓" prop="domesticWarehouseId" required>
|
||||
<el-select v-model="form.domesticWarehouseId" filterable
|
||||
:loading="domesticWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||
placeholder="请选择国内作业仓">
|
||||
<el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
|
||||
:value="String(warehouse.id)" />
|
||||
</el-select>
|
||||
<p v-if="domesticWarehousesError" class="field-error">
|
||||
{{ domesticWarehousesError.message }}
|
||||
<el-button link type="primary" :loading="domesticWarehousesLoading" @click="loadDomesticWarehouses">重新加载</el-button>
|
||||
</p>
|
||||
<p v-else class="field-help">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingUser" label="最后登录">
|
||||
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection class="density-form-section" v-if="!editingUser || canResetPassword" title="登录密码" description="密码为 8-64 位,并同时包含字母和数字。">
|
||||
<el-checkbox v-if="editingUser" v-model="form.resetPassword">同时重置密码</el-checkbox>
|
||||
<div v-if="!editingUser || form.resetPassword" class="form-grid two-cols password-fields">
|
||||
<el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required>
|
||||
<el-input v-model="form.password" type="password" show-password autocomplete="new-password" placeholder="8-64 位,包含字母和数字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword" required>
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="用户基本资料">
|
||||
<h3 class="density-dialog-section__title">用户资料</h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="手机号" prop="phone" required><span v-if="editingUser && !canEdit">{{form.phone || '—'}}</span><el-input v-else v-model="form.phone" placeholder="手机号码" inputmode="tel" maxlength="11" /></el-form-item></td><td><el-form-item label="邮箱" prop="email"><span v-if="editingUser && !canEdit">{{form.email || '—'}}</span><el-input v-else v-model="form.email" placeholder="邮箱地址" inputmode="email" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="角色" prop="roleIds" required><span v-if="editingUser && (!canEdit || !canGrantRoles || editingUser.protectedAccount)">{{selectedRoleNames}}</span><el-select v-else v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择角色"><el-option v-for="role in availableRoles" :key="role.id" :label="role.name" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" /></el-select></el-form-item></td></tr>
|
||||
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section" data-tone="work" aria-label="用户作业仓库">
|
||||
<h3 class="density-dialog-section__title">作业仓库</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
|
||||
<template #label>实际海外收货仓 <InlineHelp title="海外仓作业范围">该人员仅在绑定的海外仓作业,海外 PDA 不提供仓库切换。</InlineHelp></template>
|
||||
<span v-if="editingUser && !canEdit">{{selectedOverseasWarehouse}}</span><el-select v-else v-model="form.overseasWarehouseId" filterable :loading="overseasWarehousesLoading" placeholder="请选择平台共享海外收货仓"><el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode} · ${warehouse.countryCode})`" :value="warehouse.id" /></el-select>
|
||||
<LoadErrorState v-if="overseasWarehousesError" compact title="共享海外收货仓加载失败" :message="overseasWarehousesError.message" :loading="overseasWarehousesLoading" @retry="loadOverseasWarehouses"><template #details><ApiErrorMeta :details="overseasWarehousesError" /></template></LoadErrorState>
|
||||
</el-form-item></td></tr>
|
||||
<tr v-if="hasDomesticPdaRole"><td><el-form-item label="国内作业仓" prop="domesticWarehouseId" label-width="145px" required>
|
||||
<template #label>国内作业仓 <InlineHelp title="国内仓作业范围">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</InlineHelp></template>
|
||||
<span v-if="editingUser && !canEdit">{{selectedDomesticWarehouse}}</span><el-select v-else v-model="form.domesticWarehouseId" filterable :loading="domesticWarehousesLoading" placeholder="请选择国内作业仓"><el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select>
|
||||
<LoadErrorState v-if="domesticWarehousesError" compact title="国内作业仓加载失败" :message="domesticWarehousesError.message" :loading="domesticWarehousesLoading" @retry="loadDomesticWarehouses"><template #details><ApiErrorMeta :details="domesticWarehousesError" /></template></LoadErrorState>
|
||||
</el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section" data-tone="work" aria-label="用户登录密码">
|
||||
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
|
||||
<tr v-if="!editingUser || form.resetPassword"><td><el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" placeholder="8-64 位,包含字母和数字" /></el-form-item></td><td><el-form-item label="确认密码" prop="confirmPassword" required><el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-fields { margin-top: var(--admin-space-6); }
|
||||
.field-help, .field-error { margin: 6px 0 0; font-size: var(--admin-font-caption); line-height: 1.5; }
|
||||
.field-help { color: var(--cc-text-secondary); }
|
||||
.field-error { color: var(--el-color-danger); }
|
||||
:global(.internal-user-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.internal-user-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
@media (max-width: 720px) {
|
||||
.form-grid.two-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
.user-editor-table { table-layout: fixed; }
|
||||
.user-editor-table td:not([colspan="2"]) { width: 50%; }
|
||||
:global(.internal-user-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
|
||||
@media (max-width: 720px) { .user-editor-table tr { display: grid; } .user-editor-table td:not([colspan="2"]) { width: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -40,8 +40,9 @@ describe('permission editor modal', () => {
|
||||
expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe('min(720px, calc(100vw - 28px))')
|
||||
expect(document.querySelector('.el-drawer')).toBeNull()
|
||||
expect(dialog.querySelectorAll('.el-form-item')).toHaveLength(6)
|
||||
const codeInput = dialog.querySelector<HTMLInputElement>('input[placeholder="如:platform:customer:list"]')!
|
||||
expect(codeInput.disabled).toBe(mode === 'edit')
|
||||
const codeInput = dialog.querySelector<HTMLInputElement>('input[placeholder="如:platform:customer:list"]')
|
||||
if (mode === 'edit') { expect(codeInput).toBeNull(); expect(dialog.textContent).toContain('platform:sample:list') }
|
||||
else expect(codeInput?.disabled).toBe(false)
|
||||
// Vitest externalizes Element Plus: its CJS validator interop differs from the Vite browser bundle.
|
||||
// Exercise the dialog's validation gate explicitly, without changing production rules.
|
||||
const form = wrapper.findComponent({ name: 'ElForm' })
|
||||
@@ -64,6 +65,7 @@ describe('permission editor modal', () => {
|
||||
if (mode === 'create') expect(createPlatformPermission).toHaveBeenCalledWith({ type: 'MENU', code: 'platform:new:list', name: '新增菜单', parentId: '0', routePath: '/new', httpMethod: null, sortNo: 10 })
|
||||
else expect(updatePlatformPermission).toHaveBeenCalledWith('90071992547409931', { name: '测试菜单', parentId: null, routePath: '/sample', httpMethod: null, sortNo: 10 })
|
||||
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(codeInput.value).toBe(mode === 'create' ? 'platform:new:list' : 'platform:sample:list')
|
||||
if (mode === 'create') expect(codeInput?.value).toBe('platform:new:list')
|
||||
else expect(dialog.textContent).toContain('platform:sample:list')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,13 @@ import '@/styles/adminDensity.css'
|
||||
import '../iamDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Braces, FolderTree, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import { FolderTree, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
@@ -59,7 +59,7 @@ const formRules = computed<FormRules>(() => ({
|
||||
method: form.type === '接口' ? [{ required: true, message: '请选择 HTTP 方法', trigger: 'change' }] : [],
|
||||
sort: [{ required: true, type: 'number', message: '请输入显示排序', trigger: 'change' }],
|
||||
}))
|
||||
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
|
||||
const editorLabel = computed(() => form.id ? `编辑权限资源 · ${form.name.trim() || form.code.trim() || '—'}` : '新增权限资源')
|
||||
const requiredPermission = computed(() => form.id ? 'platform:permission:edit' : 'platform:permission:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'permission-editor',
|
||||
@@ -206,7 +206,7 @@ async function toggleStatus(row: Permission) {
|
||||
if (row.builtIn) return
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, '权限状态', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, `${target}权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformPermissionStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformPermission(updated))
|
||||
@@ -222,7 +222,7 @@ async function toggleStatus(row: Permission) {
|
||||
async function removePermission(row: Permission) {
|
||||
if (row.builtIn) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, '删除权限', { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, `删除权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformPermission(row.id)
|
||||
await loadPermissions()
|
||||
@@ -299,26 +299,25 @@ onMounted(loadPermissions)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="density-form">
|
||||
<FormSection class="density-form-section" title="资源定义" description="定义权限名称、编码、类型和层级关系。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="权限编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="资源类型" prop="type" required><el-select v-model="form.type" :disabled="Boolean(form.id)" @change="handleTypeChange"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="上级资源" prop="parentId" :required="['按钮', '页面', '接口'].includes(form.type)"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="['菜单', '页面'].includes(form.type)" label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item>
|
||||
<el-form-item v-if="form.type === '接口'" label="HTTP 方法" prop="method" required><el-select v-model="form.method"><el-option v-for="method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']" :key="method" :label="method" :value="method" /></el-select></el-form-item>
|
||||
<el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" placeholder="请输入排序值" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;资源类型与后端 MENU、PAGE、BUTTON、API 契约一致。</p></div></div>
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density" fullscreenable :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" class="density-form" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="权限资源定义">
|
||||
<h3 class="density-dialog-section__title">资源定义 <InlineHelp title="权限编码约定">使用稳定的“业务域:资源:动作”编码;资源类型与后端 MENU、PAGE、BUTTON、API 契约一致。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable permission-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item></td><td><el-form-item label="权限编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="资源类型" prop="type" required><span v-if="form.id">{{form.type}}</span><el-select v-else v-model="form.type" @change="handleTypeChange"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item></td><td><el-form-item label="上级资源" prop="parentId" :required="['按钮', '页面', '接口'].includes(form.type)"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="['菜单', '页面'].includes(form.type)"><td colspan="2"><el-form-item label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item></td></tr>
|
||||
<tr><td v-if="form.type === '接口'"><el-form-item label="HTTP 方法" prop="method" required><el-select v-model="form.method"><el-option v-for="method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']" :key="method" :label="method" :value="method" /></el-select></el-form-item></td><td :colspan="form.type === '接口' ? 1 : 2"><el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" placeholder="请输入排序值" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permission-editor-table { table-layout: fixed; }
|
||||
.permission-editor-table td:not([colspan="2"]) { width: 50%; }
|
||||
.permission-tree-tools { display: flex; align-items: center; gap: var(--admin-space-6); }
|
||||
.permission-protection-hint { color: var(--cc-muted); }
|
||||
.tree-view-actions { display: flex; align-items: center; gap: var(--admin-space-1); white-space: nowrap; }
|
||||
@@ -328,6 +327,8 @@ onMounted(loadPermissions)
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.permission-editor-table tr { display: grid; }
|
||||
.permission-editor-table td:not([colspan="2"]) { width: auto; }
|
||||
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import RolesView from './RolesView.vue'
|
||||
import PermissionsView from './PermissionsView.vue'
|
||||
import * as api from '../api/platformIam'
|
||||
const { default: ElementPlus, ElDialog, ElTree, ElFormItem, ElSelect, ElOption, ElMessageBox } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
vi.mock('../api/platformIam', () => ({ listPlatformRoles: vi.fn(), listPlatformPermissions: vi.fn(), createPlatformRole: vi.fn(), updatePlatformRole: vi.fn(), replacePlatformRolePermissions: vi.fn(), changePlatformRoleStatus: vi.fn(), deletePlatformRole: vi.fn(), createPlatformPermission: vi.fn(), updatePlatformPermission: vi.fn(), changePlatformPermissionStatus: vi.fn(), deletePlatformPermission: vi.fn() }))
|
||||
const roleId = '9223372036854775807', rootId = '9223372036854775806', childId = '9223372036854775805', hiddenId = '9223372036854775804'
|
||||
let role: api.PlatformRoleResource
|
||||
let resources: api.PlatformPermissionResource[]
|
||||
const table = { props: ['data'], template: '<div><slot name="toolbar-actions"/><div class="real-row"><slot v-if="data?.length" name="actions" :row="data[0]"/></div></div>' }
|
||||
function button(label: string, selector = 'button') {
|
||||
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
|
||||
expect(found, label).toBeDefined(); return found!
|
||||
}
|
||||
async function view(component: typeof RolesView | typeof PermissionsView) {
|
||||
const wrapper = mount(component, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { DataTable: table, QueryToolbar: true } } }); await flushPromises(); return wrapper
|
||||
}
|
||||
async function roleEditor(create = false) {
|
||||
const wrapper = await view(RolesView); button(create ? '新增角色' : '配置').click(); await flushPromises()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('opened'); await flushPromises(); return wrapper
|
||||
}
|
||||
async function permissionEditor(create = false) {
|
||||
const wrapper = await view(PermissionsView)
|
||||
if (create) button('新增权限').click()
|
||||
else wrapper.findComponent(RowActions).vm.$emit('action', 'edit')
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
async function input(placeholder: string, value: string) {
|
||||
const node = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
|
||||
expect(node, placeholder).not.toBeNull(); node.value = value; node.dispatchEvent(new Event('input', { bubbles: true })); node.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
}
|
||||
function select(wrapper: Awaited<ReturnType<typeof view>>, label: string) {
|
||||
const field = wrapper.findAllComponents(ElFormItem).find(field => field.props('label') === label)
|
||||
expect(field, label).toBeDefined(); return field!.findComponent(ElSelect)
|
||||
}
|
||||
function permissions(codes: string[]) { useAuthStore().permissions = codes; useAccessPreviewStore().initializeAfterLogin() }
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => ElMessageBox.close())
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); document.body.innerHTML = ''; setActivePinia(createPinia()); useAuthStore().token = 'test-session'
|
||||
permissions(['platform:role:list','platform:role:create','platform:role:edit','platform:role:grant','platform:role:status','platform:role:delete','platform:permission:list','platform:permission:create','platform:permission:edit','platform:permission:status','platform:permission:delete'])
|
||||
role = { id: roleId, tenantId: '0', appCode: 'PLATFORM', roleCode: 'TEST_OPERATOR', roleName: '测试角色', roleType: 'CUSTOM', status: 'ACTIVE', description: '原说明', memberCount: 0, permissionIds: [childId] }
|
||||
resources = [
|
||||
{ id: rootId, tenantId: '0', appCode: 'PLATFORM', permissionType: 'MENU', parentId: '0', permissionCode: 'platform:customer:list', permissionName: '测试客户菜单', routePath: '/customers', httpMethod: null, sortNo: 1, status: 'ACTIVE', builtIn: false },
|
||||
{ id: childId, tenantId: '0', appCode: 'PLATFORM', permissionType: 'BUTTON', parentId: rootId, permissionCode: 'platform:customer:create', permissionName: '测试新增客户', routePath: null, httpMethod: null, sortNo: 2, status: 'ACTIVE', builtIn: false },
|
||||
{ id: hiddenId, tenantId: '0', appCode: 'PLATFORM', permissionType: 'MENU', parentId: '0', permissionCode: 'platform:express:list', permissionName: '正式隐藏菜单权限', routePath: '/master-data/express-companies', httpMethod: null, sortNo: 3, status: 'ACTIVE', builtIn: false },
|
||||
]
|
||||
vi.mocked(api.listPlatformRoles).mockImplementation(async () => [role])
|
||||
vi.mocked(api.listPlatformPermissions).mockImplementation(async () => resources)
|
||||
vi.mocked(api.createPlatformRole).mockImplementation(async () => role)
|
||||
vi.mocked(api.updatePlatformRole).mockImplementation(async () => role)
|
||||
vi.mocked(api.replacePlatformRolePermissions).mockResolvedValue(undefined)
|
||||
vi.mocked(api.changePlatformRoleStatus).mockImplementation(async (_id,status) => ({ ...role, status }))
|
||||
vi.mocked(api.deletePlatformRole).mockResolvedValue(undefined)
|
||||
vi.mocked(api.createPlatformPermission).mockImplementation(async () => resources[0]!)
|
||||
vi.mocked(api.updatePlatformPermission).mockImplementation(async () => resources[0]!)
|
||||
vi.mocked(api.changePlatformPermissionStatus).mockImplementation(async (_id,status) => ({ ...resources[0]!, status }))
|
||||
vi.mocked(api.deletePlatformPermission).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('roles and permission dialogs', () => {
|
||||
it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => {
|
||||
const wrapper = await roleEditor(); const dialog = wrapper.findComponent(AppDialog); const tree = wrapper.findComponent(ElTree)
|
||||
expect(dialog.props()).toMatchObject({ title: '配置角色 · TEST_OPERATOR', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull()
|
||||
expect(tree.vm.getCheckedKeys()).toContain(childId)
|
||||
expect(tree.vm.getNode(hiddenId)).toBeTruthy()
|
||||
tree.vm.setCheckedKeys([childId, hiddenId]); tree.vm.$emit('check'); await flushPromises()
|
||||
expect(tree.vm.getHalfCheckedKeys().length).toBeGreaterThan(0)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
|
||||
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
|
||||
expect(tree.vm.getCheckedKeys()).toContain(hiddenId)
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.replacePlatformRolePermissions).toHaveBeenCalledWith(roleId, expect.arrayContaining([childId, hiddenId]))
|
||||
const granted = vi.mocked(api.replacePlatformRolePermissions).mock.calls[0]![1]
|
||||
expect(granted.every(id => !String(id).startsWith('navigation-'))).toBe(true)
|
||||
})
|
||||
|
||||
it('grants permissions with readonly role facts when no profile-edit permission exists', async () => {
|
||||
permissions(['platform:role:list','platform:role:grant'])
|
||||
const wrapper = await roleEditor()
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:订单运营专员"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="角色基本资料"]')?.textContent).toContain('原说明')
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformRole).not.toHaveBeenCalled()
|
||||
expect(api.replacePlatformRolePermissions).toHaveBeenCalledWith(roleId, [childId])
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('edits only role facts when grant permission is absent', async () => {
|
||||
permissions(['platform:role:list','platform:role:edit'])
|
||||
const wrapper = await roleEditor()
|
||||
expect(wrapper.findComponent(ElTree).exists()).toBe(false)
|
||||
await input('如:订单运营专员', '修改角色')
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformRole).toHaveBeenCalledWith(roleId, { roleName: '修改角色', description: '原说明' })
|
||||
expect(api.replacePlatformRolePermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates create, normalizes its code and attaches checked grants only after creation', async () => {
|
||||
const wrapper = await roleEditor(true)
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises(); expect(api.createPlatformRole).not.toHaveBeenCalled()
|
||||
await input('如:订单运营专员', '新角色'); await input('如:ORDER_OPERATOR', 'new_operator')
|
||||
wrapper.findComponent(ElTree).vm.setCheckedKeys([childId]); wrapper.findComponent(ElTree).vm.$emit('check'); await flushPromises()
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformRole).toHaveBeenCalledWith({ roleCode: 'NEW_OPERATOR', roleName: '新角色', description: '' })
|
||||
expect(api.replacePlatformRolePermissions).toHaveBeenCalledWith(roleId, [childId])
|
||||
})
|
||||
|
||||
it('locks the tree and close controls during save then preserves failed selection', async () => {
|
||||
let rejectGrant!: (error: Error) => void
|
||||
vi.mocked(api.replacePlatformRolePermissions).mockImplementation(() => new Promise((_resolve,reject) => { rejectGrant = reject }))
|
||||
const wrapper = await roleEditor()
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
const checks = Array.from(document.querySelectorAll<HTMLInputElement>('.permission-tree input[type="checkbox"]'))
|
||||
expect(checks.length).toBeGreaterThan(0); expect(checks.every(input => input.disabled)).toBe(true)
|
||||
rejectGrant(new Error('授权失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(wrapper.findComponent(ElTree).vm.getCheckedKeys()).toContain(childId)
|
||||
})
|
||||
|
||||
it.each(['停用','启用','删除'] as const)('identifies the role %s confirmation and retains the original command', async verb => {
|
||||
if (verb === '启用') role.status = 'DISABLED'
|
||||
await view(RolesView); button(verb, '.real-row button').click(); await flushPromises()
|
||||
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}角色 · 测试角色`)
|
||||
expect(api.changePlatformRoleStatus).not.toHaveBeenCalled(); expect(api.deletePlatformRole).not.toHaveBeenCalled()
|
||||
button(verb === '删除' ? '删除' : '确认', '.el-message-box button').click(); await flushPromises()
|
||||
if (verb === '删除') expect(api.deletePlatformRole).toHaveBeenCalledWith(roleId)
|
||||
else expect(api.changePlatformRoleStatus).toHaveBeenCalledWith(roleId, verb === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
})
|
||||
|
||||
it('retains built-in role protection for status and delete', async () => {
|
||||
role.roleType = 'SYSTEM'
|
||||
await view(RolesView)
|
||||
expect(button('停用', '.real-row button').disabled).toBe(true)
|
||||
expect(button('删除', '.real-row button').disabled).toBe(true)
|
||||
expect(api.changePlatformRoleStatus).not.toHaveBeenCalled(); expect(api.deletePlatformRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([['菜单','MENU'],['页面','PAGE'],['按钮','BUTTON'],['接口','API']] as const)('creates %s resources with original conditional payload', async (kind,type) => {
|
||||
const wrapper = await permissionEditor(true)
|
||||
await input('用户可理解的中文名称', '新增资源'); await input('如:platform:customer:list', 'platform:new:action')
|
||||
select(wrapper, '资源类型').vm.$emit('update:modelValue', kind); select(wrapper, '资源类型').vm.$emit('change', kind); await flushPromises()
|
||||
if (kind !== '菜单') {
|
||||
button('保存权限', '.el-dialog__footer button').click(); await flushPromises(); expect(api.createPlatformPermission).not.toHaveBeenCalled()
|
||||
select(wrapper, '上级资源').vm.$emit('update:modelValue', rootId); await flushPromises()
|
||||
}
|
||||
if (kind === '菜单' || kind === '页面') await input('如:/customers', '/new')
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:/customers"]') !== null).toBe(kind === '菜单' || kind === '页面')
|
||||
button('保存权限', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformPermission).toHaveBeenCalledWith({ type, code: 'platform:new:action', name: '新增资源', parentId: kind === '菜单' ? '0' : rootId, routePath: kind === '菜单' || kind === '页面' ? '/new' : null, httpMethod: kind === '接口' ? 'GET' : null, sortNo: 10 })
|
||||
})
|
||||
|
||||
it('keeps code/type fixed and excludes current resource and descendants from parent choices', async () => {
|
||||
const wrapper = await permissionEditor()
|
||||
expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '编辑权限资源 · 测试客户菜单', width: 'min(720px, calc(100vw - 28px))', fullscreenable: true })
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:platform:customer:list"]')).toBeNull()
|
||||
expect(select(wrapper, '资源类型').exists()).toBe(false)
|
||||
const parents = select(wrapper, '上级资源').findAllComponents(ElOption).map(option => option.props('value'))
|
||||
expect(parents).toContain(hiddenId); expect(parents).not.toContain(rootId); expect(parents).not.toContain(childId)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
|
||||
await input('用户可理解的中文名称', '重命名资源')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformPermission).toHaveBeenCalledWith(rootId, { name: '重命名资源', parentId: null, routePath: '/customers', httpMethod: null, sortNo: 1 })
|
||||
})
|
||||
|
||||
it('keeps failed permission input visible after locking close during submission', async () => {
|
||||
let rejectSave!: (error: Error) => void
|
||||
vi.mocked(api.updatePlatformPermission).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
|
||||
const wrapper = await permissionEditor(); await input('用户可理解的中文名称', '失败输入')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props('showClose')).toBe(false)
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
rejectSave(new Error('保存失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="用户可理解的中文名称"]')!.value).toBe('失败输入')
|
||||
})
|
||||
|
||||
it.each(['停用','启用','删除'] as const)('identifies permission %s confirmation without losing original restrictions', async verb => {
|
||||
if (verb === '启用') resources[0]!.status = 'DISABLED'
|
||||
const wrapper = await view(PermissionsView)
|
||||
wrapper.findComponent(RowActions).vm.$emit('action', verb === '删除' ? 'delete' : 'status'); await flushPromises()
|
||||
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}权限 · 测试客户菜单`)
|
||||
if (verb === '删除') expect(document.querySelector('.el-message-box')?.textContent).toContain('没有下级资源且未分配给角色')
|
||||
expect(api.deletePlatformPermission).not.toHaveBeenCalled(); expect(api.changePlatformPermissionStatus).not.toHaveBeenCalled()
|
||||
button(verb === '删除' ? '删除' : '确认', '.el-message-box button').click(); await flushPromises()
|
||||
if (verb === '删除') expect(api.deletePlatformPermission).toHaveBeenCalledWith(rootId)
|
||||
else expect(api.changePlatformPermissionStatus).toHaveBeenCalledWith(rootId, verb === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
})
|
||||
|
||||
it('never opens editing for a built-in permission resource', async () => {
|
||||
resources[0]!.builtIn = true
|
||||
const wrapper = await view(PermissionsView)
|
||||
wrapper.findComponent(RowActions).vm.$emit('action', 'edit'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(api.updatePlatformPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -121,7 +121,9 @@ describe('RolesView', () => {
|
||||
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
expect(rolesSource).toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
|
||||
expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
|
||||
expect(rolesSource).toContain(':global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"')
|
||||
expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))')
|
||||
expect(rolesSource).not.toContain('max-height: 380px')
|
||||
})
|
||||
|
||||
it('loads platform roles from IAM for a server session', async () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
@@ -62,7 +62,7 @@ const formRules: FormRules = {
|
||||
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
}
|
||||
const editorLabel = computed(() => form.id ? '配置角色' : '新增角色')
|
||||
const editorLabel = computed(() => form.id ? `配置角色 · ${form.code.trim() || form.name.trim() || '—'}` : '新增角色')
|
||||
const canEdit = computed(() => access.can('platform:role:edit'))
|
||||
const canGrant = computed(() => access.can('platform:role:grant'))
|
||||
const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
|
||||
@@ -197,7 +197,7 @@ async function removeRole(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能删除'); return }
|
||||
if (row.userCount > 0) { ElMessage.warning('请先移除该角色下的用户'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, '删除角色', { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, `删除角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformRole(row.id)
|
||||
serverRoles.value = serverRoles.value.filter((item) => item.id !== row.id)
|
||||
@@ -214,7 +214,7 @@ async function toggleStatus(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能停用'); return }
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, '角色状态', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, `${target}角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformRoleStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformRole(updated))
|
||||
@@ -261,33 +261,30 @@ onMounted(loadRoles)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="role-editor-form density-form">
|
||||
<div class="role-editor-layout">
|
||||
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。" class="role-editor-basic density-form-section">
|
||||
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
|
||||
</FormSection>
|
||||
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions density-form-section"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span></div></template></el-tree></FormSection>
|
||||
</div>
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-dialog-sections role-editor-layout" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="角色基本资料">
|
||||
<h3 class="density-dialog-section__title">基本信息</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="角色编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限">
|
||||
<h3 class="density-dialog-section__title">菜单与操作权限 <small>当前 {{form.permissionIds.length}} 项授权</small><InlineHelp title="权限选择说明">按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选。</InlineHelp></h3>
|
||||
<el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children', disabled: () => saving }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span></div></template></el-tree>
|
||||
</section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-editor-layout { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); gap: var(--admin-space-5); }
|
||||
.role-editor-layout { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); align-items: start; }
|
||||
.role-editor-layout:not(:has(.role-editor-permissions)) { grid-template-columns: minmax(0, 1fr); }
|
||||
.role-editor-layout > .role-editor-permissions.density-form-section { min-width: 0; padding: 0 0 0 var(--admin-space-5); border-left: 1px solid var(--cc-border); }
|
||||
.permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; }
|
||||
.permission-node span { min-width: 0; }
|
||||
.permission-node.is-navigation-group span { font-weight: 650; }
|
||||
:global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.role-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
@media (max-width: 720px) {
|
||||
.role-editor-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.role-editor-layout > .role-editor-permissions.density-form-section { padding-top: 18px; padding-left: 0; border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||
}
|
||||
:global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
|
||||
@media (max-width: 720px) { .role-editor-layout { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Admin 入库模块指引
|
||||
|
||||
## 路径基准与读取顺序
|
||||
|
||||
- 本文件适用于 `src/modules/inbounds/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,字段与计费细则见 `../../../docs/business-index.md`。
|
||||
- 模块按子域分目录:`measurement/`(测量)、`review/`(测量审核)、`weight/`(称重与 OCR)、`fee/`(费用与人工定价)、`core/`(入库单公共)。先定子域再进文件。
|
||||
- 后端入口为 `firstleg/`(测量、审核、计价、扣货、称重库位)与 `warehouse/`(库位);仅涉及展示、文案且不涉及接口、权限、状态、金额或数据归属时,默认不读后端;涉及上述边界时沿实际 API 进入对应服务。
|
||||
|
||||
## 页面与入口
|
||||
|
||||
| 目标 | 页面/子域入口 | API 与后端 |
|
||||
|---|---|---|
|
||||
| 订单测量列表与工作区 | `pages/InboundMeasurementsView.vue`、`components/InboundWorkflowWorkspace.vue`;`inbounds/measurements` | `measurement/api.ts`;`firstleg/service/InboundMeasurementService.java` |
|
||||
| 测量弹框、测量面板、尺寸策略与证据图 | `measurement/InboundMeasurementDialog.vue`(组合壳)、`measurement/InboundMeasurementPanel.vue`(数据与操作)、`measurement/components/MeasurementEvidenceThumbnail.vue` | 实际请求在 `measurement/InboundMeasurementPanel.vue` → `measurement/api.ts` → 平台链路 `firstleg/controller/PlatformInboundMeasurementController.java` → `firstleg/service/InboundMeasurementService.java`、`MeasurementSubmissionService.java`;设备上报是另一条链路(`MojiaMeasurementOpenApiService`),不要混;弹框只组合面板与 OCR 面板,不直接调接口 |
|
||||
| 测量数据审核 | `pages/MeasurementReviewsView.vue`、`review/MeasurementReviewPanel.vue`;`inbounds/measurement-reviews` | `review/api.ts`;`firstleg/service/MeasurementReviewService.java`、`firstleg/service/InitialInboundPricingService.java` |
|
||||
| 称重图片、OCR 与库位槽状态 | `weight/components/WeightOcrPanel.vue`、`weight/components/WeightImageThumbnail.vue` | `weight/api/weightOcr.ts`;`firstleg/service/MeasurementLocationOccupationService.java`、`firstleg/service/MeasurementSubmissionService.java` |
|
||||
| 入库单列表、摘要与历史 | `pages/InboundOrdersView.vue`、`core/InboundSummaryPanel.vue`、`core/InboundHistoryPanel.vue`;`inbounds` | `core/api.ts`、`core/types.ts`;`firstleg/service/InboundOrderQueryService.java` |
|
||||
| FBA 人工定价(无匹配价格) | `fee/InboundFeePanel.vue` | `fee/api.ts` 的 `/manual-quote`;`firstleg/service/FbaPricingService.java` |
|
||||
| 一件代发托盘打印 | `core/DropshipPalletPrintPanel.vue` | `core/api.ts`;`print/`;仅一件代发订单展示 |
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 客户四档箱规与默认算法(`ADD/MULTIPLY`)的规则正文在详细索引维护;本模块只按 `../dimension-config/` 消费,保存后以外箱尺寸、体积重和计费基数只认服务端返回值,页面不独立重算。
|
||||
- 费用确认的实际箱数、毛重和体积取值口径以详细索引“入库、测量”节(`actualMeasurementRows`)为准,本文件不复制该规则。
|
||||
- 称重卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或推导整票预留引导;删除图片须提示该槽位仍归本票保留,不按图片下标推导托盘或库位。
|
||||
- 人工定价仅 FBA、已有实际测量且价格未命中时开放,客户已确认费用禁止改价;`MANUAL_QUOTE` 不伪造价格簿关联。
|
||||
- 扣货与解除、凭证下载走 `../unified-orders/` 与 `../inbounds/` 的正式入口,本模块不另建凭证链路。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/inbounds/<目标测试>`;计费或库位相关变更同时核对 `firstleg` 对应合同测试。
|
||||
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user