57 Commits

Author SHA1 Message Date
b2894lxlx 50ab33f369 Merge remote-tracking branch 'websoft/master'
# Conflicts:
#	src/views/business/project-apply.vue
2026-09-18 18:26:13 +08:00
b2894lxlx bcbc45bb03 调整 用户 2026-09-18 18:25:52 +08:00
gxwebsoft ff9fb9168b fix(router): 修复运单管理详情路由及合同详情路由重复定义问题
- 为 waybill-manage-page、transport-plan-page 等组件增加 routePathLocked 变量锁定路由路径
- 添加 isOwnedRouteActive 计算属性,防止缓存实例错误读取其它路由的 detailId 导致误请求
- watcher 和 openDetail 方法中添加路由守卫,避免多余的请求和误开详情标签
- 修改宿主视图绑定 detailId,避免直接绑定全局 $route.query 导致缓存污染
- 删除 router/views/index.js 中合同管理详情路由重复定义,防止内容区空白问题
- 优化 waybill-import-dialog 组件样式和结构,增加 section-card 包裹表单区域
- 调整相关组件 data、computed、watch 使用,确保第一次立即 watcher 正确读取路由数据
- 验证所有改动后相关页面 HTTP 200,标签页行为正常,解决「运单管理不存在」和合同详情空白问题
2026-09-18 12:22:25 +08:00
gxwebsoft 986feff7b7 fix(waybill-manage): 修复运单详情弹窗残留问题
- 页面形态状态(列表/独立表单页/独立详情页)在实例创建时锁定,避免随路由变化切换导致弹窗残留
- 模板中形态相关所有判定改为使用锁定状态变量,保证渲染一致性
- 修改 detailContainer、crudContainer、pageFormOption 等方法使用锁定状态,避免缓存实例 option 变形
- 新增 closeInnerDialogs 方法,在组件 deactivated 和 beforeUnmount 生命周期关闭所有 append-to-body 弹窗
- 解决标签页 keep-alive 机制下弹窗 Teleport 持续遗留导致界面异常的问题
- 对运单管理页面多处弹窗和视图控制做统一修复,提升页面稳定性和体验
2026-09-18 11:29:52 +08:00
gxwebsoft 3e7110ca8c fix(waybill-manage): 修复运单详情弹窗残留问题
- 页面形态状态(列表/独立表单页/独立详情页)在实例创建时锁定,避免随路由变化切换导致弹窗残留
- 模板中形态相关所有判定改为使用锁定状态变量,保证渲染一致性
- 修改 detailContainer、crudContainer、pageFormOption 等方法使用锁定状态,避免缓存实例 option 变形
- 新增 closeInnerDialogs 方法,在组件 deactivated 和 beforeUnmount 生命周期关闭所有 append-to-body 弹窗
- 解决标签页 keep-alive 机制下弹窗 Teleport 持续遗留导致界面异常的问题
- 对运单管理页面多处弹窗和视图控制做统一修复,提升页面稳定性和体验
2026-09-18 11:17:50 +08:00
gxwebsoft b4e12a671f fix(views): 修复独立页切换时弹窗残留问题
- 独立整页固定使用普通 div 容器,取消动态切换 el-dialog 避免切换时弹窗 DOM 残留
- 删除相关 dialogVisible、dialogTitle、页脚弹窗动态分支代码以简化逻辑
- watch $route 调整,优化独立页初始化流程,避免不必要的关闭弹窗操作
- 新增 closeInnerDialogs 方法,页面卸载或 keep-alive deactivate 时关闭所有二级弹窗
- 对 loading-manage 与 project-apply 两个模块同步改造,统一处理弹窗状态和容器渲染
- 解决 keep-alive 缓存导致旧弹窗一直挂载在 body 上的问题,改善用户切换体验
2026-09-18 10:17:48 +08:00
gxwebsoft 5bb072223f feat(loading-manage): 配载管理详情及重新派单改用独立页面
- 新增 `/business/loading-manage/detail` 路由,详情页由独立页面承载
- 统一将新增、编辑、详情和重新派单均采用独立表单页/详情页显示
- 修改组件内逻辑,新增 `isStandalonePage` 计算属性统一控制页面与弹窗切换
- 修改列表页链接及操作行为,详情和重新派单按钮跳转至对应独立页面
- 去除旧详情弹窗模式,相关样式调整支持独立页面自然滚动
- 添加路由监听,支持页面刷新和切换时正确初始化详情或表单数据
- 兼容老链接 `/business/loading-manage?detailId=x` 自动跳详情新路由
- 修正弹窗底栏样式,去除写死间距,统一由全局规则控制
- 代码中禁止顶层 scoped style 使用 `//` 注释,改用 `/* */` 避免编译错误
2026-09-17 22:40:41 +08:00
gxwebsoft 3d6e297147 feat(vehicle): 归一化保险记录导入日期格式,修复设备台账批量删除按钮
- 保险记录批量导入时,前端读取 Excel 并规范日期格式为 YYYY-MM-DD,兼容多种日期写法
- 重写 `handleImport` 支持自定义上传逻辑,调用归一化处理后再上传文件
- 添加日期归一化相关方法,确保不合法或已标准格式保持原值
- 设备台账界面新增批量删除按钮及相关逻辑,支持多选数据批量删除并提示确认
- 补充导出含创建时间和更新时间的后端修复说明,避免导出时间字段为空问题
- 说明后端导出分配逻辑,强调导出字段由后端控制,前端修改导出列无效
2026-09-17 21:21:33 +08:00
gxwebsoft 82fbb99593 fix(payment): 优化发票申请及收据表单提示和样式
- 简化合并开票结算单的合同限制提示文案
- 调整发票申请表单搜索区域的内容左对齐样式
- 修正发票收据表单关键词输入框的占位符文字错误
2026-09-17 14:59:26 +08:00
gxwebsoft 17c482e21a feat(waybill): 优化打卡时间轴按打卡时间排序展示
- 新增 computed 属性 orderedWaybillPunchRecords,按打卡时间升序排序已打卡记录
- 未打卡记录保持后端返回的节点顺序,排在已打卡记录之后
- 引入方法 waybillPunchRecordTimestamp 解析打卡时间,支持多种日期格式
- 修正 el-timeline v-for 使用 orderedWaybillPunchRecords 避免 key 冲突
- 保持原有流程节点顺序不变,仅调整时间轴展示顺序
2026-09-17 14:47:29 +08:00
gxwebsoft 2684624979 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/views/business/contract-manage.vue
#	src/views/business/temporary-credit-limit.vue
#	src/views/transportCapacity/vehicle.vue
2026-09-17 14:42:33 +08:00
b2894lxlx 84d9e098ba 调整 业务 2026-09-16 23:43:12 +08:00
b2894lxlx c1c4fa18c9 调整 iam 2026-09-16 06:30:30 +08:00
b2894lxlx 6a87066097 调整 合同、运力、客商、项目 2026-09-15 11:59:55 +08:00
b2894lxlx 79aa4f9fae 调整 合同、运力、基础配置、客商 2026-09-14 23:43:20 +08:00
b2894lxlx f1e2313e4c 调整 合同 2026-09-14 15:01:13 +08:00
b2894lxlx acbc9d3322 调整 基础数据、运力、车船务 2026-09-14 01:49:23 +08:00
b2894lxlx dbeb174ae2 调整项目 2026-09-13 01:01:24 +08:00
b2894lxlx 764c0f4581 运单调整 2026-09-11 14:39:30 +08:00
gxwebsoft 2131e99ec4 feat(contract-manage): 将合同详情由弹窗改为独立页面
- 新增路由 /business/contract-manage/detail,详情页共享合同管理组件
- 列表、表单、详情三分支用 isFormPage 和 isDetailPage 控制
- openDetail 方法改为跳转详情路由,initDetailPage 获取详情数据
- 新增 resetDetailState 和 closeDetailPage 方法,复用关闭逻辑
- 详情页去除弹窗高度限制,样式改为页面自滚动
- 详情页底栏使用固定位置关闭按钮,保持页面布局一致
- watch 和 created 中添加对 isDetailPage 的初始化调用和监听
2026-09-11 12:30:05 +08:00
gxwebsoft 6e5b15f203 Merge remote-tracking branch 'origin/master' 2026-09-11 10:41:13 +08:00
gxwebsoft fbfd5cde89 fix(api): 移除项目资金风险统计请求调用
- 由于后端/fund-risk-stats端点尚未实现,删除列表页中refreshFundRiskStats调用
- 从data中移除fundRiskStats相关数据,避免接口请求报错
- 列表头部风险筛选标签调整,仅显示“高风险/中风险”,去掉数量显示
- api/business/project-apply.js中保留getFundRiskStats定义,并添加注释说明后端就绪后恢复调用
2026-09-11 10:41:09 +08:00
b2894lxlx 1a9cd55d66 1、调整导入运单
2、调整对账
2026-09-10 22:50:18 +08:00
b2894lxlx 38e53e01af Merge remote-tracking branch 'websoft/master' 2026-09-10 02:01:42 +08:00
b2894lxlx 6ff672211f 1、调整导入运单
2、调整对账
2026-09-10 02:01:28 +08:00
gxwebsoft 1bbc97758d fix(contract-manage-change): 修正自动生成结算单问号提示图标位置
- 解决图标夹在开启/关闭 radio 按钮之间,开启按钮右侧多余间距问题
- 清除开启 radio 默认 margin-right,调整提示图标左右间距
- 提示图标紧靠开启按钮一侧,关闭按钮保持较大间距
- 优化样式,使布局更美观一致
2026-09-09 19:25:06 +08:00
gxwebsoft c1813e308f feat(vehicle): 重构车辆认证审核弹窗布局与验证规则
- 新增车辆认证审核弹窗,取消独立尺寸重量卡片,外廓尺寸改为长/宽/高组合布局
- 基础信息调整为3行4列,证件信息新增车辆资质图片行,所属组织标签更名为所有组织
- 行驶证有效期必填星号动态显示,长期有效时不显示且不校验,删除旧校验方法
- driver.vue中从业资格证有效期改为起止日期区间,新增起始日期字段,调整验证规则
- 驾驶证和从业资格证长期有效时禁用起止日期输入,校验逻辑同步调整,提交保留旧值
- contract-manage-change.vue合同文件批量下载按钮移入标题栏,与其它附件分区样式统一
- 样式更新,新增 dimension-group 用于外廓尺寸排版复用及其他局部样式调整
- 修复因后端未实现接口导致的降级方案预留,等待后端补充支持
2026-09-09 19:06:26 +08:00
gxwebsoft 716ce43862 feat(driver): 优化驾驶证信息必填规则和表单placeholder统一
- 驾驶证有效期必填加红星,长期有效去除必填红星,保持校验逻辑不变
- 统一司机弹窗所有输入框和选择框的placeholder为“请输入”或“请选择”,去除字段名
- 保留复合操作、日期范围起止及默认值说明的特例placeholder不变
- 新增3处自动生成结算单开关后悬浮问号图标和提示,增强用户理解
- customer-archive中新增customerKindOptions和映射,用于客户类别选择
- 提升archive-form label与控件垂直居中样式至全局,修正多页标签顶置问题
2026-09-09 17:35:19 +08:00
gxwebsoft 14c6ad0a31 feat(vehicle): 优化车辆管理相关表单布局和字段
- 车牌号组件改为单一输入框,去除省简称和颜色选择,实现完整车牌号输入
- 重写车牌号校验逻辑,校验完整车牌号格式,错误提示更加明确
- 车辆新增/编辑弹窗输入框宽度统一调整,基础信息和资质图片区按四列布局排列
- 修复所有组织字段宽度不一致问题,补充级联选择器样式支持
- 司机新增/编辑弹窗调整为四列布局,调整字段排列顺序,提升界面整洁度
- 客商档案中「客商类型」改名为「客商分类」,新增「客商类型」字段支持外部客商和内部组织区分
- 上传证件项 label 贴顶问题修复,统一提升到全局样式实现垂直居中
- 移除车牌颜色只读展示,审核弹窗同步删除车牌颜色字段
- 保持后端接口兼容,数据库字段未变,OCR同步调用链路无影响
2026-09-09 17:19:33 +08:00
gxwebsoft 1972115173 feat(transportCapacity): 优化车辆与司机弹窗布局并新增字段支持
- 车辆新增弹窗:所有组织移至基础车辆信息区末尾,新增使用部门字段,使用部门复用部门下拉
- 车牌号三段合并为整体输入框,优化样式为flex布局,去除多余边框及阴影,统一视觉
- 长期有效勾选时清空对应日期且禁用输入,新增handleLongTermChange方法,表单校验支持长期有效跳过必填
- 司机弹窗基础身份信息新增placeholder,调整布局,基础身份三列保持不变,其他信息行按需求拆分重排为一致三列
- 后端新增use_department字段,相关mapper、service及校验同步更新,新增增量迁移脚本支持
- 车辆表单相关输入控件宽度统一调整为6列布局,保持与其它区块一致,提升整体UI协调性
- 移除车辆车牌号拆分相关字段及逻辑,车牌号统一管理为单个字段,简化处理流程
- 代码中新增校验规则validateDrivingLicenseEndDate及validateRoadTransportCertEndDate,支持长期有效逻辑
- 优化图片上传区和证件信息区表单字段排列,提升表单整体易用性与规范性
2026-09-09 15:37:40 +08:00
gxwebsoft 006dc95bf0 Merge remote-tracking branch 'origin/master' 2026-09-09 15:11:56 +08:00
gxwebsoft d187da68d2 fix(contract-manage): 优化合同管理页面布局及字典数据读取
- 合同文件「批量下载」按钮调整为与标题同排且垂直居中
- 修改内联组件render函数结构及相关样式,统一合同文件和其它附件布局
- 合同格式下拉改为动态读取业务字典contractFormat,保留静态兜底
- 合同类别contractCategory下拉改为业务字典读取,替代硬编码选项
- 表单增加所属组织、签订日期、回款账期、开票周期、结算币种等字段
- 调整字段顺序,完善表单输入限制和控件属性
- 替换部分按钮类型,优化用户交互体验
- 修复detailValue字典映射,显示合同格式标签
- 修正样式`:deep`选择器,解决scoped样式对子组件无效问题
2026-09-09 15:11:53 +08:00
b2894lxlx 70bb94a2e6 Merge remote-tracking branch 'websoft/master' 2026-09-09 14:43:49 +08:00
b2894lxlx f78e61b433 1、调整导入运单
2、调整对账
2026-09-09 14:43:38 +08:00
gxwebsoft 508d73fc0d Merge remote-tracking branch 'origin/master' 2026-09-09 12:45:50 +08:00
gxwebsoft e73e1da877 feat(contract-manage): 将付款比例设置提示改为tooltip展示
- 付款比例设置区块标题后添加问号图标悬浮提示,替换原红色文字说明
- 编辑弹窗、详情弹窗、合同变更页统一使用el-tooltip + el-icon-question-filled组件
- 移除contract-manage.vue中原有.red tip提示及相应样式

feat(temporary-credit-limit): 优化临时额度申请界面及校验规则

- 增加新增/编辑/查看临时额度申请标题,覆盖默认菜单注入标题
- 所有表单字段统一设置placeholder为“请输入/请选择”无字段名
- 搜索栏占位符统一为“请输入/请选择”,与其他模块一致
- 临时额度申请有效期至字段增加“必须晚于当前日期”日期校验规则
- 增添futureDateRule通用校验规则,支持多种日期格式即时触发验证

feat(project-apply): 新增资金使用风险快速筛选及风险提示

- 项目申请列表增加资金使用风险列,显示高风险/中风险标签
- 新增资金使用风险快速筛选区,可点击标签筛选项目风险等级
- 提供资金使用风险阈值配置,支持中风险≥80%、高风险≥90%
- 调整页面样式及交互,优化风险筛选体验
- 新增后端接口getFundRiskStats,用于获取风险统计数据并实时刷新
2026-09-09 12:45:46 +08:00
gxwebsoft 4d5b99da82 feat(contract): 优化合同变更及车辆认证审核弹窗
- 合同变更页「其它附件」上传按钮样式调整,与变更材料区按钮统一
- 新增分区标题必填星号样式,改用 ::after 避免冲突
- 变更页 load() 强制清空表单关键字段,避免带入旧数据
- 实现 mergeChangeAttachments() 合并上次变更附件,去重并格式化大小
- 合同变更记录表格新增「变更内容」列,支持单行摘要显示与悬浮全量提示
- 操作列由单「流程」改为「详情 + 流程」按钮,详情文字统一
- 变更记录值格式化增强,支持方案名、支付比例、结算规则等摘要展示
- buildDetailChangeRecordRows 过滤条件更新,前后均为空时跳过展示
- 车辆认证审核弹窗样式及结构重构,使用 section-card 白卡分组,内容补全证件图片区
- 证件图片实现只读大图展示,复用全局样式,无需新增规则
- label 单位统一为中文全角括号,提升界面一致性
- 新增长期有效显示逻辑,避免日期未填「-」与长期有效混淆
- 删除无用计算属性 auditDetailItems,改用简洁 auditValue 方法返回展示值
- 全站弹窗标题统一增加 4px 主色竖条,符合设计规范,且对无标题弹窗进行排除处理
- 客户端临时额度申请期限字段拆分为两个独立字段,清晰明了
- 统一合同变更页变更原因必填标识及上传按钮样式
- 修正 EP 表单项标签高度和对齐,完善星号方案兼容性设计
2026-09-09 11:57:52 +08:00
gxwebsoft f3021d4bae style(ship): 调整航区字段布局及上传器宽度
- 将航区选择控件从宽度12调整为宽度6的列中展示
- 保留航区选择及提示信息功能不变,位置更紧凑
- 调整上传组件宽度从240px增加到248px以适配新布局
2026-09-09 11:20:52 +08:00
gxwebsoft 7eb6cce2db fix(project-apply): 修正项目简称输入框的必填校验
- 取消了项目简称表单项的 required 属性
- 在项目简称的验证规则中新增必填校验
- 使得项目简称必填校验逻辑与表单项显示一致
- 保持校验触发事件为失焦(blur)事件
2026-09-09 11:14:44 +08:00
gxwebsoft be2888d428 fix(styles): 修复必填星号首行对齐问题
- 调整 `.is-required .el-form-item__label` 采用 `align-items: normal` 实现星号首行对齐
- 覆盖 Element Plus 固定高度 `height: 32px` 为 `height: auto` 以支持内容自适应高度
- 保持 `align-self: center` 使单行 label 与输入框垂直居中,折行时星号贴首行
- 修改星号 `::before` 的高度为 `auto` 并继承行高,保证首行基线一致
- 修复多行 label 下星号因 flex 居中导致位置偏移的问题
- 兼容 label 多种位置(右、左、顶)及多行折行场景,影响 project-apply 等表单页
2026-09-09 11:11:39 +08:00
gxwebsoft 06f6950434 refactor(contract-manage): 优化结算货币标签显示
- 结算货币选项中仅显示字典值,去除字典键拼接
- 注释掉样式中 el-form-item 标签的行高设置,避免样式冲突
2026-09-09 10:39:30 +08:00
gxwebsoft 09f1af9a0a fix(styles): 调整必填星号与标签首行对齐样式
- 修改 .el-form-item__label 的对齐方式使必填星号与首行文字齐平
- 设置多标签布局下星号对齐,兼容单行和多行标签显示
- 调整 ::before 伪元素行高和外边距,确保星号基线一致

fix(project-apply): 更新表单字段“资金需求”为“履约保证金”

- 修改项目申请页面中“资金需求”标签为“履约保证金”
- 更新对应验证规则标签名称,保持校验一致性

chore(config): 更新开发服务器端口及代理设置

- 注释旧服务器配置,启用新端口 2889
- 更新 /api 代理目标地址为 172.16.203.228:8000
- 保持代理跨域设置不变
2026-09-09 10:31:22 +08:00
b2894lxlx 6d4961f404 1、调整凭证
2、调整对账
3、解决正式结算冲突bug
2026-09-09 10:21:19 +08:00
b2894lxlx cba2ad3b3b 1、调整凭证
2、调整运单
2026-09-08 17:50:34 +08:00
b2894lxlx a7315b4491 调整结算模块 2026-09-07 19:37:27 +08:00
b2894lxlx 0d070d4b34 调整业务模块 2026-09-07 10:05:32 +08:00
b2894lxlx 6a6d10dc79 调整业务模块 2026-09-05 23:26:37 +08:00
b2894lxlx 3bac9b6472 调整结算 2026-09-05 00:04:46 +08:00
b2894lxlx 96f2fa0eb5 Merge remote-tracking branch 'websoft/master'
# Conflicts:
#	src/views/business/contract-manage-change.vue
2026-09-04 05:24:02 +08:00
b2894lxlx e789f1647a 调整业务模块 2026-09-04 05:23:34 +08:00
b2894lxlx c63c7c3e33 调整基础数据、车船务、项目、合同 2026-09-03 22:40:11 +08:00
gxwebsoft 23b23dc8e5 style(element-ui): 区分 el-select 选中值与占位符颜色
- 修复 Element Plus el-select 组件选中值与占位符使用同一节点导致无法区分颜色的问题
- 只有未选值或已展开无搜索时节点带 .is-transparent,用此类排除选中颜色样式应用
- 更新
2026-09-03 17:03:08 +08:00
gxwebsoft a0a3d53927 style(region): 调整表单项底部外边距为9px
- 将 .el-form-item 的 margin-bottom 从8px修改为9px
- 保持样式其他部分不变
2026-09-03 16:58:34 +08:00
gxwebsoft 42e4925fd6 style(contract): 优化附件上传按钮样式及必填提示
- 为“上传附件”和“上传变更材料”按钮添加图标及主色调样式
- 在变更原因标题及输入框添加“必填”提示文字

style(customer-archive): 调整客户档案页面细节排版和格式
- 修复联系人列表相关代码缩进及注释格式
-
2026-09-03 16:06:43 +08:00
b2894lxlx 024e77801e 1、修复基础配置
2、修复业务模块
3、拆分页面代码
2026-09-03 15:59:34 +08:00
gxwebsoft 397490a165 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/views/business/contract-manage.vue
2026-09-03 15:45:29 +08:00
gxwebsoft 9e14f31604 fix(business): 修复下拉框定位及输入框宽度问题
- 统一 business-crud-page.vue 表单控件宽度,包含 el-autocomplete 及其内部元素
- 调整调度弹窗收发货地址行网格,使地址输入框宽度自适应避免溢出
- contract-manage.vue 中所有 el-select 加 :teleported="false" 和 :fit-input-width="true"
- 强制 contract-manage.vue 下拉框在输入框正下方显示,防止弹出位置漂移
- 禁用 contract-manage.vue 下拉弹窗自动翻转,确保下拉框始终在下方显示
2026-09-03 15:45:01 +08:00
152 changed files with 24240 additions and 5841 deletions
+24
View File
@@ -0,0 +1,24 @@
## 合同管理独立表单页下拉框定位修复
- `src/views/business/contract-manage.vue` 全部 12 处 `el-select``:teleported="false"` + `:fit-input-width="true"`:下拉保持在输入框正下方、宽度与输入框一致(原 teleport 到 body 导致下拉漂移且按内容撑宽)。
- 若后续新增独立表单页出现同样问题,照加这两个属性即可。
- 追加:teleported=false 后 popper 因下方空间不足自动上翻,再给 12 处 el-select 加 `placement="bottom-start"` + `:popper-options="{ modifiers: [{ name: 'flip', enabled: false }] }"` 强制始终在下方展示。
## 调度弹窗司机输入框宽度不一致修复
- 现象:「编辑调度信息(精简录入)」弹窗中,司机(el-autocomplete)输入框比同行 250px 的其他输入框宽。
- 根因:`business-crud-page.vue` 约 13434 行的「表单控件统一 250px」规则漏了 `.el-autocomplete`autocomplete 根节点是包装层,内部才是 .el-input)。
- 修复:在该规则选择器列表追加 `> .el-autocomplete``> .el-autocomplete > .el-input`,与运单独立表单页(13658 行 business-crud-page--form-page 先例)一致。
- 经验:凡「控件统一 250px」类规则,选择器必须包含 `.el-autocomplete` 包装层。
## 调度弹窗收发货地址行溢出修复
- 现象:「编辑调度信息」弹窗收发货信息行,地址输入框过宽,右侧「联系方式」列被挤出弹窗。
- 根因:`__dispatch-shipping-form __route-address-row` 网格地址列为 `minmax(280px, 1fr)`,各列最小宽度合计 1030px,弹窗不够宽时 grid 不换行直接溢出。
- 修复:地址列改 `minmax(0, 1fr)`(宽屏仍 1fr 拉伸,窄窗自动收窄,总最小宽降到 750px)。
- 备注:基础规则 `__route-address-row`(13750 行,运单独立表单页等在用,7 列含 40px 按钮列)仍是 minmax(280px,1fr),同样窄窗有溢出风险,暂未动(用户未反馈该页问题)。
## el-select 选中值 / 占位符颜色区分(全局)
- 需求:已选值 #606266,未选值保持 placeholder 灰 #909399
- 根因:Element Plus 的选中值文本与占位符**共用同一节点** `<div class="el-select__selected-item el-select__placeholder [is-transparent]">`,仅靠 `.el-select__selected-item` 无法区分,导致未选态也被染成选中色。
- 区分标志:只有「未选值」或「已展开但无搜索输入」时才带 `.is-transparent`
- 修复(`src/styles/element-ui.scss`):选中规则改为 `.el-select__wrapper:not(.is-disabled) .el-select__selected-item:not(.is-transparent)`;多选 tag 文字另加 `.el-select__tags-text` 规则(tag 自身 color 优先级更高,父级规则盖不到)。
- 经验:以后写任何「区分 el-select 有值/无值」的 CSS,一律用 `:not(.is-transparent)`
+215
View File
@@ -0,0 +1,215 @@
## 付款比例设置提示改为 tooltipcontract-manage / contract-manage-change
- 需求:将「付款比例设置」区块原有的红色文字提示改为标题右侧的 `?` 图标悬停提示。
- 改动:编辑弹窗、详情弹窗、合同变更页三处标题后添加 `el-tooltip` + `el-icon-question-filled`,content 为「非必填;配置付款比例时,合计必须等于100%。」;同时移除 contract-manage.vue 中原 `.contract-manage-form__tip` 红色提示文字及对应样式。
## 必填星号与 label 首行齐平(全局)
- 根因:`element-ui.scss``.el-form-item--label-right .el-form-item__label { align-items:center }` 使 inline-flex label 的星号 `::before`(独立 flex 子项)在多行折行时被垂直居中。
- 修复:label 改 `align-self:center`(整体仍随行垂直居中)+ `align-items:flex-start`(星号/文字首行齐平),覆盖 label-right/left/top 三种位置;旧 inline-block 注释已修正。
- 影响页面:project-apply 独立表单页等所有多行 label 场景(弹窗内 `white-space:nowrap` 不受影响)。
## 必填星号首行对齐:改用 align-items: normalstretch 方案)
- 最终写法(`src/styles/element-ui.scss`):`.is-required .el-form-item__label { align-self:center; align-items:normal; }` + `::before { display:inline-block; height:auto; line-height:inherit; margin-right:4px }`
- 原理:stretch 让星号盒子在「换行时」被拉伸到 label 全高、内容从顶部排布 → 贴首行;单行时盒子=一行高,视觉不变(比 flex-start 更贴合「只换行才生效」)。
- 前提:::before 高度必须 auto 且继承行高;label 不能是 固定 height + 等高 line-height,否则退化成居中,需 JS 判行数加 class。
## 合同变更页(contract-manage-change.vue
- 「其它附件」上传按钮改为 `type="primary" plain icon="el-icon-upload"`,与「变更材料」区 vehicle-attachment-upload 内置按钮一致。
- 分区标题必填星号:`.dialog-section-title.is-required::after { content:'*'; color:#f56c6c }``::before` 已被蓝色竖条占用,星号走 `::after`)。
- 再次变更逻辑:`load()` 里 form 强制重置 `changeContent/changeReason/changeAttachmentsJson = ''``changeMaterials = []`(不带上一次内容);新增 `mergeChangeAttachments()` 把上一次的 `changeAttachmentsJson` 归集进 `attachments`(其它附件),按 url/名去重,数字 size 转格式化文本,提交时随 attachmentsJson 持久化。
## 合同变更记录对齐客商变更记录(contract-manage.vue
- 编辑页与详情弹窗的两处「变更记录」表格新增「变更内容」列:`formatContractChangeContent(row)` 复用 `buildDetailChangeRecordRows` 生成「字段:变更前:x → 变更后:y」摘要,单行省略 + `el-tooltip` 全量悬浮(样式 `.contract-change-record-content-cell/-tooltip` 照搬客商 `change-record-content-*`)。
- 编辑页操作列由单个「流程」改为「详情 + 流程」(`openDetailChangeRecord` + `openFlow`,宽度 120→150);详情弹窗表格「查看详情」改「详情」与客商一致。
- 值格式化增强:`billingPlanJson`→方案名(默认)列表、`paymentRatioJson`→「第N笔 xx%」、`settlementRuleJson/preSettlementConfigJson/formalSettlementConfigJson`→可读摘要(自动生成开关/结算类型/周期类型/截单日/周期天数,含预结算+正式结算双段)。
- `buildDetailChangeRecordRows` 过滤条件补「前后都为空则跳过」(原来只比 JSON 字符串,'' 与 null 会漏)。
## 星号方案补丁(关键)
- EP 的 `.el-form-item__label` 写死 `height:32px; line-height:32px`(项目只覆盖了 line-height:14px),导致 stretch 方案文字顶到 32px 盒子顶部、单行不居中。
- 修复:`.is-required .el-form-item__label { height:auto; align-self:center; align-items:normal }`——height:auto 收缩到内容高 + align-self:center 行内居中 + normal(stretch) 仅换行时星号贴首行。
## 车辆认证审核弹窗重构(transportCapacity/vehicle.vue
- 背景:用户反馈「样式不统一、内容不全」。根因是审核弹窗用 `<el-descriptions :column="3" border>`(大白表 + 居中 label),与同页编辑弹窗的 `<section-card>` 白卡分组风格不一致。
- 改造:弹窗主体改为 4 个 `section-card` 分组 —— 基础信息 / 尺寸重量 / 证件信息 / 证件图片;内部 `el-form label-position="right" label-width="auto"` + `el-row/el-col`3 列 `:span="8"`,所属组织与备注占 `:span="24"`)。
- 内容补全:新增「证件图片」分组,6 张证件照用 `<image-upload-field readonly large class-prefix="vehicle">` 只读展示(可点击放大);证件图片尺寸 240×151 复用已有全局 `:deep(.vehicle-uploader--large)` 规则,无需新写。
- label 单位统一为中文全角括号:`外廓长度(毫米)` / `核定载质量(KG`(原为半角 `外廓长度(mm)`)。
- 方法重构:`formatAuditValue(item)``auditValue(prop)`,签名更简洁;新增 longTermMap,对 drivingLicenseEndDate / roadTransportCertEndDate / annualReviewEndDate / compulsoryScrapDate 在勾选长期有效时返回「长期有效」,避免与未填写的「-」混淆(与列表页 formatEndDate 行为一致)。
- 删除已无引用的 `auditDetailItems` computed。
## 全站弹窗标题补 4px 主色竖条(全局 element-ui.scss
- 发现:`.el-dialog__title` 全局只配了 `inline-flex + gap:8px`**没有竖条**(竖条此前只在 `.dialog-section-title::before`),导致全站 el-dialog 标题都不带竖条,不符合 AGENTS.md 4.8。
- 修复:新增 `.el-dialog__title:not(:empty)::before { width:4px; height:16px; border-radius:2px; background:var(--el-color-primary) }`
- 注意:必须加 `:not(:empty)`,否则无标题弹窗会出现孤立蓝条。
- customer-archive.vue 评分详情弹窗:将「临时额度申请使用期限」区间行(span=12 + 至 连接)拆分为两个 span=6 独立字段「临时申请额度有效期起 / 止」,字段名 tempCreditStartDate/End 不变,删除废弃样式 .score-detail-form__range。
- temporary-credit-limit 文案调整:option 增加 addTitle/editTitle/viewTitle=「新增/编辑/查看临时额度申请」(覆盖 crud-dialog-title mixin 按菜单名注入的「临时额度管理」);remark label 改「临时额度信息」(hide:true 无表格副作用,rules 同步);projectName 表单 label 用 `#projectName-label` 插槽改「项目额度信息」,列 label 保持「项目名称」不动表格列头/搜索。Avue 支持-{prop}-label / -header / -search 插槽分离表单/表头/搜索文案。
- temporary-credit-limit「申请有效期至」增加「必须晚于当前日期」校验:common.js 新增通用 `futureDateRule(label)`trigger: ['change','blur'],空值/非法值放行,按 0 点比较,当天也算过期),temporary-credit-limit.js 的 validUntil rules 追加该规则。已验证 Element Plus 通用 picker 在选中日期时调用 formItem.validate('change'),故选择即刻提示;form.validate() 不带 trigger 时会跑全部规则,提交仍拦截。
- temporary-credit-limit 搜索占位文案统一:createCrudOption(withSearchPlaceholders([...])),搜索栏 placeholder 变为「请输入/请选择」不带字段名,与 waybill-manage / transport-plan / shipping-template 同一模式。
- temporary-credit-limit 弹窗表单 placeholder 统一为「请输入/请选择」不带字段名:option 各列加 placeholderprojectCode/undertakeDeptName/projectFundLimit/usedFundLimit/remainingFundLimit/remark=请输入,validUntil=请选择),视图 formslot 自定义控件(projectName select、applyLimit inputplaceholder 同步改。
## 【项目管理】列表增加「项目资金使用预警」(project-apply
- 需求:列表新增「资金使用风险」列,高风险红色、中风险黄色;列表头部增加「资金使用风险」快速筛选标签(高风险(数)/中风险(数)),点击筛选。规则 tooltip:中风险≥80%、高风险≥90%,使用率=已使用资金/项目资金使用额度×100%。
- 前端(已落地,src/views/business/project-apply.vue + src/option/business/project-apply.js + src/api/business/project-apply.js):
- option/project-apply.js 新增列 `fundUseRisk`(slot:true, hide 列表显示) + 导出 `fundUseRiskOptions`(high/medium/none) 与 `fundUseRiskThreshold`{high:90, medium:80}。
- view 新增 `#header` 插槽:`el-check-tag` 快速筛选(复用 contract-manage 到期标签交互),带数量(fundRiskStats[statKey])与 `?` tooltip 规则;新增 `#fundUseRisk` 插槽渲染彩色 `el-tag`high→type=danger 红,medium→type=warning 黄,无→"-")。
- data 加 fundRiskScope/fundRiskStats{high,medium,none}/fundRiskTagOptionsonLoad 追加 `fundUseRisk` 筛选参数 + 成功后 refreshFundRiskStats;新增 changeFundRiskScope(再次点击取消)、refreshFundRiskStatssearchReset 清空风险筛选。
- api 新增 `getFundRiskStats` → GET /blade-transport/project-apply/fund-risk-stats。
- 后端契约(未改,待确认数据来源):ProjectApplyVO 补齐 `fundUseRisk`(high/medium/none)、`fundUseRiskName``fundUseRate``usedFundLimit`/`remainingFundLimit`;列表 selectProjectApplyPage 填充每行风险 + buildQuery 支持 `fundUseRisk` 过滤;新增 `/fund-risk-stats` 返回 {high,medium,none}。
- ⚠️ 关键阻塞:系统里项目与任何费用表(运输计划/运单/账单/客商)均无关联字段,`usedFundLimit`(已使用资金) 无真实取数来源;临时额度模块的 project-apply/list 返回里 usedFundLimit 实际为空。需业务方确认「已使用资金」口径后才能算风险等级,否则列表风险列全空、统计为 0。
## 合同文件「批量下载」按钮与标题同排(contract-manage.vue 内联 AttachmentSection
- 需求:编辑页「合同文件」分组里「批量下载」按钮目前独立一行、靠右偏上,高度与标题区错位;要放到「合同文件」标题右侧同排、垂直居中。
- 修复:`const AttachmentSection = defineComponent(...)` 内联组件 render() —— 原结构是 `div.dialog-section-title`(标题) 与 `div.contract-manage-form__attachment-head`(批量下载按钮) 两个并列 div;改为把标题与按钮放进同一个 `contract-manage-form__attachment-head` flex 容器(标题左、按钮右)。
- 样式:`:deep(.contract-manage-form__attachment-head)``justify-content:flex-end` 改为 `space-between` + `align-items:center`,并 `.dialog-section-title { margin-bottom:0 }` 抵消标题自身 20px 下边距(2674 行 `:deep(.dialog-section-title)` 会命中子组件渲染的标题)。
- 注意:AttachmentSection 用 render 函数产出 DOM,不带当前组件 scoped data 属性,因此样式必须走 `:deep()`scoped 的 `&__billing-head, &__attachment-head`(2723) 只作用于当前组件模板内(计费信息),对子组件 DOM 无效。
- 影响:4 处使用(编辑/详情 × 合同文件/其它附件)布局统一变为「标题 + 批量下载」同排。
- 主人二次反馈「下面的其它附件也一样」——确认 L463(编辑/其它附件 description) / L765(详情/其它附件 description+readonly) 与 L313(编辑/合同文件) / L671(详情/合同文件) 都走同一份 render+样式,无需新代码;指挥浏览器 Cmd+Shift+R 硬刷即可看到效果(render 函数组件的 :deep() 样式改 HMR 经常不传播)。
## 合同格式下拉改读业务字典 contractFormatcontract-manage.vue
- 需求:新增/编辑合同管理页「合同格式」下拉由硬编码改为读取业务字典 `contractFormat`
- 原实现:`option/business/contract-manage.js``contractFormat``dicData` 硬编码 `[{label:'电子合同',value:'电子合同'},{label:'纸质合同',value:'纸质合同'}]`view 里 `contractFormatOptions` computed 直接 `columnOptions('contractFormat')` 读 column.dicData。
- 修复(4 处):
1. data 加 `contractFormatDictOptions: []`(与 settlementModeDictOptions 并列)。
2. computed `contractFormatOptions` 改为优先 `contractFormatDictOptions`,为空回退 `columnOptions('contractFormat')`(保留静态兜底)。
3. `loadSettlementDictionaries``Promise.all` 追加 `getBizDictionary({ code: 'contractFormat' })`,用 `records()` 映射 `{label:dictValue,value:dictKey}` 填充 `contractFormatDictOptions`;再**条件覆盖** column.dicData(仅当字典非空时),避免字典未配时清掉静态兜底。
4. `detailValue` 的字典映射分支加入 `prop === 'contractFormat'`,详情页「合同格式」也走 `displayStatus` 显示 label。
- 后端契约:`getBizDictionary``GET /blade-system/dict-biz/dictionary?code=contractFormat`,返回 `List<DictBiz>{code,dictKey,dictValue,remark}`DictBizController#dictionary 按 code 精确匹配)。字典 code 按用户口述用驼峰 `contractFormat`(现有 settle_method 为下划线,若后台实际是 `contract_format` 需调整 code)。
- 参考模式:与 `settlementMode`settle_method)完全一致。
## 合同类别 contractCategory 也读业务字典(contract-manage.vue,续上)
- 需求:模板里 label 实为「合同类型」(prop `contractCategory`),下拉由硬编码 `common.js#contractCategoryOptions`(客户合同/承运商合同/补充协议,value 中文)改为读业务字典 `contractCategory`
- 修复(3 处,与 contractFormat 同模式):
1. data 加 `contractCategoryDictOptions: []`
2. computed `contractCategoryOptions` 改为优先 `contractCategoryDictOptions`,空回退 `columnOptions('contractCategory')`(静态兜底)。
3. `loadSettlementDictionaries``Promise.all` 追加 `getBizDictionary({ code: 'contractCategory' })`,映射填充后**条件覆盖** `contractCategory` 列 dicData(仅非空时)。
- 列表页列显示/搜索下拉、详情页(detailValue 已有 `prop==='contractCategory'` 分支走 displayStatus)均因 column.dicData 覆盖而自动跟随业务字典。
- 注意:`config.defaultForm.contractCategory: '客户合同'`option/contract-manage.js)为硬编码中文默认值,若业务字典 dictKey 非中文需同步调整。
## 车辆新增弹窗:所有组织移位 + 使用部门 + 长期有效清空(vehicle.vue + 后端)
- 需求(transportCapacity/vehicle):①所有组织移到「基础车辆信息」区末尾;②新增「使用部门」字段(后端 transport_vehicle 无该字段,需加);③车牌号三段(省/号码/颜色)包成整体输入框+减宽;④勾选「长期有效」清空对应日期且不可编辑。
- 用户确认:所有组织+使用部门放「基础车辆信息」区末尾;使用部门用部门下拉(复用 getDeptTree→organizationOptions,存部门名,仅部门名非级联);车牌号包成整体输入框+减宽;长期有效 4 组全部统一清空(强制报废/行驶证/道路运输证/年审)。
- 前端(vehicle.vue):
1. emptyForm 加 `useDepartment: ''`
2. 删「基础车辆信息」首行的「所有组织」(原 span24),在区末新增一行:`使用部门`(span8, el-select+organizationOptions, 存 rawLabel) + `所有组织`(span8, el-cascader)。organizationOptions 由 initDeptTree 填充。
3. `.plate-group` 由 grid(112px 1fr 112px) 改为 flex 整体输入框:外层 border+radius+focus-within 变化;内部 el-select/el-input 去 box-shadow、无边框,第一/最后 select flex 0 0 30%、中间 input flex 1,子项间用 `::before` 竖线分隔。
4. 4 组 date-picker 加 `:disabled="xxxLongTerm === 1"`(行驶证/道路运输证原缺,补上),checkbox 加 `@change="handleLongTermChange(日期prop, longterm prop)"`;新增方法 handleLongTermChange(勾选时清空日期并 validateField)。
5. formRulesdrivingLicenseEndDate / roadTransportCertEndDate 由 required 改为 validator(新增 validateDrivingLicenseEndDate / validateRoadTransportCertEndDatelongTerm===1 时跳过必填),annualReview 原 validator 已支持。
- 后端(tms-erp-api-ws,新增 use_department 列):
- `TransportVehicle.java``private String useDepartment`
- `blade_transport_vehicle.sql` 建表加 `use_department varchar(50)`
- `TransportVehicleExcel.java``使用部门` 列。
- `TransportVehicleMapper.xml` resultMap + BaseColumn + QueryCondition(useDepartment LIKE) 各加。
- `TransportVehicleServiceImpl.prepare()` trim useDepartmentvalidate() 加长度≤50 校验。
- 新增增量迁移 `doc/sql/transport/blade_transport_vehicle_use_department_patch_20260909.sql`ALTER ADD use_department AFTER organization_name)。
- 注意:VO 继承 entity 无需改;Wrapper BeanUtil 同名透传。已上线库须执行 patch SQL(建表脚本是 DROP+CREATE,勿直接跑)。
- 后续补充(vehicle.vue「车辆资质图片」区输入框对齐):第一行原 span `6/8/10` 不等宽(行驶证档案编号 6 / 行驶证有效期 8 / 道路运输证号 10)导致输入框宽度不齐,统一改为 `8/8/8`,与下方各行 span=8 一致。仅改 2 处 span,无逻辑改动。
## 车辆车牌号:改为单一 input 输入框(vehicle.vue3 段 → 1 段)
- 需求:基础车辆信息区「车牌号」原本是「省简称 select + 号码 input + 车牌颜色 select」三段拼装(plateProvince/plateNoBody/plateColor),改为**单个 el-input** 直接输入完整车牌号(如 京A12345)。
- 改动(vehicle.vue):
1. 模板:`plate-group` 三段 → `<el-input v-model="vehicleForm.plateNo" maxlength="8" show-word-limit placeholder="如:京A12345" @input="handlePlateNoInput" @blur="validatePlateFormat">`。删除 `.plate-group` 样式(含 focus-within、竖线分隔、child flex 占比等整段)。
2. emptyForm:删 `plateProvince``plateNoBody`,保留 `plateNo``plateColor`(后端字段继续存历史车牌颜色)。
3. data:删 `plateProvinceOptions``plateColorOptions` 暴露;常量 `plateProvinceOptions`31 省简称数组)整段删除;`plateColorOptions` 常量删除。
4. validatePlateNo 重写:直接校验 `value`(完整车牌号),正则 `/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼港澳]{1}[A-Z][A-Z0-9挂学警港澳]{5,6}$/`,错误提示改为「请输入标准车牌号(如:京A12345)」。
5. syncPlateNo / splitPlateNo 重写:splitPlateNo 改为 noop(保留以兼容 OCR 后回调链路);syncPlateNo 改为 trim+upperCase 单一字段。
6. handlePlateBodyInput 改名为 handlePlateNoInput,新增 validatePlateFormat 失焦校验。
7. 车辆认证审核弹窗只读详情同步删「车牌颜色」展示(与录入表单一致)。
- 后端不动:`plate_no` / `plate_color` 字段保留(数据库兼容历史车牌颜色),Service 无强制校验。
- 受影响:OCR 自动识别(行驶证/机动车销售统一发票)后续调用链路无需改,因 syncPlateNo 仍对 plateNo 做归一化。
## 车辆新增/编辑弹窗:输入框宽度统一(vehicle.vue,前后两区)
- 需求(transportCapacity/vehicle):新增/编辑弹窗「基础车辆信息」+「车辆资质图片」两个 section 各输入框宽度要一致;截图红框重点为车牌号、外廓尺寸、所有组织/使用部门。
- 根因:基础区原本列宽混乱(车牌号 span10 / 车辆类型 span6 / 外廓尺寸 span6,其余 span6),行内 3 列与 2 列混排;图片区文本字段全 span6,与基础区不一致。
- 改动(vehicle.vue 仅模板 el-col span):
1. 「基础车辆信息」区所有字段统一为 **span=83 列布局)**:车牌号、车辆类型、外廓尺寸、核定载质量、准牵引总质量、业务关系、能源类型、强制报废日期、海关备案号、所有组织、使用部门。
2. 「车辆资质图片」区文本/日期字段(行驶证档案编号、行驶证有效期、道路运输证号、道路运输证有效期、道路运输年审有效期、机动车登记编号、机动车登记日期)统一为 **span=8**
- 不改:只读「车辆认证审核」弹窗(L140 起的 span6 只读详情 + large 图片上传)与新增弹窗内「证件图片」上传控件(固定尺寸)保持 span6。外廓尺寸为复合控件(长/宽/高三 input),口径同 span8。
- ⚠️ 注意:操作中曾误删核定载质量 el-col 标签(453-460 行开始时少 el-col、尾部多一个 `</el-col>`),已修复为完整 `<el-col :span="8">...`
- 后续纠偏(2026-09-09 傍晚):主人反馈「为什么不是按4列显示」——上一步把"基础车辆信息/车辆资质图片"两区误改成 3 列(span8)。同项目惯例 driver.vue/customer-archive.vue 全为 **4 列(span6,仅复合长字段 span12**,故统一重排为 4 列:
- 基础车辆信息:`车牌号(6)+车辆类型(6)+外廓尺寸(12)``核定载质量/准牵引总质量/业务关系/能源类型(4×6)``强制报废日期/海关备案号/所有组织/使用部门(4×6)`
- 车辆资质图片:`行驶证有效期/道路运输证号/道路运输证有效期/道路运输年审有效期(4×6)``机动车登记编号/机动车登记日期/行驶证档案编号(3×6)`,图片上传控件仍 span6
- 该区 span8 已全部移除,编辑表单区仅剩外廓尺寸 span12(复合控件长/宽/高)+ 图片上传 span6。
- 宽度微调(2026-09-09 傍晚2):主人圈出「所有组织」字段宽度与同行不一致。根因:`.vehicle-form` 里的 `:deep(...){width:100%}` 选择器只列了 `el-select / el-input / el-input-number / el-date-editor`**漏了 `el-cascader`**(所有组织用的就是级联选择器,默认不撑满整列)。已补 `:deep(.el-cascader)` 进该规则,全表单仅此一处用到 el-cascader,故所有组织与同行等宽。
## 司机弹窗:补 placeholder + 每行统一 3 列(transportCapacity/driver.vue
- 需求:新增/编辑司机弹窗 ①输入框无提示文案;②基础身份信息 3 列,其它 4 列 / 6 列行不统一。
- 改动(仅 1819 行 driver.vue 的 126-540 行 `<driver-dialog>` 模板,script/style 不动):
1. **基础身份信息**:行结构不动,7 个 el-form-item 控件补 placeholder(司机姓名/身份证号/出生年月/性别/民族/学历/详细地址)。住址已有「请选择省市区」、驾驶车辆已有「请输入或选择车辆」,不动。
2. **驾驶证信息**:第一行 4 列(:span=6)拆为两行 — 行 1(准驾车型 / 档案编号 / 有效期 date-range:span=8+ 行 2(长期有效,:span=8)。起/止 placeholder 保留简短「起」「止」,符合主人 1.B 偏好(保留原 date-range 二联结构,不改 daterange)。
3. **从业资格证信息**:第一行 4 列拆为两行 — 行 1(从业资格认证类型 / 资格证号 / 有效期,:span=8+ 行 2(长期有效,:span=8)。资格证号 placeholder 保留「默认身份证号」不动。
4. **司机联系信息**:一行 6 列拆为两行 — 行 1(司机类型 / 手机号 / 紧急联系人姓名,:span=8)+ 行 2(紧急联系人手机号 / 与联系人关系 / 所属组织,:span=8)。所属组织已有 placeholder,不动。
- 决策点(主人确认):1.B(保留原 date-range 二联 + 第三列留空后拆行的方案);2 保留;3.A(驾驶证主页/副页、封面页/内容页两组上传图仍 :span=12,与占整行宽的原貌一致)。
- 校验:node 脚本统计 4 个 section-card / 13 个 el-row / 31 个 el-col / 24 个 el-form-item,全平衡。改动只在 driver.vue 一文件,~25 处 Edit 调用。
## 新增/修改司机弹窗改为 4 列布局(driver.vue16:xx
- 主人要求 /transportCapacity/driver 新增司机弹窗改 4 列显示。原文件各分组已是 span=6(4 列栅格)但每行只放 3 个字段,第 4 列空置。
- 重排结果(仅删行断 + 移动字段顺序,字段/校验/逻辑零改动):
1. 基础身份信息:行1 司机姓名/身份证号/出生年月/性别;行2 民族/学历/住址/详细地址;行3 驾驶车辆/岗位;行4 三张证件照(不变)。
2. 驾驶证信息:行1 准驾车型/档案编号/有效期/长期有效;上传主页/副页 span 12→6。
3. 从业资格证信息:行1 类型/资格证号/有效期/长期有效;上传封面页/内容页 span 12→6。
4. 司机联系信息:行1 司机类型/手机号/紧急联系人姓名/紧急联系人手机号;行2 与联系人关系/所属组织。
- 注意:此前日志记录的「3 列 span=8」方案已被后续改动覆盖为 span=6,本次以当前文件实际状态为准。
## 客商档案:客商类型→客商分类 + 新增客商类型(customer-archive.vue
- ⚠️ 补救(17:34):data 里的 `customerKindOptions`/`customerKindMap` 定义一度丢失(模板 v-for 空 → 下拉显示「无数据」,主人截图反馈),已补回 datacustomerTypeOptions 之后)。其余 8 处改动经 grep 确认完好。
- 需求:原「客商类型」(prop `customerType`,字典 customer_type:客户/承运商,多选)改名「客商分类」;另新增「客商类型」字段,取值为 外部客商 / 内部组织。
- 约定:prop `customerType` 不动(其它页面 loading-manage / dept / business-crud-page 都以 `customerType:'承运商'` 作为后端查询参数),只改中文标签。
- 新增字段 prop = **`customerKind`**(前端自定义命名,后端尚无对应字段,需后端在客商表加列并透传),值 `external`(外部客商) / `internal`(内部组织),单选下拉,默认 `external`
- 改动点(8 处,全在 `src/views/vehicle/customer-archive.vue`):
1. 表单:227 行 label 改「客商分类」;其后新增一个 `el-col :span="6"` 表单项「客商类型」prop `customerKind`el-select + `customerKindOptions`)。
2. data 新增 `customerKindOptions` / `customerKindMap`external/internal ↔ 中文)。
3. formRules`customerType` 提示文案改「请选择客商分类」。
4. 列表列:「客户类型」label 改「客商分类」;新增「客商类型」列(prop customerKindtype selectdicData 内外部,minWidth 120**未加 search**)。
5. `emptyArchive()``customerKind: 'external'`
6. `normalizeDetail()``customerKind: detail.customerKind || 'external'`
7. 变更记录 `getChangeFieldLabel``customerType:'客商分类'`,新增 `customerKind:'客商类型'`
8. `formatChangeFieldValue`:别名映射加 `客商分类/客户类型 → customerType``客商类型 → customerKind`valueMap 加 `customerKind:{external:'外部客商',internal:'内部组织'}`
- 校验:用 @vue/compiler-sfc 编译模板,0 错误。
## 上传证件项 label 贴顶修复(全局 archive-form 垂直居中提升)
- 症状:driver.vue 司机弹窗「驾驶证主页/副页」「封面页/内容页」等证件上传项(el-form-item 内容高 151pxlabel 文字贴顶,普通 32px 控件行看不出来。
- 根因:`archive-form` class 被 driver / vehicle / ship / credit-score-quantification / loading-manage / waybill-import-dialog 等 8+ 页面复用,但「label 与控件垂直居中」规则 `.archive-form :deep(.el-form-item){align-items:center}` 锁在 customer-archive.vue 的 scoped 样式里,其它页面拿不到;flex 容器默认 stretchlabel(32px) 在高控件行内贴顶。
- 修复:规则提升到 `src/styles/element-ui.scss` 全局(紧跟 is-required 星号配套规则之后):`.archive-form .el-form-item { align-items:center }`。全站 archive-form 表单一并生效,label 与控件垂直居中。SCSS 编译通过。
## 司机弹窗「驾驶证信息」:有效期必填、长期有效不必填(transportCapacity/driver.vue
- 需求:驾驶证信息区「有效期」(起/止,prop `drivingLicenseEndDate`)要必填(显示红*),「长期有效」复选框(prop `drivingLicenseLongTerm`)不必填(去掉红*)。
- 改动(formRules 两处):
1. `drivingLicenseEndDate` 规则加 `required: true``{ required: true, validator: this.validateDrivingLicenseEndDate, trigger: 'change' }`)——仅用于渲染红*,实际校验仍走自定义 validator。
2. 删除 `drivingLicenseLongTerm``{ required: true, message: '请选择驾驶证是否长期有效' }` 规则块。
- 关键机制(Element Plus 2.14 + async-validator 4.2.5 已核实):
- 红* 由 `form-item``isRequired = normalizedRules.some(rule => rule.required)` 决定,只要规则对象带 `required` 就显示。
- async-validator 的 `rule.validate` 中,若存在 `rule.validator`,直接走自定义 validator 并 return**不会**再执行 required 空值检查 → 勾「长期有效」时止日被清空/disabled 也不会误报必填。
- 校验逻辑保持原样:起始日期恒必填;`longTerm !== 1` 时止日必填。
- 未同步:「从业资格证信息」区仍是「有效期无红* + 长期有效有红*」的旧形态(待主人确认是否一并统一)。
## 司机弹窗 placeholder 统一为「请输入/请选择」(transportCapacity/driver.vue
- 需求:新增/修改司机弹窗输入框 placeholder 去掉后面的字段名称,只保留「请输入」「请选择」。
- 改动:18 处 placeholder 简化(7 处 → `请输入`10 处 → `请选择`1 处动态 cascader `driverForm.addressRegion || '请选择省市区'``|| '请选择'`)。
- **保留 4 处**(主人明确确认):
1. 驾驶车辆 `请输入或选择车辆` —— 复合操作(既可输入又可点按钮选车),去掉会丢语义。
2. 日期范围 `起` / `止` —— 起止标记,非字段提示。
3. 资格证号 `默认身份证号` —— 默认值说明,非「请输入」格式。
- 未动:表单**校验错误提示 message 仍带字段名**(如「请选择准驾车型」),因报错需指明具体字段,与 placeholder 定位不同;主人未要求,暂保持。
- 做法:Python 脚本按完整 placeholder 字符串精确替换(18 规则全命中,未匹配即 exit 1 中断),比逐个 Edit 高效且不会误伤。
- 校验:`@vue/compiler-sfc` parse + compileTemplate + compileScript 三项全通过。注意脚本必须放在**项目根目录**下运行(放 /tmp 解析不到 node_modules 里的 @vue/compiler-sfc)。
- ⚠️ git diff 显示的 `drivingLicenseLongTerm` 校验规则变动是**本次之前工作区已有的未提交改动**(见上一条日志),非本次引入,勿混淆。
## 「自动生成结算单」开关加 ? 悬浮提示(3 处)
- 需求:开关「开启」后面加问号图标,hover 提示「开启时,系统根据配置规则归集运单,定时生成结算单」。
- 改动文件(同一文案,为保持全站一致):
1. `src/views/business/components/business-crud-page.vue`1884 行附近,`.business-crud-page__settlement-switch`
2. `src/views/business/contract-manage.vue`357 行附近,`.contract-manage-form__settlement-switch`
3. `src/views/business/contract-manage-change.vue`55 行,`.settlement-switch`,原为单行长标签已拆行)
- 写法:`<el-radio :label="1">开启</el-radio>` 后插 `<el-tooltip content="..." placement="top"><el-icon class="settlement-switch-tip"><QuestionFilled /></el-icon></el-tooltip>`,再接「关闭」radio。
- 样式:`.settlement-switch-tip { margin:0 4px; color:#a8abb2; cursor:help; font-size:14px }`,各文件 scoped 样式内一份(contract-manage 写在 `&__settlement-switch` 内)。
- 注意:图标走 main.js 全局注册(@element-plus/icons-vue 全量注册),无需 import;写法与 `project-apply.vue``__label-tip` 一致。
- 这三个大文件本身已存在 prettier 格式问题(改动前 git HEAD 版本同样 fail),非本次引入。
## 车辆认证审核弹窗排版对齐新增弹窗(vehicle.vue
- 诊断 `/project-apply/fund-risk-stats` No endpoint:前端已定义 getFundRiskStatsapi/business/project-apply.js:13),后端未实现;方案已给(后端补 R<{high,medium}> / 前端 catch 降级),待主人选。
- vehicle.vue 认证审核弹窗重排对齐新增弹窗:取消独立「尺寸重量」卡片,外廓尺寸改长/宽/高组合(span12dimension-group);基础信息 3 行×4 列(车牌号/车辆类型/外廓尺寸;载质量/牵引质量/业务关系/能源类型;强制报废日期/海关备案号/所有组织/使用部门);证件信息按新增「车辆资质图片」行分布(止+运输证号+止+年审 / 登记编号+登记日期+档案编号+行驶证起 / 运输证起 / 备注整行);label「所属组织」改「所有组织」对齐新增;dimension-group 样式复制进 .certification-audit 作用域。@vue/compiler-sfc 编译校验通过。
- vehicle.vue 行驶证有效期必填星号:原规则是纯 validatorElement Plus 不渲染星号),改为 data 内 baseRules + computed formRulesdrivingLicenseEndDate 动态返回 required 规则(长期有效=1 时返回 [],星号与校验同步消失);删除 validateDrivingLicenseEndDate。经验:Element Plus 星号只在规则含 required:true 时渲染,且 el-form-item 的 :required 属性会被注入为 {required} 规则并产生英文默认提示,不宜用它控制星号。
- 备注:车辆保存的「车牌颜色不能为空」「行驶证有效期:必填」均为后端校验(前端无对应规则),需后端改。
- driver.vue 从业资格证有效期对齐驾驶证:单日期改 date-range 起/止(列宽 6+6+6+6 → 6+6+8+4),新增 qualificationStartDate 字段(emptyForm + handleSubmit normalize);qualificationEndDate 规则加 required:true(渲染星号,validator 豁免长期);删除 qualificationLongTerm 的 required 规则(长期有效不必填);勾长期后起止均 disabled(比驾驶证更严,驾驶证起仍可编辑)。⚠️ 后端需新增 qualification_start_date 字段否则起日期无法存取。
- driver.vue 补充:驾驶证「有效期起」加 :disabled(长期=1 时禁用);驾驶证与从业资格证两个 validator 统一在「长期有效=1」时直接 callback() 放行(起止都不校验、不提示)。禁用不清空旧值,提交时保留原日期(后端以 longTerm 为准)。
- contract-manage-change.vue「合同文件」批量下载按钮上移:标题与按钮合并进 .section-headflex space-between 同排),与「其它附件/计费信息」分区一致;删除无引用的 .attachment-head 样式。
- contract-manage-change.vue「自动生成结算单」问号提示图标位置:原 icon 夹在开启/关闭 radio 之间,因 .el-radio 默认 margin-right:32px 导致离开启远、贴关闭;修复 = 开启 radio margin-right 清零 + icon margin 改 0 32px 0 8px。
+6
View File
@@ -0,0 +1,6 @@
# 2026-09-10
- 移除 project-apply 的 fund-risk-stats 请求(后端未实现端点,每次列表加载都弹 No endpoint 报错):删除 onLoad 内 refreshFundRiskStats 调用与该方法、data 中 fundRiskStats;头部风险标签去掉「(数量)」只留「高风险/中风险」;api/business/project-apply.js 保留 getFundRiskStats 定义并注释「后端就绪后恢复」。
EOF2
}
cat >> /Users/gxwebsoft/VUE/tms-erp-web-ws/.workbuddy/memory/2026-09-10.md <<'EOF'
+28
View File
@@ -0,0 +1,28 @@
# 2026-09-11
## 上传证件区域固定宽度导致跨机器错位(transportCapacity
- 现象:driver.vue 新增弹窗的身份证/大头照上传框在本机对齐,他人电脑不对齐。
- 根因:`--large` 上传框写死 `width:240px`ship 写 248px),而弹窗 `width:90%` + `el-col :span=6` 列宽是弹性的、上方输入框 `width:100%`;列宽随分辨率 / Windows 系统缩放 / 浏览器缩放变化,240px 只在特定宽度下恰好等于列宽。叠加 `labelWidth:auto` 靠 JS 实测字体(苹方 vs 微软雅黑)宽度,label 列宽也不同 → 起始位置漂移。
- 修复:三个文件改为 `width:100%; max-width:240px; flex:none`ship 保留 248px 上限),窄屏跟随列宽、宽屏保持比例上限,左边缘恒与输入框对齐。
- `src/views/transportCapacity/driver.vue`.driver-uploader--large
- `src/views/transportCapacity/vehicle.vue`.vehicle-uploader--large
- `src/views/transportCapacity/ship.vue`.ship-uploader--large
-@vue/compiler-sfc(脚本放项目根目录)验证三个 SFC 编译通过。
- 教训:本项目所有「固定 px 宽度的上传/控件」都要配 `max-width` + `width:100%`,否则高分屏/低分屏下必然错位。
## 合同详情由弹窗改为独立页面(contract-manage
- 路由:新增 `/business/contract-manage/detail``src/router/views/index.js`name『合同详情』,meta `keepAlive:false` + `activeMenu:'/business/contract-manage'`),组件仍复用 `contract-manage.vue`(与 form 页同一套路)。
- 页面内三分支:`v-if="!isFormPage && !isDetailPage"`(列表)/ `v-else-if="isFormPage"`(表单)/ `v-else`(详情)。**v-else 必须紧邻 form 分支的 `</template>`**,中间不能插其它组件,否则 Vue 编译报「v-else 无相邻 v-if」——弹窗类组件(billing-plan-editor / 附件预览 / 变更记录详情 / 流程)放在三个分支之后。
- 关键改动:`openDetail``router.push({ path:'/business/contract-manage/detail', query:{ id, name } })`;新增 `initDetailPage()`(按 query.id 拉详情,`resetDetailState()` 先清空)、`closeDetailPage()`closeTag + 回列表)、`isDetailPage` / `detailPageTitle` computed`created``$route.fullPath` watch 都要加 `else if (this.isDetailPage)`
- 样式:`.contract-manage-detail--page { max-height:none; overflow:visible }`,去掉弹窗的 74vh 限制;底栏复用 `.contract-manage-page__footer`(fixed 浮动),只读详情页按钮保留「关闭」。
- 校验方式:dev server 已运行时 `curl "http://localhost:2889/src/views/business/contract-manage.vue"` 返回 200 即 Vite 编译通过(比整包 build 快)。
## 只给「查看弹窗」加宽 labeltemporary-credit-limit
- 场景:详情页长 label(「剩余项目资金使用额度(万元)」)换行超 2 行撑坏详情表格,但又不能影响新增/编辑页。
- **关键发现:Avue 的 `avue-form` 在 detail 模式下根节点会挂 `.avue--detail` 类**(源码 `node_modules/@smallwei/avue/lib/packages/element-plus/form/index3.js``class: normalizeClass([b(), { 'avue--detail': isDetail }])`)。所以「只针对详情态」的选择器就是:
```scss
.temporary-credit-limit-dialog .avue--detail .el-form-item__label { width:210px !important; flex:0 0 210px !important; }
```
新增/编辑弹窗无 `.avue--detail`,天然不受影响,不需要在 option 里做 mode 判断。
- 校验样式块编译:dev server 请求 `?vue&type=style&index=1&lang.scss`**lang 必须写 scss,写 css 会 500**),grep 规则确认产物。
- 结论备忘:长 label 优先「加宽 + 换行 ≤2 行」,不要用单行省略 + tooltip —— 详情是纯阅读场景,hover 才能看全字段名的可发现性差。
+63
View File
@@ -0,0 +1,63 @@
# 2026-09-17 工作日志
## 配载管理(loading-manage):弹窗全部改为独立表单页/详情页
- 需求:`/business/loading-manage` 不再用弹窗,改成与新增合同(`/business/contract-manage/form?mode=add&name=新增合同管理`)一致的独立页面。
- 现状:新增/编辑**已经**是独立页(组件内 `isStandaloneFormPage` + `/business/loading-manage/form` 路由),但「详情」和「重新派单」仍是 el-dialog(用户截图即详情弹窗)。
- 改动(3 个文件):
1. `src/router/views/index.js`:新增 `/business/loading-manage/detail`name 配载单详情,`meta.keepAlive:false, activeMenu:'/business/loading-manage'`),组件仍是 `loading-manage.vue`
2. `src/views/business/loading-manage.vue`
- computed 新增 `isStandaloneDetailPage`path==='/business/loading-manage/detail')、`isStandalonePage`form|detail 并集,作为隐藏列表容器的统一开关)、`detailPageTitle``formPageDefaultTitle`add/edit/reassign 映射);`isStandaloneFormPage` 放行 `mode=reassign`
- 模板中所有控制「是否独立页/是否弹窗」的 `isStandaloneFormPage` 改为 `isStandalonePage`;顶部标题按详情/表单取 `detailPageTitle`/`formPageTitle`
- 新增 `gotoLoadingDetail(row)`push `/detail?id=&name=配载单详情`,替换「配载单号」链接与操作列「详情」)、`gotoLoadingReassign(row)`push `/form?mode=reassign&id=&name=重新派单`)、`initStandaloneDetailPage()``initStandaloneFormPage` 模式透传 edit/reassign`openLoadingDialog` 对 add/edit/reassign 一律转独立页;`closeLoadingDialog` 判据改 `isStandalonePage`
- `mounted` / `watch $route` 增加详情页分支(直接打开与页内切换都要 init);detail 分支除 id 变化外,`dialogMode !== 'view'` 也重新 init(防止从表单页切详情时残留表单态)。
- 兼容旧链接:`/business/loading-manage?detailId=x` 仍可用,会 replace 到详情页。
- 样式:底栏去掉写死的 `gap:8px`(全站统一 12px);页面模式的内容区解除 `max-height:78vh` 内嵌滚动盒 —— 原 `:global(.loading-manage-form-page .loading-manage-dialog__body)` 权重 (0,2,0) 低于 scoped 规则 (0,3,0) 一直没生效,改为 `.loading-manage-page .loading-manage-form-dialog.loading-manage-form-page .loading-manage-dialog__body` (0,4,0),内容改随 `#avue-view` 自然滚动(与合同页一致)。
3. `src/views/business/components/waybill-manage-page.vue``openLoadingDetail``/business/loading-manage?detailId=` 改为 push `/business/loading-manage/detail?id=&name=配载单详情`
- 校验:dev server2889)单独编译 4 个模块均 HTTP 200(含 scoped scss);`vite build` 仍会在无关文件 `src/page/login/facelogin.vue` 被沙箱敏感内容保护中断。
- 踩坑:**`<style scoped lang="scss">` 顶层写 `//` 注释**会让该样式模块编译 500`Unexpected '/'. Escaping special characters with \ may help.`),已实测确认;顶层必须用 `/* */`
- 未做:登录态无法在浏览器实测(无测试账号),需用户点 `/business/loading-manage` → 详情/重新派单 自测;`voucher-manage-detail` 跳配载时带的 `?loadingNo=` 参数本来就未被列表消费(历史遗留)。
## 运单管理详情:打卡时间轴改为按打卡顺序展示
- 文件:`src/views/business/components/waybill-manage-page.vue`(详情页「执行详情 → 打卡详情」tab)
- 需求:时间轴不再固定按流程节点顺序(发货/在途/到货/卸货/签收/回单),改为按打卡时间先后展示。
- 实现(不改后端、不改原始数据):
1. 新增 computed `orderedWaybillPunchRecords`:映射出 `punchedAt`(毫秒时间戳)后排序 —— **已打卡记录按打卡时间升序排在前**,未打卡记录保持后端返回的节点顺序排在其后;时间相同用原索引稳定排序。
2. 新增 method `waybillPunchRecordTimestamp(record)``punched === false``statusName === '未打卡'` 直接返回 null(防止后端返回计划时间造成误序);优先 `this.$dayjs(text).valueOf()`,失败兜底 `new Date(text.replace(/-/g,'/'))`(兼容 Safari)。
3. 模板 `el-timeline``v-for` 改用 `orderedWaybillPunchRecords``key``record.nodeCode || record.id || index` 改为 `${nodeCode||id||'punch'}-${index}`,避免同节点多次打卡(在途)时 key 重复。
- 校验:项目根目录临时脚本 `_verify-sfc.mjs`@vue/compiler-sfc parse + compileScript + compileTemplate)通过,已删除脚本。
- 注意:`getPunchRecords``src/api/business/waybill-manage.js``/punch-records`)后端未提供节点排序字段,排序只能在前端做。
## 保险记录批量导入:前端归一化日期格式
- 文件:`src/views/vehicle/insurance-record.vue`(仅此一个文件)
- 背景:保险记录批量导入是**后端驱动**——前端 `handleImport``importBlob` 把原始 Excel 直接 POST 到 `/blade-transport/insurance-record/import-insurance-record`,前端不解析行。后端只认 `2026-09-01``2026-9-1` 导入失败。
- 决策:用户选择**前端归一化**(不动后端)。把 `openImportDialog` 改为自写 `httpRequest`:用 `xlsx` 读取 Excel → 把日期列(开始日期/结束日期/开票日期)`2026-9-1` 归一为 `2026-09-01` → 重写成新 File 再交给 `handleImportExcel` 上传;复用其失败明细下载与成功刷新逻辑。无日期列时回退为原文件(行为不变)。
- 关键方法:`normalizeExcelDates` / `readExcelRows` / `writeExcelFile` / `normalizeDateString`(正则 `^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?$`,用 `new Date` 校验合法性后补零;非法/已标准格式返回 null 保持原值)。
- 校验:本机 devlocalhost:2889)待用户部署自测。注意所有单元格经 `sheet_to_json(raw:false)` 后均为字符串,与 transport-plan-import 的客户端解析模式一致。
## 车/船务模块导出 xlsx「创建时间/更新时间」为空(后端 tms-api 修复)
- 现象:`/vehicle/insurance-record` 批量导出的 xlsx 里「创建时间」「更新时间」两列空白,「更新人」有值。用户要求车/船务模块所有页面统一修。
- 定位:导出由**后端**生成(前端只传查询参数,`exportColumns` 参数后端未使用)。实测 `~/Downloads/保险记录2026-09-17 20_46_08.xlsx`K/M 列单元格无 `<v>` 节点(值 null),E/F/J 日期列是数值 46266.0(带 numFmt `yyyy-MM-dd`WPS 显示为日期)。
- 根因:Service 用 BladeX `org.springblade.core.tool.utils.BeanUtil`**继承 Spring `BeanUtils`**)做 `copyProperties`。实体 `TenantEntity.createTime/updateTime``java.util.Date`,导出类字段是 `java.time.LocalDateTime` → Spring BeanUtils **类型不兼容静默跳过**,值为 null。而「更新人」是手动 `UserCache.getUserRealName(...)` 赋值的,所以有值。(Hutool 的 BeanUtil 能转 Date→LocalDateTime,但本项目用的是 BladeX 版本,不能。)
- 佐证:项目里正常的导出(Waybill / LoadingManage / CustomerArchive / CommonCargo)时间字段一律用 `java.util.Date`,并在 Service 里手动 `excel.setCreateTime(record.getCreateTime())`
- 修复(后端仓库 `/Users/gxwebsoft/JAVA/tms-api`blade-transport 模块):在 13 个导出方法里补显式赋值(Date→LocalDateTime 用 BladeX `DateUtil.fromDate``Func.isEmpty` 判空),不改 Excel 类字段类型(避免影响自定义 converter 与导入模板)。
```java
excel.setCreateTime(Func.isEmpty(x.getCreateTime()) ? null : DateUtil.fromDate(x.getCreateTime()));
excel.setUpdateTime(Func.isEmpty(x.getUpdateTime()) ? null : DateUtil.fromDate(x.getUpdateTime()));
```
- 涉及文件(13 个 Service + 1 新建 Excel 类 + 设备台账 controller/接口):InsuranceRecord / ViolationRecord / MaintenanceRecord / MaintenancePlan / TireReplacementRecord / AccidentRecord / AnnualInspectionRecord / MileageRecord / TransportChangeRecord / EtcRecord / OilElectricRecord / OtherExpenseRecord / EquipmentLedger。
- 设备台账特殊:原 `EquipmentLedgerExcel` 根本没有审计列(前端列表有),新建 `EquipmentLedgerExportExcel extends EquipmentLedgerExcel`(创建时间/更新人/更新时间),导出链路改用新类,**导入模板仍用旧类不受影响**。
- 未验证:本机无 mvn(仅 IDEA 内置),且离线模式缺 `blade-bom` 无法编译;用 javap 确认 `DateUtil.fromDate(Date)` 存在 + 逐行 grep 复核代替。需用户在 IDEA 里编译并重新部署后自测。
- 同类隐患(本次未改,其他模块):TemporaryCreditLimit、ProcessConfig、CommonRoute、CommonAddress、ContractManage、ProjectApply、ShippingTemplate 的导出 Excel 也是 LocalDateTime 且未手动 set 时间。
## 保险记录保额 -1 问题排查(未结案,待用户验证 DB 环境)
- 症状:新增弹窗保额不填 → 再开编辑弹窗显示 -1,触发「不能小于 0」校验。
- 已排除:前端(源码/dist 构建/git 全历史)、后端 tms-api + tms-erp-api-ws(源码/编译产物 javap/git 历史)均无任何 -1 赋值逻辑;保额为 formslot el-input,空值传 ''。
- 关键链路:编辑弹窗 beforeOpen 走 `/detail` 接口(getOne 原生查询),**不经过**列表 SQL 的 `CASE WHEN insured_amount<0 THEN 0` 兜底——所以表格显示 0、弹窗显示原始值。
- 数据库截图(用户 Navicat):该行 insured_amount=NULL,全表无负数 → 说明用户浏览的库与后端实际连接的库**不是同一个/DDL 不一致**。系统存在「空数值=-1」惯例(MaintenanceRecordMapper 里程 `CASE WHEN mileage=-1 THEN NULL`;前端 waybill-manage.js/process-config.js 均有 `Number(v)===-1?'':v` 掩码)→ 后端真实库的 insured_amount 列默认值很可能是 -1,MP 插入排除 null 列 → 默认值 -1 入库。
- 待验证:在后端 Nacos 数据源指向的库执行 `SHOW CREATE TABLE blade_insurance_record` + `SELECT insured_amount FROM blade_insurance_record WHERE policy_no='11111' AND vehicle_no='沪AE31312'`。
- 修复方案(待确认):1) ALTER 列默认值改 NULL + UPDATE 存量负数为 NULL2) 前端 beforeOpen 对 insuredAmount/premium 做 null/-1 → '' 归一(参照 waybill-manage normalizeNumericDisplayValue);3) 列表 SQL 的 CASE 兜底与 detail 不一致,可顺手统一。
+122
View File
@@ -0,0 +1,122 @@
# 2026-09-18
## 修复:改成独立整页后点左侧菜单仍弹出旧弹窗(loading-manage / project-apply
**现象**:配载单管理、项目管理已改为「新增/编辑/详情走独立整页」,但从独立页返回或直接点左侧菜单时,页面上仍会盖出一个老的 `el-dialog`(新增项目管理 / 配载单)。
**根因**(已读 Vue runtime-core 源码确认):
- `src/router/tab.js``tabKey`(默认 fullPath)为每个标签建一个具名 wrapper 组件放进 `wrapperMap``src/page/index/layout.vue``<keep-alive :include="$store.getters.tagsKeep">` 缓存 —— 每个标签页一个实例,全部缓存不销毁。
- 组件被 deactivate 后**仍会随 `$route`(全局响应式)重新渲染**;此时 `isStandalonePage` 变 false`<component :is="isStandalonePage ? 'div' : 'el-dialog'">` 由 div 翻回 `el-dialog`,而 `v-model``projectBox`/`dialogVisible`)仍是 true`append-to-body` 使弹窗 Teleport 到 body。
- `KeepAlive.deactivate``move(vnode, storageContainer, ..., moveType=OUT)`,但 `TeleportImpl.move``moveTeleport`)忽略传入的 moveType、一律按 REORDER 处理 → **teleport 出去的 DOM 不会被搬回 storage container**,永久残留在 `document.body` 上并显示出来。
**修法**(不回退 tab.js 的 `return wrapper` 提交 c1c4fa1,改修根因):
- 独立页容器固定为 `<div v-if="isXxxPage">`,不再按路由回退成 `el-dialog`
- 页内其它 `append-to-body` 二级弹窗加 `deactivated()` / `beforeUnmount()``closeInnerDialogs()` 把 v-model 置 false。
**改动文件**
- `src/views/business/loading-manage.vue`:容器改 `<div v-if="isStandalonePage">`;删 `dialogVisible``dialogTitle``resetLoadingDialog``clearLoadingDialog`、页脚弹窗分支(含「清空」);`watch.$route` 简化;新增 `closeInnerDialogs()`routeChange / commonAddress / candidateSearch+ `deactivated`/`beforeUnmount`
- `src/views/business/project-apply.vue`:容器改 `<div v-if="isProjectFormPage">`;删 `projectFormContainer`/`projectFormContainerProps``projectBox``resetProjectDialog``handleCancelProject``closeProjectForm` 简化为直接 push 列表;新增 `closeInnerDialogs()`changeRecordDetail / 附件文档预览 / 图片预览 / 选人)+ `deactivated`/`beforeUnmount`
**校验**dev server(2889) curl 两个 .vue 及其 scoped style 模块,均 HTTP 200500 才是编译失败)。`vite build` 因沙箱敏感内容保护会在 facelogin.vue 中断,未用。
**遗留同类风险(未改,已告知用户)**`business/components/waybill-manage-page.vue``detailContainer` 在 PageDetail / el-dialog 间切)、`settlement/components/{pre,formal}-settlement-editor.vue``transport-reconciliation-editor.vue``pageMode ? 'div':'el-dialog'`)、`business/components/waybill-import-dialog.vue``standalone`/`createPage`)。统一修法:容器类型在 `created()` 锁定,不随路由/prop 翻转。
---
## 追加修复:运单详情弹窗残留(waybill-manage-page.vue
用户复现路径:打开 `/business/waybill-manage/detail?id=xxx` → 点「配载管理」菜单 → 页面上又冒出运单详情弹窗。与上面同一根因:`detailContainer()``isStandaloneWaybillDetailPage` 在 PageDetail / el-dialog 间翻转,`:append-to-body="!isStandaloneWaybillDetailPage"` 随之为 true`v-model="detailBox"` 仍 true → Teleport 进 body 搬不回来。
**改动**`src/views/business/components/waybill-manage-page.vue`):
- data 新增三个锁定标志 `formPageLocked` / `detailPageLocked` / `formModeLocked`,在 `created()` 一次性从 `isStandaloneWaybillFormPage` / `isStandaloneWaybillDetailPage` / `$route.query.mode` 取值。
- 模板里所有形态判定(根 class、`v-if="!detailPageLocked"` 的列表容器、`form-page-title``status`、独立表单页按钮块、分页 `v-show``:append-to-body`、detail-page class、内容 `v-if="detailBox || detailPageLocked"`、独立页 footer / 弹窗 footer slot)全部改读锁定标志。
- `detailContainer()` / `crudContainer()` / `pageFormOption()`(含 `boxType`)改读锁定标志,避免缓存实例的 option 变形。
- 新增 `closeInnerDialogs()`(关 detailBox、里程补录、附件文档/图片预览、变更路线、常用地址、流程图、运输路线/地址/站点/地图、常用货物、货物导入、过程配置、Excel 导入)+ `deactivated()` / `beforeUnmount()`
**关键验证**`onDeactivated` 在子组件里也会触发 —— apiLifecycle 的 `injectHook` 会向上遍历父链,发现 KeepAlive 父级就把钩子注入到根实例(runtime-core 的 `injectToKeepAliveRoot`),所以写在子组件 waybill-manage-page 上的 `deactivated` 有效。
**校验**dev server curl 该 .vue 与 scoped style 均 200。仍未 commit。
**未提交**:上述改动 + 上一轮 loading-manage 独立页改造均未 commit。
---
## 排查(未改码):配载单详情页反复弹「运单管理不存在」
用户截图:打开 `/business/loading-manage/detail?id=2097607162718441474&name=配载单详情`,连续弹出 6-7 条红色「运单管理不存在」,标签栏堆了一串「运单管理详情」。
**文案来源**:前端 src 里没有这句话,是后端 `BusinessException` 的 msg,被 `src/axios.js` 的响应拦截器(`status !== 200``ElMessage.error(message)`)统一弹出。
**主因(通道 A**`views/business/waybill-manage-detail.vue` 只有 23 行,写了 `:detail-id="$route.query.id"` —— 直接绑全局 `$route`**没有路径守卫**。`waybill-manage-page.vue``detailId` watcher 是 `immediate` 且对任何非空 id 都 `openDetail({id})`。于是:
1. 之前打开过的每个运单详情标签(`/business/waybill-manage/detail?id=A``?id=B`…)各是一个被 keep-alive 缓存的独立实例;
2. 跳到配载单详情后,这些实例被 deactivate 但仍随 `$route` 重渲染 → `$route.query.id` 变成配载单 id
3. prop 变化 → `openDetail({id: 配载单id})``getDetail(配载单id)` 去查运单 → 后端「运单管理不存在」→ 拦截器弹一次。**几个旧标签就几条提示**;
4. 且被污染实例的 `isStandaloneWaybillDetailPage` 仍是 live computed(false) → `openDetail``$router.push` 分支 → 又生成新的「运单管理详情」标签(标签栏那串的来源)。
同理风险:`waybill-manage.vue:6``transport-plan.vue:6``:detail-id="$route.query.detailId"``business-crud-page.vue:7356` 会 push `/business/loading-manage?detailId=id`,正好会污染运单列表实例)。
**次因(通道 B**`loading-manage.vue``restoreWaybillRows` 会用 `waybillIdsJson` 里的每个运单 id 并发 `getWaybillDetail(id)`,若历史运单已被删/失效,也会报同一句文案且并发多条。单条 `.catch(() => null)` 挡不住拦截器已弹出的提示。
**区分方法**Network 里看报错请求的 `id` —— 等于 `2097607162718441474`(配载单 id)→ 通道 A;是别的数字(运单 id)→ 通道 B。或先关掉所有「运单管理详情」标签再复现,通道 A 会消失。
**待确认的修法**(用户要求先报不改):
1. `waybill-manage-page.vue``created()``routePathLocked = this.$route.path``detailId` watcher 首行加 `if (this.$route.path !== this.routePathLocked) return;`
2. `openDetail()` 的 push 分支加同样守卫(防其它调用路径)。
3. 宿主 view 绑定加路径守卫(`waybill-manage-detail.vue` / `waybill-manage.vue` / `transport-plan.vue`)双保险。
4. 可选:`getWaybillDetail` 支持静默模式,供 `restoreWaybillRows` 用,避免历史脏数据刷屏。
## 修复(已执行):「运单管理不存在」误报 + 误开标签
用户确认后按上方案落地,5 个文件:
1. `src/views/business/components/waybill-manage-page.vue`
- `data()` 新增 `routePathLocked: this.$route.path` —— **必须放 data,不能放 created**Vue Options API 顺序是 data → computed → watch(immediate) → created(已在 runtime-core `applyOptions` 源码中核实)。放 created 会让首个 immediate watcher 读到空值。
- 新增 computed `isOwnedRouteActive() { return this.$route.path === this.routePathLocked; }`
- `detailId` watcher 首行加 `if (!this.isOwnedRouteActive) return;`
- `openDetail``$router.push` 分支同样加守卫。
2. `src/views/business/components/transport-plan-page.vue`:完全同构的写法(`:detail-id` host 一致),加同样的 `routePathLocked` + `isOwnedRouteActive` + watcher 守卫。
3. `src/views/business/waybill-manage-detail.vue``:detail-id="$route.query.id"``:detail-id="detailId"`computed 做 `this.$route.path === '/business/waybill-manage/detail'` 守卫。
4. `src/views/business/waybill-manage.vue`:同上,守卫 `/business/waybill-manage`(保留旧链接 `?detailId=` 兼容)。
5. `src/views/business/transport-plan.vue`:同上,守卫 `/business/transport-plan`
**验证**
- dev server curl 5 个 .vue + 2 个 scoped style 全部 HTTP 200。
-`@vue/server-renderer` 写了临时 mjs(已删)模拟带 `$route` 的 app,确认 **data() 里访问 `this.$route` 可行**immediate watcher 触发时 `routePathLocked` 已是正确路径、首次加载不被拦截。
- `$route` 是 vue-router 挂在 `app.config.globalProperties` 上的 gettervue-router.mjs:1498),组件实例化时已就绪。
**未做(第 4 条可选)**:给 `getWaybillDetail` 加静默模式供 `restoreWaybillRows` 使用(历史失效运单仍会刷多条红字),待用户定夺。
**另注**`business-crud-page.vue:6651` 有同构的 `detailId` immediate watcher,但全项目只有上述 3 个宿主 view 传 `:detail-id`,它暂无调用方,未动。
**仍未 commit。**
## 修复(已执行):合同详情页整页空白 —— 路由表重复定义
**现象**:主订单详情点合同编号 → `/business/contract-manage/detail?id=...&name=合同详情` 内容区空白(侧栏/标签正常)。
**定位过程(关键:真实浏览器复现,不猜)**
1. 静态排查全部排除:`contract-manage.vue` 及其 26 个依赖 HTTP 200、模块 `import()` 成功、无循环依赖、详情分支引用的 24 个成员全部有定义。
2. dev 环境登录态无法直接拿(验证码 + `admin/admin``error_code 2004 用户密码强度过低`),改为**注入式复现**
- `agent-browser open <目标 URL>`(落 `/login`);
- eval 取 `document.getElementById('app').__vue_app__.config.globalProperties``$router` / `$store`
- 写 cookie `saber3-access-token`(注意 token 走 **js-cookie**,不是 localStorage+ `$store.state.user.token`
- 覆写 `XMLHttpRequest.prototype.open/send` 让所有 `/api/**` 返回 `{code:200,data:{...}}`defineProperty 伪造 readyState/status/responseText 后手动触发 `onloadend`)。
-`$router.push(...)` 做 SPA 跳转。
3. 复现出空白,并拿到决定性证据:
- `/business/contract-manage/detail``$route.matched.length === 1`(只有 Layoutname undefined),`#avue-view.children.length === 0`innerHTML 为 `<!---->`
- 对照 `/business/contract-manage/change`(结构相同但未重复定义)→ `matched.length === 2`(Layout + 合同变更),内容完整渲染。
- `$router.getRoutes()` 里 detail 有 **3 条**记录:2 个 Layout 父记录 + 1 个子记录(`合同详情`)。
**根因**`src/router/views/index.js``/business/contract-manage/detail` **被复制粘贴定义了两次**,两次的子路由都叫 `合同详情`。vue-router 的 `addRoute` 遇到重名会 `removeRoute(name)` 把先注册的子记录删掉,但**不会把它从父记录的 `children` 数组里摘除**;于是第一条 Layout 壳记录留在 matcher 列表里且排在前面,`resolve('/business/contract-manage/detail')` 命中这个"无有效子路由"的壳 → matched 只有 1 层 → `page/index/index.vue``#avue-view` 的二级 `<router-view>` 无匹配 → 内容区空白。
`getRoutes()` 计数是判据:form / change 都是 2 条 = 父 + 子(健康),detail 是 3 条 = 2 父 + 1 子(坏)。)
**修复**:删除重复块(原 314-325 行),只保留 159-169 行那一份。
**验证**
- 重复扫描脚本(正则匹配「顶层 path + `component: Layout` + 首个子路由 name」):views/index.js 27 条 Layout 子路由中仅 `合同详情` 重复;page/index.js 无重复 → 已清零。
- 修复后浏览器复验:detail → `matched.length === 2`Layout + 合同详情)、`#avue-view.children.length === 1`,正文完整渲染「基本信息 / 签约类型 / 合同编号 HT-2026-001 / … / 变更记录」。
- 全量回归:48 条 business/vehicle/payment/settlement 路由 `resolve()``matched.length` 全部 ≥ 2`bad: []`
- `curl` 校验:`views/index.js``contract-manage.vue` 均 200。
- 残留报错均为 mock 数据形状不符所致(所有接口都返回同一个合同对象,导致 `tree.reduce` / `feeCategories.find` 之类失败),非真实缺陷。
**未 commit**(连同 1、2 轮改动一起待确认)。
+19 -89
View File
@@ -1,97 +1,27 @@
# 项目长期记忆(tms-erp-web-ws / Saber3 客商模块
# 项目长期记忆(tms-erp-web-ws / Saber3
## 设计约定(已确认)
后端:`/Users/gxwebsoft/JAVA/tms-api``tms-erp-api-ws` 滞后,别看错仓库)。
### 弹窗分组:统一用全局 `<section-card>`
- 弹窗 body 背景由 `src/styles/element-ui.scss` 全局 `.el-dialog__body { background:#f5f6fa; padding:16px }` 统一灰底
- 每组 = 白底卡片:4px 主色竖条 + 8px 间距 + 圆角 6px + 淡阴影。组件全局注册 `<section-card title="...">`,支持 `#title` / `#extra` slot。
- 仅 3 项的小分组用 `el-col :span="8"` 均分一行。
## 列表 / 表单 / 详情三态同组件分流
`/xxx``/xxx/form``/xxx/detail` 指向同一 `.vue`form/detail 在 `src/router/views/index.js``component: Layout` + 空 `children.path` + `meta:{keepAlive:false}`detail 另带 `activeMenu`)。组件内按 `$route.path` 分流,必须 `watch:{$route}` 重新 init;标签标题取 `query.name`;独立页容器固定 `<div v-if>`,不用 `el-dialog`
### Avue 内置弹窗用 section-carddialogCustomClass 透明化 avue-form
- option 顶层加 `dialogCustomClass:'xxx-dialog'`,再在 `element-ui.scss` 写三条:
1. `.xxx-dialog .avue-form { background:transparent; box-shadow:none; padding:0 }`
2. `.xxx-dialog .avue-form__group > .el-col > .el-form-item { margin-bottom:0 }`
3. `.xxx-dialog .avue-dialog__footer, .avue-crud__dialog .xxx-dialog .avue-dialog__footer { margin-top:0 !important }`
## ⚠️ keep-alive 三坑
背景(`router/tab.js` + `page/index/index.vue`):按 `fullPath` 建 wrapper`<keep-alive :include="tagsKeep">` 缓存,**每标签一实例、永不销毁**,deactivate 后**仍随全局 `$route` 重渲染**。
### 弹窗底部操作栏浮动(全局 CSS)
- `.el-dialog__body { max-height:calc(100vh - 200px); overflow-y:auto; overflow-x:hidden }`
- `.el-dialog__footer { margin:0; padding:12px 20px; background:#fff; border-top:1px solid #ebeef5; box-shadow:0 -2px 8px rgba(0,0,0,.04) }`
- Avue 内置弹窗 footer 真实 class 是 `.avue-dialog__footer`,须 `position:sticky; bottom:0; flex:none; background:#fff` 贴底。
**坑一:独立页容器禁用 `<component :is>` 在 div / el-dialog 间切换。** `append-to-body` 走 Teleport`TeleportImpl.move` 忽略 `moveType`,不会把 DOM 搬回缓存容器 → 切菜单冒出旧弹窗。修法:固定 `<div v-if>` + `deactivated(){closeInnerDialogs()}` + `beforeUnmount()`(写在子组件也生效)。已修 loading-manage / project-apply / waybill-manage-page;未修 `settlement/components/{pre,formal}-settlement-editor.vue``transport-reconciliation-editor.vue``waybill-import-dialog.vue`
### 上传证件区域尺寸(transportCapacity
- 身份证/行驶证/道路运输证等固定 **240×151px**1.586:1)。
- scoped 覆盖 `.xxx-uploader--large { width:240px }``--large .el-upload { width:100%; height:151px }`
**坑二:宿主 view 直绑全局 `$route.query.*` 给子组件 prop。** 缓存实例被灌入当前页 id → 拿错 id 查后端(批量弹「运单管理不存在」),live computed 还会误 push 标签。修法:`routePathLocked` 锁路径 + watcher / push 前判 `isOwnedRouteActive` + 宿主绑定加路径守卫。⚠️ `routePathLocked` **必须放 `data()`**Options API 顺序 data → computed → watch(immediate) → created;放 created 会读到空值、挡掉首次加载)。已修 waybill-manage-page / transport-plan-page + 三个宿主 view。
### 上传图片点击放大预览
- `src/components/image-upload-field/main.vue``<el-image preview-src-list fit="contain" preview-teleported :z-index="3000" @click.stop>`,阻止冒泡到 el-upload。
**坑三:路由表重复定义 → 内容区整页空白(2026-09-18)。** `/business/contract-manage/detail``router/views/index.js` 定义两次、子路由同名 `合同详情`。重名 `addRoute``removeRoute(name)` 移除旧子记录,但**不从父记录 children 里摘除**,第一条 Layout 壳记录留在 matcher 且靠前 → `resolve()` 命中它 → `$route.matched.length === 1` → 二级 router-view 无匹配 → 侧栏/标签正常但内容空白。修法:删重复定义。排查:扫「顶层 path + 首个子路由 name」重复项。
### 独立表单页标题 `archive-page-form__title`
- 样式全局化到 `src/styles/element-ui.scss`18px/600/#303133 + margin-bottom:20px + 4px 主色竖条 + 8px 间距
- business-crud-page 系列通过 `formPageTitle` prop 注入;loading-manage / master-order 手写前置标题
## 样式硬规则
- 底栏 `flex; justify-content:flex-end`**不写 gap**(靠 EP 默认 `.el-button+.el-button{margin-left:12px}`);浮动底栏必须 `position:fixed`(祖先 overflow:hidden 使 sticky 失效)+ `:global(.avue--collapse .x){left:60px}``:global(.avue-layout--horizontal .x){left:0}``:global()` 要包整个选择器
- 按钮层级:次要=无 type;中间步骤=`primary plain`;主操作=`primary`(禁绿、禁两个蓝实心)。顺序 `[辅助][取消][保存草稿][提交]`
- 独立页标题 `.archive-page-form__title`;分组用全局 `<section-card>`;上传证件区 `width:100%; max-width:240px` + `.el-upload{height:151px}`
- ⚠️ `<style scoped lang="scss">` 顶层禁 `//` 注释(Vite5 + sass `Unexpected '/'` → 500),顶层用 `/* */`。其余见 `src/styles/element-ui.scss``AGENTS.md` 4.4。
###格列内嵌输入控件全宽
- `element-ui.scss` 全局兜底:`.el-table .el-table__body td.el-table__cell .cell > .el-input-number/.el-input/.el-select/.el-date-editor/.el-cascader { width:100% }`
### 业务状态标签文字化
- 全站"状态语义"的 `el-tag` `class="status-text"`,转为普通文字,不带颜色/背景/边框/圆点
- 实现:`element-ui.scss` 末尾 `.el-tag.status-text { ... }`
### 弹窗 / 独立表单页底栏按钮统一规则
- **排序**:从左到右按次→主排(次要居左、主操作居右),主操作永远放最后。
- 顺序模板:`[可选辅助工具(如同步/导入)][取消][保存草稿][提交/确认]`
- **容器布局(2026-08-27 改为「去 gap」)**`display:flex; justify-content:flex-end;`**不写 `gap`**)。按钮间距统一由 Element Plus 全局默认的相邻按钮 `.el-button+.el-button{margin-left:12px}` 提供,全站恒为 12px。原各 footer 写死的 `gap:8/12px` 已全部移除。`src/styles/element-ui.scss``.el-dialog__footer .el-button + .el-button` 也已从 `8px``12px`,与之一致。弹窗关闭按钮继承 Element Plus 自带 `.el-dialog__footer` 全局规则(见上条),独立页用 `.archive-form__footer``.archive-page-form & { position:fixed; right:0; left:230px; bottom:0; z-index:10; margin:0; padding:12px 24px; border-top:1px solid #eff1f7; background:#fff; box-shadow:0 -2px 8px rgba(0,0,0,.06); }`
- **⚠️ 浮动底部必须用 `position:fixed`,不能用 `position:sticky`**:本项目布局 `.app-main` / `.basic-container` 祖先带 `overflow:hidden`,会导致 `sticky` 永不触发(footer 不吸底)。固定写法照搬 customer-archive
```scss
.xxx-actions {
position: fixed; right: 0; left: 230px; bottom: 0; margin: 0; z-index: 10;
/* flex-end + padding + border-top + 白底 + 上投影 */
}
:global(.avue--collapse .xxx-actions) { left: 60px; } /* 侧栏折叠 */
:global(.avue-layout--horizontal .xxx-actions) { left: 0; } /* 横向布局 */
```
注意 `:global()` 要把「整个选择器」包进去(customer-archive 写法),不要只包前缀,否则 scoped 会在中间选择器上追加 data 属性导致不匹配。
- **颜色层级 3 档**
| 语义 | type | 备注 |
| --- | --- | --- |
| 次要(取消 / 关闭) | 不写 type(默认) | 灰描边 |
- **全站底部操作栏「返回」已统一改为「取消」**:独立表单页返回列表、弹窗关闭、主从编辑器返回等场景一律写「取消」;仅只读查看 / 纯关闭场景保留「关闭」。
| 中间步骤(保存草稿 / 按匹配结果更新) | `type="primary" plain` | 蓝描边 |
| 主操作(提交 / 完成对账 / 复评确认) | `type="primary"` | 蓝实心 |
- **禁区**
- 保存草稿和主操作不允许同为 `type="primary"`,否则两个蓝实心无视觉层级。
- 主操作禁止用 `type="success"`(绿)。全站统一深蓝实心。
- **生效页面(已按规则统一)**:
- `business/project-apply.vue`footer + 变更 footer
- `vehicle/customer-archive.vue` 全部 5 处 footer`archive-form__footer` 主表单页 + `contact-dialog`/`receipt-dialog`/`invoice-dialog` 子弹窗 + `score-detail-dialog` 含总分合计)
- `business/components/business-crud-page.vue` 的 PageAvueForm 独立表单页(`#menu-form-before` slot 新增 `取消` 按钮,shipping-template 模式验证通过;影响 `/business/waybill-manage/form`、`/business/transport-plan/form`、`/business/shipping-template/form` 三处独立表单页)
- `business/components/master-order-editor.vue` 主 `<footer>`(取消 default + 暂存/确认创建 plain + 创建并调度 primary`position:fixed` 浮动,含 `avue--collapse`/`avue-layout--horizontal` 左偏移)
- **payment 模块**表单页(`__actions` 均 `flex-end` + `position:fixed` 浮动,含 `avue--collapse`/`avue-layout--horizontal` 左偏移):
- `payment-application-form.vue``[取消][保存 plain][提交]`
- `invoice-application-form.vue` / `invoice-receipt-form.vue``[取消][同步 plain][保存 plain][提交]`
- `bill-ledger-form.vue``[取消][确认 primary]`
- `bill-payment-form.vue``[取消][保存 plain][提交]`
- `receipt-flow-form.vue``[取消][确认 primary]`
- `receipt-claim-record-form.vue`(单「关闭」按钮,居中→右对齐浮动)
- **settlement 模块**
- `settlement/components/pre-settlement-editor.vue`(弹窗 + pageMode 两处 footer:保存加 `plain``&__page-actions` 加 `position:fixed` 浮动,含 `avue--collapse`/`avue-layout--horizontal` 左偏移)
- `settlement/components/formal-settlement-editor.vue``.formal-editor__page-actions` 加 `position:fixed` 浮动,含 `avue--collapse`/`avue-layout--horizontal` 左偏移;按钮已 `[取消][提交]` 合规)
- `settlement/receivable-payable-detail.vue`(生成费用弹窗「上一步」改 `plain`
- **已合规未动**`settlement/components/transport-reconciliation-editor.vue``[取消][保存草稿 plain][按匹配结果更新账单 plain][完成对账 primary]`)、`settlement/components/settlement-adjustment-editor.vue`(弹窗 `[取消][保存 primary]`)、`business/temporary-credit-limit.vue`。
- **Avue-form footer 结构注意**`#menu-form-before` slot 内容在 Avue 内置按钮之**左**;`#menu-form` slot 在 Avue 内置按钮之**右**。要实现 `[次要][Avue 主操作]` 顺序,把次要按钮放进 `#menu-form-before`,主操作保留 Avue 内置即可。
- **Avue 表单 footer 按钮去图标(统一纯文字)**:Avue 的 `avue-form` 提交/清空按钮内置图标(`submitIcon: 'el-icon-check'`、`emptyIcon: 'el-icon-delete'`),且 Avue 源码用 `|| 'el-icon-xxx'` 兜底,无法通过 option 配置去掉。已在 `business-crud-page.vue` 样式里用 `:global(.business-crud-form-page-dialog .avue-form__menu .el-icon){display:none}` 统一隐藏,仅作用于独立表单页(PageAvueForm 容器),不影响其它弹窗。新增/编辑弹窗内的 Avue 表单若也要去图标,复用此选择器即可。
- **复杂 footer 例外**:当 footer 需要在按钮组左侧展示「总分合计 / 数量统计」等聚合信息时,块用 `flex:1` 推自己到最左,按钮组按上述规则排在右半边(参考 `score-detail-dialog`)。
## 关键文件
- `src/components/section-card/main.vue`
- `src/styles/element-ui.scss`
- `src/views/vehicle/customer-archive.vue`
- `src/views/transportCapacity/{driver,vehicle,ship}.vue`
- `src/views/business/project-apply.vue`
## 环境注意
- Vite 代理跨域:`axios.withCredentials=true`,改 `.env`/`vite.config.mjs` 须重启 dev。
## business-crud-page 复用模式
- option 由 prop `crudOption` 经 `cloneOption` 克隆。
- 独立表单页走 `PageAvueForm`,不包 el-dialog。
- 自定义详情弹窗:`config.detailButton=true` + `detailSections:[{title, fields}]`。
## 表单 / 自检 / 导出
- placeholder 只写 `请输入`/`请选择`;例外:`请输入或选择车辆`、日期区间 `起`/`止`、示例值。校验 message 须带字段名
- `business-crud-page.vue`option 经 `cloneOption` 克隆,独立表单页走 `PageAvueForm`
- 自检:`curl "localhost:2889/src/xxx.vue"``"...?vue&type=style&index=0&scoped=true&lang.scss"`200 / 500)。`vite build``facelogin.vue` 被沙箱中断,不能作唯一校验。dev `VITE_APP_API=/api` 代理 `172.16.203.228:8000`
- 导出:前端 `exportColumns` 后端未用,以 `XxxExportExcel.java` 为准;BladeX `BeanUtil` 类型不兼容静默跳过(`Date` `LocalDateTime` 丢值,用 `DateUtil.fromDate`
+2
View File
@@ -13,6 +13,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="format-detection" content="telephone=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<link rel="stylesheet" href="/iconfont/index.css" />
<link rel="stylesheet" href="/iconfont/avue/iconfont.css" />
<link rel="stylesheet" href="/iconfont/saber/iconfont.css" />
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>oa登录</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.2/jquery.min.js">
</script>
<script>
// 获取URL查询字符串
const queryString = window.location.search;
// 使用URLSearchParams解析查询字符串
const urlParams = new URLSearchParams(queryString);
// 获取特定的参数值
var userid = urlParams.get('userid'); // 用户id
var username = urlParams.get('username'); // 用户名
var redirectUrl = urlParams.get('redirectUrl'); // 重定向地址
if(userid==null){
userid="";
}
if(username==null){
username="";
}
$.get("/oaApi/getToken?userid="+userid+"&username="+username,function(result){
debugger
//成功返回token
if(result.data.token){
//设置系统的cookie
document.cookie = "X-AUTH-TOKEN="+result.data.token;
if(redirectUrl==null){
//如果是登录就跳转到系统首页
window.location.href = "./";
}else {
//如果是待办有重定向就跳转
window.location.href = redirectUrl;
}
}else {
alert("该业务系统未同步当前oa用户");
}
});
</script>
</head>
<body>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+52
View File
@@ -0,0 +1,52 @@
import request from '@/axios';
export const getList = (current, size, params) => {
return request({
url: '/blade-system/measurement-unit/list',
method: 'get',
params: {
...params,
current,
size,
},
});
};
export const getDetail = id => {
return request({
url: '/blade-system/measurement-unit/detail',
method: 'get',
params: {
id,
},
});
};
export const remove = ids => {
return request({
url: '/blade-system/measurement-unit/remove',
method: 'post',
params: {
ids,
},
});
};
export const submit = row => {
return request({
url: '/blade-system/measurement-unit/submit',
method: 'post',
data: row,
});
};
export const changeStatus = (id, status) => {
return request({
url: '/blade-system/measurement-unit/status',
method: 'post',
params: {
id,
status,
},
});
};
+3 -4
View File
@@ -92,13 +92,12 @@ export const createCrudApi = baseUrl => ({
data,
});
},
reassign(id) {
reassign(data) {
const payload = data && typeof data === 'object' ? data : { id: data };
return request({
url: `${baseUrl}/reassign`,
method: 'post',
params: {
id,
},
data: payload,
});
},
batchComplete(ids) {
+3 -3
View File
@@ -59,14 +59,14 @@ export const withdraw = id =>
params: { id },
});
export const startChange = (id, reason) =>
export const startChange = (id, changeContent, changeReason = changeContent) =>
request({
url: `${baseUrl}/start-change`,
method: 'post',
params: {
id,
changeContent: reason,
changeReason: reason,
changeContent: changeContent || '',
changeReason,
},
});
+9
View File
@@ -79,6 +79,15 @@ export const cancel = id =>
},
});
export const start = id =>
request({
url: `${baseUrl}/start`,
method: 'post',
params: {
id,
},
});
export const complete = id =>
request({
url: `${baseUrl}/complete`,
+5 -2
View File
@@ -13,11 +13,14 @@ export const getDetail = (id, waybillId) =>
...(waybillId === undefined ? {} : { waybillId }),
},
});
export const getVoucherImages = waybillId =>
export const getVoucherImages = (waybillId, params = {}) =>
request({
url: '/blade-transport/process-config/voucher-images',
method: 'get',
params: { waybillId },
params: {
waybillId,
...params,
},
});
export const submit = api.submit;
export const remove = api.remove;
+15
View File
@@ -6,9 +6,24 @@ const api = createCrudApi(baseUrl);
export const getList = api.getList;
export const getDetail = api.getDetail;
export const getChangeRecordDetail = (id, recordIndex) =>
request({
url: `${baseUrl}/change-record/detail`,
method: 'get',
params: { id, recordIndex },
});
export const submit = api.submit;
export const remove = api.remove;
// 项目资金使用风险统计(高风险 / 中风险数量),供列表头部快速筛选标签使用
// 暂未启用:后端 /fund-risk-stats 端点尚未实现,列表页已移除调用,等后端就绪后恢复
export const getFundRiskStats = params =>
request({
url: `${baseUrl}/fund-risk-stats`,
method: 'get',
params,
});
export const saveDraft = row =>
request({
url: `${baseUrl}/save-draft`,
+24
View File
@@ -35,3 +35,27 @@ export const importTransportPlan = ({
timeout: 60000,
});
};
export const validateTransportPlan = ({
file,
projectId,
projectName,
customerName,
contractId,
contractName,
}) => {
const data = new FormData();
data.append('file', file);
data.append('projectId', projectId || '');
data.append('projectName', projectName || '');
data.append('customerName', customerName || '');
data.append('contractId', contractId || '');
data.append('contractName', contractName || '');
return request({
url: `${baseUrl}/validate-transport-plan`,
method: 'post',
data,
responseType: 'blob',
timeout: 60000,
});
};
+25
View File
@@ -5,7 +5,29 @@ const baseUrl = '/blade-transport/voucher-manage';
export const getList = (current, size, params) =>
request({ url: `${baseUrl}/list`, method: 'get', params: { current, size, ...params } });
export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get', params: { id } });
export const getFolderPage = (voucherId, current, size, params) =>
request({ url: `${baseUrl}/folder-page`, method: 'get', params: { voucherId, current, size, ...params } });
export const getFolderDetail = (voucherId, plateNo) =>
request({ url: `${baseUrl}/folder-detail`, method: 'get', params: { voucherId, plateNo } });
export const replaceFolder = (voucherId, plateNo, file) => {
const data = new FormData();
data.append('voucherId', voucherId);
data.append('plateNo', plateNo);
data.append('file', file);
return request({ url: `${baseUrl}/folder-replace`, method: 'post', data });
};
export const replaceFolderByObject = (voucherId, plateNo, fileInfo) =>
request({
url: `${baseUrl}/folder-replace-object`,
method: 'post',
params: { voucherId, plateNo, ...fileInfo },
timeout: 120000,
});
export const removeFolder = (voucherId, plateNo) =>
request({ url: `${baseUrl}/folder-remove`, method: 'post', params: { voucherId, plateNo } });
export const submit = data => request({ url: `${baseUrl}/submit`, method: 'post', data });
export const changeWaybillBatch = data =>
request({ url: `${baseUrl}/change-waybill-batch`, method: 'post', data });
export const createUploadDraft = data =>
request({ url: `${baseUrl}/upload-draft`, method: 'post', data });
export const completeUploadFile = data =>
@@ -17,6 +39,9 @@ export const getWaybillBatches = (current, size, params) =>
method: 'get',
params: { current, size, ...params },
});
export const auditPass = id => request({ url: `${baseUrl}/audit-pass`, method: 'post', params: { id } });
export const auditReject = (id, rejectReason) =>
request({ url: `${baseUrl}/audit-reject`, method: 'post', params: { id, rejectReason } });
const fileTaskBaseUrl = '/blade-file/fileTask';
export const queryFileTask = md5 =>
+9
View File
@@ -26,6 +26,13 @@ export const maintainMileage = data =>
method: 'post',
data,
});
/** 运单打卡记录 + 司机上传凭证图 */
export const getPunchRecords = waybillId =>
request({
url: `${baseUrl}/punch-records`,
method: 'get',
params: { waybillId },
});
export const roadLoading = ids =>
request({
url: `${baseUrl}/road-loading`,
@@ -36,9 +43,11 @@ export const roadLoading = ids =>
});
export const getImportBatches = params => request({ url: `${baseUrl}/import-batch/list`, method: 'get', params });
export const getImportBatchNextCode = () => request({ url: `${baseUrl}/import-batch/next-code`, method: 'get' });
export const getImportDetails = params => request({ url: `${baseUrl}/import-batch/details`, method: 'get', params });
export const removeImportBatches = ids => request({ url: `${baseUrl}/import-batch/remove`, method: 'post', params: { ids } });
export const getImportOptions = () => request({ url: `${baseUrl}/import-batch/options`, method: 'get' });
export const exportImportTemplate = () => request({ url: `${baseUrl}/import-batch/export-template`, method: 'get', responseType: 'blob' });
export const saveImportDraft = data => request({ url: `${baseUrl}/import-batch/draft`, method: 'post', data });
export const validateImport = data => request({ url: `${baseUrl}/import-batch/validate`, method: 'post', data, responseType: 'blob' });
export const confirmImport = data => request({ url: `${baseUrl}/import-batch/confirm`, method: 'post', data });
+2
View File
@@ -18,6 +18,8 @@ export const returnBill = data => request({ url: `${baseUrl}/return`, method: 'p
export const voidBill = data => request({ url: `${baseUrl}/void`, method: 'post', data });
export const syncKingdee = id =>
request({ url: `${baseUrl}/sync-kingdee`, method: 'post', params: { id } });
export const syncKingdeeBatch = ids =>
request({ url: `${baseUrl}/sync-kingdee-batch`, method: 'post', data: ids });
export const paymentTypeOptions = [
{ label: '项目预付', value: 'project_advance' },
+4
View File
@@ -4,6 +4,8 @@ const baseUrl = '/blade-transport/formal-settlement';
export const getList = (current, size, params) =>
request({ url: `${baseUrl}/list`, method: 'get', params: { current, size, ...params } });
export const exportList = params =>
request({ url: `${baseUrl}/export`, method: 'get', params, responseType: 'blob' });
export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get', params: { id } });
export const getCandidates = (current, size, params) =>
request({
@@ -44,3 +46,5 @@ export const applyPayment = data =>
request({ url: `${baseUrl}/apply-payment`, method: 'post', data });
export const applyPayments = data =>
request({ url: `${baseUrl}/apply-payments`, method: 'post', data });
export const claimInvoices = data =>
request({ url: `${baseUrl}/claim-invoices`, method: 'post', data });
+10 -4
View File
@@ -4,6 +4,8 @@ const baseUrl = '/blade-transport/transport-reconciliation';
export const getList = (current, size, params) =>
request({ url: `${baseUrl}/list`, method: 'get', params: { current, size, ...params } });
export const exportList = params =>
request({ url: `${baseUrl}/export`, method: 'get', params, responseType: 'blob' });
export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get', params: { id } });
export const getFormalOptions = (current, size, params) =>
request({
@@ -14,17 +16,21 @@ export const getFormalOptions = (current, size, params) =>
export const save = data => request({ url: `${baseUrl}/save`, method: 'post', data });
export const remove = id => request({ url: `${baseUrl}/remove`, method: 'post', params: { id } });
export const match = id => request({ url: `${baseUrl}/match`, method: 'post', params: { id } });
export const matchPreview = data =>
request({ url: `${baseUrl}/match-preview`, method: 'post', data });
export const manualMatch = data =>
request({ url: `${baseUrl}/manual-match`, method: 'post', data });
export const unmatch = internalId =>
request({ url: `${baseUrl}/unmatch`, method: 'post', params: { internalId } });
export const adjust = data => request({ url: `${baseUrl}/adjust`, method: 'post', data });
export const updateByMatch = id =>
request({ url: `${baseUrl}/update-by-match`, method: 'post', params: { id } });
export const updateByMatch = (id, data) =>
request({ url: `${baseUrl}/update-by-match`, method: 'post', params: { id }, data });
export const complete = id =>
request({ url: `${baseUrl}/complete`, method: 'post', params: { id } });
export const template = mode =>
request({ url: `${baseUrl}/template`, method: 'get', params: { mode }, responseType: 'blob' });
export const completeWithData = data =>
request({ url: `${baseUrl}/complete-with-data`, method: 'post', data });
export const template = (mode, params) =>
request({ url: `${baseUrl}/template`, method: 'get', params: { mode, ...params }, responseType: 'blob' });
const importFile = (url, id, file) => {
const data = new FormData();
+14
View File
@@ -41,6 +41,13 @@ export const add = row => {
});
};
export const syncIamOrganizations = () => {
return request({
url: '/blade-system/dept/sync-iam-organizations',
method: 'post',
});
};
export const update = row => {
return request({
url: '/blade-system/dept/submit',
@@ -78,3 +85,10 @@ export const getDeptLazyTree = parentId => {
},
});
};
export const getPlatformCompanySelect = () => {
return request({
url: '/blade-system/dept/platform-company-select',
method: 'get',
});
};
+7
View File
@@ -86,3 +86,10 @@ export const getDictionary = params => {
params,
});
};
export const getDictionaryAll = () => {
return request({
url: '/blade-system/dict-biz/select-all',
method: 'get',
});
};
+13
View File
@@ -31,6 +31,19 @@ export const add = row => {
});
};
export const syncIamAccounts = (current = 1, size = 50, signal) => {
return request({
url: '/blade-system/user/sync-iam-accounts',
method: 'post',
params: {
current,
size,
},
timeout: 60000,
signal,
});
};
export const update = row => {
return request({
url: '/blade-system/user/update',
@@ -0,0 +1,25 @@
import request from '@/axios';
const baseUrl = '/blade-transport/vehicle-dispatch';
export const getList = (current, size, params) =>
request({ url: `${baseUrl}/list`, method: 'get', params: { ...params, current, size } });
export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get', params: { id } });
export const submit = row => request({ url: `${baseUrl}/submit`, method: 'post', data: row });
export const remove = ids => request({ url: `${baseUrl}/remove`, method: 'post', params: { ids } });
export const submitApproval = id =>
request({ url: `${baseUrl}/submit-approval`, method: 'post', params: { id } });
export const approve = id => request({ url: `${baseUrl}/approve`, method: 'post', params: { id } });
export const exportVehicleDispatch = params =>
request({
url: `${baseUrl}/export-vehicle-dispatch`,
method: 'get',
params,
responseType: 'blob',
});
@@ -47,3 +47,11 @@ export const update = row => {
data: row,
});
};
export const getExpiryStat = params => {
return request({
url: '/blade-transport/annual-inspection-record/expiry-stat',
method: 'get',
params,
});
};
+73 -25
View File
@@ -1,11 +1,5 @@
<template>
<el-dialog
v-model="visible"
title="地图选择地址"
width="920px"
append-to-body
@opened="initMap"
>
<el-dialog v-model="visible" title="地图选择地址" width="920px" append-to-body @opened="initMap">
<div class="address-map-picker__toolbar">
<el-input
v-model="keyword"
@@ -15,7 +9,14 @@
/>
<el-button type="primary" :loading="searching" @click="searchKeyword">搜索</el-button>
</div>
<div ref="map" class="address-map-picker__map"></div>
<div class="address-map-picker__content">
<map-search-results
v-if="searchResults.length"
:results="searchResults"
@select="selectSearchResult"
/>
<div ref="map" class="address-map-picker__map"></div>
</div>
<div class="address-map-picker__info">{{ status }}</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
@@ -54,6 +55,8 @@ export default {
amap: null,
marker: null,
geocoder: null,
searchResults: [],
mapInitializing: null,
};
},
watch: {
@@ -65,6 +68,7 @@ export default {
this.keyword = this.address || '';
this.selected = {};
this.status = '可搜索地址或点击地图选点';
this.searchResults = [];
}
},
},
@@ -96,25 +100,39 @@ export default {
return amapLoader;
},
initMap() {
this.loadAmap()
if (this.mapInitializing) return this.mapInitializing;
this.mapInitializing = this.loadAmap()
.then(() => {
this.$nextTick(() => {
if (!this.amap) {
this.amap = new window.AMap.Map(this.$refs.map, {
center: [116.40769, 39.89945],
zoom: 11,
});
this.amap.on('click', event => this.pickPoint(event.lnglat));
} else {
this.amap.resize();
this.clearMarker();
}
if (!window.AMap?.Map) {
throw new Error('高德地图组件未就绪');
}
return new Promise(resolve => {
this.$nextTick(() => {
if (!this.$refs.map) return resolve(null);
if (!this.amap) {
this.amap = new window.AMap.Map(this.$refs.map, {
center: [116.40769, 39.89945],
zoom: 11,
});
this.amap.on('click', event => this.pickPoint(event.lnglat));
} else {
this.amap.resize();
this.clearMarker();
}
resolve(this.amap);
});
});
})
.catch(() => {
this.$message.error('高德地图组件加载失败,请稍后重试');
this.visible = false;
return null;
})
.finally(() => {
this.mapInitializing = null;
});
return this.mapInitializing;
},
ensureGeocoder() {
if (this.geocoder) return Promise.resolve(this.geocoder);
@@ -154,15 +172,28 @@ export default {
this.$message.warning('请输入地址关键词');
return;
}
if (this.searching) return;
this.searchResults = [];
this.searching = true;
this.loadAmap()
.then(() => this.runGeocode('location', keyword))
this.initMap()
.then(map => (map ? this.runGeocode('location', keyword) : null))
.then(result => {
const point = result.geocodes?.[0]?.location || result.location;
if (!result) return;
const geocodes = result.geocodes || (result.location ? [result] : []);
this.searchResults = geocodes.map((item, index) => ({
id: item.id || index,
name: item.formattedAddress || item.address || keyword,
address: item.formattedAddress || item.address || keyword,
location: item.location,
}));
const point = geocodes[0]?.location || result.location;
if (!point) {
throw new Error('未找到匹配地址');
}
this.pickPoint(point, keyword);
return this.$nextTick().then(() => {
this.amap?.resize();
this.pickPoint(point, geocodes[0]?.formattedAddress || keyword);
});
})
.catch(() => {
this.status = '未找到匹配地址';
@@ -172,6 +203,11 @@ export default {
this.searching = false;
});
},
selectSearchResult(item) {
if (item && item.location) {
this.pickPoint(item.location, item.address || item.name || '');
}
},
pickPoint(point, fallbackAddress = '') {
const longitude = typeof point.getLng === 'function' ? point.getLng() : point.lng;
const latitude = typeof point.getLat === 'function' ? point.getLat() : point.lat;
@@ -181,7 +217,11 @@ export default {
}
const lnglat = new window.AMap.LngLat(Number(longitude), Number(latitude));
this.renderMarker(lnglat);
this.selected = { longitude: Number(longitude), latitude: Number(latitude), address: fallbackAddress };
this.selected = {
longitude: Number(longitude),
latitude: Number(latitude),
address: fallbackAddress,
};
this.status = '正在解析地址...';
this.resolving = true;
this.runGeocode('address', lnglat)
@@ -238,11 +278,19 @@ export default {
}
&__map {
flex: 1 1 auto;
width: 100%;
min-width: 0;
height: 420px;
border: 1px solid #eff1f7;
}
&__content {
display: flex;
gap: 12px;
min-width: 0;
}
&__info {
margin-top: 10px;
color: #606266;
@@ -0,0 +1,65 @@
<template>
<div v-if="results.length" class="map-search-results">
<div class="map-search-results__title">搜索结果</div>
<el-empty v-if="!results.length" description="暂无搜索结果" :image-size="60" />
<div
v-for="(item, index) in results"
:key="item.id || index"
class="map-search-results__item"
@click="$emit('select', item)"
>
<div class="map-search-results__name">{{ item.name || item.address || '未命名地址' }}</div>
<div class="map-search-results__address">{{ item.address || item.name || '-' }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
results: {
type: Array,
default: () => [],
},
},
emits: ['select'],
};
</script>
<style lang="scss" scoped>
.map-search-results {
width: 260px;
height: 420px;
overflow-y: auto;
border: 1px solid #eff1f7;
background: #fff;
flex: 0 0 260px;
&__title {
padding: 10px 12px;
border-bottom: 1px solid #eff1f7;
font-weight: 600;
}
&__item {
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
}
&__item:hover {
background: #f5f9ff;
}
&__name {
color: #303133;
font-size: 14px;
}
&__address {
margin-top: 4px;
color: #909399;
font-size: 12px;
}
}
</style>
+3 -1
View File
@@ -15,10 +15,12 @@
</template>
<script>
import VuePdfEmbed from 'vue-pdf-embed';
import { defineAsyncComponent } from 'vue';
import 'vue-pdf-embed/dist/styles/annotationLayer.css';
import 'vue-pdf-embed/dist/styles/textLayer.css';
const VuePdfEmbed = defineAsyncComponent(() => import('vue-pdf-embed'));
export default {
name: 'PdfPreview',
components: { VuePdfEmbed },
@@ -75,8 +75,8 @@ import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { baseUrl } from '@/config/env';
import { getUploadHeaders } from '@/utils/upload';
const UNIFIED_ATTACHMENT_TIP =
'支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过500M';
const UNIFIED_ATTACHMENT_TIP_PREFIX =
'支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过';
const MIME_MAP = {
jpg: 'image/jpeg',
@@ -215,7 +215,7 @@ export default {
return this.acceptedList.join(',');
},
tipText() {
return this.tip || UNIFIED_ATTACHMENT_TIP;
return this.tip || `${UNIFIED_ATTACHMENT_TIP_PREFIX}${this.maxSize}M`;
},
uploadingCount() {
return this.fileList.filter(file => ['ready', 'uploading'].includes(file.status)).length;
@@ -335,7 +335,12 @@ export default {
const data = (file.response && file.response.data) || file.data || file;
const url = data.link || data.url || data.domain || file.url || '';
const originalName =
data.originalName || file.originalName || data.name || file.name || this.getFileName(url) || '附件';
data.originalName ||
file.originalName ||
file.name ||
data.name ||
this.getFileName(url) ||
'附件';
const extension = this.getExtension({ name: originalName, url });
return {
...data,
+1
View File
@@ -1,6 +1,7 @@
FROM nginx
VOLUME /tmp
ENV LANG en_US.UTF-8
ADD ./src/docker/nginx.conf /etc/nginx/conf.d/default.conf
ADD ./dist/ /usr/share/nginx/html/
EXPOSE 80
EXPOSE 443
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri $uri/ /index.html;
}
}
+4
View File
@@ -1,8 +1,12 @@
import store from './store';
import { isChunkLoadError, reloadForChunkError } from './utils/chunk-reload';
export default {
install: app => {
app.config.errorHandler = (err, vm, info) => {
if (isChunkLoadError(err) && reloadForChunkError()) {
return;
}
store.commit('ADD_LOGS', {
type: 'error',
message: err.message,
+4
View File
@@ -1,4 +1,5 @@
import { createApp } from 'vue';
import { installChunkReload } from './utils/chunk-reload';
import website from './config/website';
import axios from './axios';
import router from './router/';
@@ -43,8 +44,10 @@ import tenantPackage from './views/system/tenantpackage.vue';
import tenantDatasource from './views/system/tenantdatasource.vue';
// 通用弹窗分组白卡(灰底弹窗内使用,标题在卡内)
import sectionCard from './components/section-card/main.vue';
import mapSearchResults from './components/map-search-results/main.vue';
window.$crudCommon = crudCommon;
installChunkReload();
debug();
window.axios = axios;
const app = createApp(App);
@@ -66,6 +69,7 @@ app.component('formSetting', formSetting);
app.component('tenantPackage', tenantPackage);
app.component('tenantDatasource', tenantDatasource);
app.component('sectionCard', sectionCard);
app.component('mapSearchResults', mapSearchResults);
app.config.globalProperties.$app = app;
app.config.globalProperties.$dayjs = dayjs;
app.config.globalProperties.website = website;
+3
View File
@@ -26,6 +26,8 @@ export const getCargoTypeOption = ctx => ({
height: 'auto',
calcHeight: 22,
dialogWidth: 760,
// 货物类型弹窗单独使用样式,避免全局 Avue footer 上移遮挡底部备注字段
dialogCustomClass: 'cargo-type-dialog',
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
@@ -172,6 +174,7 @@ export const getCargoTypeOption = ctx => ({
label: '备注',
prop: 'remark',
type: 'textarea',
formslot: true,
minRows: 2,
span: 24,
minWidth: 180,
+133
View File
@@ -0,0 +1,133 @@
const dimensionOptions = [
{ label: '重量', value: '重量' },
{ label: '体积', value: '体积' },
{ label: '数量', value: '数量' },
];
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 2 },
];
export const createOption = () => ({
height: 'auto',
calcHeight: 32,
dialogWidth: 680,
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
saveBtnText: '提交',
updateBtnText: '提交',
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
addBtn: false,
viewBtn: false,
editBtn: false,
delBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 240,
menuFixed: 'right',
column: [
{
label: '计量单位',
prop: 'unitName',
minWidth: 150,
search: true,
searchOrder: 3,
searchSpan: 6,
maxlength: 50,
showWordLimit: true,
rules: [
{ required: true, message: '请输入计量单位', trigger: 'blur' },
{ max: 50, message: '计量单位不能超过50个字', trigger: 'blur' },
],
},
{
label: '计量维度',
prop: 'dimension',
type: 'select',
minWidth: 130,
search: true,
searchOrder: 2,
searchSpan: 6,
dicData: dimensionOptions,
rules: [{ required: true, message: '请选择计量维度', trigger: 'change' }],
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
minRows: 2,
span: 24,
minWidth: 200,
maxlength: 200,
showWordLimit: true,
overHidden: true,
rules: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
},
{
label: '状态',
prop: 'status',
type: 'select',
search: true,
searchOrder: 1,
searchSpan: 6,
slot: true,
dataType: 'number',
dicData: statusOptions,
clearable: true,
value: 1,
addDisplay: false,
editDisplay: false,
minWidth: 100,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
viewDisplay: false,
display: false,
},
{
label: '更新人',
prop: 'updateUserName',
addDisplay: false,
editDisplay: false,
viewDisplay: false,
display: false,
},
{
label: '创建时间',
prop: 'createTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
sortable: true,
addDisplay: false,
editDisplay: false,
display: false,
},
{
label: '更新时间',
prop: 'updateTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
sortable: true,
addDisplay: false,
editDisplay: false,
display: false,
},
],
});
+58 -36
View File
@@ -2,7 +2,6 @@ import {
auditColumns,
createCrudOption,
packageOptions,
priceUnitOptions,
textRule,
} from './common';
@@ -25,10 +24,42 @@ export const config = {
enableAllDept: false,
};
// 单价统一按两位小数展示;格式非法时保留原值,交由表单校验提示。
export const formatCargoValue = value => {
if (value === undefined || value === null) return '';
const raw = String(value).trim();
if (!raw || raw === '-1') return '';
if (!/^\d+(?:\.\d+)?$/.test(raw)) return raw;
const number = Number(raw);
return Number.isFinite(number) ? number.toFixed(2) : raw;
};
export const option = createCrudOption([
{
label: '一级货物类型',
prop: 'firstCargoTypeName',
formslot: true,
order: 60,
span: 12,
minWidth: 150,
editDisabled: true,
rules: [{ required: true, message: '请选择一级货物类型', trigger: 'change' }],
},
{
label: '二级货物类型',
prop: 'secondCargoTypeName',
formslot: true,
order: 59,
span: 12,
minWidth: 150,
editDisabled: true,
rules: [{ required: true, message: '请选择二级货物类型', trigger: 'change' }],
},
{
label: '货物名称',
prop: 'cargoName',
order: 50,
span: 12,
minWidth: 150,
placeholder: '请输入',
rules: textRule('货物名称', 100, true),
@@ -37,6 +68,8 @@ export const option = createCrudOption([
label: '货物编号',
prop: 'cargoCode',
formslot: true,
order: 49,
span: 12,
minWidth: 120,
maxlength: 20,
showWordLimit: true,
@@ -45,14 +78,6 @@ export const option = createCrudOption([
{ max: 20, message: '货物编号不能超过20个字符', trigger: 'blur' },
],
},
{
label: '一级货物类型',
prop: 'firstCargoTypeName',
formslot: true,
minWidth: 150,
editDisabled: true,
rules: [{ required: true, message: '请选择一级货物类型', trigger: 'change' }],
},
{
label: '一级货物类型编码',
prop: 'firstCargoTypeCode',
@@ -61,14 +86,6 @@ export const option = createCrudOption([
addDisplay: false,
editDisabled: true,
},
{
label: '二级货物类型',
prop: 'secondCargoTypeName',
formslot: true,
minWidth: 150,
editDisabled: true,
rules: [{ required: true, message: '请选择二级货物类型', trigger: 'change' }],
},
{
label: '二级货物类型编码',
prop: 'secondCargoTypeCode',
@@ -174,12 +191,16 @@ export const option = createCrudOption([
prop: 'packageType',
type: 'select',
dicData: packageOptions,
order: 39,
span: 12,
minWidth: 100,
},
{
label: '品牌',
prop: 'brand',
search: false,
order: 40,
span: 12,
minWidth: 120,
placeholder: '请输入',
rules: textRule('品牌', 50),
@@ -187,22 +208,18 @@ export const option = createCrudOption([
{
label: '规格',
prop: 'specification',
order: 29,
span: 12,
minWidth: 120,
placeholder: '请输入',
rules: textRule('规格', 50),
},
{
label: '型号',
prop: 'model',
minWidth: 120,
placeholder: '请输入',
rules: textRule('型号', 50),
},
{
label: '单价',
label: '货值',
prop: 'cargoValue',
formslot: true,
formatter: row => (String(row.cargoValue) === '-1' ? '' : row.cargoValue),
order: 30,
formatter: row => formatCargoValue(row.cargoValue),
minWidth: 120,
rules: [
{
@@ -225,10 +242,19 @@ export const option = createCrudOption([
label: '计价单位',
prop: 'priceUnit',
type: 'select',
dicData: priceUnitOptions,
dicData: [],
display: false,
minWidth: 120,
},
{
label: '型号',
prop: 'model',
order: 19,
span: 12,
minWidth: 120,
placeholder: '请输入',
rules: textRule('型号', 50, true),
},
{
label: '尺寸',
prop: 'sizeText',
@@ -238,23 +264,19 @@ export const option = createCrudOption([
rules: textRule('尺寸', 100),
},
{
label: '其他说明1',
label: '说明',
prop: 'descriptionOne',
order: 20,
span: 12,
minWidth: 160,
placeholder: '请输入',
rules: textRule('其他说明1', 100),
},
{
label: '其他说明2',
prop: 'descriptionTwo',
minWidth: 160,
placeholder: '请输入',
rules: textRule('其他说明2', 100),
rules: textRule('说明', 100),
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
order: 10,
minRows: 2,
span: 24,
maxlength: 200,
+38 -1
View File
@@ -41,7 +41,7 @@ export const planStatusOptions = [
export const waybillStatusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '待执行', value: 'pending' },
{ label: '进行中', value: 'processing' },
{ label: '进行中', value: 'running' },
{ label: '已完成', value: 'completed' },
{ label: '已取消', value: 'cancelled' },
];
@@ -97,6 +97,11 @@ export const businessTypeOptions = [
{ label: '普通业务', value: '普通业务' },
];
export const businessModeOptions = [
{ label: '国内运输', value: '国内运输' },
{ label: '跨境运输', value: '跨境运输' },
];
export const settlementModeOptions = [
{ label: '先票后款', value: '先票后款' },
{ label: '先款后票', value: '先款后票' },
@@ -125,12 +130,18 @@ export const contractApprovalStatusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '审批中', value: 'reviewing' },
{ label: '已驳回', value: 'rejected' },
{ label: '已撤回', value: 'withdrawn' },
{ label: '审批通过', value: 'approved' },
{ label: '变更审批中', value: 'change_reviewing' },
{ label: '变更驳回', value: 'change_rejected' },
{ label: '变更审批通过', value: 'change_approved' },
];
export const contractArchiveStatusOptions = [
{ label: '未归档', value: '未归档' },
{ label: '已归档', value: '已归档' },
];
export const nodeOptions = [
{ label: '接单', value: '接单' },
{ label: '到场', value: '到场' },
@@ -169,6 +180,32 @@ export const nonNegativeRule = label => ({
trigger: 'blur',
});
// 日期必须晚于当前日期(当天不可选):选择后立即校验,trigger 固定为 change
// 取值支持 YYYY-MM-DD / YYYY-MM-DD HH:mm:ss / 时间戳
export const futureDateRule = label => ({
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback();
return;
}
const raw = String(value).trim().replace(/-/g, '/');
const selected = new Date(raw);
if (Number.isNaN(selected.getTime())) {
callback();
return;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
selected.setHours(0, 0, 0, 0);
if (selected.getTime() <= today.getTime()) {
callback(new Error(`${label}必须晚于当前日期`));
return;
}
callback();
},
trigger: ['change', 'blur'],
});
export const phoneRule = label => ({
pattern: /^1[3-9]\d{9}$/,
message: `${label}格式不正确`,
+70 -4
View File
@@ -1,6 +1,7 @@
import {
auditColumns,
contractApprovalStatusOptions,
contractArchiveStatusOptions,
contractCategoryOptions,
contractStageOptions,
createCrudOption,
@@ -27,6 +28,12 @@ const listDicFormatter = res => {
return [];
};
const projectNameDicFormatter = res => {
const list = listDicFormatter(res);
// 过滤掉 projectName 为空的记录
return list.filter(item => item && item.projectName && String(item.projectName).trim());
};
export const config = {
title: '合同管理',
permission: 'contract_manage',
@@ -42,7 +49,7 @@ export const config = {
batchDelete: false,
enableProjectSelect: true,
projectQueryParams: {
approvalStatuses: 'approved,change_approved',
contractSelectable: true,
},
actions: ['copy'],
statusProp: 'approvalStatus',
@@ -65,6 +72,10 @@ export const config = {
['legalSealFlag', '是否需要加盖法人章'],
['copyCount', '一式(份)'],
['paymentDays', '回款账期(天)'],
['contractAmount', '合同金额'],
['templateFlag', '是否范本'],
['originalContractNo', '原件合同编号'],
['electronicSealFlag', '是否电子章'],
['remark', '备注', 2],
],
},
@@ -78,6 +89,7 @@ export const config = {
['effectiveType', '生效类型'],
['contractStage', '合同阶段'],
['approvalStatus', '审核状态'],
['archiveStatus', '归档状态'],
['currentNode', '当前节点'],
['currentProcessor', '当前处理人'],
],
@@ -98,7 +110,7 @@ export const config = {
},
deleteStatus: ['draft'],
deleteStage: ['draft'],
editStatus: ['draft', 'rejected', 'change_rejected'],
editStatus: ['draft', 'withdrawn', 'rejected', 'change_rejected'],
operations: [
{
action: 'flow',
@@ -265,8 +277,8 @@ export const option = createCrudOption([
formslot: true,
order: 165,
dicUrl:
'/blade-transport/project-apply/list?current=1&size=9999&approvalStatuses=approved,change_approved',
dicFormatter: listDicFormatter,
'/blade-transport/project-apply/list?current=1&size=9999&contractSelectable=true',
dicFormatter: projectNameDicFormatter,
props: {
label: 'projectName',
value: 'projectName',
@@ -479,6 +491,47 @@ export const option = createCrudOption([
order: 50,
minWidth: 130,
},
{
label: '合同金额',
prop: 'contractAmount',
type: 'number',
min: 0,
precision: 2,
hide: true,
order: 45,
minWidth: 130,
},
{
label: '是否范本',
prop: 'templateFlag',
type: 'select',
dicData: [
{ label: '否', value: 0 },
{ label: '是', value: 1 },
],
hide: true,
order: 40,
minWidth: 100,
},
{
label: '原件合同编号',
prop: 'originalContractNo',
hide: true,
order: 35,
minWidth: 160,
},
{
label: '是否电子章',
prop: 'electronicSealFlag',
type: 'select',
dicData: [
{ label: '否', value: 0 },
{ label: '是', value: 1 },
],
hide: true,
order: 30,
minWidth: 110,
},
{
label: '创建时间',
prop: 'createTime',
@@ -516,6 +569,19 @@ export const option = createCrudOption([
addDisplay: false,
editDisplay: false,
},
{
label: '归档状态',
prop: 'archiveStatus',
type: 'select',
search: true,
searchOrder: 5,
searchPlaceholder: '请选择',
slot: true,
dicData: contractArchiveStatusOptions,
minWidth: 110,
addDisplay: false,
editDisplay: false,
},
{
label: '当前节点',
prop: 'currentNode',
+1 -1
View File
@@ -8,7 +8,7 @@ export const transportTypeDict = {
},
};
export const carrierTypeOptions = ['承运商', '自运', '网货平台'];
export const carrierTypeOptions = ['承运商', '自运'];
export const loadingStatusOptions = [
{ label: '草稿', value: 'draft' },
-1
View File
@@ -138,7 +138,6 @@ export const option = {
searchPlaceholder: '请选择',
dicData: processStatusOptions,
slot: true,
hide: true,
fixed: 'right',
minWidth: 100,
addDisplay: false,
+44 -4
View File
@@ -1,5 +1,6 @@
import {
auditColumns,
businessModeOptions,
businessTypeOptions,
createCrudOption,
nonNegativeRule,
@@ -16,6 +17,18 @@ const projectEffectiveTypeOptions = [
{ label: '正式', value: 'formal' },
];
export const fundUseRiskOptions = [
{ label: '高风险', value: 'high' },
{ label: '中风险', value: 'medium' },
{ label: '正常', value: 'none' },
];
// 项目资金额度使用率阈值:中风险 ≥ 80%,高风险 ≥ 90%
export const fundUseRiskThreshold = {
high: 90,
medium: 80,
};
const amountRules = label => [
{ required: true, message: `请输入${label}`, trigger: 'blur' },
nonNegativeRule(label),
@@ -117,7 +130,7 @@ export const option = createCrudOption([
hide: true,
minWidth: 140,
maxlength: 20,
rules: textRule('项目简称', 20, true),
rules: textRule('项目简称', 20),
},
{
label: '项目类型',
@@ -156,16 +169,16 @@ export const option = createCrudOption([
rules: textRule('业务部门', 50, true),
},
{
label: '承办部门',
label: '平台公司',
prop: 'undertakeDeptName',
search: true,
searchPlaceholder: '请输入',
searchOrder: 8,
minWidth: 150,
rules: textRule('承办部门', 50, true),
rules: textRule('平台公司', 50, true),
},
{
label: '承办部门ID',
label: '平台公司ID',
prop: 'undertakeDeptId',
hide: true,
},
@@ -179,6 +192,15 @@ export const option = createCrudOption([
formatter: row => formatUserRealName(row, 'principal'),
rules: textRule('项目负责人', 50, true),
},
{
label: '资金使用风险',
prop: 'fundUseRisk',
slot: true,
minWidth: 150,
addDisplay: false,
editDisplay: false,
hide: false,
},
{
label: '项目负责人ID',
prop: 'principalUserId',
@@ -293,6 +315,15 @@ export const option = createCrudOption([
hide: true,
minWidth: 130,
},
{
label: '业务模式',
prop: 'businessMode',
type: 'select',
dicData: businessModeOptions,
hide: true,
minWidth: 120,
rules: [selectRule('业务模式')],
},
{
label: '项目规模(万元)',
prop: 'projectScale',
@@ -311,6 +342,15 @@ export const option = createCrudOption([
minWidth: 150,
rules: [nonNegativeRule('预计利润')],
},
{
label: '利润率(%',
prop: 'profitRate',
type: 'number',
precision: 2,
hide: true,
minWidth: 120,
rules: [nonNegativeRule('利润率')],
},
{
label: '资金需求(万元)',
prop: 'fundDemand',
+18 -16
View File
@@ -55,8 +55,8 @@ export const config = {
'templateType',
'transportType',
'createUserName',
'remark',
'updateTime',
'remark',
'createTime',
],
exportColumns: [
@@ -65,8 +65,8 @@ export const config = {
{ prop: 'templateType', label: '模板类型' },
{ prop: 'transportType', label: '运输方式' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'remark', label: '备注' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'remark', label: '备注' },
{ prop: 'createTime', label: '创建时间' },
],
enableAllDept: false,
@@ -82,12 +82,14 @@ export const config = {
detailAttachmentDescriptionPlain: true,
enableTransportPlanForm: true,
enableShippingTemplateFreight: true,
editableRoadAddress: true,
editableRoadAddress: false,
fixedTransportAddressType: true,
enableTemplateCodePreview: true,
attachmentTitle: '附件',
defaultForm: {
templateType: '运输计划',
transportType: '公路运输',
transportTypeName: '公路运输',
},
transportFormRequiredFields: [
['projectName', '项目'],
@@ -119,15 +121,16 @@ export const option = {
labelWidth: 0,
},
{
label: '模板编号',
prop: 'templateCode',
label: '模板类型',
prop: 'templateType',
type: 'select',
search: true,
searchOrder: 4,
searchOrder: 2,
span: 8,
order: 390,
minWidth: 170,
disabled: true,
placeholder: '系统自动生成',
dicData: templateTypeOptions,
minWidth: 120,
rules: selectRule('模板类型'),
display: true,
},
{
@@ -142,16 +145,15 @@ export const option = {
display: true,
},
{
label: '模板类型',
prop: 'templateType',
type: 'select',
label: '模板编号',
prop: 'templateCode',
search: true,
searchOrder: 2,
searchOrder: 4,
span: 8,
order: 370,
dicData: templateTypeOptions,
minWidth: 120,
rules: selectRule('模板类型'),
minWidth: 170,
disabled: true,
placeholder: '系统自动生成',
display: true,
},
{
+68 -38
View File
@@ -4,6 +4,7 @@ import {
createCrudOption,
nonNegativeRule,
textRule,
withSearchPlaceholders,
} from './common';
const listDicFormatter = res => {
@@ -15,6 +16,12 @@ const listDicFormatter = res => {
return [];
};
const projectNameDicFormatter = res => {
const list = listDicFormatter(res);
// 过滤掉 projectName 为空的记录
return list.filter(item => item && item.projectName && String(item.projectName).trim());
};
const userDicFormatter = res =>
listDicFormatter(res).map(user => ({
...user,
@@ -50,30 +57,25 @@ export const config = {
detailAlignCenter: true,
detailSections: [
{
title: '申请信息',
title: '项目额度信息',
fields: [
['applicationNo', '申请单号', 1],
['projectName', '项目', 1],
['projectName', '项目名称', 1],
['projectCode', '项目编号', 1],
['undertakeDeptName', '承办部门', 1],
['applyDeptName', '申请部门', 1],
['applicantName', '申请人', 1],
['approvalStatus', '审批状态', 1],
['currentNode', '当前节点', 1],
['currentProcessor', '当前处理人', 1],
['validUntil', '申请有效期至', 1],
['remark', '备注', 1],
],
},
{
title: '额度信息',
fields: [
['projectFundLimit', '项目资金使用额度(万元)', 1],
['usedFundLimit', '已使用项目资金使用额度(万元)', 1],
['remainingFundLimit', '剩余项目资金使用额度(万元)', 1],
['applyLimit', '申请临时额度(万元)', 1],
],
},
{
title: '临时额度信息',
fields: [
['applyLimit', '申请临时额度(万元)', 1],
['validUntil', '申请有效期至', 1],
['remark', '备注', 4],
],
showAttachment: true,
},
],
operations: [
{
@@ -118,7 +120,10 @@ export const config = {
export const option = {
dialogCustomClass: 'temporary-credit-limit-dialog',
...createCrudOption([
addTitle: '新增临时额度申请',
editTitle: '编辑临时额度申请',
viewTitle: '查看临时额度申请',
...createCrudOption(withSearchPlaceholders([
{
label: '申请单号',
prop: 'applicationNo',
@@ -129,6 +134,14 @@ export const option = {
editDisplay: false,
editDisabled: true,
},
{
label: '',
prop: 'projectQuotaInfoTitle',
formslot: true,
span: 24,
hide: true,
labelWidth: 0,
},
{
label: '项目名称',
prop: 'projectName',
@@ -137,7 +150,7 @@ export const option = {
searchType: 'select',
formslot: true,
dicUrl: '/blade-transport/project-apply/list?current=1&size=9999',
dicFormatter: listDicFormatter,
dicFormatter: projectNameDicFormatter,
props: {
label: 'projectName',
value: 'projectName',
@@ -158,6 +171,7 @@ export const option = {
prop: 'projectCode',
hide: true,
disabled: true,
placeholder: '请输入',
span: 6,
minWidth: 150,
rules: textRule('项目编号', 50),
@@ -167,6 +181,7 @@ export const option = {
prop: 'undertakeDeptName',
hide: true,
disabled: true,
placeholder: '请输入',
span: 6,
minWidth: 150,
rules: textRule('承办部门', 50, true),
@@ -176,6 +191,7 @@ export const option = {
prop: 'projectFundLimit',
hide: true,
disabled: true,
placeholder: '请输入',
span: 6,
minWidth: 200,
rules: [nonNegativeRule('项目资金使用额度')],
@@ -185,6 +201,7 @@ export const option = {
prop: 'usedFundLimit',
hide: true,
disabled: true,
placeholder: '请输入',
span: 6,
minWidth: 220,
value: 0,
@@ -195,11 +212,20 @@ export const option = {
prop: 'remainingFundLimit',
hide: true,
disabled: true,
placeholder: '请输入',
span: 6,
minWidth: 230,
value: 0,
rules: [nonNegativeRule('剩余项目资金使用额度')],
},
{
label: '',
prop: 'temporaryCreditInfoTitle',
formslot: true,
span: 24,
hide: true,
labelWidth: 0,
},
{
label: '申请临时额度(万元)',
prop: 'applyLimit',
@@ -217,9 +243,31 @@ export const option = {
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
placeholder: '请选择',
span: 6,
minWidth: 140,
rules: [{ required: true, message: '请选择申请有效期至', trigger: 'change' }],
rules: [
{ required: true, message: '请选择申请有效期至', trigger: 'change' },
],
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
span: 24,
minRows: 2,
hide: true,
placeholder: '请输入',
maxlength: 200,
showWordLimit: true,
rules: textRule('备注', 200),
},
{
label: '附件',
prop: 'attachmentsJson',
formslot: true,
span: 24,
hide: true,
},
{
label: '申请部门',
@@ -284,26 +332,8 @@ export const option = {
addDisplay: false,
editDisplay: false,
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
span: 24,
minRows: 2,
hide: true,
maxlength: 200,
showWordLimit: true,
rules: textRule('备注', 200),
},
{
label: '附件',
prop: 'attachmentsJson',
formslot: true,
span: 24,
hide: true,
},
...auditColumns.map(column => ({ ...column, hide: true })),
]),
])),
dialogWidth: '96%',
menuFixed: 'right',
menuWidth: 320,
+14 -6
View File
@@ -23,6 +23,12 @@ const listDicFormatter = res => {
return [];
};
const projectNameDicFormatter = res => {
const list = listDicFormatter(res);
// 过滤掉 projectName 为空的记录
return list.filter(item => item && item.projectName && String(item.projectName).trim());
};
const parseGoodsRows = value => {
if (!value) return [];
if (Array.isArray(value)) return value;
@@ -203,13 +209,13 @@ export const config = {
attachmentTitle: '附件',
searchRangeMap: {
planStartDateRange: ['planStartDateStart', 'planStartDateEnd'],
planEndDateRange: ['planEndDateStart', 'planEndDateEnd'],
createTimeRange: ['createTimeStart', 'createTimeEnd', '00:00:00', '23:59:59'],
},
actions: ['copy', 'complete'],
statusProp: 'businessStatus',
statusTextProp: 'businessStatusName',
deleteStatus: ['draft', 'waiting_dispatch'],
editStatus: ['draft', 'waiting_dispatch'],
deleteStatus: ['draft'],
editStatus: ['draft', 'waiting_dispatch', 'dispatching'],
detailButton: true,
detailSections: [
{
@@ -335,6 +341,7 @@ export const option = {
{
label: '发货地址',
prop: 'departureAddress',
slot: true,
formslot: true,
search: true,
searchOrder: 7,
@@ -346,6 +353,7 @@ export const option = {
{
label: '到货地址',
prop: 'arrivalAddress',
slot: true,
formslot: true,
search: true,
searchOrder: 6,
@@ -364,7 +372,7 @@ export const option = {
span: 6,
order: 400,
dicUrl: '/blade-transport/project-apply/list?current=1&size=9999',
dicFormatter: listDicFormatter,
dicFormatter: projectNameDicFormatter,
props: {
label: 'projectName',
value: 'projectName',
@@ -571,8 +579,8 @@ export const option = {
viewDisplay: false,
},
{
label: '计划结束日期',
prop: 'planEndDateRange',
label: '创建时间',
prop: 'createTimeRange',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
+73 -30
View File
@@ -19,6 +19,12 @@ const transportTypeDict = {
const isEmpty = value => value === undefined || value === null || value === '';
const normalizeNumericDisplayValue = value => (Number(value) === -1 ? '' : value);
const formatMoneyDisplay = value => {
if (isEmpty(value) || Number(value) === -1) return '';
const number = Number(value);
if (!Number.isFinite(number)) return '';
return number.toFixed(2);
};
const parseJsonArray = value => {
if (Array.isArray(value)) return value;
@@ -92,14 +98,32 @@ const getProcessConfigNodes = row => {
const isAcceptProcessNode = node => node.key === 'accept' || node.name === '接单';
const isTruthyFlag = value => {
if (value === true || value === 1) return true;
if (value === false || value === 0 || value === null || value === undefined || value === '') {
return false;
}
return ['true', '1', 'yes'].includes(String(value).trim().toLowerCase());
};
const requiresDriverAcceptConfirmation = row =>
getProcessConfigNodes(row).some(
node =>
isAcceptProcessNode(node) &&
node.enabled !== false &&
node.confirmMode === 'yes' &&
node.confirmDriver === true
);
getProcessConfigNodes(row).some(node => {
if (!isAcceptProcessNode(node) || node.enabled === false) return false;
if (node.confirmMode === 'no_confirm_accept') return false;
// 与后端一致:是否确认=是 即需接单(不强制依赖 confirmDriver
return node.confirmMode === 'yes' || !node.confirmMode;
});
const getDriverRejectReason = row => {
if (!row) return '';
const reason =
row.driverRejectReason ||
row.driverRefuseReason ||
row.acceptRejectReason ||
row.rejectReason ||
'';
return String(reason || '').trim();
};
const isDriverRejectedStatus = value => {
if (value === null || value === undefined || typeof value === 'boolean') return false;
@@ -122,6 +146,7 @@ const isDriverRejectedStatus = value => {
};
const hasDriverRejectRecord = row => {
if (String(row.driverAcceptStatus || '').toLowerCase() === 'rejected') return true;
const rejectRecordValues = [
row.driverRejectTime,
row.driverRejectedTime,
@@ -161,9 +186,16 @@ const hasDriverRejectRecord = row => {
};
const isDriverRejectedWaybill = row =>
requiresDriverAcceptConfirmation(row) && hasDriverRejectRecord(row);
(row.requireAccept === true ||
row.requireAccept === 1 ||
requiresDriverAcceptConfirmation(row)) &&
hasDriverRejectRecord(row);
const hasDriverAcceptRecord = row => {
if (String(row.driverAcceptStatus || '').toLowerCase() === 'accepted') return true;
if (['rejected', 'pending'].includes(String(row.driverAcceptStatus || '').toLowerCase())) {
return false;
}
const recordValues = [
row.driverAcceptTime,
row.driverAcceptedTime,
@@ -172,6 +204,7 @@ const hasDriverAcceptRecord = row => {
row.driverAcceptedBy,
row.acceptUserId,
row.acceptUserName,
row.driverAcceptDriverId,
];
if (recordValues.some(value => value !== null && value !== undefined && value !== '')) {
return true;
@@ -315,10 +348,10 @@ const formatUnitPrice = row => {
return billingUnit ? `${billingPrice}(${billingUnit})` : billingPrice;
};
const formatAmount = (row, props) => getFirstValue(row, props);
const formatAmount = (row, props) => formatMoneyDisplay(getFirstValue(row, props));
const sumAmount = list =>
list.reduce((sum, item) => {
const sumAmount = (rows = []) =>
rows.reduce((sum, item) => {
const value = getFirstValue(item, ['freightAmount', 'amount', 'totalAmount']);
return isEmpty(value) ? sum : sum + Number(value || 0);
}, 0);
@@ -337,7 +370,7 @@ const calculateFreightTotal = rows => {
return sum + Number(item.unitPrice || 0) * Number(item.quantity || 0);
}, 0);
if (!hasAmountField) return '';
return Number.isFinite(total) ? Number(total.toFixed(2)).toString() : '';
return Number.isFinite(total) ? total.toFixed(2) : '';
};
const formatFreight = row => {
@@ -345,9 +378,9 @@ const formatFreight = row => {
if (!isEmpty(value)) return value;
const freight = parseJsonObject(row.freightJson);
const freightValue = getFirstValue(freight, ['freightAmount', 'transportFee']);
if (!isEmpty(freightValue)) return freightValue;
if (!isEmpty(freightValue)) return formatMoneyDisplay(freightValue);
const total = sumAmount(Array.isArray(freight.freightItems) ? freight.freightItems : []);
return total ? total : '';
return total ? formatMoneyDisplay(total) : '';
};
const formatOtherFeeTotal = row => {
@@ -364,10 +397,10 @@ const formatFreightTotal = row => {
const otherFeeTotal = !isEmpty(otherFee) ? otherFee : formatOtherFeeTotal(row);
if (!isEmpty(calculated)) {
const total = Number(calculated || 0) + Number(otherFeeTotal || 0);
return Number.isFinite(total) ? Number(total.toFixed(2)).toString() : calculated;
return Number.isFinite(total) ? total.toFixed(2) : calculated;
}
const value = getFirstValue(freight, ['totalFreightAmount', 'totalFreight', 'totalAmount']);
if (!isEmpty(value)) return value;
if (!isEmpty(value)) return formatMoneyDisplay(value);
return formatAmount(row, ['freightTotal', 'totalFreight', 'totalAmount']);
};
@@ -377,6 +410,8 @@ export const config = {
title: '运单管理',
permission: 'waybill_manage',
importUrl: '/blade-transport/waybill-manage/import-waybill-manage',
exportUrl: '/blade-transport/waybill-manage/export-waybill-manage',
exportName: '运单管理',
defaultForm: {
carrierType: '承运商',
transportType: 'road',
@@ -423,29 +458,37 @@ export const config = {
statusProp: 'businessStatus',
statusTextProp: 'businessStatusName',
formatStatus(row, prop, defaultText) {
if (
prop === 'businessStatus' &&
requiresDriverAcceptConfirmation(row) &&
isInProgressStatus(row, defaultText) &&
!hasDriverAcceptRecord(row)
) {
if (prop !== 'businessStatus') return defaultText;
const status = String(row.businessStatus || '');
const terminalStatuses = ['draft', 'completed', 'cancelled', 'waiting_dispatch', 'dispatching'];
const needAccept =
row.requireAccept === true ||
row.requireAccept === 1 ||
requiresDriverAcceptConfirmation(row);
// 需司机接单且尚未接单 → 待执行(含库中仍为 running 的历史数据展示校正)
if (needAccept && !hasDriverAcceptRecord(row) && !terminalStatuses.includes(status)) {
return '待执行';
}
if (
prop === 'businessStatus' &&
row.businessStatus === 'pending' &&
!requiresDriverAcceptConfirmation(row)
) {
return '进行中';
}
if (status === 'pending') return '待执行';
if (status === 'running' || status === 'processing') return '进行中';
return defaultText;
},
canReassign(row) {
return isDriverRejectedWaybill(row);
},
getDriverRejectReason,
isDriverRejectedWaybill,
canEdit(row) {
// 进行中的运单不允许编辑
if (isInProgressStatus(row, row.businessStatusName)) {
return false;
}
return !isDriverRejectedWaybill(row);
},
canCancel(row) {
// 进行中的运单不允许取消
return !isInProgressStatus(row, row.businessStatusName);
},
deleteStatus: ['draft'],
editStatus: ['draft', 'pending'],
};
@@ -637,7 +680,7 @@ export const option = {
viewDisplay: false,
},
{
label: '单价(计价单位)',
label: '单价',
prop: 'unitPrice',
formatter: row => formatUnitPrice(row),
minWidth: 140,
@@ -67,9 +67,7 @@ export const feeDetailBaseColumns = [
];
export const feeDetailTailColumns = [
{ label: '原总金额', prop: 'originalAmountText', minWidth: 120 },
{ label: '调整金额', prop: 'adjustAmountText', minWidth: 120 },
{ label: '调整后总金额', prop: 'afterAmountText', minWidth: 140 },
{ label: '结算金额', prop: 'originalAmountText', minWidth: 120 },
{ label: '备注', prop: 'remark', minWidth: 160 },
{ label: '最后录入人', prop: 'updateUserName', minWidth: 120 },
{ label: '最后录入时间', prop: 'updateTime', minWidth: 170 },
@@ -2,10 +2,8 @@ export const transportReconciliationFormFields = [
{ prop: 'reconciliationNo', label: '对账单号', readonly: true },
{ prop: 'reconciliationMode', label: '对账模式', type: 'mode' },
{ prop: 'formalSettlementNo', label: '正式结算单', type: 'formal' },
{ prop: 'payerName', label: '付款方', readonly: true },
{ prop: 'payeeName', label: '收款方', readonly: true },
{ prop: 'customerName', label: '客户/承运商', readonly: true },
{ prop: 'projectName', label: '项目', readonly: true },
{ prop: 'deptName', label: '所属组织', readonly: true },
{ prop: 'contractName', label: '合同名称', readonly: true },
{ prop: 'paidAmount', label: '已付合计', money: true, readonly: true },
{ prop: 'reconcilerName', label: '对账人', readonly: true },
@@ -1,12 +1,12 @@
export const reconciliationMatchStatusOptions = [
{ label: '全部', value: '' },
{ label: '全部', value: 'all' },
{ label: '未匹配', value: 'unmatched' },
{ label: '部分匹配', value: 'partial' },
{ label: '已匹配', value: 'matched' },
];
export const reconciliationStatusOptions = [
{ label: '全部', value: '' },
{ label: '全部', value: 'all' },
{ label: '未完成', value: 'unfinished' },
{ label: '已完成', value: 'completed' },
];
@@ -18,7 +18,7 @@ export const transportReconciliationTableColumns = [
export const internalColumns = [
{ prop: 'documentNo', label: '单据号', minWidth: 150 },
{ prop: 'waybillNo', label: '运单号', minWidth: 120 },
{ prop: 'matchedExternalLineNo', label: '匹配外部账单行号', minWidth: 140 },
{ prop: 'matchedExternalLineNo', label: '匹配外部账单行号', minWidth: 180 },
{ prop: 'vehicleNo', label: '车号', minWidth: 100 },
{ prop: 'departureAddress', label: '发货地址', minWidth: 180 },
{ prop: 'arrivalAddress', label: '到货地址', minWidth: 180 },
@@ -27,13 +27,10 @@ export const internalColumns = [
{ prop: 'transportType', label: '运输类型', minWidth: 120 },
{ prop: 'cargoName', label: '货物名称', minWidth: 120 },
{ prop: 'cargoType', label: '货物类型', minWidth: 120 },
{ prop: 'specification', label: '规格', minWidth: 100 },
{ prop: 'model', label: '型号', minWidth: 100 },
{ prop: 'transportQuantity', label: '运输总量', minWidth: 100, number: true },
{ prop: 'mileage', label: '里程(KM', minWidth: 100, number: true },
{ prop: 'mileage', label: '里程(KM', minWidth: 120, number: true },
{ prop: 'batchNo', label: '批次号', minWidth: 100 },
{ prop: 'unitPrice', label: '运输单价', minWidth: 110, money: true },
{ prop: 'freightAmount', label: '运输费', minWidth: 110, money: true },
{ prop: 'settlementAmount', label: '结算金额', minWidth: 120, money: true },
{ prop: 'matchResult', label: '匹配结果', minWidth: 100 },
{ prop: 'updateResult', label: '账单更新结果', minWidth: 130 },
@@ -50,7 +47,7 @@ export const externalVehicleColumns = [
{ prop: 'cargoName', label: '货物名称', minWidth: 120 },
{ prop: 'cargoType', label: '货物类型', minWidth: 120 },
{ prop: 'transportQuantity', label: '运输总量', minWidth: 100, number: true },
{ prop: 'mileage', label: '里程(KM', minWidth: 100, number: true },
{ prop: 'mileage', label: '里程(KM', minWidth: 120, number: true },
{ prop: 'batchNo', label: '批次号', minWidth: 100 },
{ prop: 'unitPrice', label: '运输单价', minWidth: 110, money: true },
{ prop: 'freightAmount', label: '运费', minWidth: 110, money: true },
@@ -69,9 +66,9 @@ export const externalCargoColumns = [
{ prop: 'cargoType', label: '货物类型', minWidth: 120 },
{ prop: 'specification', label: '规格', minWidth: 100 },
{ prop: 'model', label: '型号', minWidth: 100 },
{ prop: 'transportQuantity', label: '运输量', minWidth: 100, number: true },
{ prop: 'transportQuantity', label: '运输量', minWidth: 100, number: true },
{ prop: 'unitPrice', label: '运输单价', minWidth: 110, money: true },
{ prop: 'mileage', label: '里程(KM', minWidth: 100, number: true },
{ prop: 'mileage', label: '里程(KM', minWidth: 120, number: true },
{ prop: 'freightAmount', label: '运输费', minWidth: 110, money: true },
{ prop: 'settlementAmount', label: '结算金额', minWidth: 120, money: true },
{ prop: 'settlementAmount', label: '结算费用合计', minWidth: 130, money: true },
];
+5
View File
@@ -76,6 +76,11 @@ export const option = {
prop: 'posts',
minWidth: 150,
},
{
label: '驾驶车辆',
prop: 'drivingVehicle',
minWidth: 130,
},
{
label: '所属组织',
prop: 'organizationName',
@@ -21,7 +21,7 @@ export const option = {
label: '车牌号',
prop: 'plateNo',
search: true,
searchOrder: 6,
searchOrder: 7,
slot: true,
minWidth: 120,
},
@@ -29,7 +29,7 @@ export const option = {
label: '业务关系',
prop: 'businessRelation',
search: true,
searchOrder: 5,
searchOrder: 6,
searchValue: '',
type: 'select',
dicData: [
@@ -50,11 +50,18 @@ export const option = {
slot: true,
minWidth: 150,
},
{
label: '使用部门',
prop: 'useDepartment',
search: true,
searchOrder: 3,
minWidth: 140,
},
{
label: '车辆类型',
prop: 'vehicleType',
search: true,
searchOrder: 4,
searchOrder: 5,
type: 'select',
dicData: [
{ label: '全部', value: '' },
@@ -108,7 +115,7 @@ export const option = {
label: '车辆状态',
prop: 'status',
search: true,
searchOrder: 3,
searchOrder: 4,
type: 'select',
slot: true,
dicData: [
@@ -0,0 +1,181 @@
const approvalStatusOptions = [
{ label: '草稿', value: 'draft' },
{ label: '审批中', value: 'reviewing' },
{ label: '已驳回', value: 'rejected' },
{ label: '审批通过', value: 'approved' },
];
export const statusName = status =>
approvalStatusOptions.find(item => item.value === status)?.label || status || '-';
export const option = {
height: 'auto',
calcHeight: 32,
dialogWidth: 1200,
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
saveBtnText: '提交',
updateBtnText: '提交',
searchShow: true,
searchMenuSpan: 24,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
selection: false,
dialogClickModal: false,
menuFixed: 'right',
menuWidth: 320,
column: [
// ========== 表格列(顺序固定)==========
{
label: '申请单号',
prop: 'applicationNo',
search: true,
searchOrder: 5,
searchPlaceholder: '请输入',
minWidth: 180,
slot: true,
addDisplay: true,
editDisabled: true,
disabled: true,
placeholder: '保存后自动生成',
},
{
label: '车牌号',
prop: 'plateNo',
search: true,
searchOrder: 4,
searchPlaceholder: '请输入',
minWidth: 120,
formslot: true,
rules: [{ required: true, message: '请选择车牌号', trigger: 'blur' }],
},
{
label: '所属组织',
prop: 'organizationName',
search: true,
searchOrder: 3,
searchPlaceholder: '请输入',
minWidth: 140,
disabled: true,
placeholder: '选择车牌号后自动填充',
rules: [{ required: true, message: '请输入所属组织', trigger: 'blur' }],
},
{
label: '使用部门',
prop: 'useDepartment',
search: true,
searchOrder: 2,
searchPlaceholder: '请输入',
minWidth: 140,
formslot: true,
span: 12,
rules: [{ required: true, message: '请选择使用部门', trigger: 'blur' }],
},
{
label: '车辆类型',
prop: 'vehicleType',
minWidth: 110,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
{
label: '审批状态',
prop: 'approvalStatus',
search: true,
searchOrder: 1,
type: 'select',
dicData: approvalStatusOptions,
slot: true,
minWidth: 120,
addDisplay: false,
editDisplay: false,
},
{
label: '当前节点',
prop: 'currentNode',
minWidth: 120,
addDisplay: false,
editDisplay: false,
},
{
label: '当前处理人',
prop: 'currentProcessor',
minWidth: 130,
addDisplay: false,
editDisplay: false,
},
{
label: '创建人',
prop: 'createUserName',
minWidth: 120,
addDisplay: false,
editDisplay: false,
},
{
label: '创建时间',
prop: 'createTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
minWidth: 170,
addDisplay: false,
editDisplay: false,
},
// ========== 新增/编辑表单专用(不进表格)==========
{
label: '申请人',
prop: 'applicantName',
hide: true,
display: true,
addDisplay: true,
editDisplay: true,
viewDisplay: true,
disabled: true,
span: 12,
},
{
label: '申请时间',
prop: 'applyTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
hide: true,
display: true,
addDisplay: true,
editDisplay: true,
viewDisplay: true,
disabled: true,
span: 12,
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
hide: true,
span: 24,
maxlength: 200,
showWordLimit: true,
rules: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
},
{
label: '附件',
prop: 'attachments',
hide: true,
span: 24,
minWidth: 100,
formslot: true,
},
],
};
+17 -2
View File
@@ -70,14 +70,16 @@ export const option = {
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 140,
span: 12,
placeholder: '输入车牌号/船号查询选择',
placeholder: '请选择车牌号/船号',
rules: [
{ required: true, message: '请输入车牌号/船号', trigger: 'blur' },
{ required: true, message: '请选择车牌号/船号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -268,6 +270,19 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'accidentDate', label: '事故发生日期' },
{ prop: 'accidentLocation', label: '事故发生地点' },
{ prop: 'accidentNature', label: '事故性质' },
{ prop: 'accidentResponsibility', label: '事故责任' },
{ prop: 'directEconomicLoss', label: '直接经济损失' },
{ prop: 'insuranceClaimAmount', label: '保险理赔金额' },
{ prop: 'accidentReasonDamage', label: '事故原因及损坏情况' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+22 -16
View File
@@ -75,14 +75,16 @@ export const option = {
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 140,
span: 12,
placeholder: '输入车牌号/船号查询选择',
placeholder: '请选择车牌号/船号',
rules: [
{ required: true, message: '请输入车牌号/船号', trigger: 'blur' },
{ required: true, message: '请选择车牌号/船号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -105,6 +107,7 @@ export const option = {
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
slot: true,
minWidth: 140,
span: 12,
placeholder: '请输入',
@@ -191,23 +194,12 @@ export const option = {
formslot: true,
},
{
label: '检测评定开始日期',
prop: 'inspectionAssessmentDateStart',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
search: true,
hide: true,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
{
label: '检测评定结束日期',
prop: 'inspectionAssessmentDateEnd',
label: '检测评定日期',
prop: 'inspectionAssessmentDateRange',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
searchRange: true,
search: true,
hide: true,
addDisplay: false,
@@ -278,6 +270,20 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'inspectionAssessmentDate', label: '检测评定日期' },
{ prop: 'validUntilDate', label: '有效期截止日' },
{ prop: 'vehicleTechnicalLevel', label: '车辆技术等级' },
{ prop: 'shipInspectionType', label: '船舶检验类型' },
{ prop: 'passengerTypeLevel', label: '客车类型及等级' },
{ prop: 'inspectionUnit', label: '检测评定单位' },
{ prop: 'fee', label: '费用(元)' },
{ prop: 'assessmentUnit', label: '评定(复核)单位' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+15
View File
@@ -54,9 +54,11 @@ export const option = {
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
searchOrder: 4,
span: 12,
order: 10,
@@ -211,6 +213,19 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'equipmentCode', label: '设备号' },
{ prop: 'equipmentName', label: '设备名称' },
{ prop: 'factoryDate', label: '出厂日期' },
{ prop: 'equipmentBrand', label: '设备品牌' },
{ prop: 'equipmentType', label: '设备类型' },
{ prop: 'specificationModel', label: '规格型号' },
{ prop: 'remark', label: '备注' },
{ prop: 'originalEquipmentNo', label: '原厂设备号' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+16 -2
View File
@@ -49,14 +49,16 @@ export const option = {
{
label: '车牌号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 140,
span: 12,
placeholder: '输入车牌号模糊查询选择',
placeholder: '请选择车牌号',
rules: [
{ required: true, message: '请输入车牌号', trigger: 'blur' },
{ required: true, message: '请选择车牌号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -206,6 +208,18 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'entryTime', label: '入口时间' },
{ prop: 'etcCardNo', label: 'ETC卡号' },
{ prop: 'exitTime', label: '出口时间' },
{ prop: 'entryStation', label: '入口站' },
{ prop: 'transactionAmount', label: '交易金额' },
{ prop: 'exitStation', label: '出口站' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+19 -1
View File
@@ -78,17 +78,20 @@ export const option = {
prop: 'policyFile',
formslot: true,
hide: true,
viewDisplay: false,
span: 24,
},
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 140,
rules: [
{ required: true, message: '请输入车牌号/船号', trigger: 'blur' },
{ required: true, message: '请选择车牌号/船号', trigger: 'change' },
{ max: 50, message: '最多 50 个字符', trigger: 'blur' },
],
},
@@ -108,6 +111,7 @@ export const option = {
rules: [
{ required: true, message: '请输入保单号', trigger: 'blur' },
{ max: 80, message: '最多 80 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9]+$/, message: '保单号只能输入数字、字母', trigger: 'blur' },
],
},
{
@@ -240,6 +244,20 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'insuranceType', label: '保险类型' },
{ prop: 'policyNo', label: '保单号' },
{ prop: 'startDate', label: '开始日期' },
{ prop: 'endDate', label: '结束日期' },
{ prop: 'insuredAmount', label: '保额' },
{ prop: 'premium', label: '保费' },
{ prop: 'invoiceNo', label: '发票号' },
{ prop: 'invoiceDate', label: '开票日期' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+12 -1
View File
@@ -40,13 +40,15 @@ export const option = {
{
label: '车牌号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
searchSpan: 6,
minWidth: 130,
rules: [
{ required: true, message: '请输入车牌号', trigger: 'blur' },
{ required: true, message: '请选择车牌号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -199,6 +201,15 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'previousMonthMileage', label: '上月统计里程数' },
{ prop: 'currentMonthMileage', label: '本月统计里程数' },
{ prop: 'monthlyMileage', label: '本月行驶里程数' },
{ prop: 'totalMileage', label: '累计行驶里程数' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+21 -2
View File
@@ -92,13 +92,15 @@ export const option = {
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 150,
placeholder: '输入车牌号/船号查询选择',
placeholder: '请选择车牌号/船号',
rules: [
{ required: true, message: '请输入车牌号/船号', trigger: 'blur' },
{ required: true, message: '请选择车牌号/船号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -287,6 +289,23 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'cardNo', label: '卡号' },
{ prop: 'transactionTime', label: '交易时间' },
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'feeType', label: '费用类型' },
{ prop: 'oilProduct', label: '油品' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'cardHolder', label: '持卡人' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'quantity', label: '数量' },
{ prop: 'unitPrice', label: '单价' },
{ prop: 'transactionAmount', label: '交易金额' },
{ prop: 'balance', label: '余额' },
{ prop: 'station', label: '站点' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+15 -13
View File
@@ -17,16 +17,6 @@ export const vehicleTypeDic = [
{ label: '船舶', value: '船舶' },
];
export const expenseTypeDic = [
{ label: '过路费', value: '过路费' },
{ label: '停车费', value: '停车费' },
{ label: '维修费', value: '维修费' },
{ label: '保险费', value: '保险费' },
{ label: '年检费', value: '年检费' },
{ label: '装卸费', value: '装卸费' },
{ label: '其他', value: '其他' },
];
export const option = {
height: 'auto',
calcHeight: 32,
@@ -68,14 +58,16 @@ export const option = {
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 150,
span: 12,
placeholder: '输入车牌号/船号查询选择',
placeholder: '请选择车牌号/船号',
rules: [
{ required: true, message: '请输入车牌号/船号', trigger: 'blur' },
{ required: true, message: '请选择车牌号/船号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -95,7 +87,7 @@ export const option = {
label: '费用类型',
prop: 'expenseType',
type: 'select',
dicData: expenseTypeDic,
dicData: [],
search: true,
minWidth: 150,
span: 12,
@@ -209,6 +201,16 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'expenseDate', label: '费用日期' },
{ prop: 'expenseType', label: '费用类型' },
{ prop: 'amount', label: '金额' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+15 -2
View File
@@ -49,14 +49,16 @@ export const option = {
{
label: '车牌号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 130,
span: 12,
placeholder: '输入车牌号查询选择',
placeholder: '请选择车牌号',
rules: [
{ required: true, message: '请输入车牌号', trigger: 'blur' },
{ required: true, message: '请选择车牌号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -206,6 +208,17 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'handler', label: '处理人' },
{ prop: 'replacementTime', label: '换胎时间' },
{ prop: 'tireBrand', label: '轮胎品牌' },
{ prop: 'tireQuantity', label: '换胎数量' },
{ prop: 'replacementCost', label: '换胎费用' },
{ prop: 'replacementDescription', label: '换胎说明' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+11 -1
View File
@@ -47,11 +47,13 @@ export const option = {
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 140,
placeholder: '输入车牌号/船号模糊查询选择',
placeholder: '请选择车牌号/船号',
rules: [{ max: 30, message: '最多 30 个字符', trigger: 'blur' }],
},
{
@@ -161,6 +163,14 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'changeItem', label: '变更事项' },
{ prop: 'changeContent', label: '变更内容' },
{ prop: 'remark', label: '备注' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+25 -7
View File
@@ -70,14 +70,16 @@ export const option = {
{
label: '车牌号/船号',
prop: 'vehicleNo',
type: 'select',
slot: true,
formslot: true,
search: true,
searchType: 'input',
minWidth: 140,
span: 12,
placeholder: '输入车牌号/船号查询选择',
placeholder: '请选择车牌号/船号',
rules: [
{ required: true, message: '请输入车牌号/船号', trigger: 'blur' },
{ required: true, message: '请选择车牌号/船号', trigger: 'change' },
{ max: 30, message: '最多 30 个字符', trigger: 'blur' },
],
},
@@ -117,13 +119,13 @@ export const option = {
{
label: '日期',
prop: 'violationTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
minWidth: 170,
minWidth: 140,
span: 12,
placeholder: '请选择',
rules: [{ required: true, message: '请选择时间', trigger: 'click' }],
rules: [{ required: true, message: '请选择日期', trigger: 'click' }],
},
{
label: '地点',
@@ -173,7 +175,7 @@ export const option = {
prop: 'processStatus',
type: 'radio',
dicData: processStatusDic,
value: '处理',
value: '处理',
search: true,
clearable: true,
minWidth: 120,
@@ -278,6 +280,22 @@ export const option = {
],
};
export const exportColumns = [
{ prop: 'vehicleType', label: '车船类型' },
{ prop: 'vehicleNo', label: '车牌号/船号' },
{ prop: 'driverName', label: '驾驶人' },
{ prop: 'violationType', label: '类型' },
{ prop: 'violationItem', label: '事项' },
{ prop: 'violationTime', label: '日期' },
{ prop: 'location', label: '地点' },
{ prop: 'fineAmount', label: '罚款' },
{ prop: 'deductPoints', label: '被扣分数' },
{ prop: 'penaltyUnit', label: '被罚单位' },
{ prop: 'processStatus', label: '状态' },
{ prop: 'processDescription', label: '过程描述' },
{ prop: 'processResult', label: '处理结果' },
];
export const excelOption = {
labelPosition: 'right',
submitBtn: false,
+12 -1
View File
@@ -1,7 +1,18 @@
<template>
<router-view #="{ Component }">
<keep-alive :include="$store.getters.tagsKeep">
<component :is="Component" />
<component :is="tabView($route, Component)" />
</keep-alive>
</router-view>
</template>
<script>
import { tabView } from '@/router/tab';
export default {
name: 'layout',
methods: {
tabView,
},
};
</script>
+7 -7
View File
@@ -55,13 +55,13 @@
<el-button type="primary" @click.prevent="handleLogin" class="login-submit"
>{{ $t('login.submit') }}
</el-button>
<el-button
v-if="this.registerMode"
type="danger"
@click.prevent="handleRegister"
class="register-submit"
>{{ $t('login.register') }}
</el-button>
<!-- <el-button-->
<!-- v-if="this.registerMode"-->
<!-- type="danger"-->
<!-- @click.prevent="handleRegister"-->
<!-- class="register-submit"-->
<!-- >{{ $t('login.register') }}-->
<!-- </el-button>-->
</el-form-item>
<behavior-captcha ref="behaviorCaptcha" @success="handleBehaviorSuccess"></behavior-captcha>
</el-form>
+28 -1
View File
@@ -2,11 +2,38 @@ import router from './router/';
import store from './store';
import { tabKeyOf } from '@/router/tab';
import { getToken } from '@/utils/auth';
import {
consumeReloadQuery,
hasReloadQuery,
isChunkLoadError,
reloadForChunkError,
setPendingRoutePath,
} from '@/utils/chunk-reload';
import { ElMessage } from 'element-plus';
import NProgress from 'nprogress'; // progress bar
import 'nprogress/nprogress.css'; // progress bar style
NProgress.configure({ showSpinner: false });
const lockPage = '/lock'; //锁屏页
// 懒加载 chunk / CSS 失败时整页刷新,避免菜单点击后卡死
router.onError(error => {
if (!isChunkLoadError(error)) return;
if (reloadForChunkError()) {
ElMessage.warning('页面资源已更新,正在重新加载…');
}
});
router.beforeEach((to, from, next) => {
if (hasReloadQuery(to.query)) {
next({
path: to.path,
query: consumeReloadQuery(to.query),
hash: to.hash,
replace: true,
});
return;
}
setPendingRoutePath(to.fullPath);
const meta = to.meta || {};
const isMenu = meta.menu === undefined ? to.query.menu : meta.menu;
store.commit('SET_IS_MENU', isMenu === undefined);
@@ -35,7 +62,7 @@ router.beforeEach((to, from, next) => {
fullPath: tabKeyOf(to),
params: to.params,
query: to.query,
meta: meta,
meta: { ...meta, keepAlive: true },
});
}
next();
+15 -10
View File
@@ -2,6 +2,9 @@ import website from '@/config/website';
import { getToken } from '@/utils/auth';
import store from '@/store';
import { generateIframePath, processUrlForQuery, isURL } from './router';
import { wrapViewLoader } from '@/utils/chunk-reload';
// 保持懒加载,避免 eager 与 store 循环依赖(Cannot access 'store' before initialization
const modules = import.meta.glob('../**/**/*.vue');
// 将多级路由扁平化为二级路由,支持 keep-alive 跨层级缓存
@@ -96,21 +99,23 @@ RouterPlugin.install = function (option = {}) {
component: (() => {
// 判断是否为首路由
if (first) {
return modules[
option.store.getters.isMacOs || !website.setting.menu
? '../page/index/layout.vue'
: '../page/index/index.vue'
];
return wrapViewLoader(
modules[
option.store.getters.isMacOs || !website.setting.menu
? '../page/index/layout.vue'
: '../page/index/index.vue'
]
);
// 判断是否为多层路由
} else if (isChild && !first) {
return modules['../page/index/layout.vue'];
return wrapViewLoader(modules['../page/index/layout.vue']);
// 判断是否为最终的页面视图
} else {
let result = modules[`../${component}.vue`];
if (!result) {
isComponent = false;
}
return result;
return wrapViewLoader(result);
}
})(),
name,
@@ -127,7 +132,7 @@ RouterPlugin.install = function (option = {}) {
if (first) {
oMenu[propsDefault.path] = `${path}`;
let componentPath = oMenu.component || component;
let result = modules[`../${componentPath}.vue`];
let result = wrapViewLoader(modules[`../${componentPath}.vue`]);
if (!result) {
isComponent = false;
}
@@ -173,7 +178,7 @@ export const formatPath = (ele, first) => {
const icon = ele[propsDefault.icon];
ele[propsDefault.icon] = icon || '';
ele.meta = {
keepAlive: ele.isOpen === 2,
keepAlive: true,
};
const iframeComponent = 'components/iframe/main';
const iframeSrc = href => {
@@ -215,7 +220,7 @@ export const formatPath = (ele, first) => {
ele[propsDefault.children].forEach(child => {
child.component = 'views' + child[propsDefault.path];
child.meta = {
keepAlive: child.isOpen === 2,
keepAlive: true,
};
if (isURL(child[propsDefault.href])) {
let href = child[propsDefault.href];
+1 -1
View File
@@ -128,5 +128,5 @@ export function tabView(route, Component) {
};
wrapperMap.set(tabKey, wrapper);
}
return h(wrapper);
return wrapper;
}
+149 -30
View File
@@ -1,5 +1,8 @@
import Layout from '@/page/index/index.vue';
import Store from '@/store/';
import { wrapViewLoader } from '@/utils/chunk-reload';
const loadView = loader => wrapViewLoader(loader);
export default [
{
@@ -10,7 +13,7 @@ export default [
path: '',
name: '汇票付款表单',
meta: { keepAlive: false, activeMenu: '/payment/bill-payment' },
component: () => import('@/views/payment/bill-payment-form.vue'),
component: loadView(() => import('@/views/payment/bill-payment-form.vue')),
},
],
},
@@ -22,7 +25,7 @@ export default [
path: '',
name: '汇票台账表单',
meta: { keepAlive: false, activeMenu: '/payment/bill-ledger' },
component: () => import('@/views/payment/bill-ledger-form.vue'),
component: loadView(() => import('@/views/payment/bill-ledger-form.vue')),
},
],
},
@@ -34,7 +37,7 @@ export default [
path: '',
name: '认领记录详情',
meta: { keepAlive: false, activeMenu: '/payment/receipt-claim-record' },
component: () => import('@/views/payment/receipt-claim-record-form.vue'),
component: loadView(() => import('@/views/payment/receipt-claim-record-form.vue')),
},
],
},
@@ -46,7 +49,7 @@ export default [
path: '',
name: '收款流水认领',
meta: { keepAlive: false, activeMenu: '/payment/receipt-flow' },
component: () => import('@/views/payment/receipt-flow-form.vue'),
component: loadView(() => import('@/views/payment/receipt-flow-form.vue')),
},
],
},
@@ -58,7 +61,7 @@ export default [
path: '',
name: '收票登记',
meta: { keepAlive: false, activeMenu: '/payment/invoice-receipt' },
component: () => import('@/views/payment/invoice-receipt-form.vue'),
component: loadView(() => import('@/views/payment/invoice-receipt-form.vue')),
},
],
},
@@ -70,7 +73,7 @@ export default [
path: '',
name: '开票申请',
meta: { keepAlive: false, activeMenu: '/payment/invoice-application' },
component: () => import('@/views/payment/invoice-application-form.vue'),
component: loadView(() => import('@/views/payment/invoice-application-form.vue')),
},
],
},
@@ -82,7 +85,7 @@ export default [
path: '',
name: '付款申请',
meta: { keepAlive: false, activeMenu: '/payment/payment-application' },
component: () => import('@/views/payment/payment-application-form.vue'),
component: loadView(() => import('@/views/payment/payment-application-form.vue')),
},
],
},
@@ -97,7 +100,7 @@ export default [
keepAlive: false,
activeMenu: '/settlement/pre-settlement',
},
component: () => import('@/views/settlement/pre-settlement-form.vue'),
component: loadView(() => import('@/views/settlement/pre-settlement-form.vue')),
},
],
},
@@ -112,7 +115,34 @@ export default [
keepAlive: false,
activeMenu: '/settlement/formal-settlement',
},
component: () => import('@/views/settlement/formal-settlement-form.vue'),
component: loadView(() => import('@/views/settlement/formal-settlement-form.vue')),
},
],
},
{
path: '/settlement/transport-reconciliation/form',
component: Layout,
children: [
{
path: '',
name: '新增编辑运输对账单',
meta: {
keepAlive: false,
activeMenu: '/settlement/transport-reconciliation',
},
component: loadView(() => import('@/views/settlement/transport-reconciliation-form.vue')),
},
],
},
{
path: '/business/voucher-manage/detail',
component: Layout,
children: [
{
path: '',
name: '执行凭证批次详情',
meta: { keepAlive: false, activeMenu: '/business/voucher-manage' },
component: loadView(() => import('@/views/business/voucher-manage-detail.vue')),
},
],
},
@@ -124,7 +154,19 @@ export default [
path: '',
name: '新增合同管理',
meta: { keepAlive: false },
component: () => import('@/views/business/contract-manage.vue'),
component: loadView(() => import('@/views/business/contract-manage.vue')),
},
],
},
{
path: '/business/contract-manage/detail',
component: Layout,
children: [
{
path: '',
name: '合同详情',
meta: { keepAlive: false, activeMenu: '/business/contract-manage' },
component: loadView(() => import('@/views/business/contract-manage.vue')),
},
],
},
@@ -136,7 +178,7 @@ export default [
path: '',
name: '新增项目申请',
meta: { keepAlive: false },
component: () => import('@/views/business/project-apply.vue'),
component: loadView(() => import('@/views/business/project-apply.vue')),
},
],
},
@@ -148,7 +190,43 @@ export default [
path: '',
name: '新增编辑运单管理',
meta: { keepAlive: false },
component: () => import('@/views/business/waybill-manage.vue'),
component: loadView(() => import('@/views/business/waybill-manage.vue')),
},
],
},
{
path: '/business/waybill-manage/detail',
component: Layout,
children: [
{
path: '',
name: '运单管理详情',
meta: { keepAlive: false, activeMenu: '/business/waybill-manage' },
component: loadView(() => import('@/views/business/waybill-manage-detail.vue')),
},
],
},
{
path: '/business/waybill-import',
component: Layout,
children: [
{
path: '',
name: '导入运单',
meta: { keepAlive: false, activeMenu: '/business/waybill-import' },
component: loadView(() => import('@/views/business/waybill-import.vue')),
},
],
},
{
path: '/business/waybill-import/form',
component: Layout,
children: [
{
path: '',
name: '新建导入运单',
meta: { keepAlive: false, activeMenu: '/business/waybill-import' },
component: loadView(() => import('@/views/business/waybill-import-form.vue')),
},
],
},
@@ -160,6 +238,18 @@ export default [
path: '',
name: '新增编辑配载管理',
meta: { keepAlive: false },
component: loadView(() => import('@/views/business/loading-manage.vue')),
},
],
},
{
path: '/business/loading-manage/detail',
component: Layout,
children: [
{
path: '',
name: '配载单详情',
meta: { keepAlive: false, activeMenu: '/business/loading-manage' },
component: () => import('@/views/business/loading-manage.vue'),
},
],
@@ -172,7 +262,31 @@ export default [
path: '',
name: '新增编辑运输计划',
meta: { keepAlive: false },
component: () => import('@/views/business/transport-plan.vue'),
component: loadView(() => import('@/views/business/transport-plan.vue')),
},
],
},
{
path: '/business/transport-plan/import',
component: Layout,
children: [
{
path: '',
name: '导入运输计划',
meta: { keepAlive: false, activeMenu: '/business/transport-plan' },
component: loadView(() => import('@/views/business/transport-plan-import.vue')),
},
],
},
{
path: '/business/transport-plan-dispatch',
component: Layout,
children: [
{
path: '',
name: '计划调度',
meta: { keepAlive: false, activeMenu: '/business/transport-plan' },
component: loadView(() => import('@/views/business/transport-plan-dispatch.vue')),
},
],
},
@@ -184,7 +298,7 @@ export default [
path: '',
name: '新增编辑运单模板',
meta: { keepAlive: false },
component: () => import('@/views/business/shipping-template.vue'),
component: loadView(() => import('@/views/business/shipping-template.vue')),
},
],
},
@@ -196,7 +310,7 @@ export default [
path: '',
name: '新增客商档案',
meta: { keepAlive: false },
component: () => import('@/views/vehicle/customer-archive.vue'),
component: loadView(() => import('@/views/vehicle/customer-archive.vue')),
},
],
},
@@ -208,7 +322,7 @@ export default [
path: '',
name: '合同变更',
meta: { keepAlive: false },
component: () => import('@/views/business/contract-manage-change.vue'),
component: loadView(() => import('@/views/business/contract-manage-change.vue')),
},
],
},
@@ -224,7 +338,7 @@ export default [
meta: {
i18n: 'dashboard',
},
component: () => import(/* webpackChunkName: "views" */ '@/views/wel/index.vue'),
component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/wel/index.vue')),
},
{
path: 'dashboard',
@@ -233,7 +347,7 @@ export default [
i18n: 'dashboard',
menu: false,
},
component: () => import(/* webpackChunkName: "views" */ '@/views/wel/dashboard.vue'),
component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/wel/dashboard.vue')),
},
],
},
@@ -248,7 +362,7 @@ export default [
meta: {
i18n: 'test',
},
component: () => import(/* webpackChunkName: "views" */ '@/views/util/test.vue'),
component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/util/test.vue')),
},
],
},
@@ -263,8 +377,9 @@ export default [
meta: {
i18n: 'dict',
},
component: () =>
import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-horizontal.vue'),
component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-horizontal.vue')
),
},
],
},
@@ -279,8 +394,9 @@ export default [
meta: {
i18n: 'dict',
},
component: () =>
import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-vertical.vue'),
component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-vertical.vue')
),
},
],
},
@@ -295,7 +411,7 @@ export default [
meta: {
i18n: 'info',
},
component: () => import(/* webpackChunkName: "views" */ '@/views/system/userinfo.vue'),
component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/system/userinfo.vue')),
},
],
},
@@ -310,8 +426,9 @@ export default [
meta: {
i18n: 'work',
},
component: () =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/form.vue'),
component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/form.vue')
),
},
{
path: 'handle/:taskId/:processInstanceId/:businessId',
@@ -319,8 +436,9 @@ export default [
meta: {
i18n: 'work',
},
component: () =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/handle.vue'),
component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/handle.vue')
),
},
{
path: 'detail/:processInstanceId/:businessId',
@@ -328,8 +446,9 @@ export default [
meta: {
i18n: 'work',
},
component: () =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/detail.vue'),
component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/detail.vue')
),
},
],
},
+2 -5
View File
@@ -16,11 +16,8 @@ const getters = {
lockPasswd: state => state.common.lockPasswd,
tagList: state => state.tags.tagList,
tagsKeep: (state, getters) => {
return getters.tagList
.filter(ele => {
return (ele.meta || {}).keepAlive;
})
.map(ele => ele.fullPath);
// 所有已打开标签均纳入 keep-alive,关闭标签后自动从白名单移除并释放实例
return getters.tagList.map(ele => ele.fullPath).filter(Boolean);
},
tagWel: state => state.tags.tagWel,
token: state => state.user.token,
+17
View File
@@ -149,3 +149,20 @@ a {
top: 0;
bottom: 0;
}
.map-picker-content {
display: flex;
gap: 12px;
> [class$='__map'],
> .address-map {
flex: 1 1 auto;
width: 100%;
min-width: 0;
box-sizing: border-box;
> .amap-container {
width: 100% !important;
height: 100% !important;
}
}
}
+47 -12
View File
@@ -213,6 +213,17 @@
gap: 8px;
}
// 全站弹窗标题统一带 4px 主色竖条(与 .dialog-section-title 保持一致)
// :not(:empty) 避免无标题弹窗出现孤立竖条
.el-dialog__title:not(:empty)::before {
content: '';
flex: none;
width: 4px;
height: 16px;
border-radius: 2px;
background-color: var(--el-color-primary);
}
.dialog-section-title::before {
content: '';
flex: none;
@@ -708,9 +719,9 @@
.avue-crud .el-table__body-wrapper tr.el-table__row:nth-child(even) td.el-table__cell,
.avue-crud .el-table__body-wrapper tr.el-table__row:nth-child(even) td.el-table-fixed-column--left,
.avue-crud
.el-table__body-wrapper
tr.el-table__row:nth-child(even)
td.el-table-fixed-column--right {
.el-table__body-wrapper
tr.el-table__row:nth-child(even)
td.el-table-fixed-column--right {
background-color: #fafafa;
}
@@ -1050,21 +1061,38 @@
.el-form-item--label-right .el-form-item__label {
align-items: center;
}
// ============ 必填星号 * 与 label 首行齐平(仅换行时生效) ============
// label 为 inline-flex,星号 ::before 与文字是两个独立 flex 子项。
// align-items: normal= stretch):单行时星号盒子 = 一行高,位置不变;
// 折行后星号盒子被拉伸到 label 全高,但其内部按 line-height 从顶部排布 → 星号停在第一行。
// height: auto 覆盖 Element Plus 写死的 32px,让 label 收缩到内容高;
// align-self: center 再让收缩后的 label 在表单行内垂直居中(单行与输入框对齐)。
.is-required .el-form-item__label {
height: auto;
align-self: center;
align-items: normal;
}
.el-form-item--default {
margin-bottom: 14px;
}
// ============ 必填星号 * 与首行文字垂直居中 ============
// Element Plus 默认 .el-form-item__label 为 inline-flex + align-items: flex-start
// 星号 :before 高度(1em) 与文字行盒(line-height) 不一致时,多行/折行 label 会偏上。
// 让 :before 跟随 label 行高、以 inline-block 参与行内对齐即可修复,
// 兼容单行(默认 32px)与多行(auto + 18px)两种 label 高度场景。
// 配套:星号必须保持「高度 auto + 继承行高」,stretch 才会按首行排布;
// 写死 height 或 line-height 与 height 相等时效果会退化成居中。
// 注:客商档案(customer-archive)的 label 为 auto 高度 + padding 下沉方案,scoped 特异性更高会覆盖本规则。
.el-form-item.is-required .el-form-item__label::before {
display: inline-block;
height: auto;
line-height: inherit;
margin-right: 4px;
}
// ============ archive-formlabel 与控件垂直居中(全站复用) ============
// archive-form class 被 driver / vehicle / ship / credit-score-quantification / loading-manage 等
// 多个页面复用,但「垂直居中」规则原本锁在 customer-archive.vue 的 scoped 样式里,
// 其它页面拿不到 → 高控件行(证件上传 151px)label 贴顶。提升为全局统一生效。
.archive-form .el-form-item {
align-items: center;
}
//.el-form-item {
// margin-bottom: 15px !important;
//}
@@ -1117,11 +1145,18 @@
color: #909399 !important;
}
// 已选中:选中文本用主文字色 #333333,与占位符明显区分。
// 注意Element Plus 选中值节点同时带 el-select__selected-item 与 el-select__placeholder 两个类
// 而上面占位符规则用了 !important,故此处必须同样 !important 才能胜出(本规则 specificity 更高)。
// 已选中:选中文本用主文字色 #606266,与占位符明显区分。
// 关键Element Plus 的「选中值文本」与「占位符」共用同一个节点
// <div class="el-select__selected-item el-select__placeholder [is-transparent]">
// 只有未选值(或已展开但无搜索输入)时才会带 .is-transparent。
// 因此这里必须排除 .is-transparent,否则未选值的占位符也会被染成选中色,与占位符灰无法区分。
// 仅作用于非禁用态,禁用态保留 Element Plus 的禁用灰(--el-select-disabled-color),避免被覆盖。
.el-select__wrapper:not(.is-disabled) .el-select__selected-item {
.el-select__wrapper:not(.is-disabled) .el-select__selected-item:not(.is-transparent) {
color: #606266 !important;
}
// 多选:选中项渲染为 el-tag,文字色由 tag 自身控制,上面规则覆盖不到,单独处理。
.el-select__wrapper:not(.is-disabled) .el-select__tags-text {
color: #606266 !important;
}
+168
View File
@@ -0,0 +1,168 @@
/**
* 懒加载 chunk / CSS preload 失败后的整页恢复。
* 常见于:部署后旧 hash 失效、静态服务空闲断连、代理 CONNECTION_RESET。
*
* 失败后必须整页刷新:旧入口里的 hashed 资源地址不会自行更新,
* 仅捕获路由错误而不刷新时,未打开过的页面会一直无法进入。
*/
const RELOAD_FLAG = 'app:chunk-reload-ts';
const RELOAD_QUERY = '_chunkreload';
const RELOAD_COOLDOWN_MS = 15000;
const CHUNK_ERROR_RE =
/Failed to fetch dynamically imported module|Importing a module script failed|Unable to preload CSS|error loading dynamically imported module|Loading CSS chunk|Loading chunk .+ failed|ChunkLoadError|Unable to preload|error loading module|Load failed/i;
/** 最近一次路由跳转目标,供 onError 时整页落到正确地址 */
let pendingFullPath = '';
let installed = false;
let reloading = false;
export function setPendingRoutePath(fullPath = '') {
pendingFullPath = fullPath || '';
}
function collectErrorText(error, depth = 0) {
if (!error || depth > 3) return '';
if (typeof error === 'string') return error;
const parts = [
error.message,
error.msg,
error.name,
error.stack,
error.error && collectErrorText(error.error, depth + 1),
error.reason && collectErrorText(error.reason, depth + 1),
error.cause && collectErrorText(error.cause, depth + 1),
error.payload && collectErrorText(error.payload, depth + 1),
];
const target = error.target;
if (target && (target.src || target.href)) {
parts.push(target.src || target.href);
}
try {
parts.push(String(error));
} catch (e) {
/* ignore */
}
return parts.filter(Boolean).join(' ');
}
export function isChunkLoadError(error) {
const text = collectErrorText(error);
if (!text) return false;
if (CHUNK_ERROR_RE.test(text)) return true;
return /Failed to fetch/i.test(text) && /\.m?js(\?|$|:)/i.test(text);
}
export function hasReloadQuery(query = {}) {
return !!(query && query[RELOAD_QUERY] !== undefined);
}
export function consumeReloadQuery(query = {}) {
if (!hasReloadQuery(query)) return query;
const next = { ...query };
delete next[RELOAD_QUERY];
return next;
}
function buildReloadUrl(targetPath) {
const url = new URL(targetPath, window.location.origin);
url.searchParams.set(RELOAD_QUERY, String(Date.now()));
return url.pathname + url.search + url.hash;
}
/**
* @returns {boolean} 是否已触发刷新(冷却期内返回 false,避免死循环)
*/
export function reloadForChunkError(targetPath) {
if (reloading) return false;
const now = Date.now();
const last = Number(sessionStorage.getItem(RELOAD_FLAG) || 0);
if (now - last < RELOAD_COOLDOWN_MS) {
return false;
}
reloading = true;
sessionStorage.setItem(RELOAD_FLAG, String(now));
const path =
targetPath ||
pendingFullPath ||
`${window.location.pathname}${window.location.search}${window.location.hash}`;
const dest = buildReloadUrl(path);
// cache: 'reload' 会回写 HTTP 缓存,降低 index.html 仍指向旧 hash 资源的概率
fetch(`${window.location.pathname}?${RELOAD_QUERY}=${now}`, {
cache: 'reload',
credentials: 'same-origin',
headers: {
Pragma: 'no-cache',
'Cache-Control': 'no-cache',
},
})
.catch(() => {})
.finally(() => {
window.location.replace(dest);
});
return true;
}
export function wrapViewLoader(loader) {
if (typeof loader !== 'function') return loader;
return () =>
Promise.resolve()
.then(() => loader())
.catch(error => {
if (isChunkLoadError(error)) {
reloadForChunkError();
}
throw error;
});
}
export function installChunkReload() {
if (installed || typeof window === 'undefined') return;
installed = true;
window.addEventListener('vite:preloadError', event => {
if (reloadForChunkError()) {
event.preventDefault();
}
});
window.addEventListener('unhandledrejection', event => {
if (!isChunkLoadError(event.reason)) return;
if (reloadForChunkError()) {
event.preventDefault();
}
});
window.addEventListener(
'error',
event => {
const el = event.target;
const isAssetNode =
el &&
el !== window &&
(el.tagName === 'SCRIPT' ||
(el.tagName === 'LINK' && /modulepreload|stylesheet/i.test(el.rel || '')));
if (isAssetNode || isChunkLoadError(event.error || event.message)) {
if (reloadForChunkError()) {
event.preventDefault();
}
}
},
true
);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') checkDeployedAssets();
});
}
function checkDeployedAssets() {
const el = document.querySelector('script[type="module"][src*="/assets/"]');
if (!el || !el.src) return;
fetch(el.src, { method: 'HEAD', cache: 'no-store', credentials: 'same-origin' })
.then(res => {
if (res.status === 404) reloadForChunkError();
})
.catch(() => {});
}
+28
View File
@@ -55,6 +55,19 @@
{{ row.createUserName || row.createUser || '' }}
</template>
<template #remark-form>
<el-input
v-model="form.remark"
class="remark-input"
type="textarea"
:rows="2"
:disabled="dialogType === 'view'"
maxlength="200"
show-word-limit
placeholder="请输入"
/>
</template>
<template #parentId-form>
<el-select
v-model="form.parentId"
@@ -644,3 +657,18 @@ export default {
}
}
</style>
<style lang="scss">
/* 货物类型新增/编辑弹窗:footer 不上移,避免覆盖备注文本域底部内容 */
.cargo-type-dialog.avue-dialog.avue-crud__dialog .avue-dialog__footer {
margin-top: 0 !important;
}
.cargo-type-dialog.avue-dialog.avue-crud__dialog .el-dialog__body {
padding-bottom: 16px !important;
}
.cargo-type-dialog .remark-input {
width: 100%;
}
</style>
+16 -1
View File
@@ -232,7 +232,10 @@
搜索
</el-button>
</div>
<div ref="amap" class="common-address-page__map"></div>
<div class="map-picker-content">
<map-search-results :results="mapSearchResults" @select="selectMapSearchResult" />
<div ref="amap" class="common-address-page__map"></div>
</div>
<div class="common-address-page__map-info">
<span>{{ mapStatus }}</span>
<span v-if="mapSelected.regionName">行政区划{{ mapSelected.regionName }}</span>
@@ -298,6 +301,7 @@ export default {
mapKeyword: '',
mapStatus: '可搜索地址或点击地图选点',
mapSelected: {},
mapSearchResults: [],
amap: null,
amapMarker: null,
amapGeocoder: null,
@@ -1113,6 +1117,12 @@ export default {
this.ensureAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword))
.then(result => {
this.mapSearchResults = (result.geocodes || []).map((item, index) => ({
id: item.id || index,
name: item.formattedAddress || keyword,
address: item.formattedAddress || keyword,
location: item.location,
}));
const point = this.resolveMapPoint(result);
if (!point) {
this.mapStatus = '未找到匹配地址';
@@ -1133,6 +1143,11 @@ export default {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
});
},
selectMapSearchResult(item) {
if (item && item.location) {
this.pickMapPoint(item.location, item.address || item.name || '');
}
},
pickMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat);
+81 -6
View File
@@ -30,6 +30,9 @@
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
<template #feeCategory="{ row }">
{{ formatFeeCategory(row.feeCategory) }}
</template>
<template #feeCategory-form>
<el-select
v-model="form.feeCategory"
@@ -47,6 +50,19 @@
/>
</el-select>
</template>
<template #taxRate="{ row }">{{ formatTaxRate(row.taxRate) }}</template>
<template #taxRate-form>
<el-input
v-model="form.taxRate"
inputmode="numeric"
maxlength="3"
clearable
placeholder="请输入税率"
@input="handleTaxRateInput"
>
<template #append>%</template>
</el-input>
</template>
<template #englishName-form>
<el-input
v-model="form.englishName"
@@ -78,7 +94,7 @@
<script>
import { getList, getDetail, submit, remove, changeStatus } from '@/api/base/fee-item';
import { exportBlob } from '@/api/common';
import { getDictionary } from '@/api/system/dictbiz';
import { getDictionaryAll } from '@/api/system/dictbiz';
import { getDeptTree } from '@/api/system/dept';
import { downloadXls } from '@/utils/util';
import { getToken } from '@/utils/auth';
@@ -134,10 +150,11 @@ export default {
searchOrder: 3,
filterable: true,
formslot: true,
slot: true,
span: 12,
dicData: [],
props: {
label: 'dictValue',
label: 'displayLabel',
value: 'dictKey',
},
minWidth: 120,
@@ -152,6 +169,30 @@ export default {
maxlength: 100,
rules: [{ required: true, message: '请输入费用项代码', trigger: 'blur' }],
},
{
label: '税率',
prop: 'taxRate',
type: 'input',
formslot: true,
slot: true,
minWidth: 100,
span: 12,
rules: [
{ required: true, message: '请输入税率', trigger: 'blur' },
{
validator: (rule, value, callback) => {
const text = String(value ?? '').trim();
const rate = Number(text);
if (!/^\d+$/.test(text) || rate < 0 || rate > 100) {
callback(new Error('税率必须是0到100之间的整数'));
return;
}
callback();
},
trigger: 'blur',
},
],
},
{
label: '费用项',
prop: 'name',
@@ -168,7 +209,8 @@ export default {
type: 'textarea',
minRows: 2,
span: 24,
hide: true,
minWidth: 200,
overHidden: true,
maxlength: 200,
showWordLimit: true,
rules: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
@@ -273,10 +315,17 @@ export default {
},
methods: {
initFeeCategoryOptions() {
getDictionary({ code: 'fee_category' }).then(res => {
this.feeCategoryOptions = res.data.data || [];
getDictionaryAll().then(res => {
const options = (res.data.data || [])
.filter(item => item.code === 'fee_category' && Number(item.parentId) > 0)
.sort((first, second) => Number(first.sort || 0) - Number(second.sort || 0))
.map(item => ({
...item,
displayLabel: `${item.dictValue}/${item.dictKey}`,
}));
this.feeCategoryOptions = options;
const column = this.findColumn(this.option.column, 'feeCategory');
column.dicData = this.feeCategoryOptions;
column.dicData = options;
});
},
initDeptTree() {
@@ -305,6 +354,21 @@ export default {
);
return String((option && option.dictKey) || feeCategory);
},
formatFeeCategory(value) {
const feeCategory = String(value || '').trim();
if (!feeCategory) return '';
const option = this.feeCategoryOptions.find(
item =>
String(item.dictKey || '') === feeCategory || String(item.dictValue || '') === feeCategory
);
return option ? `${option.dictValue}/${option.dictKey}` : feeCategory;
},
formatTaxRate(value) {
const text = String(value ?? '').trim();
if (text === '') return '';
const rate = Number(text);
return Number.isFinite(rate) ? `${Math.trunc(Math.abs(rate))}%` : `${text}%`;
},
getFeeItemCodeSuffix(code, feeCategory) {
const value = String(code || '').trim();
const prefix = this.getFeeCategoryPrefix(feeCategory);
@@ -326,6 +390,7 @@ export default {
const code = this.getFeeItemCodeSuffix(values.englishName, values.feeCategory);
values.feeCategory = String(values.feeCategory || '').trim();
values.name = String(values.name || '').trim();
values.taxRate = Number(values.taxRate);
values.englishName = prefix ? `${prefix}${code}` : code;
values.remark = String(values.remark || '').trim() || undefined;
if (!values.status) values.status = 1;
@@ -335,6 +400,11 @@ export default {
const values = { ...row };
values.feeCategory = String(values.feeCategory || '').trim();
values.englishName = this.getFeeItemCodeSuffix(values.englishName, values.feeCategory);
const taxRateText = String(values.taxRate ?? '').trim();
const taxRate = Number(taxRateText);
if (taxRateText !== '' && Number.isFinite(taxRate)) {
values.taxRate = String(Math.trunc(Math.abs(taxRate)));
}
return values;
},
validateUnique(row) {
@@ -434,6 +504,11 @@ export default {
this.form.englishName = '';
done();
},
handleTaxRateInput(value) {
let normalized = String(value ?? '').replace(/\D/g, '');
if (normalized.length > 1) normalized = normalized.replace(/^0+/, '') || '0';
this.form.taxRate = normalized;
},
searchReset() {
this.query = {};
this.onLoad(this.page);
+30 -5
View File
@@ -20,6 +20,16 @@
批量删除
</el-button>
<div class="insurance-ocr-template-page__search">
<el-select
v-model="query.vehicleType"
clearable
placeholder="车船类型"
style="width: 140px"
@change="search"
>
<el-option label="车辆" value="车辆" />
<el-option label="船舶" value="船舶" />
</el-select>
<el-input
v-model="query.name"
clearable
@@ -42,6 +52,7 @@
<el-table-column type="selection" width="48" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="name" label="模板名称" min-width="220" show-overflow-tooltip />
<el-table-column prop="vehicleType" label="车船类型" min-width="110" />
<el-table-column label="已配置字段" min-width="150">
<template #default="{ row }">{{ getMappingCount(row.mappingConfig) }}</template>
</el-table-column>
@@ -94,6 +105,12 @@
<el-form-item label="模板名称" prop="name">
<el-input v-model="form.name" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="车船类型" prop="vehicleType">
<el-radio-group v-model="form.vehicleType">
<el-radio label="车辆">车辆</el-radio>
<el-radio label="船舶">船舶</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="字段映射" required>
<el-table :data="mappingRows" border class="insurance-ocr-template-page__mapping-table">
<el-table-column prop="key" label="键名" width="180" align="center" />
@@ -165,8 +182,8 @@ export default {
readonly: false,
data: [],
selectionList: [],
query: { name: '' },
form: { id: '', name: '' },
query: { name: '', vehicleType: '' },
form: { id: '', name: '', vehicleType: '车辆' },
mappingRows: createMappingRows(),
page: {
pageSize: 10,
@@ -176,6 +193,7 @@ export default {
},
rules: {
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
vehicleType: [{ required: true, message: '请选择车船类型', trigger: 'change' }],
},
};
},
@@ -218,6 +236,7 @@ export default {
},
resetSearch() {
this.query.name = '';
this.query.vehicleType = '';
this.search();
},
currentChange(currentPage) {
@@ -235,18 +254,23 @@ export default {
openDialog(row, readonly = false) {
this.readonly = readonly;
if (!row?.id) {
this.form = { id: '', name: '', vehicleType: '车辆' };
this.dialogVisible = true;
return;
}
getDetail(row.id).then(res => {
const detail = res.data.data || {};
this.form = { id: detail.id, name: detail.name || '' };
this.form = {
id: detail.id,
name: detail.name || '',
vehicleType: detail.vehicleType || '车辆',
};
this.mappingRows = createMappingRows(detail.mappingConfig);
this.dialogVisible = true;
});
},
resetForm() {
this.form = { id: '', name: '' };
this.form = { id: '', name: '', vehicleType: '车辆' };
this.mappingRows = createMappingRows();
this.readonly = false;
this.submitLoading = false;
@@ -264,6 +288,7 @@ export default {
submit({
id: this.form.id || undefined,
name: String(this.form.name || '').trim(),
vehicleType: this.form.vehicleType || '车辆',
mappingConfig: JSON.stringify(
this.mappingRows.map(item => ({ key: item.key, value: String(item.value || '').trim() }))
),
@@ -311,7 +336,7 @@ export default {
align-items: center;
gap: 8px;
margin-left: auto;
width: 380px;
width: 520px;
}
&__mapping-table {
+255
View File
@@ -0,0 +1,255 @@
<template>
<basic-container class="measurement-unit-page">
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
ref="crud"
:permission="permissionList"
:before-open="beforeOpen"
@row-save="rowSave"
@row-update="rowUpdate"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
v-if="hasPermission('measurement_unit_add')"
type="primary"
@click="$refs.crud.rowAdd()"
>
新增
</el-button>
<el-button
v-if="hasPermission('measurement_unit_delete')"
type="danger"
plain
:disabled="!selectionList.length"
@click="handleDelete"
>
批量删除
</el-button>
</template>
<template #dimension="{ row }">
{{ row.dimension || '-' }}
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'primary' : 'info'" class="status-text">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
<template #menu="{ row, index }">
<el-link
v-if="hasPermission('measurement_unit_edit')"
type="primary"
@click="$refs.crud.rowEdit(row, index)"
>
编辑
</el-link>
<el-link
v-if="hasPermission('measurement_unit_status')"
type="primary"
@click="handleStatus(row)"
>
{{ row.status === 1 ? '停用' : '启用' }}
</el-link>
<el-link
v-if="hasPermission('measurement_unit_delete')"
type="danger"
@click="handleDelete(row.id)"
>
删除
</el-link>
</template>
</avue-crud>
<empty-pagination
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
</basic-container>
</template>
<script>
import { mapGetters } from 'vuex';
import { changeStatus, getDetail, getList, remove, submit } from '@/api/base/measurement-unit';
import { createOption } from '@/option/base/measurement-unit';
export default {
data() {
return {
form: {},
query: {},
loading: true,
data: [],
page: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
currentPage: 1,
total: 0,
},
selectionList: [],
option: createOption(),
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return {
addBtn: this.hasPermission('measurement_unit_add'),
};
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
isAdmin() {
return String(this.userInfo?.authority || '').includes('admin');
},
},
methods: {
hasPermission(code) {
return this.isAdmin || this.permission?.[code] === true;
},
normalizeRow(row) {
row.unitName = String(row.unitName || '').trim();
row.dimension = String(row.dimension || '').trim();
row.remark = String(row.remark || '').trim();
if (!row.status) row.status = 1;
return row;
},
rowSave(row, done, loading) {
submit(this.normalizeRow(row)).then(
() => {
this.onLoad(this.page, this.query);
this.$message.success('操作成功');
done();
},
() => loading()
);
},
rowUpdate(row, index, done, loading) {
submit(this.normalizeRow(row)).then(
() => {
this.onLoad(this.page, this.query);
this.$message.success('操作成功');
done();
},
() => loading()
);
},
handleDelete(ids = '') {
const targetIds = ids || this.ids;
if (!targetIds) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定删除所选数据,删除后不可恢复?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => remove(targetIds))
.then(() => {
this.onLoad(this.page, this.query);
this.$message.success('操作成功');
this.selectionList = [];
});
},
handleStatus(row) {
const nextStatus = row.status === 1 ? 2 : 1;
const actionName = nextStatus === 1 ? '启用' : '停用';
this.$confirm(`是否确认${actionName}该计量单位?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => changeStatus(row.id, nextStatus))
.then(() => {
this.onLoad(this.page, this.query);
this.$message.success('操作成功');
});
},
beforeOpen(done, type) {
if (['edit', 'view'].includes(type)) {
getDetail(this.form.id).then(res => {
this.form = res.data.data || {};
done();
});
return;
}
this.form.status = 1;
done();
},
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = { ...params };
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
this.selectionList = list;
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
this.onLoad(this.page, this.query);
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page = this.page, params = this.query) {
this.loading = true;
return getList(page.currentPage, page.pageSize, params)
.then(res => {
const pageData = res.data.data || {};
this.data = pageData.records || [];
this.page.total = pageData.total || 0;
this.selectionList = [];
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.measurement-unit-page {
:deep(.avue-crud__menu) {
margin-bottom: 8px;
}
:deep(.avue-crud__search .el-form-item__label) {
min-width: 160px;
white-space: nowrap;
}
:deep(.avue-crud__menu-column) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.avue-crud__menu .el-link) {
margin-right: 8px;
}
}
</style>
+135 -42
View File
@@ -93,6 +93,24 @@
<el-radio label="码头">码头</el-radio>
</el-radio-group>
</template>
<template #provinceCode-form>
<el-select
v-model="form.provinceCode"
clearable
filterable
:disabled="isProvinceDisabled"
placeholder="请选择"
style="width: 100%"
@change="handleProvinceChange"
>
<el-option
v-for="item in provinceOptions"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</template>
<template #code-form>
<el-input
v-if="form.category !== '码头'"
@@ -300,6 +318,8 @@ export default {
excelForm: {},
portOptions: [],
countryOptions: [],
provinceOptions: [],
provinceOptionCache: {},
cityOptions: [],
cityLoading: false,
cityOptionCache: {},
@@ -436,13 +456,30 @@ export default {
change: ({ value }) => this.handleCountryNameChange(value),
rules: [{ required: true, message: '请选择国家', trigger: 'change' }],
},
{
label: '所属省份',
prop: 'provinceCode',
formslot: true,
hide: true,
span: 12,
order: 88,
placeholder: '请选择',
rules: [{ required: true, message: '请选择所属省份', trigger: 'change' }],
},
{
label: '所属省份',
prop: 'provinceName',
addDisplay: false,
editDisplay: false,
minWidth: 120,
},
{
label: '城市',
prop: 'cityCode',
formslot: true,
hide: true,
span: 12,
order: 88,
order: 87,
placeholder: '请选择',
rules: [{ required: true, message: '请选择所属城市', trigger: 'change' }],
},
@@ -465,7 +502,7 @@ export default {
type: 'select',
formslot: true,
hide: true,
order: 87,
order: 86,
props: {
label: 'title',
value: 'id',
@@ -534,7 +571,7 @@ export default {
span: 12,
minWidth: 220,
overHidden: false,
order: 86,
order: 85,
placeholder: '请输入',
maxlength: 255,
showWordLimit: true,
@@ -667,6 +704,9 @@ export default {
return this.form.category === '码头';
},
isCityDisabled() {
return this.isParentRegionLocked || !this.form.provinceCode;
},
isProvinceDisabled() {
return this.isParentRegionLocked || !this.form.countryCode;
},
isDistrictDisabled() {
@@ -755,7 +795,7 @@ export default {
this.form.parentName = '';
this.form.countryCode = '';
this.form.country = '';
this.clearCityValue();
this.clearProvinceValue();
},
handleCategoryChange(value, reset = true) {
const required = value === '码头';
@@ -779,13 +819,15 @@ export default {
handleCountryChange(value) {
const country = this.countryOptions.find(item => item.id === value);
this.form.country = country ? country.title : '';
this.clearCityValue();
this.loadCityOptions(value);
this.clearProvinceValue();
this.loadProvinceOptions(value);
},
handleCountryNameChange(value) {
const country = this.countryOptions.find(item => item.title === value);
this.setProvinceOptions([]);
this.setCityOptions([]);
this.setDistrictOptions([]);
this.loadCityOptions(country ? country.id : '');
this.loadProvinceOptions(country ? country.id : '');
},
syncCountryName(countryCode) {
const country = this.countryOptions.find(item => item.id === countryCode);
@@ -818,6 +860,18 @@ export default {
}));
}
},
setProvinceOptions(provinceOptions) {
this.provinceOptions = provinceOptions;
const provinceCodeColumn = this.findColumn(this.option.column, 'provinceCode');
if (provinceCodeColumn) {
provinceCodeColumn.dicData = provinceOptions;
}
},
clearProvinceValue() {
this.form.provinceCode = undefined;
this.form.provinceName = '';
this.clearCityValue();
},
clearCityValue() {
this.form.cityCode = undefined;
this.form.city = '';
@@ -828,36 +882,38 @@ export default {
this.form.districtName = '';
this.form.regionCode = '';
},
loadCityOptions(countryCode) {
loadProvinceOptions(countryCode) {
if (!countryCode) {
this.setProvinceOptions([]);
return Promise.resolve([]);
}
if (this.provinceOptionCache[countryCode]) {
this.setProvinceOptions(this.provinceOptionCache[countryCode]);
return Promise.resolve(this.provinceOptionCache[countryCode]);
}
return getLazyTree(countryCode).then(res => {
const options = (res.data.data || []).map(this.normalizeRegionOption);
this.provinceOptionCache[countryCode] = options;
this.setProvinceOptions(options);
return options;
});
},
loadCityOptions(provinceCode) {
if (!provinceCode) {
this.setCityOptions([]);
return Promise.resolve([]);
}
if (this.cityOptionCache[countryCode]) {
this.setCityOptions(this.cityOptionCache[countryCode]);
return Promise.resolve(this.cityOptionCache[countryCode]);
if (this.cityOptionCache[provinceCode]) {
this.setCityOptions(this.cityOptionCache[provinceCode]);
return Promise.resolve(this.cityOptionCache[provinceCode]);
}
this.cityLoading = true;
return getLazyTree(countryCode)
return getLazyTree(provinceCode)
.then(res => {
const firstLevelList = (res.data.data || []).map(this.normalizeRegionOption);
return Promise.all(
firstLevelList.map(parent => {
return getLazyTree(parent.id).then(childRes => {
const childList = (childRes.data.data || []).map(this.normalizeRegionOption);
return childList.map(child => ({
...child,
parentCode: child.parentCode || parent.id,
}));
});
})
).then(cityGroups => {
const cityList = cityGroups.flat();
const options = cityList.length > 0 ? cityList : firstLevelList;
this.cityOptionCache[countryCode] = options;
this.setCityOptions(options);
return options;
});
const options = (res.data.data || []).map(this.normalizeRegionOption);
this.cityOptionCache[provinceCode] = options;
this.setCityOptions(options);
return options;
})
.finally(() => {
this.cityLoading = false;
@@ -867,6 +923,12 @@ export default {
const city = this.cityOptions.find(item => item.title === cityName || item.id === regionCode);
return city ? city.id : '';
},
resolveProvinceCode(provinceName) {
const province = this.provinceOptions.find(
item => item.title === provinceName || item.id === provinceName
);
return province ? province.id : '';
},
setDistrictOptions(districtOptions) {
this.districtOptions = districtOptions;
const districtCodeColumn = this.findColumn(this.option.column, 'districtCode');
@@ -927,6 +989,8 @@ export default {
this.form.terminalCode = terminalCode;
this.handleTerminalCodeChange(terminalCode);
this.form.country = parent.country;
this.form.provinceCode = parent.provinceCode;
this.form.provinceName = parent.provinceName;
this.form.city = parent.city;
this.form.districtCode = parent.districtCode;
this.form.districtName = parent.districtName;
@@ -935,7 +999,12 @@ export default {
.then(countryCode => {
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadCityOptions(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(() => {
this.form.provinceCode =
String(parent.provinceCode || '') || this.resolveProvinceCode(parent.provinceName);
return this.loadCityOptions(this.form.provinceCode);
})
.then(() => {
this.form.cityCode = this.resolveCityCode(parent.city, parent.regionCode);
@@ -952,6 +1021,12 @@ export default {
this.clearDistrictValue();
this.loadDistrictOptions(value);
},
handleProvinceChange(value) {
const province = this.provinceOptions.find(item => item.id === value);
this.form.provinceName = province ? province.title : '';
this.clearCityValue();
this.loadCityOptions(value);
},
handleCityNameChange(value) {
const city = this.cityOptions.find(item => item.title === value);
this.setDistrictOptions([]);
@@ -988,6 +1063,8 @@ export default {
submitRow.regionCode = String(submitRow.regionCode || '').trim();
submitRow.districtCode = String(submitRow.districtCode || '').trim();
submitRow.districtName = String(submitRow.districtName || '').trim();
submitRow.provinceCode = String(submitRow.provinceCode || '').trim();
submitRow.provinceName = String(submitRow.provinceName || '').trim();
submitRow.detailAddress = String(submitRow.detailAddress || '').trim();
submitRow.longitude = normalizeCoordinateInput(submitRow.longitude);
submitRow.latitude = normalizeCoordinateInput(submitRow.latitude);
@@ -1021,6 +1098,10 @@ export default {
this.$message.warning('请选择上级港口');
return false;
}
if (isBlank(row.provinceCode)) {
this.$message.warning('请选择所属省份');
return false;
}
if (isBlank(row.regionCode)) {
this.$message.warning('请选择区县');
return false;
@@ -1153,7 +1234,13 @@ export default {
.then(countryCode => {
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadCityOptions(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(() => {
this.form.provinceCode =
String(this.form.provinceCode || '') ||
this.resolveProvinceCode(this.form.provinceName);
return this.loadCityOptions(this.form.provinceCode);
})
.then(() => {
this.form.cityCode = this.resolveCityCode(this.form.city, this.form.regionCode);
@@ -1179,7 +1266,13 @@ export default {
.then(countryCode => {
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadCityOptions(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(() => {
this.form.provinceCode =
String(this.form.provinceCode || '') ||
this.resolveProvinceCode(this.form.provinceName);
return this.loadCityOptions(this.form.provinceCode);
})
.then(() => {
this.form.cityCode = this.resolveCityCode(this.form.city, this.form.regionCode);
@@ -1211,6 +1304,7 @@ export default {
},
searchReset() {
this.query = {};
this.setProvinceOptions([]);
this.setCityOptions([]);
this.setDistrictOptions([]);
this.onLoad(this.page);
@@ -1251,14 +1345,10 @@ export default {
});
},
handleImport() {
openImportDialog(
this,
'港口码头主数据',
() => {
this.loadPortOptions();
this.onLoad(this.page, this.query);
}
);
openImportDialog(this, '港口码头主数据', () => {
this.loadPortOptions();
this.onLoad(this.page, this.query);
});
},
handleExport() {
this.$confirm('是否导出港口码头主数据?', '提示', {
@@ -1271,7 +1361,10 @@ export default {
feedback: true,
})
.then(res => {
downloadXls(res.data, `港口码头主数据${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`);
downloadXls(
res.data,
`港口码头主数据${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
);
})
.finally(() => {
NProgress.done();
+33 -31
View File
@@ -42,13 +42,6 @@
</section-card>
<el-button-group>
<el-button
v-if="permission.region_add"
type="primary"
icon="el-icon-plus"
@click="addCountry"
>新增国家
</el-button>
<el-button
v-if="permission.region_add"
type="primary"
@@ -181,19 +174,17 @@ export default {
treeLoad: (node, resolve) => {
const parentCode = node.level === 0 ? ROOT_PARENT_CODE : node.data.id;
getLazyTree(parentCode, this.searchForm).then(res => {
resolve(
res.data.data.map(item => {
return {
...item,
leaf: !item.hasChildren,
};
})
);
const treeData = this.normalizeTreeData(res.data.data);
if (node.level === 0) {
this.setDefaultExpandedKeys(treeData);
}
resolve(treeData);
});
},
addBtn: false,
menu: false,
defaultExpandAll: false,
defaultExpandedKeys: [],
size: 'default',
props: {
labelText: '标题',
@@ -260,6 +251,12 @@ export default {
prop: 'regionLevel',
type: 'radio',
dicUrl: '/blade-system/dict/dictionary?code=region',
dicFormatter: response => {
const list = Array.isArray(response) ? response : response?.data;
return (Array.isArray(list) ? list : [])
.filter(item => Number(item.dictKey) !== 0)
.map(item => ({ ...item, dictKey: Number(item.dictKey) }));
},
props: {
label: 'dictValue',
value: 'dictKey',
@@ -411,9 +408,7 @@ export default {
computed: {
...mapGetters(['permission']),
isCountryRegion() {
return (
Number(this.regionForm.regionLevel) === 0 || this.regionForm.parentCode === this.topCode
);
return Number(this.regionForm.regionLevel) === 0;
},
isProvinceRegion() {
return Number(this.regionForm.regionLevel) === 1;
@@ -438,24 +433,31 @@ export default {
},
},
methods: {
normalizeTreeData(data) {
return data.map(item => {
return {
...item,
leaf: !item.hasChildren,
};
});
},
setDefaultExpandedKeys(treeData) {
const valueKey = this.treeOption.props.value;
this.treeOption.defaultExpandedKeys = treeData
.filter(item => item.hasChildren)
.map(item => item[valueKey]);
},
initTree() {
this.treeData = [];
getLazyTree(this.topCode, this.searchForm).then(res => {
this.treeData = res.data.data.map(item => {
return {
...item,
leaf: !item.hasChildren,
};
const treeData = this.normalizeTreeData(res.data.data);
this.treeData = treeData;
this.$nextTick(() => {
this.setDefaultExpandedKeys(treeData);
});
});
},
hasSearchCriteria() {
return Object.values(this.searchForm).some(
value => value !== '' && value !== undefined && value !== null
);
},
handleSearch() {
this.treeOption.defaultExpandAll = this.hasSearchCriteria();
this.initTree();
this.regionForm = {};
},
@@ -641,7 +643,7 @@ export default {
() => {
this.initTree();
},
{ timeout: 180000 }
{ timeout: 300000 }
);
},
syncImportAction() {
@@ -772,6 +774,6 @@ export default {
margin-bottom: 12px !important;
}
.el-form-item{
margin-bottom: 8px !important;
margin-bottom: 9px !important;
}
</style>
+59 -8
View File
@@ -113,7 +113,7 @@
:maxlength="getCargoCodeMaxLength()"
show-word-limit
placeholder="请输入"
:disabled="dialogReadonly || dialogType === 'edit'"
:disabled="dialogReadonly"
@input="handleCargoCodeInput"
>
<template v-if="getCargoCodePrefix()" #prepend>
@@ -130,11 +130,13 @@
placeholder="请输入"
:disabled="dialogReadonly"
@input="handleCargoValueInput"
@blur="handleCargoValueBlur"
/>
<el-select
v-model="form.priceUnit"
clearable
:disabled="dialogReadonly"
:loading="priceUnitLoading"
placeholder="请选择"
>
<el-option
@@ -147,6 +149,10 @@
</div>
</template>
<template #cargoValue="{ row }">
{{ formatCargoValue(row.cargoValue) }}
</template>
<template #menu="{ row, index }">
<el-link
type="primary"
@@ -196,8 +202,8 @@ import * as api from '@/api/business/common-cargo';
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { exportBlob } from '@/api/common';
import { getDeptTree } from '@/api/system/dept';
import { config, excelOption, option } from '@/option/business/common-cargo';
import { priceUnitOptions } from '@/option/business/common';
import { config, excelOption, formatCargoValue, option } from '@/option/business/common-cargo';
import { getDictionary } from '@/api/system/dictbiz';
import { getToken } from '@/utils/auth';
import { openImportDialog } from '@/utils/import-excel';
import { downloadXls } from '@/utils/util';
@@ -211,7 +217,8 @@ export default {
api,
config,
option,
priceUnitOptions,
priceUnitOptions: [],
priceUnitLoading: false,
form: {},
query: {},
loading: true,
@@ -254,8 +261,10 @@ export default {
},
created() {
this.initDeptTree();
this.loadPriceUnitOptions();
},
methods: {
formatCargoValue,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission && this.permission[code], false);
},
@@ -273,6 +282,28 @@ export default {
}
});
},
loadPriceUnitOptions() {
this.priceUnitLoading = true;
getDictionary({ code: 'unit_fee' })
.then(res => {
const payload = res?.data?.data || res?.data || [];
const list = Array.isArray(payload) ? payload : payload.records || payload.data || [];
this.priceUnitOptions = list
.map(item => ({
label: item.dictValue || item.label || item.name || item.dictKey || item.value,
value: item.dictKey || item.value || item.dictValue || item.name,
}))
.filter(item => item.label && item.value);
if (this.dialogType === 'add' && !this.form.priceUnit && this.priceUnitOptions.length) {
this.form.priceUnit = this.priceUnitOptions[0].value;
}
const priceUnitColumn = this.findColumn(this.option.column, 'priceUnit');
if (priceUnitColumn) priceUnitColumn.dicData = this.priceUnitOptions;
})
.finally(() => {
this.priceUnitLoading = false;
});
},
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
@@ -389,6 +420,9 @@ export default {
const decimal = parts.slice(1).join('').slice(0, 2);
this.form.cargoValue = parts.length > 1 ? `${integer}.${decimal}` : integer;
},
handleCargoValueBlur() {
this.form.cargoValue = formatCargoValue(this.form.cargoValue);
},
normalizeRow(row) {
const submitRow = { ...row };
Object.keys(submitRow).forEach(key => {
@@ -399,14 +433,23 @@ export default {
if (submitRow.cargoValue !== undefined && submitRow.cargoValue !== null) {
submitRow.cargoValue = String(submitRow.cargoValue).trim();
}
if (!submitRow.cargoValue || String(submitRow.cargoValue) === '-1') {
const cargoValue = submitRow.cargoValue;
if (
cargoValue === undefined ||
cargoValue === null ||
String(cargoValue).trim() === '' ||
String(cargoValue) === '-1'
) {
submitRow.cargoValue = null;
} else {
submitRow.cargoValue = formatCargoValue(submitRow.cargoValue);
}
return submitRow;
},
normalizeCargoValue(row) {
if (row && String(row.cargoValue) === '-1') {
row.cargoValue = null;
if (row && Object.prototype.hasOwnProperty.call(row, 'cargoValue')) {
const formatted = formatCargoValue(row.cargoValue);
row.cargoValue = formatted || null;
}
return row;
},
@@ -445,6 +488,14 @@ export default {
this.$message.warning('货值只能输入数字,最多保留2位小数');
return false;
}
if (!row.model) {
this.$message.warning('请输入型号');
return false;
}
if (row.model.length > 50) {
this.$message.warning('型号不能超过50个字符');
return false;
}
if (row.remark && row.remark.length > 200) {
this.$message.warning('备注不能超过200个字符');
return false;
@@ -539,8 +590,8 @@ export default {
this.dialogType = type || 'add';
if (type === 'add') {
this.form = {
priceUnit: '元/吨',
...this.form,
priceUnit: this.priceUnitOptions[0]?.value || this.form.priceUnit || '',
};
this.loadFirstCargoTypeOptions();
done();
+16 -1
View File
@@ -288,7 +288,10 @@
搜索
</el-button>
</div>
<div ref="routeAmap" class="common-route-page__map"></div>
<div class="map-picker-content">
<map-search-results :results="routeMapSearchResults" @select="selectRouteMapSearchResult" />
<div ref="routeAmap" class="common-route-page__map"></div>
</div>
<div class="common-route-page__map-info">
<span>{{ routeMapStatus }}</span>
<span v-if="routeMapSelected.regionName"
@@ -359,6 +362,7 @@ export default {
routeMapKeyword: '',
routeMapStatus: '可搜索地址或点击地图选点',
routeMapSelected: {},
routeMapSearchResults: [],
routeAmap: null,
routeAmapMarker: null,
routeAmapGeocoder: null,
@@ -612,6 +616,12 @@ export default {
})
.then(() => this.runAmapGeocode('location', keyword))
.then(result => {
this.routeMapSearchResults = (result.geocodes || []).map((item, index) => ({
id: item.id || index,
name: item.formattedAddress || keyword,
address: item.formattedAddress || keyword,
location: item.location,
}));
const point = this.resolveMapPoint(result);
if (!point) {
this.routeMapStatus = '未找到匹配地址';
@@ -628,6 +638,11 @@ export default {
this.routeMapLoading = false;
});
},
selectRouteMapSearchResult(item) {
if (item && item.location) {
this.pickRouteMapPoint(item.location, item.address || item.name || '');
}
},
pickRouteMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat);
@@ -52,7 +52,7 @@
:value="item.value" /></el-select></el-form-item
></el-col>
<el-col :span="8"
><el-form-item label="默认方案"
><el-form-item
><el-checkbox v-model="draft.defaultPlan" :disabled="readonly">默认方案</el-checkbox
><el-tooltip content="同一运输方式仅支持配置一个默认计费方案" placement="top">
<el-icon class="billing-plan-editor__default-tip"
@@ -103,12 +103,28 @@
filterable
:disabled="!row.feeType"
:loading="feeItemLoadingMap[feeTypeKey(row)]"
@change="value => handleFeeItemChange(row, value)"
><el-option
v-for="item in feeItemOptions(row)"
:key="item.id || item.name || item.englishName"
:label="item.name || item.englishName"
:value="item.name || item.englishName" /></el-select></template
></el-table-column>
<el-table-column label="税率" width="180"
><template #default="{ row }"
><span v-if="readonly">{{ formatTaxRate(row.taxRate) }}</span
><el-input
v-else
v-model="row.taxRate"
inputmode="numeric"
maxlength="3"
clearable
placeholder="请输入"
@input="value => taxRateInput(row, value)"
><template #append>%</template></el-input
></template
></el-table-column
>
<el-table-column label="计费要素" width="170"
><template #default="{ row }"
><span v-if="readonly">{{ displayValue(row.billingElement) }}</span
@@ -213,32 +229,38 @@
</div></template
></el-table-column
>
<el-table-column label="保底计费重量" width="240"
<el-table-column width="240"
><template #header
><span class="billing-plan-editor__column-title"
>保底计费重量<el-tooltip content="实际计量值小于保底数值时按保底计费" placement="top"
><el-icon class="billing-plan-editor__default-tip"><InfoFilled /></el-icon></el-tooltip></span></template
><template #default="{ row }"
><div v-if="readonly && usesRangeMinimum(row)" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
displayValue(item.minimumBillingWeight)
rangeIndex === 0
? displayValue(item.minimumBillingWeight || row.minimumBillingWeight)
: ''
}}</span>
</div>
<span v-else-if="readonly">{{ displayValue(row.minimumBillingWeight) }}</span>
<div v-else-if="usesRangeMinimum(row)" class="limit-list">
<el-input
v-for="(item, rangeIndex) in getRanges(row)"
:key="rangeIndex"
v-model="item.minimumBillingWeight"
:disabled="!canEditMinimum(row)"
:placeholder="canEditMinimum(row) ? '请输入保底计费重量' : '无需配置'"
@input="value => rangeMinimumInput(row, rangeIndex, value)"
/>
<template v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">
<el-input
v-if="rangeIndex === 0"
v-model="item.minimumBillingWeight"
:disabled="!canEditMinimum(row)"
:placeholder="canEditMinimum(row) ? '请输入' : '无需配置'"
@input="value => rangeMinimumInput(row, rangeIndex, value)"
/>
<div v-else class="limit-placeholder" />
</template>
</div>
<el-input
v-else
v-model="row.minimumBillingWeight"
:disabled="!canEditMinimum(row)"
:placeholder="
canEditMinimum(row)
? '实际计量值小于保底数值时按保底计费'
: '仅按重量、按吨·公里可填写'
canEditMinimum(row) ? '请输入' : '仅按重量、按吨·公里可填写'
"
@input="value => decimalInput(row, 'minimumBillingWeight', value)" /></template
></el-table-column>
@@ -281,9 +303,6 @@
<el-descriptions-item label="目的地">{{
displayValue(matchForm.destination)
}}</el-descriptions-item>
<el-descriptions-item label="运输方式">{{
transportModeLabel(matchForm.transportMode)
}}</el-descriptions-item>
<el-descriptions-item label="货物类型">{{
displayValue(matchForm.cargoType)
}}</el-descriptions-item>
@@ -318,21 +337,6 @@
@visible-change="v => v && ensureRegions()"
@change="v => matchRegionChange('destination', v)" /></el-form-item
></el-col>
<el-col :span="12"
><el-form-item label="运输方式"
><el-select
v-model="matchForm.transportMode"
:loading="transportModeLoading"
:disabled="readonly"
placeholder="请选择运输方式"
clearable
filterable
><el-option
v-for="item in transportModeOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
></el-col>
<el-col :span="12"
><el-form-item label="货物类型"
><el-cascader
@@ -372,6 +376,7 @@ const clone = value => JSON.parse(JSON.stringify(value));
const defaultRule = () => ({
feeType: '',
feeItem: '',
taxRate: '',
billingElement: '',
billingType: '',
billingUnit: '',
@@ -467,7 +472,6 @@ export default {
label: 'cargoName',
value: 'id',
children: 'children',
disabled: (data, node) => node.level === 1,
leaf: 'leaf',
checkStrictly: true,
emitPath: true,
@@ -544,30 +548,24 @@ export default {
return next;
},
normalizeRanges(row) {
const ranges = Array.isArray(row.limitRanges)
? row.limitRanges
.map(item => ({
lowerLimit: item?.lowerLimit ?? '',
upperLimit: item?.upperLimit ?? '',
unitPrice:
item?.unitPrice === undefined || item?.unitPrice === ''
? row.unitPrice ?? ''
: item.unitPrice,
minimumBillingWeight:
item?.minimumBillingWeight === undefined || item?.minimumBillingWeight === ''
? this.usesRangeMinimum(row)
? row.minimumBillingWeight ?? ''
: ''
: item.minimumBillingWeight,
}))
.filter(
item =>
item.lowerLimit !== '' ||
item.upperLimit !== '' ||
item.unitPrice !== '' ||
item.minimumBillingWeight !== ''
)
: [];
const rawRanges = Array.isArray(row.limitRanges) ? row.limitRanges : [];
const ranges = rawRanges
.map((item, index) => ({
lowerLimit: item?.lowerLimit ?? '',
upperLimit: item?.upperLimit ?? '',
unitPrice:
item?.unitPrice === undefined || item?.unitPrice === ''
? row.unitPrice ?? ''
: item.unitPrice,
minimumBillingWeight: this.resolveRangeMinimum(row, item, index),
}))
.filter(
item =>
item.lowerLimit !== '' ||
item.upperLimit !== '' ||
item.unitPrice !== '' ||
item.minimumBillingWeight !== ''
);
if (
!ranges.length &&
(row.lowerLimit !== '' ||
@@ -582,9 +580,23 @@ export default {
minimumBillingWeight: this.usesRangeMinimum(row) ? row.minimumBillingWeight ?? '' : '',
});
return ranges.length
? ranges
? ranges.map((item, index) => ({
...item,
minimumBillingWeight: this.usesRangeMinimum(row)
? index === 0
? item.minimumBillingWeight
: ''
: '',
}))
: [{ lowerLimit: '', upperLimit: '', unitPrice: '', minimumBillingWeight: '' }];
},
resolveRangeMinimum(row, item, index) {
if (!this.usesRangeMinimum(row) || index !== 0) return '';
if (item?.minimumBillingWeight !== undefined && item?.minimumBillingWeight !== '') {
return item.minimumBillingWeight;
}
return row.minimumBillingWeight ?? '';
},
syncLegacyLimit(row) {
const first = row.limitRanges?.[0] || {};
row.lowerLimit = first.lowerLimit ?? '';
@@ -622,27 +634,67 @@ export default {
feeItemOptions(row) {
return this.feeItems[this.feeTypeKey(row)] || [];
},
formatTaxRate(value) {
return value === undefined || value === null || value === '' ? '-' : `${value}%`;
},
loadRuleItems() {
(this.draft.rules || []).forEach(row => this.loadFeeItems(row.feeType));
},
loadFeeItems(value) {
const key = this.feeTypeKey({ feeType: value });
if (!key || this.feeItems[key]) return;
if (!key) return;
if (this.feeItems[key]) {
this.fillLoadedRuleTaxRates(key);
return;
}
this.feeItemLoadingMap[key] = true;
getFeeItemList(1, 9999, { feeCategory: key })
.then(res => {
const data = res?.data?.data || res?.data || {};
this.feeItems[key] = Array.isArray(data) ? data : data.records || [];
this.fillLoadedRuleTaxRates(key);
})
.finally(() => {
this.feeItemLoadingMap[key] = false;
});
},
fillLoadedRuleTaxRates(key) {
(this.draft.rules || [])
.filter(row => this.feeTypeKey(row) === key)
.forEach(row => this.fillRuleTaxRate(row));
},
handleFeeTypeChange(row, value) {
row.feeType = this.feeTypeKey({ feeType: value });
row.feeItem = '';
row.taxRate = '';
this.loadFeeItems(row.feeType);
},
handleFeeItemChange(row, value) {
row.feeItem = value;
const feeItem = this.feeItemOptions(row).find(
item => String(item.name || item.englishName || '') === String(value || '')
);
row.taxRate = this.hasTaxRate(feeItem?.taxRate) ? this.normalizeTaxRate(feeItem.taxRate) : '';
},
fillRuleTaxRate(row) {
if (this.hasTaxRate(row.taxRate) || !row.feeItem) return;
const feeItem = this.feeItemOptions(row).find(
item => String(item.name || item.englishName || '') === String(row.feeItem)
);
if (this.hasTaxRate(feeItem?.taxRate)) row.taxRate = this.normalizeTaxRate(feeItem.taxRate);
},
hasTaxRate(value) {
return value !== undefined && value !== null && String(value).trim() !== '';
},
normalizeTaxRate(value) {
const rate = Number(String(value ?? '').trim());
return Number.isFinite(rate) ? String(Math.trunc(Math.abs(rate))) : '';
},
taxRateInput(row, value) {
let text = String(value ?? '').replace(/\D/g, '');
if (text.length > 1) text = text.replace(/^0+/, '') || '0';
row.taxRate = text;
},
billingTypes(row) {
return this.typeMap[row.billingElement] || [];
},
@@ -747,11 +799,16 @@ export default {
if (index === 0) row.unitPrice = target.value;
},
rangeMinimumInput(row, index, value) {
if (index !== 0) return;
row.limitRanges = this.getRanges(row);
const target = { value };
this.decimalInput(target, 'value', value);
row.limitRanges[index].minimumBillingWeight = target.value;
if (index === 0) row.minimumBillingWeight = target.value;
row.limitRanges[0].minimumBillingWeight = target.value;
row.minimumBillingWeight = target.value;
row.limitRanges = row.limitRanges.map((item, rangeIndex) => ({
...item,
minimumBillingWeight: rangeIndex === 0 ? target.value : '',
}));
},
addRule() {
this.draft.rules.push(defaultRule());
@@ -771,6 +828,7 @@ export default {
const required = [
['feeType', '费用类型'],
['feeItem', '费用项'],
['taxRate', '税率'],
['billingElement', '计费要素'],
['billingType', '计费类型'],
['billingUnit', '计费单位'],
@@ -789,6 +847,13 @@ export default {
return false;
}
feeItemSet.add(feeItem);
if (this.hasTaxRate(row.taxRate)) {
const taxRate = Number(row.taxRate);
if (!/^\d+$/.test(String(row.taxRate).trim()) || taxRate < 0 || taxRate > 100) {
this.$message.warning(`${i + 1}行税率必须是0到100之间的整数`);
return false;
}
}
if (
!this.usesRangeUnitPrice(row) &&
(row.unitPrice === undefined ||
@@ -803,8 +868,9 @@ export default {
return false;
}
if (!this.canEditLimit(row)) continue;
const key = row.billingElement;
groups[key] = groups[key] || [];
const billingElement = String(row.billingElement).trim();
const key = JSON.stringify([feeItem, billingElement]);
groups[key] = groups[key] || { feeItem, billingElement, ranges: [] };
for (const range of this.getRanges(row)) {
const lower = Number(range.lowerLimit);
const upper = Number(range.upperLimit);
@@ -830,22 +896,24 @@ export default {
this.$message.warning('计费要素下限不能大于上限');
return false;
}
groups[key].push({ lower, upper });
groups[key].ranges.push({ lower, upper });
}
}
return this.validateRangeGroups(groups);
},
validateRangeGroups(groups) {
const precision = 0.000001;
for (const [element, ranges] of Object.entries(groups)) {
for (const { feeItem, billingElement, ranges } of Object.values(groups)) {
const sorted = [...ranges].sort((a, b) => a.lower - b.lower || a.upper - b.upper);
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i].lower < sorted[i - 1].upper - precision) {
this.$message.warning(`${element}的计费要素区间不能重叠`);
this.$message.warning(`费用项“${feeItem}${billingElement}的计费要素区间不能重叠`);
return false;
}
if (sorted[i].lower > sorted[i - 1].upper + precision) {
this.$message.warning(`${element}的计费要素区间必须连续,不能存在间隙`);
this.$message.warning(
`费用项“${feeItem}${billingElement}的计费要素区间必须连续,不能存在间隙`
);
return false;
}
}
@@ -862,15 +930,22 @@ export default {
plan.transportModeLabel = transportMode?.label || plan.transportMode;
plan.rules = plan.rules.map(rule => {
const ranges = this.canEditLimit(rule) ? this.getRanges(rule) : [];
const normalizedRanges = ranges.map((item, rangeIndex) => ({
...item,
minimumBillingWeight:
this.usesRangeMinimum(rule) && rangeIndex === 0 ? item.minimumBillingWeight || '' : '',
}));
return {
...rule,
unitPrice: this.usesRangeUnitPrice(rule) ? ranges[0]?.unitPrice || '' : rule.unitPrice,
limitRanges: ranges,
lowerLimit: ranges[0]?.lowerLimit || '',
upperLimit: ranges[0]?.upperLimit || '',
unitPrice: this.usesRangeUnitPrice(rule)
? normalizedRanges[0]?.unitPrice || ''
: rule.unitPrice,
limitRanges: normalizedRanges,
lowerLimit: normalizedRanges[0]?.lowerLimit || '',
upperLimit: normalizedRanges[0]?.upperLimit || '',
minimumBillingWeight: this.canEditMinimum(rule)
? this.usesRangeMinimum(rule)
? ranges[0]?.minimumBillingWeight || ''
? normalizedRanges[0]?.minimumBillingWeight || ''
: rule.minimumBillingWeight
: '',
};
@@ -883,9 +958,6 @@ export default {
this.matchIndex = index;
this.matchForm = { ...defaultRule().matchCondition, ...(row.matchCondition || {}) };
this.matchVisible = true;
this.ensureTransportModes().then(() => {
this.matchForm.transportMode = this.normalizeTransportMode(this.matchForm.transportMode);
});
this.ensureRegions();
this.ensureCargo().then(() => {
const path = this.resolveCargoPath(this.matchForm);
@@ -910,21 +982,6 @@ export default {
});
return this.transportModeRequest;
},
normalizeTransportMode(value) {
const transportMode = String(value || '').trim();
if (!transportMode) return '';
const option = this.transportModeOptions.find(item => {
const optionValue = String(item.value || '').trim();
const optionLabel = String(item.label || '').trim();
return (
optionValue === transportMode ||
optionLabel === transportMode ||
optionLabel.includes(transportMode) ||
transportMode.includes(optionLabel)
);
});
return option?.value || transportMode;
},
ensureRegions() {
if (this.regionOptions.length) return Promise.resolve(this.regionOptions);
if (this.regionRequest) return this.regionRequest;
@@ -1078,25 +1135,27 @@ export default {
},
matchCargoChange(value) {
const path =
Array.isArray(value) && value.length >= 2
Array.isArray(value) && value.length
? value.slice(0, 2).map(item => String(item))
: [];
const item = this.cargoFlatOptions.find(
option => String(option.id) === String(path[1]) && option.path?.length >= 2
option =>
option.path?.length === path.length &&
option.path.every((itemValue, index) => String(itemValue) === path[index])
);
this.matchForm.cargoTypePath = path;
this.matchForm.cargoType = item ? this.cargoLabels(path).join('/') : '';
this.matchForm.cargoTypeCode = item ? item.cargoCode || item.code || item.id : '';
},
resolveCargoPath(condition) {
if (Array.isArray(condition.cargoTypePath) && condition.cargoTypePath.length >= 2)
return condition.cargoTypePath;
if (Array.isArray(condition.cargoTypePath) && condition.cargoTypePath.length)
return condition.cargoTypePath.slice(0, 2).map(item => String(item));
const item = this.cargoFlatOptions.find(
option =>
option.path?.length >= 2 &&
(String(option.cargoCode || option.code || option.id) ===
String(condition.cargoTypeCode) ||
option.cargoName === condition.cargoType)
option.cargoName === condition.cargoType ||
this.cargoLabels(option.path).join('/') === condition.cargoType)
);
return item?.path || [];
},
@@ -1145,6 +1204,11 @@ export default {
cursor: pointer;
}
.billing-plan-editor__column-title {
display: inline-flex;
align-items: center;
}
.limit-list {
display: flex;
flex-direction: column;
@@ -1160,4 +1224,8 @@ export default {
.limit-row .el-input {
flex: 1;
}
.limit-placeholder {
height: 32px;
}
</style>
@@ -1,3 +1,4 @@
<!-- 该文件已废弃 -->
<template>
<basic-container
:class="[
@@ -1089,7 +1090,7 @@
class="business-crud-page__freight-form business-crud-page__freight-form--road"
>
<template v-for="(cargo, index) in transportCargoRows" :key="index">
<el-form-item :label="`单价${index + 1}`">
<el-form-item label="单价">
<el-input
v-model="cargo.unitPrice"
placeholder="请输入"
@@ -1115,7 +1116,7 @@
</template>
</el-input>
</el-form-item>
<el-form-item :label="`数量合计${index + 1}`">
<el-form-item label="数量合计">
<el-input :model-value="taskFullFreightQuantity(index)" disabled>
<template #append>
<el-select
@@ -1132,7 +1133,7 @@
</template>
</el-input>
</el-form-item>
<el-form-item :label="`运费${index + 1}`">
<el-form-item label="运费">
<el-input
:model-value="taskFullFreightAmount(cargo)"
placeholder="请输入运费"
@@ -1674,7 +1675,7 @@
class="business-crud-page__freight-form business-crud-page__freight-form--road"
>
<template v-for="(row, index) in shippingTemplateFreight.freightItems" :key="index">
<el-form-item :label="`单价${index + 1}`">
<el-form-item label="单价">
<el-input
v-model="row.unitPrice"
placeholder="请输入"
@@ -1701,7 +1702,7 @@
</template>
</el-input>
</el-form-item>
<el-form-item :label="`数量合计${index + 1}`">
<el-form-item label="数量合计">
<el-input :model-value="shippingTemplateRoadFreightQuantity(index)" disabled>
<template #append>
<el-select
@@ -1718,7 +1719,7 @@
</template>
</el-input>
</el-form-item>
<el-form-item :label="`运费${index + 1}`">
<el-form-item label="运费">
<el-input :model-value="shippingTemplateRoadFreightAmount(row, index)" disabled>
<template #suffix>{{ shippingTemplateCurrencyLabel }}</template>
</el-input>
@@ -1882,6 +1883,12 @@
<span>自动生成结算单</span>
<el-radio-group v-model="settlementRuleForm.autoGenerate" :disabled="dialogReadonly">
<el-radio :label="1">开启</el-radio>
<el-tooltip
content="开启时,系统根据配置规则归集运单,定时生成结算单"
placement="top"
>
<el-icon class="settlement-switch-tip"><QuestionFilled /></el-icon>
</el-tooltip>
<el-radio :label="0">关闭</el-radio>
</el-radio-group>
</div>
@@ -1920,13 +1927,13 @@
</el-form-item>
<el-form-item
v-if="showSettlementBillCycleType"
label="账单周期类型"
label="结算周期"
required
:error="settlementRuleErrors.billCycleType"
>
<el-select
v-model="settlementRuleForm.billCycleType"
placeholder="请选择账单周期类型"
placeholder="请选择结算周期"
:disabled="dialogReadonly"
@change="handleSettlementBillCycleTypeChange"
>
@@ -1979,6 +1986,59 @@
</el-select>
</el-form-item>
</el-form>
<div
v-if="settlementRuleEnabled && showSettlementCustomPeriods"
class="business-crud-page__custom-periods"
>
<el-table :data="settlementRuleForm.customPeriods || []" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="运单区间-开始日" min-width="180" align="center">
<template #default="{ row, $index }">
<el-select
:model-value="row.startDay"
placeholder="请选择"
clearable
:disabled="dialogReadonly"
@update:model-value="value => handleCustomPeriodStartDayChange($index, value)"
>
<el-option
v-for="item in billCutoffDayOptions"
:key="`start-${item.value}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="运单区间-结束日" min-width="200" align="center">
<template #default="{ row, $index }">
<el-select
:model-value="row.endDay"
placeholder="请选择"
clearable
:disabled="dialogReadonly"
@update:model-value="value => handleCustomPeriodEndDayChange($index, value)"
>
<el-option
v-for="item in customPeriodEndDayOptions(row)"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column v-if="!dialogReadonly" label="操作" width="100" align="center">
<template #default="{ $index }">
<el-link v-if="$index === 0" type="primary" @click="addCustomPeriodRow">添加</el-link>
<el-link v-else type="danger" @click="removeCustomPeriodRow($index)">删除</el-link>
</template>
</el-table-column>
<template v-if="!dialogReadonly" #empty>
<el-link type="primary" @click="addCustomPeriodRow">添加</el-link>
</template>
</el-table>
</div>
</div>
</template>
@@ -2046,7 +2106,7 @@
v-model="attachmentRows"
:readonly="dialogReadonly"
:file-types="attachmentFileTypes"
:max-size="500"
:max-size="50"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
@@ -3267,8 +3327,6 @@
</template>
</el-dialog>
<waybill-import-dialog v-if="config.enableWaybillImport" v-model="waybillImportBox" />
<flow-design
v-if="website.design.designMode"
is-dialog
@@ -3799,7 +3857,7 @@
class="business-crud-page__dispatch-item-grid business-crud-page__dispatch-item-grid--full"
>
<template v-for="(cargo, index) in dispatchItemCargoRows" :key="cargo._key || index">
<el-form-item :label="`单价${index + 1}`">
<el-form-item label="单价">
<el-input
v-model="cargo.unitPrice"
placeholder="请输入"
@@ -3819,12 +3877,12 @@
></template>
</el-input>
</el-form-item>
<el-form-item :label="`数量合计${index + 1}`"
<el-form-item label="数量合计"
><el-input :model-value="formatDispatchQuantity(cargo.quantity)" disabled
><template #append>{{ cargo.quantityUnit || '吨' }}</template></el-input
></el-form-item
>
<el-form-item :label="`运费${index + 1}`"
<el-form-item label="运费"
><el-input :model-value="dispatchCargoFreightAmount(cargo)" disabled
><template #suffix>{{ dispatchItemFreightCurrencyLabel }}</template></el-input
></el-form-item
@@ -4266,7 +4324,7 @@
<vehicle-attachment-upload
v-model="dispatchItemAttachmentRows"
:file-types="attachmentFileTypes"
:max-size="500"
:max-size="50"
:show-file-list="false"
button-text="上传附件"
@change="handleDispatchItemAttachmentChange"
@@ -4966,7 +5024,10 @@
搜索
</el-button>
</div>
<div ref="transportAmap" class="business-crud-page__map"></div>
<div class="map-picker-content">
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
<div ref="transportAmap" class="business-crud-page__map"></div>
</div>
<div class="business-crud-page__map-info">
<span>{{ transportMapStatus }}</span>
<span v-if="transportMapSelected.regionName"
@@ -5266,7 +5327,6 @@ import { h, resolveComponent } from 'vue';
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import WaybillImportDialog from './waybill-import-dialog.vue';
import BillingPlanEditor from './billing-plan-editor.vue';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
@@ -5405,13 +5465,19 @@ const defaultBillingPlan = () => ({
});
const defaultSettlementRule = () => ({
autoGenerate: 1,
autoGenerate: '',
billStartDate: '',
settlementType: '月结',
billCycleType: '固定截单日',
billCutoffDay: 25,
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
});
const normalizeCustomPeriods = (periods = []) =>
(periods || []).map(item => ({
startDay: Number(item?.startDay) > 0 ? Number(item.startDay) : '',
endDay: Number(item?.endDay) > 0 ? Number(item.endDay) : '',
}));
const defaultReconciliation = () => ({
skipReconciliation: 1,
@@ -5502,11 +5568,10 @@ const defaultDispatchRow = () => ({
remark: '',
});
const taskCarrierTypes = ['承运商', '自运', '网货平台'];
const taskCarrierTypes = ['承运商', '自运'];
export default {
components: {
WaybillImportDialog,
BillingPlanEditor,
PageAvueForm,
Rank,
@@ -5606,7 +5671,6 @@ export default {
currentPage: 1,
total: 0,
},
waybillImportBox: false,
transportPlanImportBox: false,
transportPlanImportLoading: false,
transportPlanImportFiles: [],
@@ -5739,6 +5803,7 @@ export default {
transportMapKeyword: '',
transportMapStatus: '可搜索地址或点击地图选点',
transportMapSelected: {},
transportMapSearchResults: [],
transportAmap: null,
transportAmapMarker: null,
transportAmapGeocoder: null,
@@ -5831,7 +5896,7 @@ export default {
按数量: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
},
settlementTypeOptions: ['月结', '日结', '周结', '半月结', '固定天数周期结算'],
billCycleTypeOptions: ['固定截单日', '自然月'],
billCycleTypeOptions: ['固定截单日', '自然月', '自定义多周期'],
reconciliationModeOptions: ['明细逐行核对', '按账单汇总核对', '跳过自动核对'],
packageOptions,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
@@ -6098,7 +6163,7 @@ export default {
return this.taskCarrierTypes;
},
taskCarrierLabel() {
return this.form.carrierType === '网货平台' ? '承运方' : '承运商';
return '承运商';
},
taskCarrierValue: {
get() {
@@ -6262,7 +6327,7 @@ export default {
return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType);
},
dispatchCarrierLabel() {
return this.dispatchItemForm.carrierType === '网货平台' ? '承运方' : '承运商';
return '承运商';
},
dispatchCarrierSelection: {
get() {
@@ -6442,6 +6507,11 @@ export default {
this.showSettlementBillCycleType && this.settlementRuleForm.billCycleType === '固定截单日'
);
},
showSettlementCustomPeriods() {
return (
this.showSettlementBillCycleType && this.settlementRuleForm.billCycleType === '自定义多周期'
);
},
showSettlementCycleDays() {
return this.settlementRuleForm.settlementType === '固定天数周期结算';
},
@@ -6868,7 +6938,7 @@ export default {
const status = this.statusValue(row);
return this.config.permission === 'transport_plan'
? ['waiting_dispatch', 'dispatching'].includes(status)
: ['pending', 'processing'].includes(status);
: ['pending', 'processing', 'running'].includes(status);
},
canComplete(row) {
if (
@@ -6882,7 +6952,7 @@ export default {
const status = this.statusValue(row);
return this.config.permission === 'transport_plan'
? status === 'dispatching'
: status === 'processing';
: status === 'processing' || status === 'running';
},
canMaintainMileage(row) {
return (
@@ -7019,7 +7089,7 @@ export default {
if (!row) return '-';
if (prop === 'businessStatus') return this.displayStatus(row, prop);
if (prop === 'feeGenerationMode') {
return row[prop] === 'manual' ? '手动生成' : '系统生成';
return row[prop] === 'manual' ? '账单导入生成' : '系统生成';
}
const column = this.findColumn(this.option.column, prop);
if (column?.formatter) return column.formatter(row, {}, row[prop]);
@@ -7109,7 +7179,9 @@ export default {
waybillAmountWithCurrency(row = {}, prop) {
const value = this.formatDetailValue(row, prop);
if (value === '-') return { value: '-' };
return { value: `${this.waybillCurrencyRemark(row)}${value}` };
const number = Number(value);
const display = Number.isFinite(number) ? number.toFixed(2) : value;
return { value: `${this.waybillCurrencyRemark(row)}${display}` };
},
normalizeTransportPlanWaybillRow(row = {}, index = 0) {
const status = row.businessStatus || row.status || row.waybillStatus || '';
@@ -7159,7 +7231,7 @@ export default {
this.$message.info('当前运单暂无详情数据');
return;
}
this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.id } });
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: row.id } });
},
transportPlanWaybillIndex(index) {
return (
@@ -7234,6 +7306,7 @@ export default {
.then(res => {
const detail = res?.data?.data || res?.data || row;
this.detailRow = detail;
this.loadWaybillVoucherImages();
this.ensureTransportPlanDetailTransportTypeName(detail);
if (this.isWaybillDetailLayout && detail.contractId) {
return getContractDetail(detail.contractId)
@@ -7296,7 +7369,8 @@ export default {
if (!config?.id) return [];
return getProcessConfigDetail(config.id, this.detailRow.id).then(detailRes => {
const detail = detailRes?.data?.data || detailRes?.data || config;
this.waybillHasRelatedVoucher = Boolean(detail.hasRelatedVoucher);
this.waybillHasRelatedVoucher =
this.waybillHasRelatedVoucher || Boolean(detail.hasRelatedVoucher);
return this.getProcessConfigEnabledNodes(detail);
});
})
@@ -7320,6 +7394,7 @@ export default {
getProcessConfigVoucherImages(this.detailRow.id)
.then(res => {
this.waybillVoucherImages = extractRecords(res);
if (this.waybillVoucherImages.length) this.waybillHasRelatedVoucher = true;
this.waybillVoucherImagesLoaded = true;
})
.finally(() => {
@@ -7557,23 +7632,32 @@ export default {
isFixedBillCutoffSettlement(rule = this.settlementRuleForm) {
return this.isMonthlySettlement(rule) && rule.billCycleType === '固定截单日';
},
isCustomMultiPeriodSettlement(rule = this.settlementRuleForm) {
return this.isMonthlySettlement(rule) && rule.billCycleType === '自定义多周期';
},
isFixedCycleSettlement(rule = this.settlementRuleForm) {
return rule.settlementType === '固定天数周期结算';
},
handleSettlementTypeChange(value) {
this.clearSettlementRuleError('settlementType');
if (value === '月结') {
if (!this.settlementRuleForm.billCycleType) {
this.settlementRuleForm.billCycleType = '固定截单日';
}
if (this.settlementRuleForm.billCycleType === '固定截单日') {
this.settlementRuleForm.billCutoffDay = this.settlementRuleForm.billCutoffDay || 25;
this.settlementRuleForm.customPeriods = [];
} else if (this.settlementRuleForm.billCycleType === '自定义多周期') {
this.settlementRuleForm.billCutoffDay = '';
this.settlementRuleForm.customPeriods = normalizeCustomPeriods(
this.settlementRuleForm.customPeriods || []
);
} else {
this.settlementRuleForm.billCutoffDay = '';
this.settlementRuleForm.customPeriods = [];
}
this.settlementRuleForm.cycleDays = '';
return;
}
this.settlementRuleForm.billCycleType = '';
this.settlementRuleForm.billCutoffDay = '';
this.settlementRuleForm.customPeriods = [];
if (value !== '固定天数周期结算') {
this.settlementRuleForm.cycleDays = '';
}
@@ -7581,10 +7665,17 @@ export default {
handleSettlementBillCycleTypeChange(value) {
this.clearSettlementRuleError('billCycleType');
if (value === '固定截单日') {
this.settlementRuleForm.billCutoffDay = this.settlementRuleForm.billCutoffDay || 25;
this.settlementRuleForm.customPeriods = [];
return;
}
this.settlementRuleForm.billCutoffDay = '';
if (value === '自定义多周期') {
this.settlementRuleForm.customPeriods = normalizeCustomPeriods(
this.settlementRuleForm.customPeriods || []
);
return;
}
this.settlementRuleForm.customPeriods = [];
},
normalizeSettlementRule(rule = this.settlementRuleForm) {
const nextRule = {
@@ -7594,14 +7685,94 @@ export default {
if (!this.isMonthlySettlement(nextRule)) {
nextRule.billCycleType = '';
nextRule.billCutoffDay = '';
nextRule.customPeriods = [];
} else if (!this.isFixedBillCutoffSettlement(nextRule)) {
nextRule.billCutoffDay = '';
}
if (this.isCustomMultiPeriodSettlement(nextRule)) {
nextRule.customPeriods = normalizeCustomPeriods(
Array.isArray(nextRule.customPeriods) ? nextRule.customPeriods : []
);
} else {
nextRule.customPeriods = [];
}
if (!this.isFixedCycleSettlement(nextRule)) {
nextRule.cycleDays = '';
}
return nextRule;
},
customPeriodEndDayOptions(row) {
const startDay = Number(row?.startDay);
if (!Number.isFinite(startDay) || startDay < 1) return this.billCutoffDayOptions;
return Array.from({ length: 31 - startDay + 1 }, (_, index) => {
const value = startDay + index;
return { label: `${value}`, value };
});
},
handleCustomPeriodStartDayChange(index, value) {
const periods = normalizeCustomPeriods(this.settlementRuleForm.customPeriods || []);
const row = periods[index];
if (!row) return;
row.startDay = value || '';
const startDay = Number(row.startDay);
const endDay = Number(row.endDay);
if (
Number.isFinite(startDay) &&
Number.isFinite(endDay) &&
(endDay < startDay || endDay > 31)
) {
row.endDay = '';
}
this.settlementRuleForm.customPeriods = periods;
},
handleCustomPeriodEndDayChange(index, value) {
const periods = normalizeCustomPeriods(this.settlementRuleForm.customPeriods || []);
const row = periods[index];
if (!row) return;
row.endDay = value || '';
this.settlementRuleForm.customPeriods = periods;
},
addCustomPeriodRow() {
const periods = normalizeCustomPeriods(this.settlementRuleForm.customPeriods || []);
periods.push({ startDay: '', endDay: '' });
this.settlementRuleForm.customPeriods = periods;
},
removeCustomPeriodRow(index) {
if (index <= 0) return;
const periods = [...(this.settlementRuleForm.customPeriods || [])];
periods.splice(index, 1);
this.settlementRuleForm.customPeriods = normalizeCustomPeriods(periods);
},
validateCustomPeriods(periods = [], label = '') {
const rows = normalizeCustomPeriods(periods);
const prefix = label ? `${label}` : '';
if (!rows.length) {
this.$message.warning(`${prefix}请至少配置一段自定义周期`);
return false;
}
for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
const startDay = Number(row.startDay);
const endDay = Number(row.endDay);
if (!Number.isFinite(startDay) || startDay < 1 || startDay > 31) {
this.$message.warning(`${prefix}请选择第${index + 1}行运单区间开始日`);
return false;
}
if (!Number.isFinite(endDay) || endDay < 1 || endDay > 31) {
this.$message.warning(`${prefix}请选择第${index + 1}行运单区间结束日`);
return false;
}
if (endDay < startDay) {
this.$message.warning(`${prefix}${index + 1}行结束日不能早于开始日`);
return false;
}
if (index > 0 && startDay !== Number(rows[index - 1].endDay) + 1) {
this.$message.warning(`${prefix}自定义多周期区间必须连续,不允许重叠或存在日期缺口`);
return false;
}
}
return true;
},
clearSettlementRuleError(field) {
if (!this.settlementRuleErrors[field]) return;
this.settlementRuleErrors = {
@@ -7623,8 +7794,8 @@ export default {
return false;
}
if (this.isMonthlySettlement(rule) && this.isBillingFieldEmpty(rule.billCycleType)) {
this.settlementRuleErrors = { billCycleType: '请选择账单周期类型' };
this.$message.warning(`${label}${label ? '' : ''}请选择账单周期类型`);
this.settlementRuleErrors = { billCycleType: '请选择结算周期' };
this.$message.warning(`${label}${label ? '' : ''}请选择结算周期`);
return false;
}
if (this.isFixedBillCutoffSettlement(rule)) {
@@ -7640,6 +7811,9 @@ export default {
return false;
}
}
if (this.isCustomMultiPeriodSettlement(rule)) {
return this.validateCustomPeriods(rule.customPeriods, label);
}
if (this.isFixedCycleSettlement(rule)) {
const cycleDays = Number(rule.cycleDays);
if (!Number.isInteger(cycleDays) || cycleDays < 1 || cycleDays > 365) {
@@ -8797,7 +8971,7 @@ export default {
}
},
isTaskCarrierRequired(carrierType) {
return ['承运商', '网货平台'].includes(String(carrierType || '').trim());
return String(carrierType || '').trim() === '承运商';
},
resetWaybillCarrierContractState() {
if (!this.isWaybillDetailLayout) return;
@@ -9271,7 +9445,7 @@ export default {
['vehicleNo', this.transportVehicleNoLabel],
];
if (this.isTaskCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']);
requiredFields.push(['carrierName', '承运商']);
}
if (this.isWaybillDetailLayout && row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']);
@@ -10110,6 +10284,12 @@ export default {
this.ensureTransportAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword))
.then(result => {
this.transportMapSearchResults = (result.geocodes || []).map((item, index) => ({
id: item.id || index,
name: item.formattedAddress || keyword,
address: item.formattedAddress || keyword,
location: item.location,
}));
const point = this.resolveMapPoint(result);
if (!point) {
this.transportMapStatus = '未找到匹配地址';
@@ -10130,6 +10310,11 @@ export default {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
});
},
selectTransportMapSearchResult(item) {
if (item && item.location) {
this.pickTransportMapPoint(item.location, item.address || item.name || '');
}
},
pickTransportMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat);
@@ -11219,8 +11404,10 @@ export default {
if (!this.canEditBillingLimit(row)) {
continue;
}
const key = row.billingElement || '';
groups[key] = groups[key] || [];
const feeItem = String(row.feeItem || '').trim();
const billingElement = String(row.billingElement || '').trim();
const key = JSON.stringify([feeItem, billingElement]);
groups[key] = groups[key] || { feeItem, billingElement, ranges: [] };
const ranges = this.getBillingLimitRanges(row);
for (const range of ranges) {
const lower = Number(range.lowerLimit);
@@ -11238,14 +11425,14 @@ export default {
this.$message.warning('计费要素下限不能大于上限');
return false;
}
groups[key].push({ lower, upper });
groups[key].ranges.push({ lower, upper });
}
}
return this.validateBillingLimitRangeGroups(groups);
},
validateBillingLimitRangeGroups(groups) {
const precision = 0.000001;
for (const [billingElement, ranges] of Object.entries(groups)) {
for (const { feeItem, billingElement, ranges } of Object.values(groups)) {
if (ranges.length <= 1) continue;
const sortedRanges = [...ranges].sort((prev, next) => {
if (prev.lower !== next.lower) return prev.lower - next.lower;
@@ -11255,11 +11442,13 @@ export default {
const prevRange = sortedRanges[index - 1];
const currentRange = sortedRanges[index];
if (currentRange.lower < prevRange.upper - precision) {
this.$message.warning(`${billingElement}的计费要素区间不能重叠`);
this.$message.warning(`费用项“${feeItem}${billingElement}的计费要素区间不能重叠`);
return false;
}
if (currentRange.lower > prevRange.upper + precision) {
this.$message.warning(`${billingElement}的计费要素区间必须连续,不能存在间隙`);
this.$message.warning(
`费用项“${feeItem}${billingElement}的计费要素区间必须连续,不能存在间隙`
);
return false;
}
}
@@ -11549,7 +11738,7 @@ export default {
},
handleImport() {
if (this.config.enableWaybillImport) {
this.waybillImportBox = true;
this.$router.push({ path: '/business/waybill-import' });
return;
}
if (this.config.enableTransportPlanImport) {
@@ -12675,7 +12864,7 @@ export default {
this.loadDispatchCarrierOptions(this.dispatchRow);
},
isDispatchCarrierRequired(carrierType) {
return ['承运商', '网货平台'].includes(String(carrierType || '').trim());
return String(carrierType || '').trim() === '承运商';
},
getProjectCarrierOptions(project = {}) {
const rows = this.parseJsonArray(project.carrierJson);
@@ -13062,7 +13251,7 @@ export default {
],
];
if (this.isDispatchCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', row.carrierType === '网货平台' ? '承运方' : '承运商']);
requiredFields.push(['carrierName', '承运商']);
}
if (row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']);
@@ -13568,7 +13757,9 @@ export default {
:deep(.el-form-item__content > .el-select),
:deep(.el-form-item__content > .el-cascader),
:deep(.el-form-item__content > .el-input-number),
:deep(.el-form-item__content > .el-date-editor) {
:deep(.el-form-item__content > .el-date-editor),
:deep(.el-form-item__content > .el-autocomplete),
:deep(.el-form-item__content > .el-autocomplete > .el-input) {
width: 250px;
max-width: 100%;
}
@@ -13639,7 +13830,7 @@ export default {
&__shipping-title,
&__goods-title,
&__task-title {
min-width: 100%;
//min-width: 100%;
}
&__shipping-route-action {
@@ -13886,7 +14077,7 @@ export default {
);
gap: 8px;
align-items: center;
width: 100%;
width:98%;
:deep(.el-input),
:deep(.el-select),
@@ -13940,7 +14131,9 @@ export default {
}
&__dispatch-shipping-form &__route-address-row {
grid-template-columns: 260px minmax(280px, 1fr) 58px minmax(160px, 220px) 72px minmax(
// minmax(0, 1fr)
//
grid-template-columns: 260px minmax(0, 1fr) 58px minmax(160px, 220px) 72px minmax(
160px,
220px
);
@@ -14435,6 +14628,13 @@ export default {
color: #303133;
}
.settlement-switch-tip {
margin: 0 4px;
color: #a8abb2;
cursor: help;
font-size: 14px;
}
&__module-form {
width: 100%;
@@ -15494,7 +15694,6 @@ export default {
&__waybill-route-addr {
overflow: hidden;
color: #606266;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,596 @@
<template>
<section
class="contract-manage-form__section contract-manage-form__section--panel contract-manage-form__section--attachment"
>
<div class="contract-manage-form__attachment-head">
<div class="dialog-section-title">{{ title }}</div>
<el-button type="primary" :disabled="!rows.length" @click="batchDownload">批量下载</el-button>
</div>
<el-table
:data="rows"
border
class="contract-manage-form__attachment-table"
@selection-change="selected = $event"
>
<el-table-column v-if="!readonly" type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column v-if="attachmentType" label="附件类型" min-width="160" align="center">
<template #default="{ row }">
<span v-if="readonly || isRowLocked(row)">{{ row.fileType || '-' }}</span>
<el-select
v-else
:model-value="row.fileType"
placeholder="请选择"
:teleported="false"
:fit-input-width="true"
@update:model-value="value => updateRowFileType(row, value)"
>
<el-option
v-for="item in contractAttachmentTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="文件名"
min-width="240"
align="left"
header-align="left"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="preview?.(row, rows)">{{ attachmentName(row) }}</el-link>
</template>
</el-table-column>
<el-table-column v-if="description" label="附件描述" min-width="220">
<template #default="{ row }">
<span v-if="readonly || isRowLocked(row)">{{ row.description || '-' }}</span>
<el-input
v-else
:model-value="row.description"
maxlength="200"
@update:model-value="value => updateRowDescription(row, value)"
/>
</template>
</el-table-column>
<el-table-column label="文件大小" width="120" align="center">
<template #default="{ row }">{{ formatSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column prop="uploadTime" label="上传时间" width="180" align="center" sortable />
<el-table-column v-if="!readonly" label="操作" width="100" align="center" fixed="right">
<template #default="{ row, $index }">
<span v-if="isRowLocked(row)">-</span>
<el-link v-else type="danger" @click="remove($index)">删除</el-link>
</template>
</el-table-column>
</el-table>
<div v-if="!readonly" class="contract-manage-form__attachment-upload">
<template v-if="useUploadDialog">
<el-button type="primary" plain @click="openUploadDialog">上传附件</el-button>
<span class="contract-manage-form__attachment-tip">
支持pdfbmpjpegpngjpgdocdocxpptpptxxlsxxlsemlmsgzip的文件格式单个文件不超过50M
</span>
</template>
<vehicle-attachment-upload
v-else
:model-value="rows"
:file-types="attachmentFileTypes"
:max-size="50"
show-tip
tip="支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过50M"
:show-file-list="false"
button-text="上传附件"
@update:model-value="handleChange"
@change="handleChange"
/>
</div>
<el-dialog
v-model="uploadDialogVisible"
title="附件上传"
width="560px"
append-to-body
destroy-on-close
:close-on-click-modal="false"
class="contract-attachment-upload-dialog"
@closed="resetUploadDialog"
>
<div class="contract-attachment-upload-dialog__body">
<div class="contract-attachment-upload-dialog__field">
<span class="contract-attachment-upload-dialog__label">附件位置</span>
<el-select :model-value="attachmentLocation" disabled style="width: 100%">
<el-option :label="attachmentLocation" :value="attachmentLocation" />
</el-select>
</div>
<div class="contract-attachment-upload-dialog__field">
<span class="contract-attachment-upload-dialog__label">附件类型</span>
<el-select v-model="uploadDialogType" placeholder="请选择附件类型" style="width: 100%">
<el-option
v-for="item in contractAttachmentTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="contract-attachment-upload-dialog__uploader">
<el-upload
drag
multiple
:action="uploadAction"
:headers="uploadHeaders"
:accept="acceptText"
:show-file-list="true"
:before-upload="beforeUpload"
:on-success="handleDialogSuccess"
:on-error="handleDialogError"
:on-remove="handleDialogRemove"
:file-list="uploadDialogFileList"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
</div>
</div>
<template #footer>
<div class="contract-attachment-upload-dialog__footer">
<el-button type="primary" @click="confirmUploadDialog">确定</el-button>
<el-button type="primary" @click="uploadDialogVisible = false">取消</el-button>
</div>
</template>
</el-dialog>
</section>
</template>
<script>
import { UploadFilled } from '@element-plus/icons-vue';
import { baseUrl } from '@/config/env';
import { getUploadHeaders } from '@/utils/upload';
import { downloadFileByUrl } from '@/utils/util';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
const CONTRACT_ATTACHMENT_TYPE_OTHER = '其它文件';
export const defaultContractAttachmentTypeOptions = [
{ label: '合同文件', value: '合同文件' },
{ label: '双章归档文件', value: '双章归档文件' },
{ label: '其它文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典 contract_attachment_type 动态加载 */
export const contractAttachmentTypeOptions = defaultContractAttachmentTypeOptions;
export const attachmentFileTypes = [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
];
export const attachmentName = row => row?.originalName || row?.name || row?.fileName || '附件';
export const attachmentUrl = row =>
row?.url || row?.link || row?.fileUrl || row?.downloadUrl || row?.domain || '';
export const isAttachmentApproved = row =>
row?.approved === true ||
row?.approved === 1 ||
row?.approved === '1' ||
row?.approvalStatus === 'approved';
const normalizeAttachmentTypeText = value => {
let text = String(value || '').split(/[?#]/)[0];
try {
text = decodeURIComponent(text);
} catch (error) {
// ignore decode error
}
return text
.replace(/\.[^.\\/]+$/, '')
.toLowerCase()
.replace(/[\s_\-—–·•()()\[\]【】{}《》<>“”"'、,,。.]/g, '');
};
const attachmentTypeKeywords = fileType => {
if (fileType === '双章归档文件') return ['双章归档文件', '双章归档', '双章'];
if (fileType === '合同文件') return ['合同文件', '合同'];
return [fileType];
};
const resolveOtherAttachmentType = (options = []) => {
const list = options.length ? options : defaultContractAttachmentTypeOptions;
const matched = list.find(
item =>
String(item.value) === CONTRACT_ATTACHMENT_TYPE_OTHER ||
String(item.label).includes('其它') ||
String(item.label).includes('其他')
);
return matched?.value || list[list.length - 1]?.value || CONTRACT_ATTACHMENT_TYPE_OTHER;
};
export const resolveContractAttachmentType = (
row = {},
options = defaultContractAttachmentTypeOptions
) => {
const list = options.length ? options : defaultContractAttachmentTypeOptions;
const values = list.map(item => item.value);
if (values.includes(row.fileType)) return row.fileType;
const fileName = normalizeAttachmentTypeText(attachmentName(row));
const otherType = resolveOtherAttachmentType(list);
if (!fileName) return otherType;
const matched = list
.filter(item => item.value !== otherType)
.flatMap(item =>
attachmentTypeKeywords(item.value).map(keyword => ({
value: item.value,
keyword: normalizeAttachmentTypeText(keyword),
}))
)
.filter(item => item.keyword)
.sort((a, b) => b.keyword.length - a.keyword.length)
.find(item => fileName.includes(item.keyword));
return matched?.value || otherType;
};
export const normalizeContractFileRows = (rows, options = defaultContractAttachmentTypeOptions) =>
(rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, options),
description: row.description || '',
}));
const mapDictOptions = response => {
const data = response?.data?.data || response?.data || [];
const records = Array.isArray(data) ? data : data.records || [];
return records
.map(item => ({
label: item.dictValue,
value: item.dictKey,
}))
.filter(item => item.label && item.value);
};
export default {
name: 'ContractAttachmentSection',
components: { UploadFilled },
props: {
title: { type: String, required: true },
rows: { type: Array, default: () => [] },
description: Boolean,
attachmentType: Boolean,
readonly: Boolean,
lockApproved: Boolean,
markApprovedOnUpload: Boolean,
useUploadDialog: Boolean,
attachmentLocation: { type: String, default: '合同文件' },
preview: { type: Function, default: null },
},
emits: ['update:rows'],
data() {
return {
selected: [],
attachmentFileTypes,
contractAttachmentTypeOptions: [...defaultContractAttachmentTypeOptions],
uploadDialogVisible: false,
uploadDialogType: resolveOtherAttachmentType(defaultContractAttachmentTypeOptions),
uploadDialogFiles: [],
uploadDialogFileList: [],
};
},
computed: {
uploadAction() {
return `${baseUrl}/blade-resource/oss/endpoint/put-file`;
},
uploadHeaders() {
return getUploadHeaders();
},
acceptText() {
return this.attachmentFileTypes.map(item => `.${item}`).join(',');
},
defaultAttachmentType() {
return resolveOtherAttachmentType(this.contractAttachmentTypeOptions);
},
},
created() {
this.loadAttachmentTypeOptions();
},
methods: {
attachmentName,
loadAttachmentTypeOptions() {
return getBizDictionary({ code: 'contract_attachment_type' })
.then(res => {
const options = mapDictOptions(res);
if (options.length) {
this.contractAttachmentTypeOptions = options;
if (
!options.some(item => String(item.value) === String(this.uploadDialogType))
) {
this.uploadDialogType = this.defaultAttachmentType;
}
}
})
.catch(() => {
//
});
},
isRowLocked(row) {
return this.lockApproved && isAttachmentApproved(row);
},
update(rows) {
this.$emit('update:rows', rows);
},
updateRowFileType(row, value) {
if (this.isRowLocked(row)) return;
row.fileType = value;
this.update([...this.rows]);
},
updateRowDescription(row, value) {
if (this.isRowLocked(row)) return;
row.description = value;
this.update([...this.rows]);
},
openUploadDialog() {
this.uploadDialogType = this.defaultAttachmentType;
this.uploadDialogFiles = [];
this.uploadDialogFileList = [];
this.uploadDialogVisible = true;
},
resetUploadDialog() {
this.uploadDialogFiles = [];
this.uploadDialogFileList = [];
this.uploadDialogType = this.defaultAttachmentType;
},
beforeUpload(file) {
const extension = String(file.name || '')
.split('.')
.pop()
?.toLowerCase();
if (!this.attachmentFileTypes.includes(extension)) {
this.$message.warning('文件类型不符合上传要求');
return false;
}
if (file.size / 1024 / 1024 > 50) {
this.$message.warning('单文件大小不能超过 50MB');
return false;
}
return true;
},
normalizeUploadFile(file, response) {
const data = response?.data || file.response?.data || {};
const url = data.link || data.url || data.domain || file.url || '';
const originalName =
data.originalName || file.name || data.name || attachmentName({ url }) || '附件';
return {
uid: file.uid,
originalName,
name: originalName,
url,
link: data.link || url,
size: data.size || data.attachSize || file.size || '',
};
},
handleDialogSuccess(response, file, fileList) {
if (!response || response.success === false || (response.code && response.code !== 200)) {
this.$message.error(response?.msg || '上传失败');
this.uploadDialogFileList = (fileList || []).filter(item => item.uid !== file.uid);
return;
}
const normalized = this.normalizeUploadFile(file, response);
this.uploadDialogFileList = fileList || [];
this.uploadDialogFiles = [
...this.uploadDialogFiles.filter(item => String(item.uid) !== String(file.uid)),
normalized,
];
this.uploadDialogType = resolveContractAttachmentType(
{
...normalized,
fileType: '',
},
this.contractAttachmentTypeOptions
);
this.$message.success('上传成功');
},
handleDialogError() {
this.$message.error('上传失败');
},
handleDialogRemove(file) {
this.uploadDialogFiles = this.uploadDialogFiles.filter(
item => String(item.uid) !== String(file.uid)
);
this.uploadDialogFileList = this.uploadDialogFileList.filter(
item => String(item.uid) !== String(file.uid)
);
},
confirmUploadDialog() {
if (!this.uploadDialogFiles.length) {
this.$message.warning('请先上传附件');
return;
}
if (!this.uploadDialogType) {
this.$message.warning('请选择附件类型');
return;
}
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const appended = this.uploadDialogFiles.map(row => ({
...row,
fileType: this.uploadDialogType,
description: row.description || '',
uploadUserName: row.uploadUserName || uploadUserName,
uploadTime: row.uploadTime || uploadTime,
...(this.markApprovedOnUpload ? { approved: true } : {}),
}));
this.update([...(this.rows || []), ...appended]);
this.uploadDialogVisible = false;
},
handleChange(rows) {
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const existingRows = this.rows || [];
this.update(
(rows || []).map(row => {
const fileUrl = attachmentUrl(row);
const existing = existingRows.find(item => {
const sameUid = row.uid && item.uid && String(row.uid) === String(item.uid);
const sameUrl = fileUrl && attachmentUrl(item) === fileUrl;
return sameUid || sameUrl;
});
if (existing && this.isRowLocked(existing)) return { ...existing };
const next = {
...existing,
...row,
description: row.description || existing?.description || '',
uploadUserName: existing?.uploadUserName || row.uploadUserName || uploadUserName,
uploadTime: existing?.uploadTime || row.uploadTime || uploadTime,
approved: existing?.approved || row.approved || false,
};
if (this.attachmentType) {
next.fileType = resolveContractAttachmentType(
{
...next,
fileType: existing?.fileType || row.fileType,
},
this.contractAttachmentTypeOptions
);
}
if (this.markApprovedOnUpload && !existing) next.approved = true;
return next;
})
);
},
remove(index) {
const row = this.rows[index];
if (this.isRowLocked(row)) {
this.$message.warning('已审核通过的附件不允许删除');
return;
}
const rows = [...this.rows];
rows.splice(index, 1);
this.update(rows);
},
download(row) {
const url = attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法下载');
return;
}
downloadFileByUrl(url, attachmentName(row));
},
batchDownload() {
(this.selected.length ? this.selected : this.rows).forEach(this.download);
},
formatSize(size) {
const value = Number(size);
if (!value) return '';
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / 1024 / 1024).toFixed(1)}MB`;
},
},
};
</script>
<style lang="scss" scoped>
.contract-manage-form__section {
margin: 12px 0 0;
padding: 14px 16px 16px;
overflow: hidden;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.dialog-section-title {
margin-bottom: 20px;
color: #303133;
font-size: 16px;
font-weight: 600;
&::before {
display: inline-block;
width: 4px;
height: 16px;
margin-right: 8px;
vertical-align: -2px;
background: #409eff;
content: '';
}
}
}
.contract-manage-form__attachment-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.dialog-section-title {
margin-bottom: 0;
}
}
.contract-manage-form__attachment-upload {
display: flex;
align-items: center;
margin-top: 12px;
}
.contract-manage-form__attachment-tip {
margin-left: 30px;
color: #909399;
font-size: 13px;
line-height: 1.4;
}
.contract-attachment-upload-dialog {
&__body {
padding: 4px 8px 0;
}
&__field {
display: flex;
align-items: center;
margin-bottom: 16px;
}
&__label {
flex: none;
width: 72px;
color: #606266;
text-align: right;
margin-right: 12px;
}
&__uploader {
margin-top: 8px;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
&__footer {
display: flex;
justify-content: center;
gap: 12px;
}
}
</style>
@@ -1,9 +1,52 @@
<template>
<div v-loading="loading" class="master-detail">
<section v-if="master" class="detail-overview">
<div class="detail-heading"><div><h2>多联总单详情 <span>|</span> {{ master.masterNo }}</h2><el-tag :type="statusType(master.businessStatus)" class="status-text">{{ statusName(master.businessStatus) }}</el-tag><el-tag v-if="transportFlowLabel" type="info" class="transport-flow-tag">{{ transportFlowLabel }}</el-tag></div><el-button @click="$emit('back')">取消</el-button></div>
<dl class="detail-meta"><div><dt>客户</dt><dd>{{ master.customerName || '-' }}</dd></div><div><dt>合同编号</dt><dd>{{ master.contractNo || '-' }}</dd></div><div><dt>项目</dt><dd>{{ master.projectName || '-' }}</dd></div><div class="detail-meta__attachments"><dt>附件</dt><dd><template v-if="attachments.length"><el-link v-for="file in attachments" :key="file.url || file.link || file.name || file.originalName" type="primary" @click="previewAttachment(file)">{{ attachmentName(file) }}</el-link></template><span v-else>-</span></dd></div></dl>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ route.departureName || '-' }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ route.arrivalName || '-' }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }}</small></div></template></div>
<div class="detail-heading"><div><h2>多联总单详情 <span>|</span> {{ master.masterNo }}</h2><el-tag :type="statusType(master.businessStatus)" class="status-text">{{ statusName(master.businessStatus) }}</el-tag><el-tag v-if="transportFlowLabel" type="info" class="transport-flow-tag">{{ transportFlowLabel }}</el-tag></div></div>
<dl class="detail-meta">
<div>
<dt>客户</dt>
<dd>
<el-link v-if="master.customerName" type="primary" @click="openCustomer">{{
master.customerName
}}</el-link>
<span v-else>-</span>
</dd>
</div>
<div>
<dt>合同编号</dt>
<dd>
<el-link v-if="master.contractNo && master.contractId" type="primary" @click="openContract">{{
master.contractNo
}}</el-link>
<span v-else>{{ master.contractNo || '-' }}</span>
</dd>
</div>
<div>
<dt>项目</dt>
<dd>
<el-link v-if="master.projectName && master.projectId" type="primary" @click="openProject">{{
master.projectName
}}</el-link>
<span v-else>{{ master.projectName || '-' }}</span>
</dd>
</div>
<div class="detail-meta__attachments">
<dt>附件</dt>
<dd>
<template v-if="attachments.length">
<el-link
v-for="file in attachments"
:key="file.url || file.link || file.name || file.originalName"
type="primary"
@click="previewAttachment(file)"
>{{ attachmentName(file) }}</el-link
>
</template>
<span v-else>-</span>
</dd>
</div>
</dl>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div></template></div>
</section>
<el-dialog v-model="documentPreviewVisible" :title="previewFile.name || '附件预览'" append-to-body destroy-on-close width="90%" top="4vh">
@@ -14,10 +57,10 @@
<section v-if="master" class="execution-detail-card">
<div class="execution-detail-heading"><h3>分段执行明细</h3></div>
<section v-for="(route, index) in segments" :key="route.segmentNo" class="segment-detail">
<header @click="toggleSegment(route.segmentNo)"><h3><span class="segment-index">{{ segmentNumber(route, index) }}</span><span>{{ route.departureName || '-' }} {{ route.arrivalName || '-' }}</span><span class="segment-detail__transport-type">{{ segmentTransportType(route) || '-' }}</span><span class="segment-detail__progress-bar"><span :style="{ width: `${segmentProgress(route)}%` }"></span></span><span class="segment-detail__progress">已调度 <em>{{ quantity(route.dispatchedQuantity) }}</em> / {{ quantity(plannedQuantity(route)) }} {{ plannedUnit(route) }}</span></h3><div class="segment-detail__header-right"><el-tooltip :content="isSegmentExpanded(route.segmentNo) ? '折叠' : '展开'" placement="top"><el-button circle :icon="isSegmentExpanded(route.segmentNo) ? ArrowUp : ArrowDown" @click.stop="toggleSegment(route.segmentNo)" /></el-tooltip></div></header>
<header @click="toggleSegment(route.segmentNo)"><h3><span class="segment-index">{{ segmentNumber(route, index) }}</span><span>{{ segmentLocationName(route, 'departure') }} {{ segmentLocationName(route, 'arrival') }}</span><span class="segment-detail__transport-type">{{ segmentTransportType(route) || '-' }}</span><span class="segment-detail__progress-bar"><span :style="{ width: `${segmentProgress(route)}%` }"></span></span><span class="segment-detail__progress">已调度 <em>{{ quantity(route.dispatchedQuantity) }}</em> / {{ quantity(plannedQuantity(route)) }} {{ plannedUnit(route) }}</span><el-button text class="segment-toggle" @click.stop="toggleSegment(route.segmentNo)"><span>{{ isSegmentExpanded(route.segmentNo) ? '收起' : '展开' }}</span><el-icon class="segment-toggle__icon"><component :is="isSegmentExpanded(route.segmentNo) ? CaretTop : CaretBottom" /></el-icon></el-button></h3></header>
<div v-show="isSegmentExpanded(route.segmentNo)" class="segment-execution">
<div class="segment-stats"><div><span>总量</span><strong>{{ quantity(master.totalQuantity) }}<small></small></strong></div><div><span>已调度</span><strong class="dispatched">{{ quantity(route.dispatchedQuantity) }}<small></small></strong></div><div><span>剩余</span><strong class="remaining">{{ quantity(remainingQuantity(route)) }}<small></small></strong></div></div>
<el-table :data="route.waybills || []" border class="waybill-table"><el-table-column label="运单号" min-width="180"><template #default="{ row }"><el-link type="primary" @click="openWaybill(row)">{{ row.waybillNo }}</el-link></template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="190" /><el-table-column label="司机/车牌" min-width="180"><template #default="{ row }">{{ driverVehicle(row) }}</template></el-table-column><el-table-column prop="cargoType" label="货物类型" min-width="120" /><el-table-column label="数量(吨)" width="130"><template #default="{ row }">{{ quantity(row.quantity) }}</template></el-table-column><el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="waybillStatusType(row.businessStatus)" size="small" class="status-text">{{ waybillStatusName(row.businessStatus) }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table>
<el-table :data="route.waybills || []" border class="waybill-table"><el-table-column label="运单号" min-width="180"><template #default="{ row }"><el-link type="primary" @click="openWaybill(row)">{{ row.waybillNo }}</el-link></template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="190" /><el-table-column :label="isRoadTransport(route) ? '司机/车牌' : '船长/船号'" min-width="180"><template #default="{ row }">{{ driverVehicle(row, route) }}</template></el-table-column><el-table-column prop="cargoType" label="货物类型" min-width="120" /><el-table-column label="数量(吨)" width="130"><template #default="{ row }">{{ quantity(row.quantity) }}</template></el-table-column><el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="waybillStatusType(row.businessStatus)" effect="dark" size="small">{{ waybillStatusName(row.businessStatus) }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table>
<el-empty v-if="!(route.waybills || []).length" description="暂无运单明细" :image-size="56" />
<div v-if="(route.transportPlans || []).length" class="transport-plan-detail"><h4>关联计划单</h4><el-table :data="route.transportPlans" border><el-table-column label="计划单号" min-width="160"><template #default="{ row }"><el-link type="primary" @click="openPlan(row)">{{ row.planNo }}</el-link></template></el-table-column><el-table-column prop="planName" label="计划名称" min-width="180" /><el-table-column prop="transportType" label="运输方式" width="130" /><el-table-column prop="planStartDate" label="计划开始日期" width="130" /><el-table-column prop="planEndDate" label="计划结束日期" width="130" /><el-table-column prop="businessStatus" label="状态" width="110" /></el-table></div>
</div>
@@ -26,20 +69,14 @@
<section v-if="master" class="master-goods-card">
<div class="master-goods-card__heading"><h3>货物信息</h3></div>
<el-table :data="master.goods || []" border>
<el-table-column type="index" label="序号" width="64" />
<el-table-column prop="cargoName" label="货物名称" min-width="150" />
<el-table-column prop="cargoType" label="货物类型" min-width="130" />
<el-table-column prop="cargoType" label="类型" min-width="130" />
<el-table-column prop="packageType" label="包装" min-width="120" />
<el-table-column label="重量(吨)" min-width="110"><template #default="{ row }">{{ row.weight ?? row.weightTon ?? (row.quantityUnit === '吨' ? row.quantity : '-') }}</template></el-table-column>
<el-table-column label="体积(方)" min-width="110"><template #default="{ row }">{{ row.volume ?? row.volumeCubic ?? '-' }}</template></el-table-column>
<el-table-column label="数量" min-width="100"><template #default="{ row }">{{ row.quantity ?? '-' }}{{ row.quantityUnit ? ` ${row.quantityUnit}` : '' }}</template></el-table-column>
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="deviceCode" label="设备编码" min-width="130" />
<el-table-column prop="brand" label="品牌" min-width="120" />
<el-table-column label="规格型号" min-width="150"><template #default="{ row }">{{ [row.specification, row.model].filter(Boolean).join('/') || '-' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" />
<el-table-column label="货物单价(元)" min-width="130"><template #default="{ row }">{{ row.unitPrice ?? '-' }}</template></el-table-column>
<el-table-column label="计划日期" min-width="130"><template #default="{ row }">{{ row.planDate || row.planStartDate || '-' }}</template></el-table-column>
<el-table-column prop="specification" label="规格" min-width="150" />
<el-table-column prop="model" label="型号" min-width="150" />
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
</el-table>
<el-empty v-if="!(master.goods || []).length" description="暂无货物信息" :image-size="56" />
</section>
@@ -48,7 +85,9 @@
<script>
import * as api from '@/api/business/master-order';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { getDetail as getProjectDetail } from '@/api/business/project-apply';
import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archive';
import { CaretBottom, CaretTop } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
@@ -57,11 +96,27 @@ import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
const viewerPlugins = [imagePlugin(), pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }), officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }), textPlugin(), fallbackPlugin()];
const parseJsonArray = value => {
if (Array.isArray(value)) return value;
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
return parsed && typeof parsed === 'object' ? [parsed] : [];
} catch (error) {
return [];
}
};
const customerRowLabel = (row = {}) =>
row.customer || row.fullName || row.shortName || row.customerName || row.customerCode || '';
const customerRowId = (row = {}) => row.id || row.customerId || '';
export default {
components: { ElImageViewer, OpenFileViewer },
props: { id: [String, Number] },
emits: ['back'],
data() { return { loading: false, master: null, expandedSegments: {}, ArrowDown, ArrowUp, imagePreviewVisible: false, imagePreviewUrls: [], imagePreviewIndex: 0, documentPreviewVisible: false, previewFile: {}, viewerPlugins, viewerToolbar: { download: true, fullscreen: true, print: true, rotate: true, zoom: true } }; },
data() { return { loading: false, master: null, expandedSegments: {}, CaretBottom, CaretTop, imagePreviewVisible: false, imagePreviewUrls: [], imagePreviewIndex: 0, documentPreviewVisible: false, previewFile: {}, viewerPlugins, viewerToolbar: { download: true, fullscreen: true, print: true, rotate: true, zoom: true } }; },
computed: {
segments() {
const routes = this.master?.routeProgress || this.master?.routes || [];
@@ -94,6 +149,36 @@ export default {
toggleSegment(segmentNo) { this.expandedSegments = { ...this.expandedSegments, [segmentNo]: !this.isSegmentExpanded(segmentNo) }; },
remainingQuantity(route) { return Math.max(0, Number(this.master?.totalQuantity || 0) - Number(route.dispatchedQuantity || 0)); },
segmentTransportType(route = {}) { return route.transportTypeName || route.transportType || ''; },
isRoadTransport(route = {}) {
const type = String(this.segmentTransportType(route)).trim().toLowerCase();
return type === 'road' || type.includes('公路');
},
formatRoadLocation(value) {
const text = String(value || '').replace(/\s+/g, '');
if (!text) return '';
const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, '');
const cityMatch = withoutProvince.match(/.+?(?:市|州(?!市)|盟(?!市))/);
if (!cityMatch) return text;
const city = cityMatch[0];
const districtSource = withoutProvince.slice(city.length);
const districtMatch = districtSource.match(/^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/);
if (!districtMatch) return city;
const district = `${districtMatch[0]}${districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : ''}`;
return `${city} ${district}`;
},
segmentLocationName(route = {}, field) {
const name = String(route[`${field}Name`] || '').trim();
const address = String(route[`${field}Address`] || '').trim();
const formattedName = this.formatRoadLocation(name);
if (/(?:市|州|盟)/.test(formattedName)) return formattedName;
const regionalName = /(?:省|自治区|特别行政区|市|州|盟|区|县|旗)/.test(name);
if (name && !regionalName) return name;
const formattedAddress = this.formatRoadLocation(address);
if (/(?:市|州|盟)/.test(formattedAddress)) return formattedAddress;
const city = String(route[`${field}CityName`] || '').trim();
const district = String(route[`${field}DistrictName`] || '').trim();
return [city, district].filter(Boolean).join(' ') || formattedName || formattedAddress || '-';
},
plannedQuantity(route = {}) { return route.planQuantity || route.totalQuantity || this.master?.totalQuantity || 0; },
plannedUnit(route = {}) { return route.quantityUnit || this.master?.goods?.[0]?.quantityUnit || '吨'; },
segmentProgress(route = {}) { const planned = Number(this.plannedQuantity(route)); return planned > 0 ? Math.min(100, Math.max(0, (Number(route.dispatchedQuantity || 0) / planned) * 100)) : 0; },
@@ -113,11 +198,81 @@ export default {
this.previewFile = { name: this.attachmentName(file), url, mimeType: file.mimeType || file.contentType || '' };
this.documentPreviewVisible = true;
},
driverVehicle(row) { return [row.driverName, row.vehicleNo].filter(Boolean).join(' / ') || '-'; },
openWaybill(row) { this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.id } }); },
driverVehicle(row = {}, route = {}) {
const vehicle = row.vehicleNo || row.vehicleNumber || row.shipNo || row.flightNo || row.trainNo;
const values = this.isRoadTransport(route)
? [row.driverName, vehicle]
: [row.captainName, vehicle];
return values.filter(Boolean).join(' / ') || '-';
},
openWaybill(row) {
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: row.id } });
},
openPlan(row) { this.$router.push({ path: '/business/transport-plan', query: { detailId: row.id } }); },
waybillStatusName(value) { return ({ pending: '待执行', running: '进行中', completed: '已完成', cancelled: '已取消' })[value] || value || '-'; },
waybillStatusType(value) { return ({ pending: 'info', running: 'warning', completed: 'success', cancelled: 'danger' })[value] || 'info'; },
openContract() {
if (!this.master?.contractId) {
this.$message.warning('合同信息缺失,无法打开');
return;
}
this.$router.push({
path: '/business/contract-manage/detail',
query: { id: this.master.contractId, name: '合同详情' },
});
},
openProject() {
if (!this.master?.projectId) {
this.$message.warning('项目信息缺失,无法打开');
return;
}
this.$router.push({
path: '/business/project-apply/form',
query: { mode: 'view', id: this.master.projectId, name: '查看项目管理' },
});
},
async resolveCustomerId() {
if (this.master?.customerId) return this.master.customerId;
const name = String(this.master?.customerName || '').trim();
if (!name) return '';
if (this.master?.projectId) {
try {
const res = await getProjectDetail(this.master.projectId);
const rows = parseJsonArray(res.data?.data?.customerJson);
const matched =
rows.find(row => customerRowLabel(row) === name) ||
rows.find(row => {
const label = customerRowLabel(row);
return label && (name.includes(label) || label.includes(name));
});
const id = customerRowId(matched);
if (id) return id;
} catch (error) {
// 退
}
}
try {
const res = await getCustomerArchiveList(1, 20, { fullName: name });
const records = res.data?.data?.records || [];
const exact =
records.find(item => item.fullName === name || item.shortName === name) || records[0];
return exact?.id || '';
} catch (error) {
return '';
}
},
async openCustomer() {
if (!this.master?.customerName) return;
const customerId = await this.resolveCustomerId();
if (!customerId) {
this.$message.warning('未找到对应客商档案,无法打开');
return;
}
this.$router.push({
path: '/vehicle/customer-archive/form',
query: { id: String(customerId), name: '查看客商档案', view: '1' },
});
},
waybillStatusName(value) { return ({ pending: '待执行', waiting: '待执行', running: '进行中', processing: '进行中', completed: '已完成', cancelled: '已取消' })[value] || value || '-'; },
waybillStatusType(value) { return ({ pending: 'info', waiting: 'info', running: 'warning', processing: 'warning', completed: 'success', cancelled: 'danger' })[value] || 'info'; },
statusName(value) { return ({ waiting_dispatch: '待调度', dispatching: '调度中', completed: '调度完成', closed: '调度关闭' })[value] || value || '-'; },
statusType(value) { return ({ waiting_dispatch: 'warning', dispatching: 'success', completed: 'primary', closed: 'danger' })[value] || 'info'; },
},
@@ -129,22 +284,23 @@ export default {
.detail-overview, .execution-detail-card { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; }
.detail-heading { display: flex; align-items: center; justify-content: space-between; padding: 18px 24px; h2 { display: inline-block; margin: 0 16px 0 0; font-size: 20px; } h2 span { margin: 0 8px; color: #909399; font-weight: 400; } }
.transport-flow-tag { margin-left: 8px; }
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #409eff; font-size: 14px; word-break: break-all; } }
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #303133; font-size: 14px; word-break: break-all; } :deep(.el-link) { font-size: 14px; } }
.route-map { display: flex; align-items: flex-start; padding: 16px 24px 20px; border-top: 1px solid #eff1f7; overflow-x: auto; }
.route-map__node { display: grid; flex: 0 0 150px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; white-space: nowrap; } }
.route-map__progress { color: #409eff !important; }
.route-map__progress { color: #303133 !important; }
.route-badge { display: inline-flex; width: 32px; height: 32px; align-items: center; justify-content: center; border-radius: 4px; background: #67c23a; color: #fff; font-weight: 600; &.start { background: #409eff; } &.end { background: #e6a23c; } }
.route-map__line { flex: 1 0 64px; min-width: 64px; height: 32px; border-bottom: 6px solid #e4e7ed; }
.execution-detail-heading { padding: 20px 24px 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } }
.segment-detail { padding: 16px 24px; border-top: 1px solid #eff1f7; header { display: flex; align-items: center; justify-content: space-between; margin: -16px -24px 8px; padding: 12px 24px; cursor: pointer; background: #fafbfc; h3 { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 12px; margin: 0; font-size: 16px; } em { color: #409eff; font-style: normal; } } }
.segment-detail { padding: 16px 24px; border-top: 1px solid #eff1f7; header { display: flex; align-items: center; justify-content: space-between; margin: -16px -24px 8px; padding: 12px 24px; cursor: pointer; background: #fafbfc; h3 { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 12px; margin: 0; font-size: 16px; } em { color: #67c23a; font-style: normal; } } }
.segment-detail__progress { color: #606266; font-size: 14px; font-weight: 400; }
.segment-detail__transport-type { color: #409eff; font-size: 14px; font-weight: 500; }
.segment-detail__progress-bar { display: inline-flex; width: 144px; height: 6px; overflow: hidden; border-radius: 3px; background: #e4e7ed; }
.segment-detail__progress-bar span { display: block; height: 100%; background: #409eff; transition: width 0.2s ease; }
.segment-detail__header-right { display: flex; align-items: center; gap: 12px; }
.segment-toggle { gap: 4px; padding: 4px 0; }
.segment-toggle__icon { font-size: 12px; }
.segment-index { display: inline-flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: 50%; background: #2088ee; color: #fff; font-size: 20px; font-weight: 500; }
.segment-execution { padding-top: 12px; }
.segment-stats { display: flex; gap: 72px; padding: 4px 0 24px; div { display: flex; flex-direction: column; gap: 8px; } span { color: #8a9bb8; font-size: 14px; } strong { color: #1f2d3d; font-size: 32px; line-height: 1; } small { margin-left: 6px; color: #8a9bb8; font-size: 16px; font-weight: 400; } .dispatched { color: #409eff; } .remaining { color: #f56c6c; } }
.segment-stats { display: flex; gap: 72px; padding: 4px 0 24px; div { display: flex; flex-direction: column; gap: 8px; } span { color: #8a9bb8; font-size: 14px; } strong { color: #1f2d3d; font-size: 32px; line-height: 1; } small { margin-left: 6px; color: #8a9bb8; font-size: 16px; font-weight: 400; } .dispatched { color: #67c23a; } .remaining { color: #e6a23c; } }
.waybill-table { :deep(th.el-table__cell) { background: #f5f7fa; color: #60738f; } }
.transport-plan-detail { margin-top: 20px; h4 { margin: 0 0 12px; padding-left: 12px; border-left: 4px solid #409eff; font-size: 15px; } }
.master-goods-card { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; }
@@ -55,28 +55,28 @@
<span class="segment-checkbox-label">
<span>{{ route.segmentNo }}</span>
<el-tooltip
:content="`${route.departureName || '-'} → ${route.arrivalName || '-'}`"
:content="`${dispatchLocationName(route, 'departure')} → ${dispatchLocationName(route, 'arrival')}`"
placement="top"
>
<span class="segment-checkbox-path"
>{{ route.departureName || '-' }} {{ route.arrivalName || '-' }}</span
>{{ dispatchLocationName(route, 'departure') }} {{ dispatchLocationName(route, 'arrival') }}</span
>
</el-tooltip>
</span>
</el-checkbox>
</div>
<div>总量 {{ formatQuantity(totalQuantity) }}已调度 <em>{{ formatQuantity(dispatchedQuantity(route)) }}</em>剩余 <em>{{ formatQuantity(remaining(route)) }}</em></div>
<div>总量 {{ formatQuantity(totalQuantity) }} {{ quantityUnitLabel }}已调度 <em>{{ formatQuantity(dispatchedQuantity(route)) }} {{ quantityUnitLabel }}</em>剩余 <em>{{ formatQuantity(remaining(route)) }} {{ quantityUnitLabel }}</em></div>
</header>
<div v-show="route.selected" class="segment-content">
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="运输方式" required class="transport-type-field"><el-input :model-value="route.transportType" readonly /></el-form-item>
<el-form-item label="运输方式" required class="transport-type-field"><el-input :model-value="route.transportType" disabled /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="发货地址" required><el-input :model-value="addressText(route.departureName, route.departureAddress)" readonly /></el-form-item>
<el-form-item label="发货地址" required><el-input :model-value="addressText(route.departureName, route.departureAddress)" disabled /></el-form-item>
</el-col>
<el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="4"><el-form-item label="联系方式"><el-input v-model="route.departurePhone" placeholder="请输入" /></el-form-item></el-col>
@@ -84,7 +84,7 @@
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="收货地址" required><el-input :model-value="addressText(route.arrivalName, route.arrivalAddress)" readonly /></el-form-item>
<el-form-item label="收货地址" required><el-input :model-value="addressText(route.arrivalName, route.arrivalAddress)" disabled /></el-form-item>
</el-col>
<el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.arrivalContact" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="4"><el-form-item label="联系方式"><el-input v-model="route.arrivalPhone" placeholder="请输入" /></el-form-item></el-col>
@@ -95,11 +95,52 @@
<div class="goods-heading"><h3>货物信息</h3><div><el-link type="primary" @click="addGoods(route)">新增货物</el-link></div></div>
<el-table :data="route.goods" border class="goods-table">
<el-table-column type="index" label="序号" width="64" />
<el-table-column label="货物类型" min-width="180"><template #default="{ row }"><el-cascader v-model="row.cargoTypePath" class="goods-table__cargo-type" :options="cargoTypeOptions" :props="cargoTypeCascaderProps" placeholder="请选择货物类型" clearable filterable @change="value => handleCargoTypeChange(route, row, value)" /></template></el-table-column>
<el-table-column label="货物名称" min-width="180"><template #default="{ row }"><el-select v-model="row.sourceIndex" class="goods-table__cargo-name" placeholder="请选择总单货物" @change="selectGoods(route, row)"><el-option v-for="item in masterGoodsByCargoType(row)" :key="item.sourceIndex" :label="item.cargoName" :value="item.sourceIndex" /></el-select></template></el-table-column>
<el-table-column prop="remainingQuantity" column-key="remainingQuantity" label="剩余数量" width="150" class-name="goods-table__remaining"><template #default="{ row }">{{ formatQuantity(goodsRemainingQuantity(route, row)) }}</template></el-table-column>
<el-table-column prop="dispatchQuantity" column-key="dispatchQuantity" label="本次数量" width="200" class-name="goods-table__dispatch"><template #default="{ row }"><el-input :model-value="row.dispatchQuantity" inputmode="decimal" placeholder="请输入" @input="value => handleDispatchQuantityInput(route, row, value)" /></template></el-table-column>
<el-table-column prop="quantityUnit" label="数量单位" width="110" />
<el-table-column min-width="180"
><template #header
><span>货物类型<span class="goods-required-mark">*</span></span></template
><template #default="{ row }"
><el-autocomplete
v-model="row.cargoType"
class="goods-table__cargo-type"
clearable
:debounce="300"
placeholder="请输入货物类型"
:fetch-suggestions="fetchCargoTypeSuggestions"
@select="item => handleCargoTypeSelect(route, row, item)"
@change="value => handleCargoTypeChange(route, row, value)"
/></template>
</el-table-column>
<el-table-column min-width="180"
><template #header
><span>货物名称<span class="goods-required-mark">*</span></span></template
><template #default="{ row }"
><el-select
v-model="row.sourceIndex"
class="goods-table__cargo-name"
placeholder="请选择总单货物"
@change="selectGoods(route, row)"
><el-option
v-for="item in masterGoodsByCargoType(row)"
:key="item.sourceIndex"
:label="item.cargoName"
:value="item.sourceIndex" /></el-select
></template>
</el-table-column>
<el-table-column
prop="dispatchQuantity"
column-key="dispatchQuantity"
width="200"
class-name="goods-table__dispatch"
><template #header
><span>本次数量<span class="goods-required-mark">*</span></span></template
><template #default="{ row }"
><el-input
:model-value="row.dispatchQuantity"
inputmode="decimal"
placeholder="请输入"
@input="value => handleDispatchQuantityInput(route, row, value)" /></template
></el-table-column>
<el-table-column prop="quantityUnit" width="110"><template #header><span>数量单位<span class="goods-required-mark">*</span></span></template></el-table-column>
<el-table-column prop="packageType" label="包装" min-width="110" />
<el-table-column prop="brand" label="品牌" min-width="110" />
<el-table-column prop="specification" label="规格" min-width="110" />
@@ -108,41 +149,13 @@
<el-table-column label="操作" width="90" fixed="right"><template #default="{ $index }"><el-link type="danger" @click="removeGoods(route, $index)">删除</el-link></template></el-table-column>
</el-table>
<div class="freight-heading"><h3>运费信息</h3></div>
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form freight-form">
<el-row v-for="(item, index) in route.freightItems" :key="item.sourceIndex" :gutter="16">
<el-col :span="6">
<el-form-item :label="`单价${index + 1}`">
<el-input :model-value="item.unitPrice" inputmode="decimal" placeholder="请输入" @input="value => handleFreightUnitPriceInput(item, value)">
<template #append><el-select v-model="item.priceUnit" class="freight-unit-select"><el-option label="元/吨" value="元/吨" /><el-option label="元/件" value="元/件" /><el-option label="元/方" value="元/方" /></el-select></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="`数量${index + 1}`">
<el-input :model-value="formatQuantity(item.quantity)" readonly>
<template #append><el-select v-model="item.quantityUnit" class="freight-unit-select" @change="value => handleFreightQuantityUnitChange(route, item, value)"><el-option v-for="unit in quantityUnitOptions" :key="unit" :label="unit" :value="unit" /></el-select></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item :label="`运费${index + 1}`">
<el-input :model-value="formatAmount(freightAmount(item))" readonly><template #suffix>{{ currencyLabel(route.currency) }}</template></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="route.freightItems.length" :gutter="16">
<el-col :span="6"><el-form-item label="运费合计"><el-input :model-value="formatAmount(freightTotal(route))" readonly><template #suffix>{{ currencyLabel(route.currency) }}</template></el-input></el-form-item></el-col>
<el-col :span="6"><el-form-item label="其他费用合计"><el-input :model-value="route.otherFeeTotal" inputmode="decimal" placeholder="请输入" @input="value => handleOtherFeeTotalInput(route, value)" /></el-form-item></el-col>
</el-row>
</el-form>
<template v-if="route.documentType === '运单'">
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-type-form"><el-form-item label="承运类型" required><el-radio-group v-model="route.carrierType" @change="value => handleCarrierTypeChange(route, value)"><el-radio-button label="承运商" /><el-radio-button label="自运" /><el-radio-button label="网货平台" /></el-radio-group></el-form-item></el-form>
<div class="goods-heading"><h3>任务信息</h3></div>
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-type-form"><el-form-item label="承运类型" required><el-radio-group v-model="route.carrierType" @change="value => handleCarrierTypeChange(route, value)"><el-radio-button label="承运商" /><el-radio-button label="自运" /></el-radio-group></el-form-item></el-form>
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-form">
<el-row :gutter="16">
<template v-if="isRoad(route)">
<el-col :span="6"><el-form-item label="承运商" :required="route.carrierType === '承运商'"><el-select :model-value="route.carrierType === '承运商' ? route.carrierContractId : route.carrierName" filterable clearable placeholder="请选择" :loading="carrierLoading" @change="value => handleCarrierContractChange(route, value)"><el-option v-for="item in carrierOptions" :key="carrierOptionValue(item)" :label="carrierOptionLabel(item)" :value="route.carrierType === '承运商' ? carrierOptionValue(item) : item.carrierName" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item label="承运商" :required="route.carrierType !== '自运'"><el-select :model-value="route.carrierType !== '自运' ? route.carrierContractId : route.carrierName" filterable clearable placeholder="请选择" :loading="carrierLoading" @change="value => handleCarrierContractChange(route, value)"><el-option v-for="item in carrierOptions" :key="carrierOptionValue(item)" :label="carrierOptionLabel(item)" :value="route.carrierType !== '自运' ? carrierOptionValue(item) : item.carrierName" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item label="司机" :required="route.carrierType !== '承运商'"><el-autocomplete :ref="element => setDriverInput(route.segmentNo, element)" v-model="route.driverName" :debounce="300" :fetch-suggestions="fetchDriverSuggestions" clearable placeholder="请输入司机" :loading="driverLoading" @select="item => handleDriverSuggestionSelect(route, item)" /></el-form-item></el-col>
<el-col :span="6"><el-form-item :label="route.carrierType === '承运商' ? '手机号' : '司机手机号'" :required="route.carrierType !== '承运商'"><el-input v-model="route.driverPhone" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="车牌号" required><el-input v-model="route.vehicleNo" placeholder="请输入" /></el-form-item></el-col>
@@ -151,10 +164,10 @@
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人手机号"><el-input v-model="route.escortPhone" placeholder="请输入" /></el-form-item></el-col>
</template>
<template v-else>
<el-col :span="6"><el-form-item label="承运商" :required="route.carrierType === '承运商'"><el-select :model-value="route.carrierType === '承运商' ? route.carrierContractId : route.carrierName" filterable clearable placeholder="请选择" :loading="carrierLoading" @change="value => handleCarrierContractChange(route, value)"><el-option v-for="item in carrierOptions" :key="carrierOptionValue(item)" :label="carrierOptionLabel(item)" :value="route.carrierType === '承运商' ? carrierOptionValue(item) : item.carrierName" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item label="承运商" :required="route.carrierType !== '自运'"><el-select :model-value="route.carrierType !== '自运' ? route.carrierContractId : route.carrierName" filterable clearable placeholder="请选择" :loading="carrierLoading" @change="value => handleCarrierContractChange(route, value)"><el-option v-for="item in carrierOptions" :key="carrierOptionValue(item)" :label="carrierOptionLabel(item)" :value="route.carrierType !== '自运' ? carrierOptionValue(item) : item.carrierName" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item label="船/航/班列号" required><el-input v-model="route.vehicleNo" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="船长"><el-input v-model="route.captainName" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="联系电话" required><el-input v-model="route.driverPhone" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="联系电话"><el-input v-model="route.driverPhone" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="箱号"><el-input v-model="route.containerNo" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="舱位"><el-input v-model="route.cabinNo" placeholder="请输入" /></el-form-item></el-col>
</template>
@@ -163,6 +176,35 @@
</el-row>
</el-form>
</template>
<div class="freight-heading"><h3>运费信息</h3></div>
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form freight-form">
<el-row v-for="(group, index) in freightGroups(route)" :key="group.key" :gutter="16">
<el-col :span="6">
<el-form-item label="单价">
<el-input :model-value="freightGroupUnitPrice(group)" inputmode="decimal" placeholder="请输入" @input="value => handleFreightGroupUnitPriceInput(group, value)" @clear="() => handleFreightGroupUnitPriceInput(group, '')">
<template #append><el-select :model-value="freightGroupPriceUnit(group)" class="freight-unit-select" @change="value => handleFreightGroupPriceUnitChange(group, value)"><el-option label="元/吨" value="元/吨" /><el-option label="元/件" value="元/件" /><el-option label="元/方" value="元/方" /></el-select></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="数量">
<el-input :model-value="formatQuantity(freightGroupQuantity(group))" readonly>
<template #append><el-select :model-value="group.quantityUnit" class="freight-unit-select" @change="value => handleFreightGroupQuantityUnitChange(route, group, value)"><el-option v-for="unit in quantityUnitOptions" :key="unit" :label="unit" :value="unit" /></el-select></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="运费">
<el-input :model-value="formatAmount(freightGroupAmount(group))" readonly><template #suffix>{{ currencyLabel(route.currency) }}</template></el-input>
</el-form-item>
</el-col>
</el-row>
<el-row v-if="route.freightItems.length" :gutter="16">
<el-col :span="6"><el-form-item label="运费合计"><el-input :model-value="formatAmount(freightTotal(route))" readonly><template #suffix>{{ currencyLabel(route.currency) }}</template></el-input></el-form-item></el-col>
<el-col :span="6"><el-form-item label="其他费用合计"><el-input :model-value="route.otherFeeTotal" inputmode="decimal" placeholder="请输入" @input="value => handleOtherFeeTotalInput(route, value)" /></el-form-item></el-col>
</el-row>
</el-form>
<div class="segment-actions"><el-button plain @click="addToPending(route, true)">加入并继续调度</el-button><el-button plain @click="addToPending(route, false)">加入调度清单</el-button></div>
</div>
</section>
@@ -171,15 +213,15 @@
<section class="pending-panel">
<div class="pending-bar">
<div class="pending-summary">
<strong>待提交 <em>{{ pending.length }}</em> <span class="pending-summary-text">{{ pendingSegmentSummary }}</span></strong>
<strong>待提交 <em>{{ pendingBatchCount }}</em> <span class="pending-summary-text">{{ pendingSegmentSummary }}</span></strong>
<el-link type="primary" @click="pendingExpanded = !pendingExpanded">{{ pendingExpanded ? '收起待调度列表' : '展开待调度列表' }}</el-link>
</div>
<div class="pending-actions"><el-button @click="$emit('back')">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">确认调度</el-button></div>
</div>
<div v-show="pendingExpanded" class="pending-list">
<template v-for="(items, segmentNo) in pendingGroups" :key="segmentNo">
<h3>{{ segmentNo }}{{ items[0].departureName }} {{ items[0].arrivalName }}</h3>
<el-table :data="items" border><el-table-column type="index" label="序号" width="70" /><el-table-column label="单据类型" prop="documentType" width="100" /><el-table-column label="货物" min-width="220"><template #default="{ row }">{{ row.cargoName }}{{ row.cargoType ? `${row.cargoType}` : '' }}</template></el-table-column><el-table-column label="本次数量" width="150"><template #default="{ row }">{{ formatQuantity(row.quantity) }} {{ row.quantityUnit }}</template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="180" /><el-table-column prop="driverName" label="司机" width="130" /><el-table-column prop="vehicleNo" label="车牌" width="130" /><el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><div class="table-actions"><el-link type="primary" @click="editPending(row)">编辑</el-link><el-link type="danger" @click="removePending(row.id)">删除</el-link></div></template></el-table-column></el-table>
<h3>{{ segmentNo }}{{ dispatchLocationName(items[0], 'departure') }} {{ dispatchLocationName(items[0], 'arrival') }}</h3>
<el-table :data="items" border><el-table-column type="index" label="序号" width="70" /><el-table-column label="货物" min-width="220"><template #default="{ row }">{{ pendingGoodsText(row) }}</template></el-table-column><el-table-column label="本次数量" min-width="150"><template #default="{ row }">{{ pendingQuantityText(row) }}</template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="180" /><el-table-column prop="driverName" label="司机" width="130" /><el-table-column prop="vehicleNo" label="车牌" width="130" /><el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><div class="table-actions"><el-link type="primary" @click="editPending(row)">编辑</el-link><el-link type="danger" @click="removePendingBatch(row)">删除</el-link></div></template></el-table-column></el-table>
</template>
<el-empty v-if="!pending.length" description="暂无待提交调度条目" :image-size="60" />
</div>
@@ -207,21 +249,40 @@ export default {
props: { id: [String, Number] },
emits: ['back'],
data() {
return { loading: false, submitting: false, master: null, routes: [], pending: [], pendingExpanded: false, editingId: null, driverInputs: {}, carrierOptions: [], driverOptions: [], carrierLoading: false, driverLoading: false, escortLoading: false, cargoTypeOptions: [], cargoTypeCascaderProps: { label: 'cargoName', value: 'id', children: 'children', emitPath: true }, quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'] };
return { loading: false, submitting: false, master: null, routes: [], pending: [], pendingExpanded: false, editingId: null, driverInputs: {}, carrierOptions: [], driverOptions: [], carrierLoading: false, driverLoading: false, escortLoading: false, quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'] };
},
computed: {
totalQuantity() { return (this.master?.goods || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0); },
quantityUnitLabel() { return this.master?.goods?.[0]?.quantityUnit || '吨'; },
goodsNames() { return (this.master?.goods || []).map(item => item.cargoName).filter(Boolean).join('、') || '-'; },
goodsTypes() { return [...new Set((this.master?.goods || []).map(item => item.cargoType).filter(Boolean))].join('、') || '-'; },
masterGoods() { return (this.master?.goods || []).map((item, sourceIndex) => ({ ...item, sourceIndex, label: [item.cargoName, item.cargoType].filter(Boolean).join(' / ') })); },
cargoTypeSelectOptions() {
return [...new Set((this.master?.goods || []).map(item => item.cargoType).filter(Boolean))];
},
dateRange() { return this.master?.planStartTime && this.master?.planEndTime ? `${String(this.master.planStartTime).slice(0, 10)} ~ ${String(this.master.planEndTime).slice(0, 10)}` : '-'; },
pendingGroups() { return this.pending.reduce((groups, item) => { (groups[item.segmentNo] ||= []).push(item); return groups; }, {}); },
pendingGroups() {
return this.pending.reduce((groups, item, index) => {
const segmentRows = (groups[item.segmentNo] ||= []);
const batchKey = item.batchNo || item.id || `${item.segmentNo}-${index}`;
let batch = segmentRows.find(row => row.batchKey === batchKey);
if (!batch) {
batch = { ...item, batchKey, items: [] };
segmentRows.push(batch);
}
batch.items.push(item);
return groups;
}, {});
},
pendingBatchCount() {
return Object.values(this.pendingGroups).reduce((count, rows) => count + rows.length, 0);
},
pendingSegmentSummary() {
return this.routes.map(route => {
const items = this.pending.filter(item => item.segmentNo === route.segmentNo);
if (!items.length) return `${route.segmentNo}:无`;
const documentSummary = items.reduce((result, item) => {
result[item.documentType] = (result[item.documentType] || 0) + 1;
const batches = this.pendingGroups[route.segmentNo] || [];
if (!batches.length) return `${route.segmentNo}:无`;
const documentSummary = batches.reduce((result, batch) => {
result[batch.documentType] = (result[batch.documentType] || 0) + 1;
return result;
}, {});
return `${route.segmentNo}${Object.entries(documentSummary).map(([type, count]) => `${type} x ${count}`).join('、')}`;
@@ -239,7 +300,6 @@ export default {
const res = await api.getDetail(this.id);
this.master = res.data?.data || res.data || res;
await this.loadContractCurrency();
this.cargoTypeOptions = this.buildMasterCargoTypeOptions(this.master.goods || []);
this.routes = this.dispatchRouteNodes().map((node, index) => this.createRoute(node, index));
} finally { this.loading = false; }
},
@@ -293,55 +353,98 @@ export default {
otherFeeTotal: '',
freightItems: [],
departureName: previous.departureName || '', departureAddress: previous.departureAddress || '', departureContact: previous.departureContact || '', departurePhone: previous.departurePhone || '',
departureCityName: previous.departureCityName || previous.arrivalCityName || '', departureDistrictName: previous.departureDistrictName || previous.arrivalDistrictName || '', departureSiteCode: previous.departureSiteCode || previous.arrivalSiteCode || '',
arrivalName: node.departureName || this.master.arrivalName || '', arrivalAddress: node.departureAddress || this.master.arrivalAddress || '', arrivalContact: node.departureContact || '', arrivalPhone: node.departurePhone || '',
arrivalCityName: node.departureCityName || node.arrivalCityName || this.master.arrivalCityName || '', arrivalDistrictName: node.departureDistrictName || node.arrivalDistrictName || this.master.arrivalDistrictName || '', arrivalSiteCode: node.departureSiteCode || node.arrivalSiteCode || this.master.arrivalSiteCode || '',
goods: (this.master.goods || []).map((goods, sourceIndex) => this.createGoodsRow(goods, sourceIndex)),
};
this.syncFreightItems(route);
return route;
},
buildMasterCargoTypeOptions(goods = []) {
return [...new Set(goods.map(item => item.cargoType).filter(Boolean))].map(cargoType => ({
id: cargoType,
cargoName: cargoType,
}));
},
masterGoodsByCargoType(row = {}) {
return this.masterGoods.filter(item => !row.cargoType || item.cargoType === row.cargoType);
const cargoType = String(row.cargoType || '').trim();
if (!cargoType) return this.masterGoods;
const matched = this.masterGoods.filter(item => item.cargoType === cargoType);
return matched.length ? matched : this.masterGoods;
},
findCargoTypeByPath(path = []) {
let options = this.cargoTypeOptions;
let selected;
(path || []).forEach(id => {
selected = (options || []).find(item => String(item.id) === String(id));
options = selected?.children || [];
});
return selected;
fetchCargoTypeSuggestions(queryString, callback) {
const keyword = String(queryString || '').trim().toLowerCase();
const options = this.cargoTypeSelectOptions.map(item => ({ value: item }));
callback(
keyword ? options.filter(item => String(item.value).toLowerCase().includes(keyword)) : options
);
},
findCargoTypePath(cargoType, options = this.cargoTypeOptions, parentPath = []) {
for (const option of options || []) {
const path = [...parentPath, option.id];
if (option.cargoName === cargoType) return path;
if (option.children?.length) {
const result = this.findCargoTypePath(cargoType, option.children, path);
if (result.length) return result;
}
}
return [];
handleCargoTypeSelect(route, row, item = {}) {
this.handleCargoTypeChange(route, row, item.value || '');
},
handleCargoTypeChange(route, row, value) {
const path = Array.isArray(value) ? value : [];
const cargoType = this.findCargoTypeByPath(path);
row.cargoTypePath = path;
row.cargoType = cargoType?.cargoName || '';
const firstGoods = this.masterGoodsByCargoType(row)[0];
row.cargoType = String(value ?? row.cargoType ?? '').trim();
const matched = this.masterGoods.filter(item => item.cargoType === row.cargoType);
const firstGoods = matched[0];
if (firstGoods) {
row.sourceIndex = firstGoods.sourceIndex;
this.selectGoods(route, row);
} else {
Object.assign(row, {
sourceIndex: undefined,
cargoName: '',
quantity: '',
quantityUnit: '',
packageType: '',
brand: '',
specification: '',
model: '',
materialCode: '',
});
}
else Object.assign(row, { sourceIndex: undefined, cargoName: '', quantity: '', quantityUnit: '' });
this.syncFreightItems(route);
},
isRoad(route) { const type = String(route.transportType || '').toLowerCase(); return type.includes('公路') || type === 'road'; },
isRoad(route) {
const values = [
route.transportTypeName,
route.transportTypeLabel,
route.transportModeName,
route.transportMode,
route.transportType,
];
const type = values
.map(value => {
if (value && typeof value === 'object') {
return value.label || value.name || value.dictValue || value.value || value.code || '';
}
return value || '';
})
.join(' ')
.trim()
.toLowerCase();
return type.includes('公路') || type.includes('道路') || type.includes('陆运') || type.includes('汽车') || type === 'road';
},
formatRoadLocation(value) {
const text = String(value || '').replace(/\s+/g, '');
if (!text) return '';
const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, '');
const cityMatch = withoutProvince.match(/.+?(?:市|州(?!市)|盟(?!市))/);
if (!cityMatch) return text;
const city = cityMatch[0];
const districtSource = withoutProvince.slice(city.length);
const districtMatch = districtSource.match(/^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/);
if (!districtMatch) return city;
const district = `${districtMatch[0]}${districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : ''}`;
return `${city} ${district}`;
},
dispatchLocationName(route = {}, field) {
const name = String(route[`${field}Name`] || '').trim();
const address = String(route[`${field}Address`] || '').trim();
const formattedName = this.formatRoadLocation(name);
if (/(?:市|州|盟)/.test(formattedName)) return formattedName;
const regionalName = /(?:省|自治区|特别行政区|市|州|盟|区|县|旗)/.test(name);
if (name && !regionalName) return name;
const city = String(route[`${field}CityName`] || '').trim();
const district = String(route[`${field}DistrictName`] || '').trim();
const formattedAddress = this.formatRoadLocation(address);
if (/(?:市|州|盟)/.test(formattedAddress)) return formattedAddress;
return [city, district].filter(Boolean).join(' ') || formattedName || formattedAddress || '-';
},
isWater(route) {
const type = String(route.transportType || '').trim().toLowerCase();
return type === 'river' || type === 'water' || type === 'sl' || type.includes('水路') || type.includes('水运');
@@ -360,6 +463,42 @@ export default {
formatAmount(value) { return Number(value || 0).toFixed(2); },
dispatchWeight(route) { return (route.goods || []).reduce((sum, item) => sum + Number(item.dispatchQuantity || 0), 0); },
freightAmount(item = {}) { return Number(item.unitPrice || 0) * Number(item.quantity || 0); },
freightGroups(route = {}) {
const groups = [];
const groupMap = new Map();
(route.freightItems || []).forEach((item, index) => {
const quantityUnit = String(item.quantityUnit || '').trim();
const key = quantityUnit || `__empty_${index}`;
let group = groupMap.get(key);
if (!group) {
group = { key, quantityUnit, rows: [] };
groupMap.set(key, group);
groups.push(group);
}
group.rows.push(item);
});
return groups;
},
freightGroupQuantity(group = {}) { return (group.rows || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0); },
freightGroupUnitPrice(group = {}) {
const prices = (group.rows || []).map(item => String(item.unitPrice ?? '').trim()).filter(Boolean);
return !prices.length || prices.some(price => price !== prices[0]) ? '' : prices[0];
},
freightGroupPriceUnit(group = {}) { return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.quantityUnit}` : ''); },
freightGroupAmount(group = {}) { return (group.rows || []).reduce((sum, item) => sum + this.freightAmount(item), 0); },
handleFreightGroupUnitPriceInput(group, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
const normalized = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
(group.rows || []).forEach(item => { item.unitPrice = normalized; });
},
handleFreightGroupPriceUnitChange(group, value) { (group.rows || []).forEach(item => { item.priceUnit = value || ''; }); },
handleFreightGroupQuantityUnitChange(route, group, value) {
(group.rows || []).forEach(item => {
item.quantityUnit = value || '';
const goods = (route.goods || []).find(row => String(row.sourceIndex) === String(item.sourceIndex));
if (goods) goods.quantityUnit = value || '';
});
},
freightTotal(route) {
const goodsFreight = (route.freightItems || []).reduce(
(sum, item) => sum + this.freightAmount(item),
@@ -422,53 +561,68 @@ export default {
return sum + this.quantityNumber(item.quantity);
}, 0);
},
pendingGoodsQuantity(route, row) {
const goodsIndex = this.masterGoodsIndex(row);
return this.pending.reduce((sum, item) => {
if (!this.isSameRouteSegment(route, item)) return sum;
const pendingGoodsIndex = this.masterGoodsIndex(item);
if (
(goodsIndex >= 0 && pendingGoodsIndex >= 0 && pendingGoodsIndex !== goodsIndex) ||
(goodsIndex < 0 && !this.isSameGoods(item, row))
) {
return sum;
}
return sum + this.quantityNumber(item.quantity);
}, 0);
validatePreviousSegmentCompletedQuantity(route, goods) {
const routeIndex = this.routes.indexOf(route);
if (routeIndex <= 0) return true;
const previousCompletedQuantity = this.quantityNumber(
this.routes[routeIndex - 1]?.arrivedQuantity
);
const currentDispatchQuantity = (goods || []).reduce(
(sum, item) => sum + this.quantityNumber(item.dispatchQuantity),
0
);
const dispatchTotal =
this.quantityNumber(route.dispatchedQuantity) +
this.pendingSegmentQuantity(route) +
currentDispatchQuantity;
if (dispatchTotal <= previousCompletedQuantity) return true;
this.$message.warning(
`${route.segmentNo}调度总量不能大于上一段已完成数量(${this.formatQuantity(previousCompletedQuantity)} ${this.quantityUnitLabel}`
);
return false;
},
isSameRouteSegment(route = {}, item = {}) {
const routeSegment = route.segmentNo || route.relationNo || '';
const itemSegment = item.segmentNo || item.relationNo || '';
return String(routeSegment) === String(itemSegment);
},
editingGoodsQuantity(route, row) {
return (route.goods || []).reduce((sum, item) => {
if (item === row || !this.isSameGoods(item, row)) return sum;
return sum + this.quantityNumber(item.dispatchQuantity);
}, 0);
},
goodsRemainingQuantity(route, row) {
return Math.max(
0,
this.baseGoodsRemainingQuantity(route, row) -
this.pendingGoodsQuantity(route, row) -
this.editingGoodsQuantity(route, row)
);
},
availableDispatchQuantity(route, row) { return this.goodsRemainingQuantity(route, row); },
handleDispatchQuantityInput(route, row, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
const nextValue = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 3)}` : integer;
const availableQuantity = this.availableDispatchQuantity(route, row);
if (Number(nextValue || 0) > availableQuantity) {
row.dispatchQuantity = this.formatQuantity(availableQuantity);
this.syncFreightItems(route);
this.$message.warning('本次数量不能超过剩余数量');
return;
}
row.dispatchQuantity = nextValue;
row.dispatchQuantity =
decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 3)}` : integer;
this.syncFreightItems(route);
},
validatePendingTotals() {
for (const route of this.routes) {
const items = this.pending.filter(item => this.isSameRouteSegment(route, item));
if (!items.length) continue;
if (!this.validatePreviousSegmentCompletedQuantity(route, [])) return false;
const totals = new Map();
for (const item of items) {
const index = this.masterGoodsIndex(item);
const key = index >= 0 ? `i:${index}` : this.goodsKey(item);
totals.set(key, (totals.get(key) || 0) + this.quantityNumber(item.quantity));
}
for (const [key, qty] of totals.entries()) {
if (!key.startsWith('i:')) continue;
const sourceIndex = Number(key.slice(2));
const masterGoods = this.master?.goods?.[sourceIndex];
if (!masterGoods) continue;
const available = this.baseGoodsRemainingQuantity(route, {
...masterGoods,
sourceIndex,
});
if (qty > available) {
this.$message.warning(
`${route.segmentNo}${masterGoods.cargoName || ''}」调度总量不能超过剩余数量(${this.formatQuantity(available)} ${masterGoods.quantityUnit || this.quantityUnitLabel}`
);
return false;
}
}
}
return true;
},
handleFreightUnitPriceInput(item, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
item.unitPrice = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
@@ -493,6 +647,23 @@ export default {
}
return true;
},
validateDispatchGoods(route, goods) {
for (const item of goods) {
const index = (route.goods || []).indexOf(item) + 1;
const fields = [
[!String(item.cargoName || '').trim() || item.sourceIndex === undefined || item.sourceIndex === null || item.sourceIndex === '', '货物名称'],
[!String(item.cargoType || '').trim(), '货物类型'],
[item.dispatchQuantity === undefined || item.dispatchQuantity === null || item.dispatchQuantity === '' || Number(item.dispatchQuantity) <= 0, '本次数量'],
[!String(item.quantityUnit || '').trim(), '数量单位'],
];
const emptyField = fields.find(([isEmpty]) => isEmpty);
if (emptyField) {
this.$message.warning(`${index}条货物的${emptyField[1]}不能为空`);
return false;
}
}
return true;
},
handleFreightQuantityUnitChange(route, freightItem, value) {
const goods = (route.goods || []).find(item => String(item.sourceIndex) === String(freightItem.sourceIndex));
if (goods) goods.quantityUnit = value;
@@ -513,7 +684,6 @@ export default {
return {
...goods,
sourceIndex,
cargoTypePath: this.findCargoTypePath(goods.cargoType),
dispatchQuantity: undefined,
};
},
@@ -529,7 +699,8 @@ export default {
selectGoods(route, row) {
const source = (this.master.goods || [])[Number(row.sourceIndex)];
if (!source) return;
Object.assign(row, this.createGoodsRow(source, Number(row.sourceIndex)));
const cargoType = String(row.cargoType || '').trim() || source.cargoType;
Object.assign(row, this.createGoodsRow(source, Number(row.sourceIndex)), { cargoType });
this.syncFreightItems(route);
},
removeGoods(route, index) { route.goods.splice(index, 1); this.syncFreightItems(route); },
@@ -552,16 +723,17 @@ export default {
},
handleCarrierTypeChange(route, value) {
route.carrierType = value;
route.carrierName = '';
route.carrierId = '';
route.carrierContractId = '';
if (value === '承运商') {
route.trailerVehicleNo = '';
route.escortName = '';
route.escortPhone = '';
}
if (value !== '承运商') {
route.carrierContractId = '';
if (value === '网货平台') route.carrierName = '';
this.loadCarrierOptions().then(() => {
if (value === '自运') this.applySelfOperatedCarrierDefaults(route);
}
});
},
handleCarrierContractChange(route, contractId) {
const contract = this.carrierOptions.find(
@@ -570,7 +742,8 @@ export default {
value => String(value || '') === String(contractId || '')
)
);
route.carrierContractId = route.carrierType === '承运商' ? contract?.contractId || '' : '';
route.carrierContractId = route.carrierType !== '自运' ? contract?.contractId || '' : '';
route.carrierId = route.carrierType !== '自运' ? contract?.carrierId || '' : '';
route.carrierName = contract?.carrierName || '';
},
carrierOptionValue(item = {}) {
@@ -596,7 +769,26 @@ export default {
async loadCarrierOptions() {
this.carrierLoading = true;
try {
this.carrierOptions = unwrapRecords(await api.getCarriers(this.id));
this.carrierOptions = unwrapRecords(await api.getCarriers(this.id))
.map(item => ({
...item,
contractId: item.contractId || item.carrierContractId || item.id || '',
carrierName:
item.partyB ||
item.partyBName ||
item.contractPartyB ||
item.customerContractPartyB ||
item.carrierName ||
'',
carrierId:
item.partyBId ||
item.partyBUserId ||
item.contractPartyBId ||
item.customerContractPartyBId ||
item.carrierId ||
'',
}))
.filter(item => item.carrierName);
} finally {
this.carrierLoading = false;
}
@@ -695,15 +887,15 @@ export default {
addToPending(route, continueDispatch) {
const goods = route.goods.filter(item => Number(item.dispatchQuantity) > 0);
if (!goods.length) return this.$message.warning('请填写本次数量');
if (goods.some(item => Number(item.dispatchQuantity) > this.availableDispatchQuantity(route, item))) return this.$message.warning('本次数量不能超过剩余数量');
if (!this.validateDispatchGoods(route, goods)) return;
this.syncFreightItems(route);
if (!this.validateFreightItems(route)) return;
if (!this.validateRoutePhones(route)) return;
if (route.documentType === '运单') {
if (this.isRoad(route)) {
if (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName || !route.vehicleNo)) return this.$message.warning('请选择承运商并填写车牌号');
if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo)) return this.$message.warning('请补全自运或网货平台的车辆与人员信息');
} else if (!route.vehicleNo || !route.driverPhone || (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName))) {
if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo)) return this.$message.warning('请补全自运的车辆与人员信息');
} else if (!route.vehicleNo || (route.carrierType === '承运商' && (!route.carrierContractId || !route.carrierName))) {
return this.$message.warning('请补全非公路运输的承运信息');
}
}
@@ -712,11 +904,11 @@ export default {
id: `${route.segmentNo}-${item.cargoName}-${Date.now()}-${Math.random()}`,
batchNo,
segmentNo: route.segmentNo, relationNo: route.segmentNo, documentType: route.documentType, transportType: route.transportType, carrierType: route.carrierType,
carrierContractId: route.carrierContractId, carrierName: route.carrierName, driverName: route.driverName, driverPhone: route.driverPhone, vehicleNo: route.vehicleNo, captainName: route.captainName, cabinNo: route.cabinNo, containerNo: route.containerNo, trailerVehicleNo: route.trailerVehicleNo, escortName: route.escortName, escortPhone: route.escortPhone, mileage: this.normalizeMileage(route.mileage), unitPrice: this.freightItemsForGoods(route, item).unitPrice || '', priceUnit: this.freightItemsForGoods(route, item).priceUnit || '', currency: route.currency || 'RMB', freightAmount: this.freightAmount(this.freightItemsForGoods(route, item)), freightTotal: this.freightTotal(route), otherFeeTotal: route.otherFeeTotal || '', freightJson: this.buildFreightJson(route), remark: route.remark,
carrierContractId: route.carrierContractId, carrierName: route.carrierName, driverName: route.driverName, driverPhone: route.driverPhone, vehicleNo: route.vehicleNo, captainName: route.captainName, cabinNo: route.cabinNo, containerNo: route.containerNo, trailerVehicleNo: route.trailerVehicleNo, escortName: route.escortName, escortPhone: route.escortPhone, mileage: this.normalizeMileage(route.mileage), unitPrice: this.freightItemsForGoods(route, item).unitPrice || '', priceUnit: this.freightItemsForGoods(route, item).priceUnit || '', currency: route.currency || 'RMB', freightAmount: this.freightAmount(this.freightItemsForGoods(route, item)), freightTotal: this.freightTotal(route), otherFeeTotal: route.otherFeeTotal || '', freightJson: this.buildFreightJson(route), routeRemark: route.remark,
departureName: route.departureName, departureAddress: route.departureAddress, departureContact: route.departureContact, departurePhone: route.departurePhone,
arrivalName: route.arrivalName, arrivalAddress: route.arrivalAddress, arrivalContact: route.arrivalContact, arrivalPhone: route.arrivalPhone,
estimatedStartTime: route.estimatedStartTime, estimatedEndTime: route.estimatedEndTime,
sourceIndex: this.masterGoodsIndex(item), cargoName: item.cargoName, cargoType: item.cargoType, quantity: item.dispatchQuantity, quantityUnit: item.quantityUnit, packageType: item.packageType, brand: item.brand, specification: item.specification, model: item.model, materialCode: item.materialCode,
sourceIndex: this.masterGoodsIndex(item), cargoName: item.cargoName, cargoType: item.cargoType, quantity: item.dispatchQuantity, quantityUnit: item.quantityUnit, packageType: item.packageType, brand: item.brand, specification: item.specification, model: item.model, materialCode: item.materialCode, deviceCode: item.deviceCode, remark: item.remark,
}));
this.editingId = null;
this.pendingExpanded = true;
@@ -727,35 +919,59 @@ export default {
}
this.$message.success('已加入调度清单');
},
editPending(item) {
const route = this.routes.find(routeItem => routeItem.segmentNo === item.segmentNo);
pendingGoodsText(batch = {}) {
return (batch.items || [batch])
.map(item => [item.cargoName, item.cargoType].filter(Boolean).join(''))
.join(' | ');
},
pendingQuantityText(batch = {}) {
return (batch.items || [batch])
.map(item => `${this.formatQuantity(item.quantity)} ${item.quantityUnit || ''}`.trim())
.join(' | ');
},
editPending(batch) {
const items = batch.items || [batch];
const first = items[0];
const route = this.routes.find(routeItem => routeItem.segmentNo === first.segmentNo);
if (!route) return;
const itemSourceIndex = Number(item.sourceIndex);
const sourceIndex = Number.isInteger(itemSourceIndex) && itemSourceIndex >= 0
? itemSourceIndex
: (this.master.goods || []).findIndex(goods => goods.cargoName === item.cargoName && goods.cargoType === item.cargoType);
let goods = route.goods.find(goodsItem => String(goodsItem.sourceIndex) === String(sourceIndex));
if (!goods && sourceIndex >= 0) {
goods = this.createGoodsRow(this.master.goods[sourceIndex], sourceIndex);
route.goods.push(goods);
}
if (goods) goods.dispatchQuantity = item.quantity;
Object.assign(route, item);
Object.assign(route, first);
const editedGoods = items.map(item => {
const itemSourceIndex = Number(item.sourceIndex);
const sourceIndex = Number.isInteger(itemSourceIndex) && itemSourceIndex >= 0
? itemSourceIndex
: (this.master.goods || []).findIndex(goods => goods.cargoName === item.cargoName && goods.cargoType === item.cargoType);
let goods = route.goods.find(goodsItem => String(goodsItem.sourceIndex) === String(sourceIndex));
if (!goods && sourceIndex >= 0) {
goods = this.createGoodsRow(this.master.goods[sourceIndex], sourceIndex);
route.goods.push(goods);
}
if (goods) goods.dispatchQuantity = item.quantity;
return { goods, item };
});
route.currency = this.normalizeCurrencyValue(
this.master?.settlementCurrency || route.currency || 'RMB'
);
this.syncFreightItems(route);
const freightItem = this.freightItemsForGoods(route, goods);
if (freightItem) { freightItem.unitPrice = item.unitPrice || ''; freightItem.priceUnit = item.priceUnit || freightItem.priceUnit; }
this.removePending(item.id);
this.editingId = item.id;
editedGoods.forEach(({ goods, item }) => {
const freightItem = this.freightItemsForGoods(route, goods);
if (freightItem) {
freightItem.unitPrice = item.unitPrice || '';
freightItem.priceUnit = item.priceUnit || freightItem.priceUnit;
}
});
this.removePendingBatch(batch);
this.editingId = batch.batchKey;
route.selected = true;
this.$nextTick(() => document.querySelector(`[data-segment="${item.segmentNo}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
this.$nextTick(() => document.querySelector(`[data-segment="${first.segmentNo}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
},
freightItemsForGoods(route, goods) {
return (route.freightItems || []).find(item => String(item.sourceIndex) === String(goods?.sourceIndex)) || {};
},
removePending(id) { this.pending = this.pending.filter(item => item.id !== id); if (this.editingId === id) this.editingId = null; },
removePendingBatch(batch = {}) {
const ids = new Set((batch.items || [batch]).map(item => item.id));
this.pending = this.pending.filter(item => !ids.has(item.id));
if (this.editingId === batch.batchKey) this.editingId = null;
},
validateRoutePhones(route = {}) {
const invalidPhone = [
[route.departurePhone, '发货联系方式'],
@@ -770,6 +986,7 @@ export default {
async submit() {
if (!this.pending.length) return this.$message.warning('请加入待提交调度清单');
if (this.pending.some(item => !this.validateRoutePhones(item))) return;
if (!this.validatePendingTotals()) return;
this.submitting = true;
try { await api.dispatch({ id: this.master.id, dispatches: this.pending.map(({ id, ...item }) => ({ ...item, mileage: this.normalizeMileage(item.mileage) })) }); this.$message.success('调度成功'); this.$emit('back'); } finally { this.submitting = false; }
},
@@ -801,7 +1018,8 @@ export default {
.transport-type-field :deep(.el-input) { width: 240px; }
.goods-heading { display: flex; align-items: center; justify-content: space-between; margin: 0 -24px 12px; padding: 14px 24px; border-top: 1px solid #eff1f7; border-bottom: 1px solid #eff1f7; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } span { color: #909399; font-size: 13px; } }
.freight-heading { margin: 16px 0 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } }
.goods-table { :deep(th.el-table__cell), :deep(td.el-table__cell) { border-color: #eff1f7; } :deep(.el-table__row--striped td.el-table__cell) { background: #fafafa; } :deep(.goods-table__remaining .cell) { white-space: nowrap; } :deep(.goods-table__dispatch .el-input) { width: 100%; min-width: 0; } :deep(.goods-table__cargo-type .el-input__inner), :deep(.goods-table__cargo-name .el-select__selected-item) { color: #303133; } }
.goods-table { :deep(th.el-table__cell), :deep(td.el-table__cell) { border-color: #eff1f7; } :deep(.el-table__row--striped td.el-table__cell) { background: #fafafa; } :deep(.goods-table__dispatch .el-input) { width: 100%; min-width: 0; } :deep(.goods-table__cargo-type), :deep(.goods-table__cargo-name) { width: 100%; } :deep(.goods-table__cargo-type .el-input__inner), :deep(.goods-table__cargo-name .el-select__selected-item) { color: #303133; } }
.goods-required-mark { margin-left: 2px; color: #f56c6c; }
.freight-form { :deep(.freight-unit-select) { width: 92px; flex: 0 0 92px; min-width: 0; } }
.carrier-type-form { margin-top: 16px; }
.carrier-form { padding-top: 8px; }
@@ -34,7 +34,7 @@
:model-value="form.masterNo || '系统自动生成'"
readonly /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="运输组织类型" required
><el-form-item label="运输类型" required
><el-select v-model="form.transportOrganizationType" disabled
><el-option label="多式联运" value="多式联运" /></el-select></el-form-item></el-col
><el-col :span="24"
@@ -54,75 +54,77 @@
<el-link type="primary" @click="openRouteDialog">选择线路</el-link>
</div>
</div>
<el-form :model="form" label-position="right" label-width="auto"
><div class="route-steps">
<el-steps direction="vertical" :active="form.routes.length + 1">
<el-step>
<template #icon><span class="route-step-icon route-step-icon--start"></span></template>
<template #description>
<div class="route-node-form">
<el-form-item label="发货地址" required class="route-address-form-item"
<el-form class="route-info-form" :model="form" label-position="right" label-width="96px"
><div class="route-flow">
<div class="route-flow-row">
<span class="route-flow-marker route-flow-marker--start"></span>
<div class="route-flow-body">
<div class="route-node-form">
<el-form-item label="发货地" required class="route-address-form-item"
><div class="route-address-inputs"
><el-cascader v-model="addressRegionPaths.departure" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('departure', value)" /><el-input v-model="form.departureAddress" class="route-address-detail" readonly placeholder="请输入详细地址" @click="openAddressMap('departure')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" @click="openCommonAddress('departure')" /></el-tooltip></div
><el-input v-if="isNonRoadAddress('departure')" v-model="form.departureName" class="route-address-name" readonly placeholder="请选择站点" /><el-cascader v-else v-model="addressRegionPaths.departure" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('departure', value)" /><el-input v-model="form.departureAddress" class="route-address-detail" readonly placeholder="请输入详细地址" @click="handleAddressDetailClick('departure')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" @click="openCommonAddress('departure')" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人"
<el-form-item label="联系人" class="route-contact-form-item"
><el-input v-model="form.departureContact" placeholder="请输入"
/></el-form-item>
<el-form-item label="联系方式"
<el-form-item label="联系方式" class="route-phone-form-item"
><el-input v-model="form.departurePhone" placeholder="请输入"
/></el-form-item>
<el-form-item label="计划开始日期"
<el-form-item label="计划开始" class="route-date-form-item"
><el-date-picker
v-model="form.planStartTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
style="width: 200px"
/></el-form-item>
</div>
</template>
</el-step>
<el-step v-for="(route, index) in form.routes" :key="index">
<template #icon><span class="route-step-icon route-step-icon--middle"></span></template>
<template #title><div class="route-step-title"><span class="route-segment-label">{{ index + 1 }}</span><el-tooltip :content="getRouteTitle(index).replace(`段${index + 1}`, '')" placement="top"><span class="route-title-path">{{ formatRouteTitle(getRouteTitle(index), index + 1) }}</span></el-tooltip><div class="route-transport-control"><span>运输方式</span><el-select v-model="route.transportType" placeholder="请选择"><el-option v-for="type in transports" :key="type" :label="type" :value="type" /></el-select></div></div></template>
<template #description>
</div>
</div>
</div>
<template v-for="(route, index) in form.routes" :key="index">
<div class="route-flow-segment">
<div class="route-step-title"><span class="route-segment-label">{{ circledSegmentNumber(index + 1) }} {{ index + 1 }}</span><el-tooltip :content="getRouteTitle(index).replace(`段${index + 1}`, '')" placement="top"><span class="route-title-path">{{ formatRouteTitle(getRouteTitle(index), index + 1) }}</span></el-tooltip><div class="route-transport-control"><span>运输方式<span class="required-asterisk">*</span></span><el-select v-model="route.transportType" placeholder="请选择"><el-option v-for="type in transports" :key="type" :label="type" :value="type" /></el-select></div></div>
</div>
<div class="route-flow-row">
<span class="route-flow-marker route-flow-marker--middle"></span>
<div class="route-flow-body">
<div class="route-node-form">
<el-form-item label="途经地" required class="route-address-form-item"
><div class="route-address-inputs"
><el-cascader v-model="addressRegionPaths[`route-${index}`]" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="!route.transportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange(`route-${index}`, value)" /><el-input v-model="route.departureAddress" class="route-address-detail" readonly :disabled="!route.transportType || !isRoadTransportType(route.transportType)" placeholder="请先选择运输方式" @click="openAddressMap(`route-${index}`)" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="!route.transportType" @click="openCommonAddress(`route-${index}`)" /></el-tooltip></div
><el-input v-if="isNonRoadAddress(`route-${index}`)" v-model="route.departureName" class="route-address-name" readonly :disabled="!route.transportType" placeholder="请先选择运输方式" /><el-cascader v-else v-model="addressRegionPaths[`route-${index}`]" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="!route.transportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange(`route-${index}`, value)" /><el-input v-model="route.departureAddress" class="route-address-detail" readonly :disabled="!route.transportType" placeholder="请先选择运输方式" @click="handleAddressDetailClick(`route-${index}`)" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="!route.transportType" @click="openCommonAddress(`route-${index}`)" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人"><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式"><div class="route-contact-actions"><el-input v-model="route.departurePhone" placeholder="请输入" /><el-tooltip v-if="index === form.routes.length - 1" content="新增途经点" placement="top"><el-button class="route-action-button" type="primary" link @click="addRoute"><el-icon><CirclePlus /></el-icon></el-button></el-tooltip><el-tooltip content="删除途经点" placement="top"><el-button class="route-action-button" type="danger" link @click="removeRoute(index)"><el-icon><Remove /></el-icon></el-button></el-tooltip></div></el-form-item>
<el-form-item label="联系人" class="route-contact-form-item"><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式" class="route-phone-form-item"><div class="route-contact-actions"><el-input v-model="route.departurePhone" placeholder="请输入" /><el-tooltip v-if="index === form.routes.length - 1" content="新增途经点" placement="top"><el-button class="route-action-button" type="primary" link @click="addRoute"><el-icon><CirclePlus /></el-icon></el-button></el-tooltip><el-tooltip content="删除途经点" placement="top"><el-button class="route-action-button" type="danger" link @click="removeRoute(index)"><el-icon><Remove /></el-icon></el-button></el-tooltip></div></el-form-item>
</div>
</template>
</el-step>
<el-step>
<template #icon><span class="route-step-icon route-step-icon--end"></span></template>
<template #title><div class="route-step-title"><span class="route-segment-label">{{ form.routes.length + 1 }}</span><el-tooltip :content="getFinalRouteTitle().replace(`段${form.routes.length + 1}`, '')" placement="top"><span class="route-title-path">{{ formatRouteTitle(getFinalRouteTitle(), form.routes.length + 1) }}</span></el-tooltip><div class="route-transport-control"><span>运输方式</span><el-select v-model="form.finalTransportType" placeholder="请选择"><el-option v-for="type in transports" :key="type" :label="type" :value="type" /></el-select></div></div></template>
<template #description>
<div class="route-node-form">
<el-form-item label="收货地址" required class="route-address-form-item"
</div>
</div>
</template>
<div class="route-flow-segment">
<div class="route-step-title"><span class="route-segment-label">{{ circledSegmentNumber(form.routes.length + 1) }} {{ form.routes.length + 1 }}</span><el-tooltip :content="getFinalRouteTitle().replace(`段${form.routes.length + 1}`, '')" placement="top"><span class="route-title-path">{{ formatRouteTitle(getFinalRouteTitle(), form.routes.length + 1) }}</span></el-tooltip><div class="route-transport-control"><span>运输方式<span class="required-asterisk">*</span></span><el-select v-model="form.finalTransportType" placeholder="请选择"><el-option v-for="type in transports" :key="type" :label="type" :value="type" /></el-select></div></div>
</div>
<div class="route-flow-row">
<span class="route-flow-marker route-flow-marker--end"></span>
<div class="route-flow-body">
<div class="route-node-form">
<el-form-item label="收货地" required class="route-address-form-item"
><div class="route-address-inputs"
><el-cascader v-model="addressRegionPaths.arrival" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="!form.finalTransportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('arrival', value)" /><el-input v-model="form.arrivalAddress" class="route-address-detail" readonly :disabled="!form.finalTransportType || !isRoadTransportType(form.finalTransportType)" placeholder="请选择省市区" @click="openAddressMap('arrival')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="!form.finalTransportType" @click="openCommonAddress('arrival')" /></el-tooltip></div
><el-input v-if="isNonRoadAddress('arrival')" v-model="form.arrivalName" class="route-address-name" readonly :disabled="!form.finalTransportType" placeholder="请先选择运输方式" /><el-cascader v-else v-model="addressRegionPaths.arrival" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="!form.finalTransportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('arrival', value)" /><el-input v-model="form.arrivalAddress" class="route-address-detail" readonly :disabled="!form.finalTransportType" placeholder="请选择运输方式" @click="handleAddressDetailClick('arrival')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="!form.finalTransportType" @click="openCommonAddress('arrival')" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人"
<el-form-item label="联系人" class="route-contact-form-item"
><el-input v-model="form.arrivalContact" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式"
<el-form-item label="联系方式" class="route-phone-form-item"
><el-input v-model="form.arrivalPhone" placeholder="请输入" /></el-form-item>
<el-form-item label="计划结束日期"
<el-form-item label="计划结束" class="route-date-form-item"
><el-date-picker
v-model="form.planEndTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
style="width: 200px"
/></el-form-item>
</div>
</template>
</el-step>
</el-steps>
</div>
</div>
</div>
</div></el-form
>
</section>
@@ -130,7 +132,7 @@
<div class="goods-heading">
<h3>货物信息</h3>
<div class="goods-toolbar">
<el-link type="primary" @click="cargoImportVisible = true">导入货物</el-link>
<!-- <el-link type="primary" @click="cargoImportVisible = true">导入货物</el-link> -->
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div>
</div>
@@ -157,7 +159,8 @@
</template></el-autocomplete
></template></el-table-column
><el-table-column label="货物类型" min-width="130"
><template #default="{ row }"
><template #header><span>货物类型<span class="required-asterisk">*</span></span></template>
<template #default="{ row }"
><el-cascader
v-model="row.cargoTypePath"
:options="cargoTypeOptions"
@@ -170,14 +173,16 @@
@change="value => handleCargoTypeChange(row, value)"
/></template></el-table-column
><el-table-column label="数量" width="180" class-name="master-editor__quantity-cell"
><template #default="{ row }"
><template #header><span>数量<span class="required-asterisk">*</span></span></template>
<template #default="{ row }"
><el-input
v-model="row.quantity"
inputmode="decimal"
placeholder="请输入"
@input="value => handleQuantityInput(row, value)" /></template></el-table-column
><el-table-column label="数量单位" width="120"
><template #default="{ row }"
><template #header><span>数量单位<span class="required-asterisk">*</span></span></template>
<template #default="{ row }"
><el-select v-model="row.quantityUnit"
><el-option v-for="unit in quantityUnitOptions" :key="unit" :label="unit" :value="unit" /></el-select></template></el-table-column
><el-table-column label="包装" width="130"
@@ -194,6 +199,15 @@
><el-table-column label="型号" min-width="120"
><template #default="{ row }"
><el-input v-model="row.model" placeholder="请输入" /></template></el-table-column
><el-table-column label="物料编码" min-width="140"
><template #default="{ row }"
><el-input v-model="row.materialCode" placeholder="请输入" /></template></el-table-column
><el-table-column label="设备编码" min-width="140"
><template #default="{ row }"
><el-input v-model="row.deviceCode" placeholder="请输入" /></template></el-table-column
><el-table-column label="备注" min-width="180"
><template #default="{ row }"
><el-input v-model="row.remark" maxlength="200" show-word-limit placeholder="请输入" /></template></el-table-column
><el-table-column label="操作" width="140"
><template #default="{ $index }"
><div class="goods-actions"
@@ -214,9 +228,9 @@
>
</section>
<section>
<h3>附件</h3>
<div class="master-editor__attachment">
<div class="master-editor__attachment-head">
<div class="section-head">
<h3>附件</h3>
<div class="section-head__actions">
<el-button
type="primary"
:disabled="!attachmentRows.length"
@@ -225,6 +239,8 @@
批量下载
</el-button>
</div>
</div>
<div class="master-editor__attachment">
<el-table
:data="attachmentRows"
border
@@ -240,6 +256,17 @@
</el-link>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
<template #default="{ row }">
<el-input
v-model="row.description"
maxlength="200"
show-word-limit
placeholder="请输入附件描述"
@input="syncAttachmentsJson"
/>
</template>
</el-table-column>
<el-table-column label="文件大小" width="120" align="center">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
@@ -256,7 +283,7 @@
<vehicle-attachment-upload
v-model="attachmentRows"
:file-types="attachmentFileTypes"
:max-size="500"
:max-size="50"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
@@ -374,7 +401,10 @@
>搜索</el-button
>
</div>
<div ref="addressMap" class="address-map" />
<div class="map-picker-content">
<map-search-results :results="mapSearchResults" @select="selectAddressMapSearchResult" />
<div ref="addressMap" class="address-map" />
</div>
<div class="address-map-status">
{{ mapStatus
}}<span v-if="mapSelected.regionName"> 行政区划{{ mapSelected.regionName }}</span>
@@ -612,6 +642,7 @@ export default {
mapKeyword: '',
mapStatus: '可搜索地址或点击地图选点',
mapSelected: {},
mapSearchResults: [],
addressMapInstance: null,
addressMapMarker: null,
addressMapGeocoder: null,
@@ -717,6 +748,11 @@ export default {
responseData(response) {
return response?.data?.data || response?.data || response || {};
},
circledSegmentNumber(number) {
const value = Number(number);
const circledNumbers = '①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳';
return circledNumbers[value - 1] || String(value);
},
async loadRegionOptions() {
if (this.regionOptions.length) return this.regionOptions;
if (this.regionRequest) return this.regionRequest;
@@ -767,9 +803,19 @@ export default {
},
extractChinaRegionOptions(regions = []) {
const china = regions.find(item => ['中国', '中华人民共和国'].includes(item.title));
if (china?.children?.length) return china.children;
if (regions.length === 1 && regions[0].children?.length) return regions[0].children;
return regions;
const chinaChildren = china?.children || [];
// parentId 0(+86)
const rootRegions = regions.filter(
item => item !== china && ['0', '', 'null', 'undefined'].includes(String(item.parentId ?? ''))
);
const merged = [...chinaChildren, ...rootRegions];
const seen = new Set();
return merged.filter(item => {
const key = String(item.id ?? item.value ?? item.title ?? '');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
},
findRegionPathByName(options = [], regionName = '', parents = []) {
const target = String(regionName || '').replace(/\s+/g, '');
@@ -805,6 +851,32 @@ export default {
});
return labels;
},
setAddressRegionNames(field, labels = []) {
if (!field) return;
const normalized = labels.map(label => String(label || '').trim()).filter(Boolean);
const province = normalized.find(label => /|自治区|特别行政区/.test(label)) || normalized[0] || '';
const city = normalized.filter(label => /||/.test(label)).at(-1) || '';
const district = normalized.filter(label => /||/.test(label)).at(-1) || '';
if (field.provinceName) field.model[field.provinceName] = province;
if (field.cityName) field.model[field.cityName] = city;
if (field.districtName) field.model[field.districtName] = district;
},
setAddressRegionNamesFromRow(field, row = {}) {
const labels = [row.provinceName, row.cityName, row.districtName].filter(Boolean);
if (labels.length) {
this.setAddressRegionNames(field, labels);
return;
}
const text = String(row.regionName || '').replace(/\s+/g, '');
if (!text) return;
const provinceMatch = text.match(/^.*?(?:省|自治区|特别行政区)/);
const rest = provinceMatch ? text.slice(provinceMatch[0].length) : text;
const cityMatch = rest.match(/.+?(?:市|州|盟)/);
const districtMatch = cityMatch
? rest.slice(cityMatch[0].length).match(/^[^市州盟]*?(?:区|县|旗)/)
: rest.match(/.+?(?:区|县|旗)/);
this.setAddressRegionNames(field, [provinceMatch?.[0], cityMatch?.[0], districtMatch?.[0]]);
},
handleAddressRegionChange(target, value) {
const field = this.addressModel(target);
const path = Array.isArray(value) ? value.map(item => String(item)) : [];
@@ -813,6 +885,8 @@ export default {
const labels = this.regionLabels(path);
field.model[field.name] = labels.join('');
field.model[field.regionCode] = path.at(-1) || '';
field.model[field.siteCode] = '';
this.setAddressRegionNames(field, labels);
},
syncAddressRegionPath(target) {
const field = this.addressModel(target);
@@ -822,6 +896,7 @@ export default {
? codePath
: this.findRegionPathByName(this.regionOptions, field.model[field.name]);
this.addressRegionPaths = { ...this.addressRegionPaths, [target]: path };
if (path.length) this.setAddressRegionNames(field, this.regionLabels(path));
},
syncAddressRegionPaths() {
if (!this.regionOptions.length) return;
@@ -880,19 +955,27 @@ export default {
const date = this.normalizeDateValue(value);
return date ? `${date} ${endOfDay ? '23:59:59' : '00:00:00'}` : '';
},
getRegionDisplay(target, fallback) {
getRoadRegionDisplay(target, fallback) {
const labels = this.regionLabels(this.addressRegionPaths[target] || []);
return labels.length ? labels.join('/') : fallback;
return labels.at(-1) || fallback;
},
getAddressDisplay(target, fallback) {
const field = this.addressModel(target);
const name = field?.model?.[field.name] || fallback;
return this.isNonRoadAddress(target) ? name : this.getRoadRegionDisplay(target, name);
},
getRouteTitle(index) {
const departureName =
index === 0
? this.getRegionDisplay('departure', this.form.departureName || '发货地址')
: this.getRegionDisplay(
? this.getAddressDisplay(
'departure',
this.form.departureName || '发货地址'
)
: this.getAddressDisplay(
`route-${index - 1}`,
this.form.routes[index - 1].departureName || '途经地'
);
const arrivalName = this.getRegionDisplay(
const arrivalName = this.getAddressDisplay(
`route-${index}`,
this.form.routes[index].departureName || '途经地'
);
@@ -912,11 +995,14 @@ export default {
return `${this.truncateRouteAddress(departure)}${separator}${this.truncateRouteAddress(arrival)}`;
},
getFinalRouteTitle() {
const departureName = this.getRegionDisplay(
const departureName = this.getAddressDisplay(
`route-${this.form.routes.length - 1}`,
this.lastRouteName
);
const arrivalName = this.getRegionDisplay('arrival', this.form.arrivalName || '收货地址');
const arrivalName = this.getAddressDisplay(
'arrival',
this.form.arrivalName || '收货地址'
);
return `${this.form.routes.length + 1}${departureName}${arrivalName}`;
},
responseRecords(response) {
@@ -937,6 +1023,7 @@ export default {
const first = this.contracts[0];
this.form.contractId = first?.id;
this.form.contractName = first?.contractName || '';
this.form.customerName = first?.partyA || '';
}
},
async projectChange(projectId) {
@@ -950,6 +1037,7 @@ export default {
contractChange(contractId) {
const contract = this.contracts.find(item => String(item.id) === String(contractId));
this.form.contractName = contract?.contractName || '';
this.form.customerName = contract?.partyA || '';
},
addressModel(target) {
if (target.startsWith('route-')) {
@@ -965,6 +1053,9 @@ export default {
latitude: 'departureLatitude',
regionCode: 'departureRegionCode',
siteCode: 'departureSiteCode',
provinceName: 'departureProvinceName',
cityName: 'departureCityName',
districtName: 'departureDistrictName',
}
: null;
}
@@ -979,13 +1070,16 @@ export default {
latitude: `${prefix}Latitude`,
regionCode: `${prefix}RegionCode`,
siteCode: `${prefix}SiteCode`,
provinceName: `${prefix}ProvinceName`,
cityName: `${prefix}CityName`,
districtName: `${prefix}DistrictName`,
};
},
addressTransportType(target) {
if (target.startsWith('route-')) {
return this.form.routes[Number(target.slice(6))]?.transportType || '';
}
return target === 'arrival' ? this.form.finalTransportType : '';
return target === 'arrival' ? this.form.finalTransportType : this.form.routes[0]?.transportType || '';
},
addressTransportMode(target) {
const type = this.addressTransportType(target);
@@ -998,6 +1092,17 @@ export default {
const value = String(type || '').trim().toLowerCase();
return value === 'road' || value.includes('公路');
},
isNonRoadAddress(target) {
const field = this.addressModel(target);
if (!field) return false;
const siteCode = String(field.model[field.siteCode] || '').trim();
//
//
return Boolean(siteCode && siteCode !== '/');
},
resolveAddressRegionCode(row = {}) {
return row.regionCode || row.districtCode || row.areaCode || row.adcode || '';
},
openCommonAddress(target) {
this.addressTarget = target;
if (this.addressTransportMode(target) !== 'road') {
@@ -1054,14 +1159,15 @@ export default {
const target = this.addressModel(this.addressTarget);
if (!target) return;
const station = this.normalizeStation(row);
target.model[target.name] = station.regionName || station.stationName;
target.model[target.name] = station.stationName || station.regionName;
target.model[target.address] = station.detailAddress;
target.model[target.longitude] = station.longitude || '';
target.model[target.latitude] = station.latitude || '';
target.model[target.regionCode] = station.regionCode || '';
target.model[target.regionCode] = this.resolveAddressRegionCode(station);
target.model[target.siteCode] = station.stationCode;
this.setAddressRegionNamesFromRow(target, station);
this.stationDialogVisible = false;
this.syncAddressRegionPath(this.addressTarget);
this.loadRegionOptions().then(() => this.syncAddressRegionPath(this.addressTarget));
},
async loadAddressList() {
this.addressLoading = true;
@@ -1096,12 +1202,21 @@ export default {
target.model[target.phone] = row.contactPhone || target.model[target.phone] || '';
target.model[target.longitude] = row.longitude || '';
target.model[target.latitude] = row.latitude || '';
target.model[target.regionCode] = row.regionCode || '';
target.model[target.regionCode] = this.resolveAddressRegionCode(row);
target.model[target.siteCode] = '';
this.setAddressRegionNamesFromRow(target, row);
this.addressDialogVisible = false;
this.syncAddressRegionPath(this.addressTarget);
this.loadRegionOptions().then(() => this.syncAddressRegionPath(this.addressTarget));
},
handleAddressDetailClick(target) {
if (this.addressTransportMode(target) !== 'road') {
this.openCommonAddress(target);
return;
}
this.openAddressMap(target);
},
openAddressMap(target) {
if ((target.startsWith('route-') || target === 'arrival') && !this.isRoadTransportType(this.addressTransportType(target))) {
if (this.addressTransportMode(target) !== 'road') {
this.$message.info('非公路运输地址不能使用地图选择');
return;
}
@@ -1170,12 +1285,24 @@ export default {
)
)
.then(result => {
const point = result?.geocodes?.[0]?.location;
const geocodes = result?.geocodes || [];
this.mapSearchResults = geocodes.map((item, index) => ({
id: item.id || index,
name: item.formattedAddress || this.mapKeyword,
address: item.formattedAddress || this.mapKeyword,
location: item.location,
}));
const point = geocodes[0]?.location;
if (!point) return this.$message.warning('地图搜索无匹配地址');
this.pickAddressMap(point, this.mapKeyword);
})
.catch(() => this.$message.error('地图搜索失败'));
},
selectAddressMapSearchResult(item) {
if (item && item.location) {
this.pickAddressMap(item.location, item.address || item.name || '');
}
},
ensureAddressGeocoder() {
if (this.addressMapGeocoder) return Promise.resolve(this.addressMapGeocoder);
return new Promise(resolve =>
@@ -1203,6 +1330,9 @@ export default {
const component = result.regeocode?.addressComponent || {};
this.mapSelected.detailAddress =
result.regeocode?.formattedAddress || this.mapSelected.detailAddress;
this.mapSelected.provinceName = component.province || '';
this.mapSelected.cityName = component.city || '';
this.mapSelected.districtName = component.district || '';
this.mapSelected.regionName = [component.province, component.city, component.district]
.filter(Boolean)
.join('');
@@ -1228,6 +1358,8 @@ export default {
target.model[target.longitude] = this.mapSelected.longitude;
target.model[target.latitude] = this.mapSelected.latitude;
target.model[target.regionCode] = this.mapSelected.regionCode || '';
target.model[target.siteCode] = '';
this.setAddressRegionNamesFromRow(target, this.mapSelected);
this.mapDialogVisible = false;
this.syncAddressRegionPath(this.mapTarget);
},
@@ -1445,6 +1577,8 @@ export default {
'brand',
'specification',
'model',
'materialCode',
'deviceCode',
'remark',
].some(key => firstGoods[key]);
@@ -1489,6 +1623,8 @@ export default {
brand: row.brand || '',
specification: row.specification || row.spec || '',
model: row.model || '',
materialCode: row.materialCode || '',
deviceCode: row.deviceCode || '',
remark: row.remark || row.descriptionOne || '',
};
},
@@ -1590,7 +1726,12 @@ export default {
this.syncAddressRegionPaths();
},
addGoods(index) {
this.form.goods.splice(index + 1, 0, { quantityUnit: quantityUnitOptions[0] });
this.form.goods.splice(index + 1, 0, {
quantityUnit: quantityUnitOptions[0],
materialCode: '',
deviceCode: '',
remark: '',
});
},
removeGoods(index) {
if (this.form.goods.length > 1) this.form.goods.splice(index, 1);
@@ -1707,13 +1848,20 @@ export default {
await this.$refs.form.validate();
if (!this.form.departureAddress || !this.form.arrivalAddress)
return this.$message.warning('请填写收发货地址');
if (!this.form.goods.some(item => item.cargoType && item.quantity > 0 && item.quantityUnit))
return this.$message.warning('请至少填写一条有效货物');
if (
this.form.routes.some(item => !item.departureName || !item.transportType) ||
!this.form.finalTransportType
)
return this.$message.warning('请完整填写途经地及运输方式');
const invalidGoodsIndex = this.form.goods.findIndex(
item =>
!String(item.cargoType || '').trim() ||
!(Number(item.quantity) > 0) ||
!String(item.quantityUnit || '').trim()
);
if (invalidGoodsIndex >= 0)
return this.$message.warning(`请完整填写第${invalidGoodsIndex + 1}条货物的类型、数量和数量单位`);
const invalidRouteIndex = this.form.routes.findIndex(
item => !item.departureName || !item.transportType
);
if (invalidRouteIndex >= 0)
return this.$message.warning(`请完整填写第${invalidRouteIndex + 1}段途经地及运输方式`);
if (!this.form.finalTransportType) return this.$message.warning('请填写最后一段运输方式');
}
const payload = {
...this.form,
@@ -1721,7 +1869,17 @@ export default {
planEndTime: this.normalizeDateTimeValue(this.form.planEndTime, true),
attachmentsJson: JSON.stringify(this.attachmentRows),
routes: [
...this.form.routes.map((route, index) => ({ ...route, segmentNo: `${index + 1}` })),
...this.form.routes.map((route, index) => ({
...route,
segmentNo: `${index + 1}`,
...(index === 0
? {
departureProvinceName: this.form.departureProvinceName,
departureCityName: this.form.departureCityName,
departureDistrictName: this.form.departureDistrictName,
}
: {}),
})),
{
segmentNo: `${this.form.routes.length + 1}`,
departureName: this.form.arrivalName,
@@ -1732,6 +1890,9 @@ export default {
departureLatitude: this.form.arrivalLatitude,
departureRegionCode: this.form.arrivalRegionCode,
departureSiteCode: this.form.arrivalSiteCode,
departureProvinceName: this.form.arrivalProvinceName,
departureCityName: this.form.arrivalCityName,
departureDistrictName: this.form.arrivalDistrictName,
transportType: this.form.finalTransportType,
},
],
@@ -1809,12 +1970,6 @@ export default {
.master-editor__attachment {
width: 100%;
}
.master-editor__attachment-head {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 16px;
}
.master-editor__attachment-upload {
display: flex;
justify-content: flex-start;
@@ -1887,57 +2042,59 @@ export default {
justify-content: flex-end;
margin-top: 12px;
}
.route-steps {
.route-flow {
position: relative;
.el-steps {
--el-text-color-placeholder: #a8abb2;
padding: 4px 0;
&::before {
position: absolute;
top: 20px;
bottom: 20px;
left: 16px;
width: 2px;
background: #dcdfe6;
content: '';
}
:deep(.el-step__main) {
padding-bottom: 16px;
}
.route-flow-row {
position: relative;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
column-gap: 16px;
align-items: start;
}
.route-flow-marker {
position: relative;
z-index: 1;
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #409eff;
color: #fff;
font-size: 16px;
font-weight: 600;
line-height: 1;
&--middle {
background: #67c23a;
}
:deep(.el-step__title) {
font-size: 16px;
color: #303133;
}
:deep(.el-step__description) {
padding: 12px 0 0;
}
:deep(.el-step__line) {
background-color: #dcdfe6;
}
:deep(.el-step__icon) {
width: 32px;
height: 32px;
flex-shrink: 0;
border: 0;
background: transparent;
}
.route-step-icon {
display: inline-flex;
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #409eff;
color: #fff;
font-size: 16px;
font-weight: 600;
line-height: 1;
&--middle {
background: #67c23a;
}
&--end {
background: #e6a23c;
}
&--end {
background: #e6a23c;
}
}
.route-flow-body {
min-width: 0;
}
.route-flow-segment {
min-height: 48px;
padding-left: 48px;
display: flex;
align-items: center;
}
.route-node-form {
display: grid;
grid-template-columns: repeat(2, minmax(420px, 1fr)) repeat(2, minmax(260px, 1fr));
grid-template-columns: 300px 360px repeat(3, 300px);
gap: 0 16px;
align-items: start;
:deep(.el-form-item) {
@@ -1963,9 +2120,24 @@ export default {
.route-address-name {
flex: 0 0 220px;
}
.route-address-detail,
.route-address-detail {
flex: 0 0 300px;
width: 300px;
}
.route-contact-actions :deep(.el-input) {
flex: 1;
flex: 0 0 200px;
width: 200px;
}
:deep(.route-contact-form-item .el-input),
:deep(.route-phone-form-item .el-input),
:deep(.route-date-form-item .el-date-editor) {
width: 200px;
}
:deep(.route-contact-actions) {
width: auto;
}
:deep(.el-form-item__label) {
white-space: nowrap;
}
.address-picker-button {
flex: 0 0 auto;
@@ -1985,6 +2157,7 @@ export default {
font-size: 16px;
.route-segment-label {
flex: 0 0 auto;
white-space: nowrap;
}
.route-title-path {
display: inline-block;
@@ -2010,6 +2183,10 @@ export default {
}
}
}
.required-asterisk {
margin-left: 4px;
color: #f56c6c;
}
.route-action-button {
flex: 0 0 auto;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+548 -54
View File
@@ -1,48 +1,203 @@
<template>
<basic-container class="contract-change-page">
<div class="archive-page-form__title">{{ pageTitle }}</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="auto" class="contract-change-form">
<section class="change-section contract-basic-section">
<div class="dialog-section-title">基本信息</div>
<div class="contract-basic-section__grid">
<el-form-item label="变更类型" class="contract-basic-section__change-type"><el-radio-group v-model="form.changeType"><el-radio label="合同信息变更" /><el-radio label="终止合同" /></el-radio-group></el-form-item>
<el-form-item label="合同编号"><el-input v-model="form.contractNo" disabled /></el-form-item>
<el-form-item label="合同名称" prop="contractName"><el-input v-model="form.contractName" /></el-form-item>
<el-form-item label="合同类型"><el-select v-model="form.contractCategory" disabled><el-option label="客户合同" value="客户合同" /><el-option label="承运商合同" value="承运商合同" /></el-select></el-form-item>
<el-form-item label="甲方"><el-input v-model="form.partyA" disabled /></el-form-item>
<el-form-item label="乙方"><el-input v-model="form.partyB" disabled /></el-form-item>
<el-form-item label="所属项目"><el-input v-model="form.projectName" disabled /></el-form-item>
<el-form-item label="变更类型" class="contract-basic-section__change-type">
<el-radio-group v-model="form.changeType">
<el-radio label="合同信息变更" />
<el-radio label="终止合同" />
</el-radio-group>
</el-form-item>
<el-form-item label="签约类型" prop="signType">
<el-select v-model="form.signType" placeholder="请选择签约类型" clearable>
<el-option
v-for="item in signTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同编号">
<el-input v-model="form.contractNo" disabled />
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="form.contractName"
maxlength="100"
show-word-limit
placeholder="请输入合同名称"
/>
</el-form-item>
<el-form-item label="合同类型">
<el-select v-model="form.contractCategory" disabled>
<el-option
v-for="item in contractCategoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="项目">
<el-input v-model="form.projectName" disabled />
</el-form-item>
<el-form-item label="所属组织">
<el-input v-model="form.organizationName" disabled />
</el-form-item>
<el-form-item label="甲方">
<el-input v-model="form.partyA" disabled />
</el-form-item>
<el-form-item label="乙方">
<el-input v-model="form.partyB" disabled />
</el-form-item>
<el-form-item label="签订日期">
<el-date-picker
v-model="form.signDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
disabled
/>
</el-form-item>
<el-form-item label="合同期限">
<div class="contract-basic-section__date-range">
<el-date-picker v-model="period[0]" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD" placeholder="YYYY-MM-DD" />
<el-date-picker
v-model="period[0]"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="YYYY-MM-DD"
/>
<span></span>
<el-date-picker v-model="period[1]" type="date" format="YYYY-MM-DD" value-format="YYYY-MM-DD" placeholder="YYYY-MM-DD" />
<el-date-picker
v-model="period[1]"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="YYYY-MM-DD"
/>
</div>
</el-form-item>
<el-form-item label="所属组织"><el-input v-model="form.organizationName" disabled /></el-form-item>
<el-form-item label="签订日期"><el-date-picker v-model="form.signDate" type="date" value-format="YYYY-MM-DD" disabled /></el-form-item>
<el-form-item label="合同格式"><el-select v-model="form.contractFormat"><el-option label="电子合同" value="电子合同" /><el-option label="纸质合同" value="纸质合同" /></el-select></el-form-item>
<el-form-item label="结算方式"><el-input v-model="form.settlementMode" /></el-form-item>
<el-form-item label="是否需要加盖法人章"><el-select v-model="form.legalSealFlag"><el-option label="是" :value="1" /><el-option label="否" :value="0" /></el-select></el-form-item>
<el-form-item label="一式(份)"><el-input v-model="form.copyCount" inputmode="numeric" maxlength="9" placeholder="请输入" @input="value => positiveIntegerInput('copyCount', value)" /></el-form-item>
<el-form-item label="回款账期"><el-input v-model="form.paymentDays" inputmode="numeric" maxlength="9" placeholder="请输入" @input="value => positiveIntegerInput('paymentDays', value)" /></el-form-item>
<el-form-item label="回款账期">
<el-input
v-model="form.paymentDays"
placeholder="请输入"
@input="value => positiveIntegerInput('paymentDays', value)"
>
<template #suffix></template>
</el-input>
</el-form-item>
<el-form-item label="合同金额">
<el-input-number
v-model="form.contractAmount"
:min="0"
:precision="2"
:controls="false"
placeholder="请输入"
/>
</el-form-item>
<el-form-item label="是否范本">
<el-select v-model="form.templateFlag" placeholder="请选择">
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="原件合同编号">
<el-input v-model="form.originalContractNo" maxlength="100" placeholder="请输入" />
</el-form-item>
<el-form-item label="是否电子章">
<el-select v-model="form.electronicSealFlag" placeholder="请选择">
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="开票周期">
<el-input
v-model="form.invoiceCycle"
placeholder="请输入"
@input="value => positiveIntegerInput('invoiceCycle', value)"
>
<template #suffix></template>
</el-input>
</el-form-item>
<el-form-item label="结算币种" prop="settlementCurrency">
<el-select v-model="form.settlementCurrency" placeholder="请选择结算币种" clearable>
<el-option
v-for="item in settlementCurrencyOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同格式">
<el-select v-model="form.contractFormat" placeholder="请选择合同格式" clearable>
<el-option
v-for="item in contractFormatOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="一式">
<el-input
v-model="form.copyCount"
placeholder="请输入"
@input="value => positiveIntegerInput('copyCount', value)"
>
<template #suffix></template>
</el-input>
</el-form-item>
<el-form-item label="是否需要加盖法人章">
<el-select v-model="form.legalSealFlag" placeholder="请选择">
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="结算方式">
<el-select v-model="form.settlementMode" placeholder="请选择结算方式" clearable>
<el-option
v-for="item in settlementModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="经办人">
<el-input v-model="form.handlerUserName" disabled />
</el-form-item>
</div>
<el-form-item label="备注" class="contract-basic-section__remark"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="2000" show-word-limit placeholder="请输入备注" /></el-form-item>
<el-form-item label="备注" class="contract-basic-section__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
</el-form-item>
</section>
<section class="change-section">
<div class="dialog-section-title">合同文件</div>
<div class="attachment-head">
<el-button type="primary" :disabled="!contractFileRows.length" @click="handleContractFileBatchDownload">批量下载</el-button>
</div>
<el-table :data="contractFileRows" border class="change-table" @selection-change="selectedContractFiles = $event"><el-table-column type="selection" width="55" /><el-table-column type="index" label="序号" width="70" /><el-table-column label="文件名" min-width="240"><template #default="{ row }"><el-link type="primary" @click="previewAttachment(row, contractFileRows)">{{ row.originalName || row.name }}</el-link></template></el-table-column><el-table-column label="文件大小" width="120"><template #default="{ row }">{{ formatFileSize(row.size) }}</template></el-table-column><el-table-column prop="uploadUserName" label="上传人" width="140" /><el-table-column prop="uploadTime" label="上传时间" width="170" /><el-table-column label="操作" width="100"><template #default="{ $index }"><el-link type="danger" @click="removeContractFile($index)">删除</el-link></template></el-table-column></el-table>
<div class="attachment-upload">
<vehicle-attachment-upload v-model="contractFileRows" :readonly="false" :file-types="attachmentFileTypes" :max-size="500" :show-file-list="false" button-text="上传附件" @change="handleContractFileChange" />
</div>
</section>
<contract-attachment-section
title="合同文件"
description
attachment-type
use-upload-dialog
:rows="contractFileRows"
:preview="previewAttachment"
@update:rows="contractFileRows = $event"
/>
<section class="change-section">
<div class="section-head"><div class="dialog-section-title">计费信息</div><el-button type="primary" plain @click="addPlan">添加</el-button></div>
<el-radio-group v-model="feeGenerationMode"><el-radio label="system">系统生成</el-radio><el-radio label="manual">手动生成</el-radio></el-radio-group>
<el-radio-group v-model="feeGenerationMode"><el-radio label="system">系统生成</el-radio><el-radio label="manual">账单导入生成</el-radio></el-radio-group>
<el-table :data="plans" border class="change-table"><el-table-column type="index" label="序号" width="70" /><el-table-column prop="planName" label="方案名称" min-width="220" /><el-table-column label="默认方案" min-width="120"><template #default="{ row }">{{ row.defaultPlan ? '是' : '否' }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="260" /><el-table-column label="操作" width="180"><template #default="{ row, $index }"><el-link type="primary" @click="editPlan(row, $index)">编辑</el-link><el-link type="danger" @click="plans.splice($index, 1)">删除</el-link></template></el-table-column></el-table>
</section>
@@ -52,18 +207,39 @@
<el-tab-pane label="预结算配置" name="pre" />
<el-tab-pane label="正式结算配置" name="formal" />
</el-tabs>
<div class="settlement-switch"><span>自动生成结算单</span><el-radio-group v-model="settlementRule.autoGenerate"><el-radio :label="1">开启</el-radio><el-radio :label="0">关闭</el-radio></el-radio-group></div>
<div class="settlement-switch">
<span>自动生成结算单</span>
<el-radio-group v-model="settlementRule.autoGenerate">
<el-radio :label="1">开启</el-radio>
<el-tooltip content="开启时,系统根据配置规则归集运单,定时生成结算单" placement="top">
<el-icon class="settlement-switch-tip"><QuestionFilled /></el-icon>
</el-tooltip>
<el-radio :label="0">关闭</el-radio>
</el-radio-group>
</div>
<el-form v-if="settlementRule.autoGenerate === 1" :model="settlementRule" label-position="right" label-width="auto" class="settlement-form">
<el-form-item label="账单起始日期" required><el-date-picker v-model="settlementRule.billStartDate" type="date" placeholder="请选择账单起始日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="结算类型" required><el-select v-model="settlementRule.settlementType" placeholder="请选择结算类型" @change="handleSettlementTypeChange"><el-option v-for="item in settlementTypeOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item>
<el-form-item v-if="showSettlementBillCycleType" label="账单周期类型" required><el-select v-model="settlementRule.billCycleType" placeholder="请选择账单周期类型" @change="handleCycleTypeChange"><el-option v-for="item in billCycleTypeOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item>
<el-form-item v-if="showSettlementBillCycleType" label="结算周期" required><el-select v-model="settlementRule.billCycleType" placeholder="请选择结算周期" @change="handleCycleTypeChange"><el-option v-for="item in billCycleTypeOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item>
<el-form-item v-if="showSettlementBillCutoffDay" label="账单截单日" required><el-select v-model="settlementRule.billCutoffDay" placeholder="请选择账单截单日"><el-option v-for="item in billCutoffDayOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item v-if="showSettlementCycleDays" label="周期天数" required><el-select v-model="settlementRule.cycleDays" placeholder="请选择周期天数"><el-option v-for="item in cycleDayOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
</el-form>
<el-table v-if="settlementRule.autoGenerate === 1 && showSettlementCustomPeriods" :data="settlementRule.customPeriods || []" border class="change-table custom-periods-table">
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="运单区间-开始日" min-width="180"><template #default="{ row, $index }"><el-select :model-value="row.startDay" placeholder="请选择" clearable @update:model-value="value => handleCustomPeriodStartDayChange($index, value)"><el-option v-for="item in billCutoffDayOptions" :key="`start-${item.value}`" :label="item.label" :value="item.value" /></el-select></template></el-table-column>
<el-table-column label="运单区间-结束日" min-width="200"><template #default="{ row, $index }"><el-select :model-value="row.endDay" placeholder="请选择" clearable @update:model-value="value => handleCustomPeriodEndDayChange($index, value)"><el-option v-for="item in customPeriodEndDayOptions(row)" :key="item.value" :label="item.label" :value="item.value" /></el-select></template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ $index }"><el-link v-if="$index === 0" type="primary" @click="addCustomPeriodRow">添加</el-link><el-link v-else type="danger" @click="removeCustomPeriodRow($index)">删除</el-link></template></el-table-column>
<template #empty><el-link type="primary" @click="addCustomPeriodRow">添加</el-link></template>
</el-table>
</section>
<section class="change-section">
<div class="dialog-section-title">付款比例设置</div>
<div class="dialog-section-title">
付款比例设置
<el-tooltip content="非必填;配置付款比例时,合计必须等于100%。" placement="top">
<el-icon style="margin-left: 4px; color: #909399; cursor: pointer"><el-icon-question-filled /></el-icon>
</el-tooltip>
</div>
<el-table :data="paymentRatioRows" border class="change-table">
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="paymentTerm" label="付款笔数" min-width="180" />
@@ -76,13 +252,41 @@
<billing-plan-editor v-model="planDialogVisible" :value="planEditor" :index="planEditorIndex" @save="savePlan" />
<section class="change-section attachment-section">
<div class="section-head"><div class="dialog-section-title">其它附件</div><el-button type="primary" plain>批量下载</el-button></div>
<el-table :data="attachments" border class="change-table"><el-table-column type="index" label="序号" width="70" /><el-table-column label="文件名" min-width="240"><template #default="{ row }"><el-link type="primary" @click="previewAttachment(row, attachments)">{{ row.originalName || row.name }}</el-link></template></el-table-column><el-table-column prop="description" label="附件描述" min-width="240" /><el-table-column prop="size" label="文件大小" width="120" /><el-table-column prop="userName" label="上传人" width="140" /><el-table-column prop="uploadTime" label="上传时间" width="180" /><el-table-column label="操作" width="100"><template #default="{ $index }"><el-link type="danger" @click="attachments.splice($index, 1)">删除</el-link></template></el-table-column></el-table>
<el-upload action="#" :auto-upload="false" multiple :show-file-list="false" @change="handleAttachment"><el-button plain>上传附件</el-button></el-upload>
<contract-attachment-section
title="其它附件"
description
attachment-type
use-upload-dialog
attachment-location="其它附件"
:rows="attachments"
:preview="previewAttachment"
@update:rows="attachments = $event"
/>
<section class="change-section change-reason-section">
<div class="dialog-section-title">变更原因</div>
<el-form-item prop="changeReason" class="change-reason-section__input">
<el-input
v-model="form.changeReason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入变更原因"
/>
</el-form-item>
</section>
<section class="change-section change-reason-section"><div class="dialog-section-title">变更原因</div><el-form-item prop="changeReason"><el-input v-model="form.changeReason" type="textarea" :rows="2" maxlength="2000" show-word-limit placeholder="请输入变更原因" /></el-form-item><div class="dialog-section-title">变更材料</div><el-upload action="#" :auto-upload="false" multiple :show-file-list="false" @change="handleChangeMaterial"><el-button plain>上传变更材料</el-button></el-upload></section>
<contract-attachment-section
title="变更材料"
description
attachment-type
use-upload-dialog
attachment-location="变更材料"
:rows="changeMaterials"
:preview="previewAttachment"
@update:rows="changeMaterials = $event"
/>
<div class="page-footer">
<el-button @click="$router.back()">取消</el-button>
<el-button type="primary" @click="submit">提交</el-button>
@@ -99,6 +303,15 @@
<script>
import * as api from '@/api/business/contract-manage';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ContractAttachmentSection, {
normalizeContractFileRows,
} from './components/contract-attachment-section.vue';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
import {
contractCategoryOptions as defaultContractCategoryOptions,
signTypeOptions as defaultSignTypeOptions,
} from '@/option/business/common';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
@@ -112,31 +325,294 @@ const viewerPlugins = [
textPlugin(),
fallbackPlugin(),
];
const defaultSettlementModeOptions = [
{ label: '现结', value: '现结' },
{ label: '日结', value: '日结' },
{ label: '周结', value: '周结' },
{ label: '月结', value: '月结' },
{ label: '按固定天数周期', value: '按固定天数周期' },
];
const defaultContractFormatOptions = [
{ label: '电子合同', value: '电子合同' },
{ label: '纸质合同', value: '纸质合同' },
];
const normalizeOptionalPositiveInteger = value => {
if (value === undefined || value === null || value === '') return null;
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : null;
};
const normalizeOptionalAmount = value => {
if (value === undefined || value === null || value === '') return null;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : null;
};
const normalizeCustomPeriods = (periods = []) =>
(periods || []).map(item => ({
startDay: Number(item?.startDay) > 0 ? Number(item.startDay) : '',
endDay: Number(item?.endDay) > 0 ? Number(item.endDay) : '',
}));
export default {
components: { BillingPlanEditor, ElImageViewer, OpenFileViewer },
data() { return { form: {}, period: [], plans: [], attachments: [], contractFiles: [], contractFileRows: [], selectedContractFiles: [], paymentRatioRows: [], settlementConfigTab: 'pre', changeMaterials: [], imagePreviewVisible: false, imagePreviewUrls: [], imagePreviewIndex: 0, documentPreviewVisible: false, previewFile: {}, viewerPlugins, viewerToolbar: { zoom: true, rotate: true, download: true, fullscreen: true }, feeGenerationMode: 'system', settlementRule: { autoGenerate: 1, settlementType: '月结', billCycleType: '固定截单日', billCutoffDay: 25, cycleDays: '' }, preSettlementConfig: {}, formalSettlementConfig: {}, settlementTypeOptions: ['月结','日结','周结','半月结','固定天数周期结算'], billCycleTypeOptions: ['固定截单日','自然月'], billCutoffDayOptions: Array.from({ length: 31 }, (_, index) => ({ label: `${index + 1}`, value: index + 1 })), cycleDayOptions: [7,15,30,60].map(value => ({ label: `${value}`, value })), planDialogVisible: false, planEditor: { planName: '', defaultPlan: false, remark: '', rules: [] }, planEditorIndex: -1, billingElements: ['按重量','按体积','按车辆','按里程','按吨·公里','固定金额(整单一口价)','按数量'], attachmentFileTypes: ['pdf','bmp','jpeg','png','jpg','doc','docx','ppt','pptx','xlsx','xls','eml','msg','zip'], rules: { contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }], changeReason: [{ required: true, message: '请输入变更原因', trigger: 'blur' }] } }; },
computed: { showSettlementBillCycleType() { return this.settlementRule.settlementType === '月结'; }, showSettlementBillCutoffDay() { return this.showSettlementBillCycleType && this.settlementRule.billCycleType === '固定截单日'; }, showSettlementCycleDays() { return this.settlementRule.settlementType === '固定天数周期结算'; } },
mounted() { this.load(); },
watch: { settlementConfigTab(tab, oldTab) { if (tab === oldTab) return; if (oldTab === 'pre') this.preSettlementConfig = { ...this.settlementRule }; else this.formalSettlementConfig = { ...this.settlementRule }; this.settlementRule = { ...(tab === 'pre' ? this.preSettlementConfig : this.formalSettlementConfig) }; } },
components: { BillingPlanEditor, ContractAttachmentSection, ElImageViewer, OpenFileViewer },
data() {
return {
form: {},
period: [],
plans: [],
attachments: [],
contractFiles: [],
contractFileRows: [],
paymentRatioRows: [],
settlementConfigTab: 'pre',
changeMaterials: [],
imagePreviewVisible: false,
imagePreviewUrls: [],
imagePreviewIndex: 0,
documentPreviewVisible: false,
previewFile: {},
viewerPlugins,
viewerToolbar: { zoom: true, rotate: true, download: true, fullscreen: true },
feeGenerationMode: 'system',
settlementRule: {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
},
preSettlementConfig: {},
formalSettlementConfig: {},
settlementTypeOptions: ['月结', '日结', '周结', '半月结', '固定天数周期结算'],
billCycleTypeOptions: ['固定截单日', '自然月', '自定义多周期'],
billCutoffDayOptions: Array.from({ length: 31 }, (_, index) => ({
label: `${index + 1}`,
value: index + 1,
})),
cycleDayOptions: [7, 15, 30, 60].map(value => ({ label: `${value}`, value })),
signTypeOptions: defaultSignTypeOptions,
contractCategoryDictOptions: [],
settlementCurrencyOptions: [],
settlementModeDictOptions: [],
contractFormatDictOptions: [],
planDialogVisible: false,
planEditor: { planName: '', defaultPlan: false, remark: '', rules: [] },
planEditorIndex: -1,
billingElements: [
'按重量',
'按体积',
'按车辆',
'按里程',
'按吨·公里',
'固定金额(整单一口价)',
'按数量',
],
rules: {
contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }],
changeReason: [{ max: 500, message: '变更原因不能超过500个字符', trigger: 'blur' }],
},
};
},
computed: {
pageTitle() {
return this.$route.query.name || '合同变更';
},
contractCategoryOptions() {
return this.contractCategoryDictOptions.length
? this.contractCategoryDictOptions
: defaultContractCategoryOptions;
},
settlementModeOptions() {
return this.settlementModeDictOptions.length
? this.settlementModeDictOptions
: defaultSettlementModeOptions;
},
contractFormatOptions() {
return this.contractFormatDictOptions.length
? this.contractFormatDictOptions
: defaultContractFormatOptions;
},
showSettlementBillCycleType() {
return this.settlementRule.settlementType === '月结';
},
showSettlementBillCutoffDay() {
return this.showSettlementBillCycleType && this.settlementRule.billCycleType === '固定截单日';
},
showSettlementCustomPeriods() {
return this.showSettlementBillCycleType && this.settlementRule.billCycleType === '自定义多周期';
},
showSettlementCycleDays() {
return this.settlementRule.settlementType === '固定天数周期结算';
},
},
mounted() {
this.loadDictionaries();
this.load();
},
watch: {
settlementConfigTab(tab, oldTab) {
if (tab === oldTab) return;
if (oldTab === 'pre') this.preSettlementConfig = { ...this.settlementRule };
else this.formalSettlementConfig = { ...this.settlementRule };
this.settlementRule = {
...(tab === 'pre' ? this.preSettlementConfig : this.formalSettlementConfig),
};
},
},
methods: {
async load() { const id = this.$route.query.id; if (!id) return; const res = await api.getDetail(id); const data = res.data?.data || res.data || {}; this.form = { ...data, copyCount: normalizeOptionalPositiveInteger(data.copyCount), paymentDays: normalizeOptionalPositiveInteger(data.paymentDays), changeType: '合同信息变更' }; this.period = data.startDate && data.endDate ? [data.startDate, data.endDate] : []; this.plans = this.parse(data.billingPlanJson); this.attachments = this.parse(data.attachmentsJson); this.contractFileRows = this.parse(data.contractFileJson); const rules = this.parseObject(data.settlementRuleJson); const pre = this.parseObject(data.preSettlementConfigJson); const formal = this.parseObject(data.formalSettlementConfigJson); const legacy = Object.keys(rules).some(key => !['preSettlementConfig', 'formalSettlementConfig'].includes(key)) ? rules : {}; this.preSettlementConfig = rules.preSettlementConfig || (Object.keys(pre).length ? pre : legacy); this.formalSettlementConfig = rules.formalSettlementConfig || (Object.keys(formal).length ? formal : legacy); this.settlementRule = { ...this.preSettlementConfig }; this.feeGenerationMode = data.feeGenerationMode || (Number(data.billingEnabled) === 0 ? 'manual' : 'system'); this.paymentRatioRows = this.parse(data.paymentRatioJson); },
parse(value) { try { const result = JSON.parse(value || '[]'); return Array.isArray(result) ? result : []; } catch { return []; } },
parseObject(value) { try { return { autoGenerate: 1, settlementType: '月结', billCycleType: '固定截单日', billCutoffDay: 25, cycleDays: 15, ...(JSON.parse(value || '{}') || {}) }; } catch { return { autoGenerate: 1, settlementType: '月结', billCycleType: '固定截单日', billCutoffDay: 25, cycleDays: 15 }; } },
positiveIntegerInput(prop, value) { this.form[prop] = String(value ?? '').replace(/\D/g, '').replace(/^0+/, ''); },
loadDictionaries() {
Promise.all([
getSystemDictionary({ code: 'currency_type' }),
getBizDictionary({ code: 'settle_method' }),
getBizDictionary({ code: 'contractFormat' }),
getBizDictionary({ code: 'contractCategory' }),
]).then(([currencyRes, methodRes, formatRes, categoryRes]) => {
const records = response => {
const data = response?.data?.data || response?.data || [];
return Array.isArray(data) ? data : data.records || [];
};
this.settlementCurrencyOptions = records(currencyRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
this.settlementModeDictOptions = records(methodRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
this.contractFormatDictOptions = records(formatRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
this.contractCategoryDictOptions = records(categoryRes).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
});
},
async load() {
const id = this.$route.query.id;
if (!id) return;
const res = await api.getDetail(id);
const data = res.data?.data || res.data || {};
this.form = {
...data,
changeContent: '',
changeReason: '',
changeAttachmentsJson: '',
copyCount: normalizeOptionalPositiveInteger(data.copyCount),
invoiceCycle: normalizeOptionalPositiveInteger(data.invoiceCycle),
paymentDays: normalizeOptionalPositiveInteger(data.paymentDays),
contractAmount: normalizeOptionalAmount(data.contractAmount),
templateFlag: data.templateFlag ?? 0,
electronicSealFlag: data.electronicSealFlag ?? 0,
archiveStatus: data.archiveStatus || '未归档',
changeType: '合同信息变更',
};
this.changeMaterials = [];
this.period = data.startDate && data.endDate ? [data.startDate, data.endDate] : [];
this.plans = this.parse(data.billingPlanJson);
this.attachments = this.mergeChangeAttachments(
this.parse(data.attachmentsJson),
this.parse(data.changeAttachmentsJson)
);
this.contractFileRows = normalizeContractFileRows(this.parse(data.contractFileJson));
const rules = this.parseObject(data.settlementRuleJson);
const pre = this.parseObject(data.preSettlementConfigJson);
const formal = this.parseObject(data.formalSettlementConfigJson);
const legacy = Object.keys(rules).some(
key => !['preSettlementConfig', 'formalSettlementConfig'].includes(key)
)
? rules
: {};
const normalizeRule = rule => {
const next = {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
...(rule || {}),
};
if (next.settlementType === '月结' && next.billCycleType === '自定义多周期') {
next.customPeriods = normalizeCustomPeriods(
Array.isArray(next.customPeriods) ? next.customPeriods : []
);
} else {
next.customPeriods = [];
}
return next;
};
this.preSettlementConfig = normalizeRule(
rules.preSettlementConfig || (Object.keys(pre).length ? pre : legacy)
);
this.formalSettlementConfig = normalizeRule(
rules.formalSettlementConfig || (Object.keys(formal).length ? formal : legacy)
);
this.settlementRule = { ...this.preSettlementConfig };
this.feeGenerationMode =
data.feeGenerationMode || (Number(data.billingEnabled) === 0 ? 'manual' : 'system');
this.paymentRatioRows = this.parse(data.paymentRatioJson);
},
parse(value) {
try {
const result = JSON.parse(value || '[]');
return Array.isArray(result) ? result : [];
} catch {
return [];
}
},
// /
mergeChangeAttachments(attachments = [], changeAttachments = []) {
const key = item => this.attachmentUrl(item) || this.attachmentName(item);
const exists = new Set(attachments.map(key));
const merged = changeAttachments
.filter(item => !exists.has(key(item)))
.map(item => ({
...item,
size: /^\d+$/.test(String(item.size ?? '')) ? this.formatFileSize(item.size) : item.size,
}));
return [...attachments, ...merged];
},
parseObject(value) {
try {
return {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
...(JSON.parse(value || '{}') || {}),
};
} catch {
return {
autoGenerate: '',
billStartDate: '',
settlementType: '',
billCycleType: '',
billCutoffDay: '',
cycleDays: '',
customPeriods: [],
};
}
},
positiveIntegerInput(prop, value) {
this.form[prop] = String(value ?? '')
.replace(/\D/g, '')
.replace(/^0+/, '');
},
addPlan() { this.planEditorIndex = -1; this.planEditor = { planName: `计费方案${this.plans.length + 1}`, defaultPlan: !this.plans.length, remark: '', rules: [{}] }; this.planDialogVisible = true; },
editPlan(row, index) { this.planEditorIndex = index; this.planEditor = JSON.parse(JSON.stringify(row)); this.planDialogVisible = true; },
toggleDefaultPlan(value) { if (value) this.plans.forEach(item => { item.defaultPlan = false; }); },
savePlan(value, index) { if (index < 0) this.plans.push(value); else this.plans.splice(index, 1, value); if (value.defaultPlan) this.plans.forEach((item, current) => { if (current !== (index < 0 ? this.plans.length - 1 : index)) item.defaultPlan = false; }); },
handleSettlementTypeChange(value) { if (value !== '月结') { this.settlementRule.billCycleType = ''; this.settlementRule.billCutoffDay = ''; } else if (!this.settlementRule.billCycleType) this.settlementRule.billCycleType = '固定截单日'; if (value !== '固定天数周期结算') this.settlementRule.cycleDays = ''; },
handleCycleTypeChange(value) { if (value === '固定截单日' && !this.settlementRule.billCutoffDay) this.settlementRule.billCutoffDay = 25; if (value !== '固定截单日') this.settlementRule.billCutoffDay = ''; },
handleAttachment(event) { const raw = event.raw; if (raw) this.attachments.push({ name: raw.name, size: `${Math.ceil(raw.size / 1024)}KB`, uploadTime: this.$dayjs().format('YYYY-MM-DD HH:mm:ss') }); },
handleContractFileChange(list) { this.contractFileRows = (list || []).map(item => ({ ...item, uploadTime: item.uploadTime || this.$dayjs().format('YYYY-MM-DD HH:mm:ss') })); },
handleSettlementTypeChange(value) { if (value !== '月结') { this.settlementRule.billCycleType = ''; this.settlementRule.billCutoffDay = ''; this.settlementRule.customPeriods = []; } else if (this.settlementRule.billCycleType === '自定义多周期') this.settlementRule.customPeriods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); else this.settlementRule.customPeriods = []; if (value !== '固定天数周期结算') this.settlementRule.cycleDays = ''; },
handleCycleTypeChange(value) { if (value !== '固定截单日') this.settlementRule.billCutoffDay = ''; if (value === '自定义多周期') this.settlementRule.customPeriods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); else this.settlementRule.customPeriods = []; },
attachmentUrl(row = {}) { return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || ''; },
attachmentName(row = {}) { return row.originalName || row.name || row.fileName || '附件'; },
attachmentExtension(row = {}) { const source = String(this.attachmentName(row) || this.attachmentUrl(row)).split('?')[0]; const index = source.lastIndexOf('.'); return index > -1 ? source.slice(index + 1).toLowerCase() : ''; },
@@ -144,12 +620,16 @@ export default {
previewAttachment(row, rows = this.attachments) { const url = this.attachmentUrl(row); if (!url) { this.$message.warning('附件地址为空,无法预览'); return; } if (this.isAttachmentImage(row)) { this.imagePreviewUrls = (rows || []).filter(item => this.isAttachmentImage(item) && this.attachmentUrl(item)).map(item => this.attachmentUrl(item)); this.imagePreviewIndex = Math.max(this.imagePreviewUrls.indexOf(url), 0); this.imagePreviewVisible = true; return; } this.previewFile = { name: this.attachmentName(row), url, mimeType: row.mimeType || row.contentType || '' }; this.documentPreviewVisible = true; },
handlePreviewUnsupported() { this.$message.warning('当前文件暂不支持在线预览'); },
handlePreviewError() { this.$message.error('附件预览失败'); },
removeContractFile(index) { this.contractFileRows.splice(index, 1); },
handleContractFileBatchDownload() { (this.selectedContractFiles.length ? this.selectedContractFiles : this.contractFileRows).forEach(row => { if (row.url) window.open(row.url, '_blank'); }); },
formatFileSize(value) { const size = Number(value || 0); return size > 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(2)}MB` : `${Math.max(1, Math.ceil(size / 1024))}KB`; },
handleChangeMaterial(event) { if (event.raw) this.changeMaterials.push(event.raw); },
addPaymentRatioRow() { this.paymentRatioRows.push({ paymentTerm: `${this.paymentRatioRows.length + 1}`, ratioLimit: '', remark: '' }); },
async submit() { await this.$refs.formRef.validate(); const total = this.paymentRatioRows.reduce((sum, row) => sum + Number(row.ratioLimit || 0), 0); if (this.paymentRatioRows.length && Math.abs(total - 100) > 0.0001) { this.$message.warning('付款比例上限合计必须等于100%'); return; } const settlementRule = { preSettlementConfig: this.preSettlementConfig, formalSettlementConfig: this.formalSettlementConfig }; await api.submitChange({ ...this.form, copyCount: normalizeOptionalPositiveInteger(this.form.copyCount), paymentDays: normalizeOptionalPositiveInteger(this.form.paymentDays), startDate: this.period[0], endDate: this.period[1], feeGenerationMode: this.feeGenerationMode, billingEnabled: this.feeGenerationMode === 'system' ? 1 : 0, billingPlanJson: JSON.stringify(this.plans), settlementRuleJson: JSON.stringify(settlementRule), preSettlementConfigJson: JSON.stringify(this.preSettlementConfig), formalSettlementConfigJson: JSON.stringify(this.formalSettlementConfig), paymentRatioJson: JSON.stringify(this.paymentRatioRows), contractFileJson: JSON.stringify(this.contractFileRows), attachmentsJson: JSON.stringify(this.attachments), changeContent: this.form.changeReason, changeReason: this.form.changeReason }); this.$message.success('变更已提交'); this.$router.back(); },
customPeriodEndDayOptions(row) { const startDay = Number(row?.startDay); if (!Number.isFinite(startDay) || startDay < 1) return this.billCutoffDayOptions; return Array.from({ length: 31 - startDay + 1 }, (_, index) => ({ label: `${startDay + index}`, value: startDay + index })); },
handleCustomPeriodStartDayChange(index, value) { const periods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); const row = periods[index]; if (!row) return; row.startDay = value || ''; const startDay = Number(row.startDay); const endDay = Number(row.endDay); if (Number.isFinite(startDay) && Number.isFinite(endDay) && (endDay < startDay || endDay > 31)) row.endDay = ''; this.settlementRule.customPeriods = periods; },
handleCustomPeriodEndDayChange(index, value) { const periods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); const row = periods[index]; if (!row) return; row.endDay = value || ''; this.settlementRule.customPeriods = periods; },
addCustomPeriodRow() { const periods = normalizeCustomPeriods(this.settlementRule.customPeriods || []); periods.push({ startDay: '', endDay: '' }); this.settlementRule.customPeriods = periods; },
removeCustomPeriodRow(index) { if (index <= 0) return; const periods = [...(this.settlementRule.customPeriods || [])]; periods.splice(index, 1); this.settlementRule.customPeriods = normalizeCustomPeriods(periods); },
validateCustomPeriods(periods = [], label) { const rows = normalizeCustomPeriods(periods); if (!rows.length) { this.$message.warning(`${label}:请至少配置一段自定义周期`); return false; } for (let index = 0; index < rows.length; index += 1) { const row = rows[index]; const startDay = Number(row.startDay); const endDay = Number(row.endDay); if (!Number.isFinite(startDay) || startDay < 1 || startDay > 31) { this.$message.warning(`${label}:请选择第${index + 1}行运单区间开始日`); return false; } if (!Number.isFinite(endDay) || endDay < 1 || endDay > 31) { this.$message.warning(`${label}:请选择第${index + 1}行运单区间结束日`); return false; } if (endDay < startDay) { this.$message.warning(`${label}:第${index + 1}行结束日不能早于开始日`); return false; } if (index > 0 && startDay !== Number(rows[index - 1].endDay) + 1) { this.$message.warning(`${label}:自定义多周期区间必须连续,不允许重叠或存在日期缺口`); return false; } } return true; },
validateSettlementRule(rule, label) { if (Number(rule.autoGenerate) !== 1) return true; if (!rule.billStartDate || !rule.settlementType) { this.$message.warning(`${label}:请完整填写账单起始日期和结算类型`); return false; } if (rule.settlementType === '月结' && !rule.billCycleType) { this.$message.warning(`${label}:请选择结算周期`); return false; } if (rule.settlementType === '月结' && rule.billCycleType === '固定截单日' && !rule.billCutoffDay) { this.$message.warning(`${label}:请选择账单截单日`); return false; } if (rule.settlementType === '月结' && rule.billCycleType === '自定义多周期') return this.validateCustomPeriods(rule.customPeriods, label); if (rule.settlementType === '固定天数周期结算' && !rule.cycleDays) { this.$message.warning(`${label}:请选择周期天数`); return false; } return true; },
async submit() { await this.$refs.formRef.validate(); const total = this.paymentRatioRows.reduce((sum, row) => sum + Number(row.ratioLimit || 0), 0); if (this.paymentRatioRows.length && Math.abs(total - 100) > 0.0001) { this.$message.warning('付款比例上限合计必须等于100%'); return; } if (this.settlementConfigTab === 'pre') this.preSettlementConfig = { ...this.settlementRule }; else this.formalSettlementConfig = { ...this.settlementRule }; if (!this.validateSettlementRule(this.preSettlementConfig, '预结算配置') || !this.validateSettlementRule(this.formalSettlementConfig, '正式结算配置')) return; const settlementRule = { preSettlementConfig: this.preSettlementConfig, formalSettlementConfig: this.formalSettlementConfig }; await api.submitChange({ ...this.form, settlementCurrency: String(this.form.settlementCurrency || '').trim() || 'RMB', copyCount: normalizeOptionalPositiveInteger(this.form.copyCount), invoiceCycle: normalizeOptionalPositiveInteger(this.form.invoiceCycle), paymentDays: normalizeOptionalPositiveInteger(this.form.paymentDays), contractAmount: normalizeOptionalAmount(this.form.contractAmount), startDate: this.period[0], endDate: this.period[1], feeGenerationMode: this.feeGenerationMode, billingEnabled: this.feeGenerationMode === 'system' ? 1 : 0, billingPlanJson: JSON.stringify(this.plans), settlementRuleJson: JSON.stringify(settlementRule), preSettlementConfigJson: JSON.stringify(this.preSettlementConfig), formalSettlementConfigJson: JSON.stringify(this.formalSettlementConfig), paymentRatioJson: JSON.stringify(this.paymentRatioRows), contractFileJson: JSON.stringify(this.contractFileRows), attachmentsJson: JSON.stringify(this.attachments), changeContent: this.form.changeContent, changeReason: this.form.changeReason, changeAttachmentsJson: JSON.stringify(this.changeMaterials) }); this.$message.success('变更已提交'); this.$router.back(); },
},
};
</script>
@@ -179,13 +659,27 @@ export default {
.change-table { margin: 16px 0; }
.ratio-tip { margin-bottom: 8px; color: #f56c6c; }
.unit { margin-left: 8px; }
.attachment-head { display: flex; align-items: center; justify-content: flex-end; margin-bottom: 12px; }
.attachment-upload { display: flex; justify-content: flex-start; margin-top: 12px; }
.attachment-upload .vehicle-attachment-upload { width: auto; }
.attachment-upload .el-upload { display: inline-flex; }
.settlement-switch { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
// radio
.settlement-switch :deep(.el-radio:first-child) { margin-right: 0; }
.settlement-switch-tip { margin: 0 32px 0 8px; color: #a8abb2; cursor: help; font-size: 14px; }
.settlement-form { display: grid; grid-template-columns: repeat(3, minmax(240px, 1fr)); gap: 8px 28px; }
.change-reason-section { margin: 20px 0; }
.change-reason-section__input {
width: 100%;
:deep(.el-textarea__inner) {
border-color: #f56c6c;
box-shadow: 0 0 0 1px #f56c6c inset;
}
:deep(.el-textarea__inner:hover),
:deep(.el-textarea__inner:focus) {
border-color: #f56c6c;
box-shadow: 0 0 0 1px #f56c6c inset;
}
}
.page-footer {
position: fixed;
right: 0;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+268 -66
View File
@@ -1,57 +1,58 @@
<template>
<basic-container class="master-order-page">
<template v-if="mode === 'list'">
<el-form :model="query" class="master-search" label-width="88px" @submit.prevent>
<el-row :gutter="16">
<el-col v-for="field in primaryFields" :key="field.prop" :span="6"
><el-form-item :label="field.label"
><el-input v-model="query[field.prop]" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="状态"
><el-select v-model="query.businessStatus" placeholder="全部"
><el-option label="全部" value="" /><el-option
<div class="master-order-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="master-order-page__search-grid">
<el-form-item v-for="field in primaryFields" :key="field.prop" :label="field.label">
<el-input v-model="query[field.prop]" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.businessStatus" clearable placeholder="全部">
<el-option label="全部" value="" />
<el-option
v-for="item in statuses"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
></el-col>
<template v-if="searchExpanded">
<el-col v-for="field in secondaryFields" :key="field.prop" :span="6"
><el-form-item :label="field.label"
><el-input v-model="query[field.prop]" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="计划开始日期"
><el-date-picker
:value="item.value"
/>
</el-select>
</el-form-item>
<template v-if="searchExpanded">
<el-form-item v-for="field in secondaryFields" :key="field.prop" :label="field.label">
<el-input v-model="query[field.prop]" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="计划开始日期">
<el-date-picker
v-model="query.planStartRange"
type="datetimerange"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="请选择"
end-placeholder="请选择" /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="计划结束日期"
><el-date-picker
end-placeholder="请选择"
/>
</el-form-item>
<el-form-item label="计划结束日期">
<el-date-picker
v-model="query.planEndRange"
type="datetimerange"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="请选择"
end-placeholder="请选择" /></el-form-item
></el-col>
</template>
<el-col :span="24" class="search-actions"
><el-button type="primary" @click="search">查询</el-button
><el-button @click="reset">重置</el-button
><el-link type="primary" @click="searchExpanded = !searchExpanded"
><el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon
>{{ searchExpanded ? '折叠' : '展开' }}</el-link
></el-col
>
</el-row>
</el-form>
end-placeholder="请选择"
/>
</el-form-item>
</template>
<div class="master-order-page__search-actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-link type="primary" @click="searchExpanded = !searchExpanded">{{
searchExpanded ? '收起' : '展开'
}}</el-link>
</div>
</div>
</el-form>
</div>
<section class="master-list-panel">
<div class="toolbar">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button
@@ -76,7 +77,13 @@
<div class="route-point">
<span :class="['route-node', node.type]">{{ node.text }}</span>
<div class="route-detail">
<el-tooltip placement="top"><template #content>{{ node.name }}</template><strong>{{ node.name }}</strong></el-tooltip>
<el-tooltip
:content="node.address || node.name || '-'"
placement="top"
:show-after="120"
>
<strong>{{ node.name }}</strong>
</el-tooltip>
<span v-for="line in node.lines" :key="line">{{ line }}</span>
</div>
</div>
@@ -125,7 +132,7 @@
/>
</template>
<master-order-dispatch v-else-if="mode === 'dispatch'" :id="routeId" @back="goList" />
<master-order-detail v-else :id="routeId" @back="goList" />
<master-order-detail v-else :id="routeId" />
<el-dialog v-model="confirm.visible" title="提示" width="400px"
><span>{{ confirm.message }}</span
><template #footer
@@ -138,7 +145,6 @@
<script>
import * as api from '@/api/business/master-order';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import MasterOrderEditor from './components/master-order-editor.vue';
import MasterOrderDispatch from './components/master-order-dispatch.vue';
import MasterOrderDetail from './components/master-order-detail.vue';
@@ -147,8 +153,6 @@ export default {
components: { MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
data() {
return {
ArrowDown,
ArrowUp,
searchExpanded: false,
loading: false,
records: [],
@@ -262,7 +266,7 @@ export default {
result.unshift({ text: '调度', run: () => this.goDispatch(row.id) });
if (row.businessStatus !== 'draft')
result.unshift({ text: '详情', run: () => this.goDetail(row.id) });
if (['dispatching', 'completed'].includes(row.businessStatus))
if (row.businessStatus === 'dispatching')
result.push({
text: '关闭调度',
run: () =>
@@ -303,7 +307,7 @@ export default {
},
timeRange(row) {
return row.planStartTime && row.planEndTime
? `${String(row.planStartTime).slice(0, 16)} - ${String(row.planEndTime).slice(0, 16)}`
? `${String(row.planStartTime).slice(0, 10)} - ${String(row.planEndTime).slice(0, 10)}`
: '-';
},
goodsSummary(row) {
@@ -330,44 +334,221 @@ export default {
const key = keys.find(item => route[item] !== undefined && route[item] !== null);
return key ? route[key] : 0;
},
isRoadTransportType(type) {
const value = String(type || '').trim().toLowerCase();
return value === 'road' || value.includes('公路');
},
isStationLikeName(value) {
const text = String(value || '').trim();
if (!text) return false;
return /(?:火车站|高铁站|客运站|货运站|港口|码头|机场|空港|航站楼)$/.test(text) || /(?<!市|州|盟|区|县|旗)站$/.test(text);
},
isNonRoadLocation({ name, siteCode, transportType, nextTransportType } = {}) {
const code = String(siteCode || '').trim();
if (code && code !== '/') return true;
if (this.isStationLikeName(name)) return true;
if (transportType && !this.isRoadTransportType(transportType)) return true;
if (nextTransportType && !this.isRoadTransportType(nextTransportType)) return true;
return false;
},
formatRoadAddress(value) {
const text = String(value || '').replace(/\s+/g, '');
if (!text) return '-';
// /
const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, '');
//
const cityMatch = withoutProvince.match(/.+?(?:市|州(?!市)|盟(?!市))/);
if (!cityMatch) return text;
const city = cityMatch[0];
const districtSource = withoutProvince.slice(city.length);
const districtMatch = districtSource.match(/^[^市州盟]*?(?:区|县|旗)/);
if (!districtMatch) return city;
//
const district = `${districtMatch[0]}${
districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : ''
}`;
return `${city} ${district}`;
},
routeNodeName(value, address, transportType, region = {}) {
const text = String(value || '').trim();
if (!text) return '-';
if (
this.isNonRoadLocation({
name: text,
siteCode: region.siteCode,
transportType,
nextTransportType: region.nextTransportType,
})
) {
return text;
}
// /
//
const parsedText = this.formatRoadAddress(text);
if (parsedText.includes(' ')) return parsedText;
const parsedAddress = this.formatRoadAddress(address);
if (parsedAddress.includes(' ')) return parsedAddress;
const city = String(region.cityName || '').trim();
const district = String(region.districtName || '').trim();
if (city && district) return `${city} ${district}`;
if (city) return city;
if (district && address) {
const formattedAddress = this.formatRoadAddress(address);
const addressCity = formattedAddress.split(' ')[0];
if (addressCity && addressCity !== formattedAddress) return `${addressCity} ${district}`;
}
const isRegional =
/省|自治区|特别行政区/.test(text) ||
/(?:市|州|盟).*?(?:区|县|旗)/.test(text) ||
/(?:区|县|旗)$/.test(text);
if (!isRegional) return text;
// 西
const source = /省|自治区|特别行政区|市|州|盟/.test(text) ? text : address || text;
const formatted = this.formatRoadAddress(source);
if (/(?:区|县|旗)$/.test(text) && !formatted.includes(text)) {
const cityName = formatted.split(' ')[0];
return cityName && cityName !== formatted ? `${cityName} ${text}` : formatted;
}
return formatted;
},
resolveRouteNodeLocation({
name,
address,
transportType,
nextTransportType,
cityName,
districtName,
siteCode,
} = {}) {
const rawName = String(name || '').trim();
const rawAddress = String(address || '').trim();
const regionText = [cityName, districtName].filter(Boolean).join(' ');
const nonRoad = this.isNonRoadLocation({
name: rawName,
siteCode,
transportType,
nextTransportType,
});
if (nonRoad) {
//
// address name
if (this.isStationLikeName(rawAddress) && !this.isStationLikeName(rawName)) {
return {
name: rawAddress,
address: rawName || regionText || rawAddress,
};
}
const displayName = rawName || rawAddress || '-';
const tipAddress =
(rawAddress && rawAddress !== displayName ? rawAddress : '') ||
regionText ||
rawAddress ||
displayName;
return { name: displayName, address: tipAddress };
}
return {
name: this.routeNodeName(rawName || rawAddress, rawAddress, transportType, {
cityName,
districtName,
siteCode,
nextTransportType,
}),
address: rawAddress || rawName || regionText || '-',
};
},
routeNodes(row = {}) {
const routes = row.routeProgress || [];
const total = this.routeNumber(row.totalQuantity);
const firstTransportType = routes[0]?.transportType || row.routes?.[0]?.transportType || row.transportType || '';
if (!routes.length) {
const start = this.resolveRouteNodeLocation({
name: row.departureName || row.departureAddress,
address: row.departureAddress,
transportType: firstTransportType,
cityName: row.departureCityName,
districtName: row.departureDistrictName,
siteCode: row.departureSiteCode,
});
const end = this.resolveRouteNodeLocation({
name: row.arrivalName || row.arrivalAddress,
address: row.arrivalAddress,
transportType: row.finalTransportType || firstTransportType,
cityName: row.arrivalCityName,
districtName: row.arrivalDistrictName,
siteCode: row.arrivalSiteCode,
});
return [
{
key: 'start',
type: 'start',
text: '起',
name: row.departureName || row.departureAddress || '-',
...start,
lines: [`已调度0/${total}`],
},
{
key: 'end',
type: 'end',
text: '终',
name: row.arrivalName || row.arrivalAddress || '-',
...end,
lines: [`到达0/${total}`],
},
];
}
const start = this.resolveRouteNodeLocation({
name: row.departureName || row.departureAddress,
address: row.departureAddress,
transportType: firstTransportType,
nextTransportType: firstTransportType,
cityName: row.departureCityName,
districtName: row.departureDistrictName,
siteCode: row.departureSiteCode,
});
return [
{
key: 'start',
type: 'start',
text: '起',
name: row.departureName || row.departureAddress || '-',
...start,
lines: [`已调度${this.routeNumber(routes[0]?.dispatchedQuantity)}/${total}`],
},
...routes.map((route, index) => {
const isEnd = index === routes.length - 1;
const arrived = this.routeNumber(this.routeArrivedQuantity(route));
const dispatched = this.routeNumber(routes[index + 1]?.dispatchedQuantity);
const address =
route.arrivalAddress ||
route.departureAddress ||
(isEnd ? row.arrivalAddress : '') ||
'';
const location = this.resolveRouteNodeLocation({
name:
route.arrivalName ||
route.departureName ||
(isEnd ? row.arrivalName : '') ||
'',
address,
transportType: route.transportType,
nextTransportType: isEnd
? row.finalTransportType || route.transportType
: routes[index + 1]?.transportType || '',
cityName:
route.arrivalCityName ||
route.departureCityName ||
(isEnd ? row.arrivalCityName : ''),
districtName:
route.arrivalDistrictName ||
route.departureDistrictName ||
(isEnd ? row.arrivalDistrictName : ''),
siteCode:
route.arrivalSiteCode ||
route.departureSiteCode ||
(isEnd ? row.arrivalSiteCode : ''),
});
return {
key: route.segmentNo || `route-${index}`,
type: isEnd ? 'end' : 'middle',
text: isEnd ? '终' : '经',
name: route.arrivalName || route.departureName || row.arrivalName || '-',
...location,
lines: isEnd
? [`到达${arrived}/${total}`]
: [`到达 ${arrived}/${total}`, `已调度 ${dispatched}/到达${arrived}/${total}`],
@@ -399,28 +580,43 @@ export default {
</script>
<style scoped lang="scss">
.master-search {
margin-bottom: 12px;
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.el-form-item {
.master-order-page {
&__search {
padding: 12px 12px 4px;
margin-bottom: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.el-input,
.el-select,
.el-date-editor {
width: 100%;
&__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
.search-actions {
&__search-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
align-items: center;
gap: 12px;
min-height: 40px;
.el-icon {
margin-right: 4px;
}
gap: 8px;
min-height: 32px;
}
:deep(.el-form-item) {
margin-bottom: 8px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper),
:deep(.el-date-editor--datetimerange) {
width: 100%;
}
}
.master-list-panel {
@@ -514,10 +710,16 @@ export default {
line-height: 1.8;
white-space: nowrap;
strong {
display: inline-block;
max-width: 150px;
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
max-width: 180px;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
cursor: default;
}
}
.route-connector {
+36 -13
View File
@@ -227,7 +227,7 @@
<el-radio-group
v-model="row.confirmMode"
:disabled="dialogReadonly || !row.enabled"
@change="syncNodeFields"
@change="handleAcceptConfirmChange(row)"
>
<el-radio label="yes"></el-radio>
<el-radio label="no_confirm_accept">无需确认接单</el-radio>
@@ -235,12 +235,7 @@
</div>
<div class="process-config-form__require-line">
<span class="process-config-form__require-label">确认接单人</span>
<el-checkbox
v-model="row.confirmDriver"
:disabled="dialogReadonly || !row.enabled || row.confirmMode !== 'yes'"
@change="syncNodeFields"
>司机</el-checkbox
>
<el-checkbox v-model="row.confirmDriver" :disabled="true">司机</el-checkbox>
</div>
</template>
@@ -551,10 +546,19 @@ export default {
hasPermission(code) {
return this.isAdmin || this.validData(this.permission && this.permission[code], false);
},
handleAcceptConfirmChange(row) {
row.confirmDriver = row.confirmMode === 'yes';
this.syncNodeFields();
},
handleReturnConfirmChange(row) {
row.confirmInternal = row.confirmMode === 'yes';
this.syncNodeFields();
},
enforceAcceptDriver(node) {
if (!node || (node.key !== 'accept' && node.name !== '接单')) return node;
node.confirmDriver = node.confirmMode === 'yes';
return node;
},
initDeptOptions() {
getDeptTree(this.userInfo.tenantId).then(res => {
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
@@ -610,7 +614,7 @@ export default {
return 'warning';
},
canEdit(row) {
return this.hasPermission('process_config_edit') && !row.readonly;
return this.hasPermission('process_config_edit') && !row.readonly && !row.hasRelatedWaybill;
},
canDelete(row) {
return (
@@ -626,7 +630,7 @@ export default {
return this.hasPermission('process_config_disable') && !row.readonly && row.status === 1;
},
cloneNode(template) {
return {
const node = {
enabled: false,
punch: true,
location: true,
@@ -641,10 +645,16 @@ export default {
supportVoucher: false,
voucherOptions: [],
confirmMode: 'yes',
confirmDriver: false,
confirmDriver: true,
confirmInternal: false,
...template,
};
if ((node.key === 'accept' || node.name === '接单') && node.confirmMode === 'yes') {
node.confirmDriver = true;
} else if ((node.key === 'accept' || node.name === '接单') && node.confirmMode !== 'yes') {
node.confirmDriver = false;
}
return node;
},
resetNodeRows() {
this.nodeRows = NODE_TEMPLATES.map(item => this.cloneNode(item));
@@ -769,20 +779,28 @@ export default {
this.syncNodeFields();
},
syncNodeFields() {
this.nodeRows.forEach(item => this.enforceAcceptDriver(item));
const enabledNodes = this.nodeRows.filter(item => item.enabled);
this.form.includedNodes = enabledNodes.map(item => item.name).join(',');
this.form.nodeConfigJson = JSON.stringify(
this.nodeRows.map(item => ({
key: item.key,
name: item.name,
type: item.type,
enabled: item.enabled,
confirmMode: item.confirmMode,
confirmDriver: item.confirmDriver,
confirmInternal: item.confirmInternal,
punch: item.punch,
location: item.location,
uploadCargo: item.uploadCargo,
cargoTypes: item.uploadCargo ? item.cargoTypes : [],
//
uploadCargo: item.type === 'transit' || item.key === 'transit' ? false : item.uploadCargo,
cargoTypes:
item.type === 'transit' || item.key === 'transit'
? []
: item.uploadCargo
? item.cargoTypes
: [],
frequencyDays: item.frequencyDays,
timeStart: item.timeStart,
timeEnd: item.timeEnd,
@@ -935,7 +953,7 @@ export default {
);
},
openConfig(row, readonly = false) {
this.dialogReadonly = readonly;
this.dialogReadonly = readonly || !!(row && row.hasRelatedWaybill);
if (!row || !row.id) {
this.form = {
configName: '',
@@ -952,6 +970,7 @@ export default {
}
api.getDetail(row.id).then(res => {
const detail = res.data.data || {};
this.dialogReadonly = readonly || !!detail.hasRelatedWaybill;
this.form = {
...detail,
defaultFinishDays:
@@ -969,6 +988,10 @@ export default {
},
submitConfig(mode) {
if (this.submitting) return;
if (this.form.id && this.form.hasRelatedWaybill) {
this.$message.warning('该项目已有运单,过程配置不可修改');
return;
}
this.syncNodeFields();
const submitRow = this.normalizeRow({
...this.form,
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More