Compare commits

...

76 Commits

Author SHA1 Message Date
gxwebsoft 5bc1f69a7f Merge remote-tracking branch 'origin/master' 2026-10-14 12:45:02 +08:00
gxwebsoft 6de87ed4e2 style(contract): 优化合同管理页面表单样式与布局
- 调整合同管理备注输入框行数和最大长度
- 细化合同变更页面表单项间距和对齐方式
- 统一弹窗表单项的底部间距样式,改为注释禁用
- 调整 port-terminal 和 region 组件的表单项底部间距
- 修改 vite 配置中 API 请求代理地址
- 优化凭证执行详情搜索表单列宽布局
- 在运单管理页面更新型号字段为非必填,调整变更记录内容格式化显示
- 引入并使用 formatRouteChangeContent 工具函数
- 统一明细按钮显示逻辑,草稿状态也显示详情按钮
- 新增路由变更相关工具函数与样式支持
2026-10-14 12:44:56 +08:00
b2894lxlx 18aeb08c90 🐛 修复变更记录按提交批次汇总,临时额度-1显示为-
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:58:42 +08:00
b2894lxlx cdd30b8211 ✨ 机构同步编码校验对齐GWWL三位数字规则
组织编码校验改为上级直拼三位数字;全量同步清除提示同步保留外部组织与集团锚点。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:45:01 +08:00
gxwebsoft 6762cf7b83 Merge remote-tracking branch 'origin/master' 2026-10-14 10:02:17 +08:00
gxwebsoft a87b5e4b95 fix(styles): 修正车辆调度弹窗表单标签与控件宽度不一致问题
- 修改选择器为后代选择器以适配 avue 表单项的嵌套结构
- 统一弹窗内表单标签宽度,避免 labelWidth: 'auto' 导致控件宽度不一
- 新增全局样式规则,固定标签宽度为 calc(4em + 24px)
- 删除用于调试控件宽度的临时文件 cascader-width-check.html
- 记录并说明 avue 弹窗宽度调整相关坑点和解决方案
2026-10-14 10:02:12 +08:00
b2894lxlx 1181aea3b9 🐛 凭证分片遇 HTTP 预签名时回退服务端代传
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 20:44:17 +08:00
gxwebsoft 68f4b2c35e style(transportCapacity): 调整车辆调度弹窗部门级联选择器宽度
- 注释掉多个页面及组件中el-form-item的margin-bottom样式
- 增加车辆调度弹窗配置dialogCustomClass: 'vehicle-dispatch-dialog'
- 在全局样式element-ui.scss中为.vehicle-dispatch-dialog内el-cascader设置宽度100%
- 解决avue-crud弹窗append-to-body导致页面容器scoped样式无法命中问题
- 更新vite配置,将开发服务器端口修改为2889,代理目标指向远程地址
- 补充工程文档说明和样式调整原因分析
2026-10-13 20:21:17 +08:00
b2894lxlx 6296c8f6f4 ⚡ 配载货物信息分列数量单位,并修复车辆尺寸 -1 展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:55:15 +08:00
b2894lxlx 6955784e93 ⚡ 优化表格工具栏、附件文件名展示与合同金额两位小数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 12:44:40 +08:00
b2894lxlx bc7755a160 ✨ 小程序用户支持重置密码,列表隐藏 openid
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:47:28 +08:00
gxwebsoft 18a02a1969 Merge remote-tracking branch 'origin/master' 2026-10-12 14:20:26 +08:00
b2894lxlx dcaa3127a0 ✨ 新增小程序用户管理页,并加宽用户账号名展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 13:49:10 +08:00
b2894lxlx 959c1821c1 🐛 司机/车辆档案日期兼容 OCR 宽松格式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 13:02:09 +08:00
gxwebsoft a4c2987c5f style(table): 全站文件名列添加省略号与溢出隐藏
- 统一给文件名相关的 el-table-column 添加 class-name="is-ellipsis-link"
- 解决了 el-link 为 flex 项导致列宽撑破的问题
- 在样式中新增 is-ellipsis-link 规则,实现文本溢出省略号效果
- 保证超长文件名不会破坏表格布局且能完整展示链接提示
- 适用于业务多个组件中涉及附件、文件名等长文本列的处理
2026-10-12 12:12:24 +08:00
b2894lxlx 2377a2e8d4 Merge remote-tracking branch 'websoft/dev' 2026-10-12 10:38:46 +08:00
b2894lxlx f455003014 ✨ 优化主数据地图回填与表单校验间距,并修复运单导入筛选与组织树定位
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 16:20:26 +08:00
b2894lxlx ea797356ce ✨ 完善组织人员增量同步与用户管理交互
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 13:56:29 +08:00
b2894lxlx eaece0209d ✨ 合同结算币种联动计费与运单计价单位,并完善合同与派车交互
结算币种驱动计费单价单位及运单/计划/总单计价单位;同步合同编号预览、部门前缀配置、派车备注与查看弹窗等交互优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 00:53:17 +08:00
weicw-dshsa cd92c36dab ✨ 认证审核详情弹窗增加审核人与审核时间 2026-10-09 20:47:35 +08:00
b2894lxlx a1da644c49 ✨ 优化港口地图回填、合同展示与运单导入空值
地图选点回填国家省市区;合同选择显示编号/名称;常用数据组织筛选修正;运单详情将-1费用字段显示为空。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 17:07:56 +08:00
weicw-dshsa 2500540933 🐛 从业资格证字段改名后审核页兼容旧键,补全封面/内容页与有效期起止
从业资格证信息组原先只列证号、照片、有效期止三项,且照片用的是字段改名前的旧键
qualificationUrl。改名之前提交的记录里仍存旧键,只读新键会得到空值,审核页把已填的
封面页显示成「—」,审核员据此以为对方没传材料。现按 VehicleCertSubmitRequest 的
字段名补全该组,并新增 readFieldValue 统一兼容旧键,新旧键并存时以新键为准。

旧键已并入所属字段展示,knownKeys 同步登记,避免同一张封面页出现两行。
2026-10-09 10:01:19 +08:00
b2894lxlx 570fbeb537 🐛 运单轨迹与实时定位WGS-84转GCJ-02后再绘制高德地图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 19:37:27 +08:00
b2894lxlx d1f804056e ✨ 审核通过后保留流程按钮并跳转MK页面
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:57:49 +08:00
weicw-dshsa 3320155416 ✨ 打卡照片按凭证类型分组,编辑弹窗去掉打卡状态开关
- 打卡记录照片按类型分组展示与上传,每个类型各自最多 9 张,组内第一张标「主图」
- 打卡照片是每个打卡节点必有的类型,不由过程配置决定,与各凭证类型并列且互不替代
- 附件区抽出 punch-record-photos 组件,运单详情与配载单详情共用;编辑态沿用 96px 固定方图
- 弹窗上传体积上限由 5MB 提到 20MB,与小程序一致
- 弹窗不再提供「打卡状态」开关:未打卡的记录点「补卡」进来,保存即补出一条已打卡流水
2026-10-07 16:57:46 +08:00
weicw-dshsa 8ad53894b9 ✨ 运单详情编辑打卡记录支持地图选点定打卡地址
打卡地址改为只读输入框,点击弹出地图选址(复用 address-map-picker,
与空港、港口、维修记录等处同一套),确定后回填地址与经纬度。
地址与经纬度成套更新——只回填地址会让坐标停留在司机打卡时的旧点上。
经纬度输入框保留,仍可手工微调。
2026-10-06 13:55:57 +08:00
weicw-dshsa 360147e46a 🐛 修复编辑打卡记录无附件时附件上传入口消失
打卡照片原先按过程配置的「上传凭证」取舍:配置没勾该开关、记录里又没有照片的
节点,整块照片区连带上传入口都不渲染,管理端再也补不了附件。
过程配置的「上传凭证」是司机端的要求,后端保存打卡记录时不校验它,
管理端补录的角色改为始终展示照片列表与上传入口。
2026-10-06 13:45:04 +08:00
weicw-dshsa b00e12c9bb ✨ 异常上报弹窗附件支持在线预览 2026-10-06 13:04:53 +08:00
weicw-dshsa 7543a8c06b ✨ 打卡时间轴不再为接单节点提供编辑入口
接单节点的完成由运单的接单事实决定,不落打卡流水:补录或取消打卡都改不动它,
放出去只会造出一条与运单事实打架的流水(时间轴显示已打卡、运单却还没人接单)。
后端已拒绝该节点的补录与取消,前端同步隐藏「编辑」链接。

判据与后端一致(key=accept / name=接单),优先取过程配置里的节点、回退记录上的节点标识。
2026-10-06 12:36:37 +08:00
weicw-dshsa 1699ae873f ✨ 运单详情打卡详情支持编辑打卡记录
打卡时间轴每条记录后面加「编辑」文本按钮,弹窗回显当前填写的信息,可改可存;
打卡状态也在可编辑之列。

- 字段按过程配置取舍:打卡定位决定地址/经纬度,上传货量类型决定重量/体积/数量,
  上传凭证决定照片;记录里已有值的项即使配置后来改小也保留,否则历史数据再也改不掉
- 接单/回单是确认节点,过程配置里没有打卡定位开关,不采信其模板默认的 location
- 未打卡保存即清除该节点打卡流水,弹窗有对应提示;在途打卡表只有单张照片字段,
  上传时只留一张
- 入口与运单编辑同权限(waybill_manage_edit),管理员天然可见
2026-10-06 04:41:22 +08:00
weicw-dshsa 462f9bbea2 🐛 修复打卡时间轴异常标签随记录重排错位
时间轴 key 与异常索引共用 `${nodeCode}-${index}`,带上了序号。时间轴按打卡时间
排序,序号随顺序变化,异常标签就会挂到别的节点上或整个消失——编辑打卡时间或把
未打卡改成已打卡都会触发,实测改完「卸货」后它的「已处理异常/详情」直接不见了。

key 改为只认节点标识(nodeCode → id → 兜底序号),异常索引与 v-for 同源,
顺序怎么变都对得上。顺带修好原本就错位的「到场」「装货」:它们的异常标签此前
一直没显示出来。
2026-10-06 04:41:13 +08:00
weicw-dshsa d62d3da60e 🐛 配载管理打卡详情删除在途节点的「在途」标签
该标签与运单管理打卡详情是同一段代码的副本,此前只改了运单管理侧,配电单侧
仍旧渲染,节点名已表明是在途。
2026-10-05 15:25:53 +08:00
weicw-dshsa 50b8abf85c 🐛 修复 pnpm-workspace 中 allowBuilds 的占位值
三个包的 allowBuilds 值是 pnpm 生成的占位文本「set this to true or false」,
不是布尔值,全新 clone 后安装依赖会失败;按实际需要改为 true。
2026-10-05 15:13:39 +08:00
weicw-dshsa 509f5e163c ✨ 打卡详情展示异常状态并新增异常节点展示
运单详情「打卡详情」时间轴按节点关联异常处置:存在未了结异常时显示「异常」标签
与「跟进」链接,全部处理完成时显示「已处理异常」与「详情」链接。异常跟节点走,
节点未打卡也可能有异常。

异常来源改用异常处置表而非打卡行的 exception_flag —— 后者由打卡接口写入且未随
异常上报维护,实测有异常的节点该值仍为 0,原「异常」标签从未显示过。

「已处理」的口径与后端打卡门禁一致(已完成、已撤销都放行),故「处理中」仍算
未处理。

异常节点字段补入各处异常信息:运单详情「异常上报」表格、编辑弹窗、跟进/详情
弹窗,统一放在异常类型之前;表格内空节点显示「-」而不是留白(存量异常无节点)。

过程节点列显示配置中的中文名而非原始 key(库里混存 key 与中文名两种格式)。

删除打卡详情中在途节点的「在途」标签 —— 节点名已表明是在途。
2026-10-05 15:13:34 +08:00
weicw-dshsa 0e853ea751 🐛 关闭内层弹窗时一并复位异常处置弹窗
closeInnerDialogs 逐个复位了页面内其余弹窗,异常处置弹窗当时未列入。
2026-10-05 08:46:25 +08:00
weicw-dshsa d67240bc3b ✨ 运单详情页新增异常上报卡片
在运费信息之后展示该运单关联的异常处置记录,表格沿用异常处置页的操作与权限,
列只保留上报人、异常类型、上报说明、承运商、最新跟进说明、状态。
跟进/编辑/详情弹窗在本页内完成,操作后局部刷新该卡片,不跳转列表页。
2026-10-05 08:43:01 +08:00
weicw-dshsa d0770c8e83 🎨 详情弹窗按运单/异常拆成两块并统一成描述列表
原「异常信息」一块混了运单字段与上报字段,且用的是 dl/dt/dd 自定义栅格。
现拆成「运单信息」与「异常信息」两个 section-card,都用 el-descriptions,
与编辑弹窗的分区口径和视觉一致。

拆出的两个字段配置与编辑弹窗同源同序,只读展示;原 detailFields 的十个字段
一个不少。同时删掉因此不再使用的 __detail-grid 样式与它的响应式断点。

原先这些字段带 link: true,渲染成 el-link 但没有任何点击行为,
看着可点却没反应,一并去掉。
2026-10-05 08:18:02 +08:00
weicw-dshsa cc0b889fef ✨ 异常处置补上司机上报时间
详情/跟进弹窗与编辑弹窗的「异常信息」里都加上上报时间,列表也加一列。
列表原本只有上报时间的筛选条件、没有这一列,筛的是看不到的值。

编辑弹窗里上报人与上报时间同属上报行为,一并放进「异常信息」,
从「运单信息」移出,避免同一个事实显示两遍。
2026-10-05 08:09:20 +08:00
weicw-dshsa 3d27e981e1 🎨 异常跟进修排版并支持上传附件
跟进弹窗按编辑弹窗的排法改造:section-card 分组 + el-row/el-col 栅格,
依次为异常信息、现场照片、跟进、历史跟进。

新增跟进附件上传(v-model 走 vehicle-attachment-upload),
历史跟进表补附件列。跟进说明与附件至少填其一,二者都为空时前端拦下,
与后端校验一致。
2026-10-05 01:09:39 +08:00
weicw-dshsa 2994539de4 ✨ 异常处置新增编辑功能
列表操作列新增「编辑」,弹窗内只呈现运单信息与可编辑项,
按项目既有习惯用 section-card 分组、el-descriptions 展示只读信息、
el-row/el-col 排可编辑字段。

异常类型与异常原因的下拉值改为中文原文:库里 exception_type 存大类中文名、
exception_reason 存子类中文名(小程序上报即如此)。原先一级 value 是英文码
(traffic),而查询按该值做精确匹配,导致按异常类型筛选一行都查不到。

现场照片支持增删,上传走与我方 OSS 端点一致的 put-file 接口。
2026-10-05 00:54:22 +08:00
weicw-dshsa 785ad08b7e 📝 车辆审核字段注释去掉事故经过描述
保留「键名对不上不报错」这一约束,去掉对既往现象的铺陈。
2026-10-04 23:55:28 +08:00
weicw-dshsa 8de51e568d 🔀 合并认证审核页面分支到 master
新增认证审核-司机/车辆页(按字段级 diff 对比待审值与生效值)、
审核列表待审核/已通过/已驳回页签、司机审核补手机号、
车辆表单补所有人与人车辆识别代号,并移除旧认证审核入口。
2026-10-04 23:38:45 +08:00
b2894lxlx d22fe4777d 🐛 过程配置与常用线路组织筛选只传单个deptId
多组织账号默认搜索不再拼接逗号分隔 ID,避免列表接口 Long 转换失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 20:48:03 +08:00
b2894lxlx 7751a735c5 ✨ 业务数量单位改走计量单位,并放开运输计划日期必填
发货模板、运输计划、运单与总单调度从计量单位取 unitName;修复调度提交顶层单位为空,并取消计划开始/结束日期必填。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 20:26:35 +08:00
weicw-dshsa 069d0e92ff 🐛 修车辆审核详情字段元数据的旧键名,止期之外不再被长期有效遮蔽
车辆字段整组停留在改造前的旧键名(licenseNo / licenseValidStart / licenseValidEnd /
licenseLongTerm / roadTransportNo / roadTransportValid* / engineNo),而快照早已改名成
drivingLicense* / roadTransportCert* / registration*。键名对不上不报错——每个字段都算「没有值」,
于是车辆审核详情里行驶证与道路运输证两组全显示「—」,真值全落到未翻译的「其他字段」组里
露着 camelCase 键名,审核员既看不到内容、也 diff 不出变更。

- 按 VehicleCertSubmitRequest 重写车辆四组字段(基本信息 / 行驶证 / 道路运输证 / 机动车登记本),
  补上此前漏登记的所有人、外廓尺寸、核定载质量、准牵引总质量、能源类型、业务关系、机动车登记编号与六张图;
- longTermKey 只挂在被长期有效作废掉的那个字段(有效期止、强制报废日期)上:
  起期是独立日期,挂上去会把真实值显示成「长期有效」,还让审核员看不到起期的改动。
2026-10-03 17:57:01 +08:00
weicw-dshsa ce54d21344 ✨ 新增/编辑车辆表单补「所有人」与「车辆识别代号」
车辆表新增这两列(所有人:行驶证登记的单位名称,与「所属组织」是两个字段;
车辆识别代号:司机端车辆认证一直在收这个值,此前车辆档案里没有对应列,
只存在于认证快照中)。管理端表单同步补上,否则两个入口建出来的车字段不一致。

所有人为必填(行驶证上的登记主体),车辆识别代号选填、填了校验 17 位。
2026-10-03 16:34:59 +08:00
weicw-dshsa 715c0dc00c ✨ 司机审核与车辆审核新增司机手机号,统一按当前姓名取数
两个审核页的表格与搜索栏都加上「司机手机号」,姓名列改绑司机档案的当前值
(原绑提交时的历史姓名)。同时去掉「提交人」列:真实路径下提交人就是司机本人,
两列必然同值,而存量记录本就没有提交人,留着一列空值加同值没有意义。

手机号不脱敏:审核员靠它联系司机核实材料,与司机管理页一致。
2026-10-03 14:36:08 +08:00
weicw-dshsa b8d74de4a4 ✨ 认证审核列表新增待审核/已通过/已驳回页签
审核对象是「一条认证记录」,每次提交产生一条,原列表只能看审核中的,
已通过与已驳回的记录无从查看。

三个页签默认停在待审核;切换页签回到第一页;只有审核中的记录显示「审核」,
终态记录显示「查看」(详情弹窗本就对终态禁用通过/驳回)。
2026-10-02 19:58:17 +08:00
weicw-dshsa 71a6f7c4f8 ♻️ 车辆管理移除旧认证审核入口,认证状态改用统一状态词表
认证审核职责已迁到「认证审核-车辆」页:审核对象是一条认证记录,生效值与
待审值分离后,原来「看车辆行当前值直接通过/驳回」的单条模型语义不再成立。

- vehicle.vue:移除操作列的「审核」入口与认证审核弹窗及其方法、样式,
  同时删除 api/transportCapacity/transport-vehicle.js 的 auditCertification 封装
  (后端旧接口仅保留兼容,前端不再调用)
- 认证状态列与筛选项统一为 审核中 / 已通过 / 已驳回 / 已作废(新增 已作废,
  原先的「认证中/已认证」不再使用),缓存为空时显示「未认证」
- 去掉按证件到期日期推断「已认证 / 已过期」的兜底:是否已认证由生效快照指针
  决定(曾审核通过即永久保持),与证件日期无关;到期信息仍由列表顶部到期标签
  与日期列标色展示
- driver.vue 选择车辆弹窗的车辆认证状态文案一并统一
2026-10-02 17:15:09 +08:00
weicw-dshsa 7c6ac2e5db ✨ 新增认证审核-司机/车辆页,审核详情按字段级 diff 对比待审值与生效值
审核对象由「司机行 / 车辆行」改为「一条认证记录」,故新增两个待审列表页
(挂在运力管理下,path /transportCapacity/cert-audit-driver|vehicle,与页面
文件名一一对应)与审核详情弹窗:

- api/transportCapacity/certification-audit.js:pending-page / detail / audit 三个接口
- utils/cert-audit.js:快照字段元数据(中文名、控件类型、分组)与两侧快照的
  字段级 diff 计算。两侧字段名一致(同一套快照结构),故逐键比较;日期归一化后
  判等、长期有效联动展示、未登记字段兜底进「其他字段」
- components/cert-audit-diff:当前生效值与待审提交值并排展示,差异行高亮并标注
  新增 / 已修改 / 已清空,支持切换只看变更项;从未认证通过(生效快照为空)时
  显示「无对比基准」,不报错
- components/cert-audit-detail:审核详情弹窗(提交信息 + diff + 审核意见),
  通过 / 驳回动作。驳回原因前端先拦一道(必填且不超过 200 字),通过时不提交原因;
  已终态记录不可再审
- components/cert-audit-list + 两个菜单页:分页与按展示键(司机姓名 / 车牌号)筛选,
  审核链接按 cert_certification_audit_{driver,vehicle}_audit 权限点控制

检索过的现有实现:driver.vue / vehicle.vue 的列表与弹窗写法(Avue option + search、
权限点 hasPermission 写法)、image-upload-field、section-card、utils/table-menu.js。
未找到可复用的快照或 diff 实现,故新增 utils/cert-audit.js 纯函数承担比对;
两个列表页除 bizType 与权限点外完全相同,故抽出通用列表组件承接分页/搜索,
避免两页逻辑各自演化。
2026-10-02 17:15:03 +08:00
gxwebsoft 0b81001ce7 refactor(wel): 重构欢迎页为模块化组件
- 将欢迎页整体结构拆分为多个功能区组件,提高代码可维护性
- 移除原有欢迎横幅、指标卡、图表区、下方区及快捷入口的静态实现
- 引入并注册 SectionKpi、SectionStructure、SectionBusiness 等八个子组件
- 删除冗余样式,更新整体背景和布局样式
- 精简模板内容,仅保留子组件渲染及页脚版权信息
- 移除无用的响应式数据、计算属性及方法
- 包含依赖包 echarts 的新增,支持后续图表功能开发
2026-09-30 17:39:42 +08:00
b2894lxlx 50c1afe9c8 ✨ 列表导出补齐创建人创建时间,并修复组织类型提交
多模块列表与导出展示创建人、创建时间;组织类型强制提交数字编码,避免中文 dictKey 反序列化失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 14:22:39 +08:00
b2894lxlx e5a7ad616c 🐛 超管业务列表默认查全部组织,并简化区划编号录入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 21:41:48 +08:00
b2894lxlx 2210e33643 🐛 修复组织类型字典非数字key导致下拉无数据,并调整用户弹窗布局
兼容 organization_type 的中文 dictKey 展示;用户弹窗所属组织与备注宽度按四列布局对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 03:31:56 +08:00
b2894lxlx 1ed524f80f ✨ 组织类型接业务字典,并支持多所属组织与MK再提交
组织管理类型改为 organization_type;用户所属组织支持多选;提交MK前保留原审批状态以识别通过后再提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 03:15:27 +08:00
b2894lxlx 4418836fcd 🐛 审核通过后再提交强制新建MK表单实例
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 02:44:49 +08:00
b2894lxlx 4641d0a5dd ✨ 多业务接入MK审核流,提交改传bizId并由后端生成表单实例
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 02:23:24 +08:00
b2894lxlx 8b9c908237 ⚡ 统一地图选址搜索为高德 place/text 并展示分页候选
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 00:19:17 +08:00
b2894lxlx b5043575e4 🐛 修复常用货物组织筛选,并调整列表字段与常用地址行政区划禁用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 23:39:38 +08:00
b2894lxlx b92625528c ⚡ 优化区划国家入口、计划-1展示、路线地图点选与对账导出
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 23:16:18 +08:00
b2894lxlx f3a33b1a04 ✨ 完善运单导入编辑回填、分批校验与运费录入
导入编辑进入独立页面并回填数据,确认导入按每批100条校验后统一导出失败明细,完整录入运费信息按数量单位联动单价单位。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 22:54:33 +08:00
b2894lxlx 98fbf123f5 🐛 修复车务非必填-1展示,并完善总单调度与运单表单体验
调度页限制上一段到达后才可调度;司机候选项点击展开;车务多模块-1显示为空;同步配载/总单编辑与导入相关优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 21:27:14 +08:00
weicw-dshsa c268d2e64d 🔀 合并 fix/import-error-message-blob
修复导入失败时前端误报「系统错误」,根因有两层:
- axios 的 validateStatus 允许 200-500,HTTP 400 走的是成功分支而不是错误分支;
- 导入请求用 responseType='blob',res.data 是 Blob,读不到 code/msg,
  于是状态码退回 res.status、文案落到「系统错误」兜底。

改法:按 Content-Type 判断,声明为 JSON 的响应体才把 Blob 读成文本解析回
对象;真·文件流(Excel 明细)保持 Blob 原样走下载分支。
另统一 notifiedError() 给 Error 打 _notified 标记,业务层据此避免同一条
错误被弹两次。
2026-09-26 15:32:00 +08:00
b2894lxlx 079be78c2c ✨ 优化配载详情执行打卡与运单切换展示
执行详情按运单切换,补充打卡记录时间线与状态展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 17:45:21 +08:00
b2894lxlx ee7160e393 ✨ 完善客商MK审核提交、地区校验与流程实例校验
提交增加 loading,拆分所在地区/详细地址校验,支持 MK 审核跳转,并在已有流程实例时跳过重复提交。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 17:45:21 +08:00
b2894lxlx aa26beda28 ✨ MK公开页、预结算体验优化、地图选址与认证密码规则完善 2026-09-23 18:36:18 +08:00
b2894lxlx a919781d4f ✨ 完善配载确认、承运商组织过滤司机与地址展示
配载草稿确认生成正式单并优化详情/列表地址市区展示;运单调度等按承运商联系人所属组织筛选司机车辆;统一地图搜索结果与登录默认账号清理。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:35:27 +08:00
b2894lxlx 694fe1a433 🔧 开发代理默认指向本地网关
补充本地网关端口说明,避免误把 Nacos 8080 当作业务网关。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 14:49:09 +08:00
b2894lxlx ae1f2fa09f 💄 调整临时额度弹窗字段宽度与 dialogWidth 配置
统一表单控件宽度,并修正 Avue dialogWidth 数值写法避免重复 px。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 14:49:09 +08:00
b2894lxlx fbfb563254 ✨ 项目管理客户信息增加是否广西百强列
在企业性质右侧展示客商档案的广西百强标识。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 14:49:09 +08:00
b2894lxlx 5352d799af ✨ 运单详情已完成隐藏变更路线并移除路线编辑
已完成运单不再展示变更运输路线入口,变更弹窗去掉拖拽路线编辑区。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 14:49:09 +08:00
b2894lxlx e2e2eb9a2f ✨ 完善合同附件上传回填、类型匹配与列表附件弹窗
修复上传成功后表格不回显,附件类型可切换,未匹配文件名默认其他文件;列表附件上传支持合同文件/其它附件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 14:49:09 +08:00
b2894lxlx 6212d68a77 ✨ 付款申请表单优化与结算单过滤,并完善相关体验
进度预付仅可选预结算、尾款付款可选正式/预结算;同步收款账户、申请金额校验、关闭标签与费用明细残留等交互,并补充运单复制需求说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 00:17:07 +08:00
b2894lxlx ce1ce02551 ✨ 完善变更记录、客商评分与车辆表单
变更记录详情统一为居中分页弹窗,并去掉经办人等信息行。
客商评分支持删除、提交前自评校验、复评后才能审批,以及 0 分提交。
车辆外廓尺寸和载质量单位改到输入框内,认证审核标签按四字换行,使用部门必填并默认当前部门。
临时额度的 -1 占位金额显示为空。新增项目编号可留空由系统生成。
修复缓存总单页在打开新增项目时误请求缺少 id 的详情接口。
详情页状态与运输方式标签样式统一。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 20:13:44 +08:00
b2894lxlx 3a8442e522 Merge remote-tracking branch 'websoft/master' 2026-09-22 12:06:53 +08:00
b2894lxlx ac87208ba9 ✨ MK公开页、预结算体验优化、地图选址与认证密码规则完善 2026-09-22 12:06:48 +08:00
211 changed files with 20875 additions and 6168 deletions
+3 -2
View File
@@ -3,8 +3,9 @@
VITE_APP_ENV = 'development'
#接口地址
# 开发环境建议填 [/api]:由下方 vite.config.mjs 的 proxy 同源转发到后端(172.16.203.228:8000),避免跨域(CORS)被浏览器拦截
# 如需直连线上绝对地址(如 http://172.16.203.228:8000/api),必须让后端 CORS 把 Allow-Origin 改为具体前端域名,不能用 `*`(配合 credentials 会被浏览器拒绝)
# 开发环境建议填 [/api]:由 vite.config.mjs 的 proxy 同源转发到本地网关 http://localhost(80)
# 本地需先启动:Nacos + blade-gateway(80) + blade-auth + blade-system 等;8080 端口一般是 Nacos 控制台不是网关
# 如需临时联调远程,把 vite proxy target 改为 http://172.16.203.228:8000 并去掉 rewrite
VITE_APP_API=/api
#调试参数
+11
View File
@@ -0,0 +1,11 @@
# 2026-09-30
## /wel/index 首页重构为运营数据看板(按蓝湖效果图)
- 新增依赖:echarts ^6.1.0(corepack pnpm add echarts)。
- 重构 `src/views/wel/index.vue`:改为「核心经营指标 / 运输结构 / 业务单元情况 / 项目情况 / 财务情况 / 车队与线路 / 车务管理 / 底部占比与趋势」8 大板块 + 版权页脚,删除原欢迎横幅/快捷入口。
- 新增文件:
- `src/views/wel/chart-options.js`:排行条形/盈亏双向/环形/分组柱/面积折线/堆叠柱 6 类 option 生成器(fmt 千分位、红涨绿跌 rich label)。
- `src/views/wel/dashboard.scss`:看板公共样式,各组件 scoped `@import '../dashboard.scss'`(子目录注意路径)。
- `src/views/wel/components/`:wel-chart.vue(ECharts 封装:ResizeObserver + activated resize + dispose)、wel-filters.vue(统计时间/条件筛选 mock)、rank-panel.vue(排行/盈亏通用面板)、section-kpi / section-structure / section-business / section-project / section-finance / section-fleet / section-vehicle / section-summary.vue。
- 数据全部为 Mock,组件内已注释标注对接点;蓝湖红字标注的指标口径未定,待后端统计接口(估 12~18 个)确定后替换。
- 自检:dev server(2889) 下所有新文件及 `?vue&type=style&...&lang.scss` 均 200;`/node_modules/.vite/deps/echarts.js` 200。vite build 因 facelogin.vue 沙箱中断不可用,未跑。
+18
View File
@@ -0,0 +1,18 @@
# 2026-10-13
## 车辆调度弹窗:使用部门级联选择器宽度对齐
- 现象:`/transportCapacity/vehicle-dispatch` 新增/编辑弹窗中,使用部门 `el-cascader` 明显窄于同行车牌号 `el-input`。
- 根因:`el-cascader` 是 `display: inline-block` 宽度按内容收缩(不像 `el-autocomplete` / `el-input` 有 `--el-input-width: 100%` 兜底);而页面里写的 `.vehicle-dispatch-page :deep(.vehicle-dispatch-form-control){width:100%}` 命中不到 —— avue-crud 弹窗默认 `append-to-body: true`(`append-to-body": validData(crud.tableOption.dialogAppendToBody, true)`),DOM Teleport 到 body,已不在 `.vehicle-dispatch-page` 之下。
- 修法(走项目既有约定):
1. `src/option/transportCapacity/vehicle-dispatch.js` 增加 `dialogCustomClass: 'vehicle-dispatch-dialog'`;
2. `src/styles/element-ui.scss` 增加全局规则 `.vehicle-dispatch-dialog .el-form-item__content > .el-cascader { width:100%; max-width:100% }`。
- 自检:dev(2889) `curl /src/styles/element-ui.scss` 与 `/src/option/transportCapacity/vehicle-dispatch.js` 均 200,编译产物含新规则。
- 遗留观察:同文件里 `.vehicle-dispatch-page :deep(.avue--detail ...)` 那一批查看态禁用样式,同样因 Teleport 属死规则(未改,待确认是否需要迁到 `.vehicle-dispatch-dialog` 作用域)。
## 追加修正(同日 20:xx):第一次改完用户反馈「还是没对齐」
- 原因一:选择器写成了 `.el-form-item__content > .el-cascader`(直接子级)。**avue 表单项在 `el-form-item__content` 与控件之间还有两层包裹 div**(`packages/element-plus/form/index3.js`:content > div > [div|elTooltip] > 控件),必须用后代选择器。已改成 `.vehicle-dispatch-dialog .el-form-item__content .el-cascader`。
- 原因二(更隐蔽):option 用 `labelWidth: 'auto'`,**EP 的 auto 是逐项计算**(实测:3 字 54/64px、2 字 40px、4 字无星号 68px、4 字带星号 78px),label 越宽控件可用宽度越小 → 车牌号 input 486px、使用部门 cascader 472px,仍差 14px。已加 `.vehicle-dispatch-dialog .el-form-item__label { flex: 0 0 calc(4em + 24px) !important; width: calc(4em + 24px) !important }`(本弹窗左右两列最长 label 均为 4 汉字)。
- 验证方式(可复用):临时在项目根建 `cascader-width-check.html`,Vite 直接服务 → `import './src/option/...'` 用真实 option + `import './src/styles/element-ui.scss'`,挂 avue-crud 并 `mounted(){ this.$refs.crud.rowAdd() }` 弹出真实弹窗;用 agent-browser(`/Users/gxwebsoft/.workbuddy/binaries/node/workspace/node_modules/.bin/agent-browser`)`open` + `eval` 读 DOM 链路与 `getBoundingClientRect`。修完已删除该临时文件。
- 结果:全部 label 80px,车牌号 input 与使用部门 cascader 均为 left=732 / width=470 / right=1202,完全一致。
+44
View File
@@ -12,8 +12,46 @@
**坑二:宿主 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。
**坑四:avue-crud 弹窗 `append-to-body` 默认 true(`dialog-form3.js`:`validData(tableOption.dialogAppendToBody, true)`),DOM Teleport 到 body。** 于是页面里以页面容器为祖先的 scoped 选择器(`.xxx-page :deep(.yyy)`)**命中不到弹窗内容**,尤其 `el-cascader`(inline-block、宽度按内容收缩,不像 `el-input`/`el-autocomplete` 有 `--el-input-width:100%` 兜底)会明显比同行 input 窄。修法:option 里配 `dialogCustomClass: 'xxx-dialog'`,规则写到 `src/styles/element-ui.scss` 的 `.xxx-dialog ...`(项目既有约定,10+ 处同款);或把基准类放在 **Teleport 内部元素**上(如 ship.vue 的 `.ship-form` 挂在 el-form 上,自身带 scope 属性,照样生效)。
**坑五:avue 表单项控件不是 `.el-form-item__content` 的直接子级。** `form/index3.js` 渲染为 `content > div > [div|elTooltip] > 控件`,所以 `.el-form-item__content > .el-cascader` 这类**直接子级选择器对 avue 表单全部失效**(手写 `<el-form-item>` 才生效),一律用后代选择器。
**坑六:`labelWidth: 'auto'` 在 Element Plus 里逐项计算,不是全表单共享。** 实测同一弹窗:3 字 54/64px、2 字 40px、4 字 68px、4 字带必填星号 78px;label 越宽 → 控件可用宽度越小,同级控件宽度就不一致(车牌号 input 486 vs 使用部门 cascader 472)。要让同一行控件等宽必须统一 label 宽度:`.xxx-dialog .el-form-item__label { flex: 0 0 calc(Nem + 24px) !important; width: calc(Nem + 24px) !important }`(N=本列最长汉字数,与 ship.vue、`.dialog-form--carded` 同款)。
**验证 avue 弹窗 DOM/宽度的可复用办法:** 项目根建临时 html(Vite 直接服务)→ 引真实 option 与 `src/styles/element-ui.scss`,挂 avue-crud 并 `mounted(){ this.$refs.crud.rowAdd() }` 弹出真弹窗;用 `agent-browser`(`/Users/gxwebsoft/.workbuddy/binaries/node/workspace/node_modules/.bin/agent-browser`)`open` + `eval` 读 DOM 链路与 `getBoundingClientRect`,用完删文件。
**坑三:路由表重复定义 → 内容区整页空白(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」重复项。
## 列表页右上角按钮组(table-header-tools 标准件)
⚠️ **2026-10-14 修正:最终方向是手写列表页改用 `avue-crud`**。用户要求与其它模块"完全一致",而 avue 的列显隐抽屉(`el-drawer`+表格,隐藏/冻结/过滤/排序,行拖拽)是 avue-crud 内部子组件(`inject: ['crud']`),脱离 avue-crud 拿不出来;第 4 键也是 avue 的"表格↔卡片视图"切换。所以 `table-header-tools` 只是过渡仿制件,**新页面不要再接**,直接写 avue-crud。
手写 `el-table` 列表页若暂时保留,右上角统一用 `src/components/table-header-tools/main.vue`,复刻 avue-crud 默认 4 键:刷新 `el-icon-refresh` / 列设置 `el-icon-operation` / 搜索显隐 `el-icon-search` / 表格尺寸 `el-icon-grid`(default→large→small 循环)。
- 接入范式:`<table-header-tools :columns="columns" v-model:visible-column-keys="visibleColumnKeys" v-model:table-size="tableSize" v-model:search-visible="searchVisible" @refresh="loadTable" />`;配套:搜索区 `v-if="searchVisible"`、el-table `:size="tableSize"`、列用 computed `visibleColumns = columns.filter(c => visibleColumnKeys.includes(c.prop))`。⚠️ 不能 `v-for` + `v-if` 同元素(Vue3 v-if 优先级更高,取不到 column 变量)。
- 表格在子组件时(如 `payment/components/bill-*-table.vue`):子组件加 props `searchVisible` + emits `refresh`/`update:searchVisible`,父页面 `v-model:search-visible` + `@refresh`,搜索子组件加 `v-if="searchVisible"`。
- `icon="el-icon-xxx"` 字符串**依赖 Avue 的全局 el-icon 映射**(`main.js` 只注册 EP 的 PascalCase 图标),临时验证页没 `app.use(Avue)` 会渲染成空圆按钮 —— 别误判成组件 bug。
- 组件已加 `deactivated()` 关列设置弹窗(防 keep-alive 下 Teleport 弹窗残留)。
- 已接入(过渡态,待逐页改 avue):business/{master-order,loading-manage,voucher-manage}、settlement、payment 模块手写页。
## avue-crud 列表页改造要点(payment 模块样板,2026-10-14)
- **列显隐抽屉**:点第 2 键弹出,标题"列显隐"(语言包 `crud.showTitle`),表头 列名/隐藏/冻结/过滤/排序。直接读写 avue crud 的列配置,页面无需实现。
- **第 4 键**:`handleGridShow()` = 表格 ↔ 卡片视图(`tableCard` 按列通用渲染),页面无需写卡片模板。
- `searchOrder` 是**降序**排序(值越大越靠前):`list.sort((a,b) => (b.searchOrder||0) - (a.searchOrder||0))`。首次改造踩过坑。
- `searchIndex: 4` + `searchIcon: true` → 默认只显示前 4 个搜索项 + "展开/收起"(columnLen > searchIndex 时才出现该按钮)。
- **搜索项 label 与表格列 label 不同**(列「收款方」/搜索「客户名称」):单独定义 hide 列 + `searchProp` 指向后端字段(avue 在 search-change 里自动改名)。
- 搜索专用列加 `hide: true` + `showColumn: false`,否则会混进列显隐抽屉。
- ⚠️ **column 数组顺序 = 表格列顺序**:搜索专用列别插在表格列中间(会把表格列顶到前面);deptName 这种"既搜索又展示"的列要放回表格列的正确位置。
- 分页:`page: { currentPage, pageSize, pageSizes, total }`;`on-load` 由 avue 内部(table-page)在 created / 翻页 / 改页容量时触发,`current-change`/`size-change` 里**只改 page 不要再调 onLoad**(否则双请求)。
- `onLoad(page)` 只用 `this.query`(avue 传的 page 对象含 total/pageSizes 等分页字段,别 spread 进查询参数)。
- 搜索参数转换放 `searchChange(params, done)`:日期区间数组拆成 `xxxStartDate/xxxEndDate`;所属组织级联用 `normalizeOrganizationSearch(params, 'deptName')`(见 `mixins/organization-search.js`,option 里该列加 `searchslot: true`)。
- 单元格空值统一 `-`:列级 `formatter: (row, value) => ...`;金额 `Number(v||0).toFixed(2)`;状态/链接用 `slot: true` + `#prop` 插槽。
- 操作列用 `#menu` 插槽(`menuWidth: 320`),`addBtn/viewBtn/delBtn/editBtn: false` 关掉 avue 自带按钮。
## 地址/行政区划展示(全站既有规则)
「省/市/区县」都要空格分段,但**省与自治区不重复展示**:`上海市浦东新区`→`上海市 浦东新区`,`广西壮族自治区南宁市邕宁区`→`南宁市 邕宁区`(同 `formatRoadLocation` / `formatRouteCityDistrict` / 总单列表 `formatRoadAddress` 一致)。
- 统一入口:`src/utils/region-address.js` 的 `formatRegionAddress(value)`(按后缀切分行政区划,`州+市`、`县+区` 连写合并;**只有整串都是行政区划**才重排,站点名「南宁站/广州南站」与含门牌的详细地址原样返回)。v-for 列表里用它做展示时,直接把函数挂进 `methods` 用 ES6 简写(`formatRegionAddress,`)。
- ⚠️ 路线节点的名称字段来自后端 `routeJson`:`routes[i].departureName` = 第 i 段**到货地/途经地**(不是本段发货地),末段 = 最终收货地;`master.departureName/arrivalName` 才是总发货/总收货。总单调度/详情都按「上一个节点的 departureName」拼分段起点。
## 样式硬规则
- 底栏 `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`(禁绿、禁两个蓝实心)。顺序 `[辅助][取消][保存草稿][提交]`。
@@ -25,3 +63,9 @@
- `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`)。
## Element Plus 图标两种用法的机制差异(易误判)
`main.js` 里 `for (...ElementPlusIconsVue) app.component(key, component)` 全局注册了全部图标,因此模板里可以写标签形式 `<Location />`(靠全局注册解析)。
但 `:icon="OfficeBuilding"` 是**表达式**,编译成 `_ctx.OfficeBuilding`,只会从 data / props / computed / setup 返回值里取 —— **组件对象必须放进 `data()`**;放进 `components: {}` 注册里模板取不到,会静默渲染不出图标(不报错,只是空白)。
项目既有惯例:需要 `:icon` 的图标一律 `import` 后在 `data()` 里 `return { Xxx }`(transport-plan-page、waybill-manage-page、business-crud-page 都是这么写的)。
注:标签形式(如 `<Location />`)不依赖 data,放不放 data 都能显示;仅 `:icon` 必须放 data。
+1
View File
@@ -23,6 +23,7 @@
"crypto-js": "^4.1.1",
"dayjs": "^1.10.6",
"disable-devtool": "^0.3.8",
"echarts": "^6.1.0",
"element-plus": "^2.14.2",
"exceljs": "^4.4.0",
"highlight.js": "^11.9.0",
+18 -44
View File
@@ -4,11 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
'@smallwei/avue':
hash: 5ba15ad41b2c301250bf24adf39da2ed526b8375143da2c5a495361b29e862db
path: patches/@smallwei__avue.patch
importers:
.:
@@ -33,7 +28,7 @@ importers:
version: 1.7.0
'@smallwei/avue':
specifier: ~3.9.2
version: 3.9.2(patch_hash=5ba15ad41b2c301250bf24adf39da2ed526b8375143da2c5a495361b29e862db)(@element-plus/icons-vue@2.3.2(vue@3.5.34))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)
version: 3.9.2(@element-plus/icons-vue@2.3.2(vue@3.5.34))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)
animate.css:
specifier: ^4.1.1
version: 4.1.1
@@ -55,6 +50,9 @@ importers:
disable-devtool:
specifier: ^0.3.8
version: 0.3.9
echarts:
specifier: ^6.1.0
version: 6.1.0
element-plus:
specifier: ^2.14.2
version: 2.14.2(vue@3.5.34)
@@ -63,13 +61,13 @@ importers:
version: 4.4.0
highlight.js:
specifier: ^11.9.0
version: 11.11.1
version: 11.12.0
js-base64:
specifier: ^3.7.4
version: 3.7.8
version: 3.9.4
js-cookie:
specifier: ^3.0.0
version: 3.0.5
version: 3.0.8
js-md5:
specifier: ^0.7.3
version: 0.7.3
@@ -406,35 +404,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@1.0.2':
resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==}
@@ -510,42 +503,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -612,79 +599,66 @@ packages:
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.3':
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.3':
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.3':
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.3':
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.3':
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.3':
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.3':
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.3':
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.3':
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.3':
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
@@ -1208,6 +1182,7 @@ packages:
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1453,8 +1428,8 @@ packages:
heic2any@0.0.4:
resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
highlight.js@11.12.0:
resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==}
engines: {node: '>=12.0.0'}
hls.js@1.6.16:
@@ -1532,12 +1507,11 @@ packages:
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
js-base64@3.9.4:
resolution: {integrity: sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==}
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-cookie@3.0.8:
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
js-md5@0.7.3:
resolution: {integrity: sha512-ZC41vPSTLKGwIRjqDh8DfXoCrdQIyBgspJVPXHBGu4nZlAEvG3nf+jO9avM9RmLiGakg7vz974ms99nEV0tmTQ==}
@@ -2653,7 +2627,7 @@ snapshots:
countup.js: 2.10.0
signature_pad: 5.1.3
'@smallwei/avue@3.9.2(patch_hash=5ba15ad41b2c301250bf24adf39da2ed526b8375143da2c5a495361b29e862db)(@element-plus/icons-vue@2.3.2(vue@3.5.34))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)':
'@smallwei/avue@3.9.2(@element-plus/icons-vue@2.3.2(vue@3.5.34))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.34)
axios: 0.21.4
@@ -3546,7 +3520,7 @@ snapshots:
heic2any@0.0.4: {}
highlight.js@11.11.1: {}
highlight.js@11.12.0: {}
hls.js@1.6.16: {}
@@ -3605,9 +3579,9 @@ snapshots:
isarray@1.0.0: {}
js-base64@3.7.8: {}
js-base64@3.9.4: {}
js-cookie@3.0.5: {}
js-cookie@3.0.8: {}
js-md5@0.7.3: {}
+5
View File
@@ -1,5 +1,10 @@
packages:
- '.'
allowBuilds:
'@parcel/watcher': true
es5-ext: true
esbuild: true
patchedDependencies:
'@smallwei/avue': patches/@smallwei__avue.patch
+8
View File
@@ -90,3 +90,11 @@ export const expireStats = params =>
method: 'get',
params,
});
/** 按合同负责部门预览下一合同编号 */
export const nextContractNo = organizationId =>
request({
url: `${baseUrl}/next-contract-no`,
method: 'get',
params: { organizationId },
});
+12
View File
@@ -22,6 +22,18 @@ export const getVoucherImages = (waybillId, params = {}) =>
...params,
},
});
export const getVoucherFolders = waybillId =>
request({
url: '/blade-transport/process-config/voucher-folders',
method: 'get',
params: { waybillId },
});
export const getVoucherFolderDetail = (voucherId, plateNo) =>
request({
url: '/blade-transport/process-config/voucher-folder-detail',
method: 'get',
params: { voucherId, plateNo },
});
export const submit = api.submit;
export const remove = api.remove;
export const copy = api.copy;
+11
View File
@@ -52,6 +52,17 @@ export const createFileTask = data =>
request({ url: `${fileTaskBaseUrl}/createFileTask`, method: 'post', data });
export const getPartUploadUrl = (id, partNumber) =>
request({ url: `${fileTaskBaseUrl}/getUploadUrl`, method: 'get', params: { id, partNumber } });
export const uploadPartByProxy = (id, partNumber, partBlob) => {
const formData = new FormData();
formData.append('file', partBlob, `part-${partNumber}.bin`);
return request({
url: `${fileTaskBaseUrl}/uploadPart`,
method: 'post',
params: { id, partNumber },
data: formData,
timeout: 600000,
});
};
export const updateFileTask = data =>
request({ url: `${fileTaskBaseUrl}/updateFileTask`, method: 'post', data });
export const pauseFileTask = ids =>
+21 -3
View File
@@ -34,6 +34,14 @@ export const getPunchRecords = waybillId =>
params: { waybillId },
});
/** 编辑打卡记录(含打卡状态;punched=false 表示取消打卡) */
export const savePunchRecord = data =>
request({
url: `${baseUrl}/punch-record/save`,
method: 'post',
data,
});
/** 运单车辆实时定位 */
export const locateVehicle = id =>
request({
@@ -67,6 +75,16 @@ export const getImportDetails = params => request({ url: `${baseUrl}/import-batc
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 });
export const saveImportDraft = data => request({ url: `${baseUrl}/import-batch/draft`, method: 'post', data, timeout: 120000 });
export const validateImport = data =>
request({ url: `${baseUrl}/import-batch/validate`, method: 'post', data, timeout: 120000 });
export const exportImportFailures = data =>
request({
url: `${baseUrl}/import-batch/export-failures`,
method: 'post',
data,
responseType: 'blob',
timeout: 120000,
});
export const confirmImport = data =>
request({ url: `${baseUrl}/import-batch/confirm`, method: 'post', data, timeout: 120000 });
+1 -1
View File
@@ -24,7 +24,7 @@ export const syncKingdeeBatch = ids =>
export const paymentTypeOptions = [
{ label: '项目预付', value: 'project_advance' },
{ label: '进度预付', value: 'progress_advance' },
{ label: '结算付款', value: 'settlement_payment' },
{ label: '尾款付款', value: 'settlement_payment' },
];
export const approvalStatusOptions = [
{ label: '草稿', value: 'draft' },
+30 -2
View File
@@ -6,8 +6,9 @@ import request from '@/axios';
* @param {string} data.templateCode 模板编码
* @param {string} data.submitIdentity 提交人(手机号)
* @param {string} data.loginName 登录账号(手机号)
* @param {string} data.formInstanceId 业务表单实例 id
* @param {string} data.bizId 业务主键(后端生成/复用 formInstanceId)
* @param {string} [data.subject] 流程标题
* @param {string} [data.bizType] 业务类型
*/
export const processSubmit = data => {
return request({
@@ -20,7 +21,7 @@ export const processSubmit = data => {
/**
* 调用 MK processDelete 删除审核流(驳回后重新提交前使用)
* @param {Object} data
* @param {string} data.formInstanceId 业务表单实例 id
* @param {string} data.bizId 业务主键
*/
export const processDelete = data => {
return request({
@@ -29,3 +30,30 @@ export const processDelete = data => {
data,
});
};
/**
* 查询 MK 流程实例是否仍存在(有数据则无需删除/重新提交)
* @param {Object} params
* @param {string} params.bizId 业务主键
*/
export const hasMkProcessInstance = params => {
return request({
url: '/blade-system/businessProcess/hasMkProcessInstance',
method: 'get',
params,
});
};
/**
* 获取 MK 审批页跳转链接(后端按 thirdParty.mk 配置拼接,含 SSO)
* @param {Object} params
* @param {string} [params.bizId] 业务 id(如客商 id)
* @param {string} [params.processInstanceId] 流程实例 id
*/
export const getMKApprovalUrl = params => {
return request({
url: '/blade-system/businessProcess/getMKApprovalUrl',
method: 'get',
params,
});
};
+21 -2
View File
@@ -48,26 +48,45 @@ export const syncIamOrganizations = () => {
});
};
export const syncOaCompany = (current = 1, size = 20, signal) => {
export const clearNonTopDept = signal => {
return request({
url: '/blade-system/dept/clear-non-top',
method: 'post',
timeout: 60000,
signal,
});
};
export const syncOaCompany = (current = 1, size = 20, signal, incremental = false, modifiedSince) => {
return request({
url: '/blade-system/dept/sync-oa-company',
method: 'post',
params: {
current,
size,
incremental: !!incremental,
modifiedSince: modifiedSince || undefined,
},
timeout: 60000,
signal,
});
};
export const syncOaDepartment = (current = 1, size = 20, signal) => {
export const syncOaDepartment = (
current = 1,
size = 20,
signal,
incremental = false,
modifiedSince
) => {
return request({
url: '/blade-system/dept/sync-oa-department',
method: 'post',
params: {
current,
size,
incremental: !!incremental,
modifiedSince: modifiedSince || undefined,
},
timeout: 60000,
signal,
+62
View File
@@ -0,0 +1,62 @@
import request from '@/axios';
export const getList = (current, size, params) => {
return request({
url: '/blade-system/mp-user/list',
method: 'get',
params: {
...params,
current,
size,
},
});
};
export const getDetail = id => {
return request({
url: '/blade-system/mp-user/detail',
method: 'get',
params: {
id,
},
});
};
export const submit = row => {
return request({
url: '/blade-system/mp-user/submit',
method: 'post',
data: row,
});
};
export const remove = ids => {
return request({
url: '/blade-system/mp-user/remove',
method: 'post',
params: {
ids,
},
});
};
export const changeStatus = (id, status) => {
return request({
url: '/blade-system/mp-user/change-status',
method: 'post',
params: {
id,
status,
},
});
};
export const resetPassword = userIds => {
return request({
url: '/blade-system/mp-user/reset-password',
method: 'post',
params: {
userIds,
},
});
};
+14 -1
View File
@@ -31,13 +31,15 @@ export const add = row => {
});
};
export const syncIamAccounts = (current = 1, size = 50, signal) => {
export const syncIamAccounts = (current = 1, size = 50, signal, incremental = false, modifiedSince) => {
return request({
url: '/blade-system/user/sync-iam-accounts',
method: 'post',
params: {
current,
size,
incremental: !!incremental,
modifiedSince: modifiedSince || undefined,
},
timeout: 60000,
signal,
@@ -52,6 +54,17 @@ export const update = row => {
});
};
export const changeStatus = (id, status) => {
return request({
url: '/blade-system/user/change-status',
method: 'post',
params: {
id,
status,
},
});
};
export const updatePlatform = (userId, userType, userExt) => {
return request({
url: '/blade-system/user/update-platform',
+2
View File
@@ -9,6 +9,8 @@ export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get'
export const follow = data => request({ url: `${baseUrl}/follow`, method: 'post', data });
export const update = data => request({ url: `${baseUrl}/update`, method: 'post', data });
export const complete = data => request({ url: `${baseUrl}/complete`, method: 'post', data });
export const batchComplete = ids =>
@@ -0,0 +1,49 @@
import request from '@/axios';
/**
* 认证记录列表:按审核状态分页,按提交时间倒序
* 审核对象是**一条认证记录**,不是一辆车或一名司机,故必须带 bizType 区分
* status 为审核状态(0审核中 1已通过 2已驳回 3已作废);不传时后端按 0 处理
*/
export const getPendingPage = (bizType, current, size, params) => {
return request({
url: '/blade-transport/certification-audit/pending-page',
method: 'get',
params: {
...params,
bizType,
current,
size,
},
});
};
/**
* 审核详情:同时返回本次待审快照与该对象当前生效快照,供前端做字段级对比
* 从未认证通过时 effectiveSnapshotJson 为空串
*/
export const getAuditDetail = id => {
return request({
url: '/blade-transport/certification-audit/detail',
method: 'get',
params: {
id,
},
});
};
/**
* 审核一条认证记录
* 通过时不传驳回原因;驳回时必须传,且原因不超过 200 字
*/
export const auditCertification = (id, pass, rejectReason) => {
return request({
url: '/blade-transport/certification-audit/audit',
method: 'post',
params: {
id,
pass,
...(rejectReason ? { rejectReason } : {}),
},
});
};
@@ -51,14 +51,6 @@ export const changeStatus = (id, status) => {
});
};
export const auditCertification = (id, certificationStatus, rejectReason) => {
return request({
url: '/blade-transport/transport-vehicle/audit-certification',
method: 'post',
params: { id, certificationStatus, rejectReason },
});
};
export const getExpiryStat = params => {
return request({
url: '/blade-transport/transport-vehicle/expiry-stat',
+8 -1
View File
@@ -189,7 +189,14 @@ axios.interceptors.response.use(
// 兼容 Blob(浏览器 responseType='blob')与 string(非浏览器环境)两种形态。
if (isJsonContentType(res) && !isPlainObject(res.data)) {
try {
const text = res.data instanceof Blob ? await res.data.text() : res.data;
let text = '';
if (typeof res.data?.text === 'function') {
text = await res.data.text();
} else if (typeof res.data === 'string') {
text = res.data;
} else if (typeof res.data?.arrayBuffer === 'function') {
text = new TextDecoder().decode(await res.data.arrayBuffer());
}
const parsed = typeof text === 'string' && text ? JSON.parse(text) : null;
if (parsed && typeof parsed === 'object') {
res.data = parsed;
+155 -32
View File
@@ -10,17 +10,17 @@
<el-button type="primary" :loading="searching" @click="searchKeyword">搜索</el-button>
</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>
<map-search-results :results="searchResults" @select="selectSearchResult" />
</div>
<div class="address-map-picker__info">{{ status }}</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selected.longitude || resolving" @click="confirm">
<el-button
type="primary"
:disabled="!hasCoordinateValue(selected.longitude) || !hasCoordinateValue(selected.latitude) || resolving"
@click="confirm"
>
确定
</el-button>
</template>
@@ -28,8 +28,10 @@
</template>
<script>
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
const AMAP_KEY = '13dcb15bc29bfe9c432d17ecd808b812';
const AMAP_SECURITY_CODE = 'f9d036ef29e376e15d309e0de5c0f7ab';
let amapLoader;
export default {
@@ -42,6 +44,14 @@ export default {
type: String,
default: '',
},
longitude: {
type: [String, Number],
default: '',
},
latitude: {
type: [String, Number],
default: '',
},
},
emits: ['update:modelValue', 'confirm'],
data() {
@@ -66,9 +76,11 @@ export default {
this.visible = value;
if (value) {
this.keyword = this.address || '';
this.selected = {};
this.status = '可搜索地址或点击地图选点';
this.searchResults = [];
this.selected = this.buildInitialSelection();
this.status = this.selected.longitude
? this.selected.address || '已选点,可确认回填'
: '可搜索地址或点击地图选点';
}
},
},
@@ -82,6 +94,32 @@ export default {
}
},
methods: {
hasCoordinateValue(value) {
return value !== '' && value !== null && value !== undefined;
},
buildInitialSelection() {
// Number('') === 0,空字符串不能当有效坐标,否则会落到 [0,0](非洲附近)
if (!this.hasCoordinateValue(this.longitude) || !this.hasCoordinateValue(this.latitude)) {
return {};
}
const longitude = Number(this.longitude);
const latitude = Number(this.latitude);
if (
!Number.isFinite(longitude) ||
!Number.isFinite(latitude) ||
longitude < -180 ||
longitude > 180 ||
latitude < -90 ||
latitude > 90
) {
return {};
}
return {
longitude,
latitude,
address: this.address || '',
};
},
loadAmap() {
if (window.AMap && window.AMap.Map) {
return Promise.resolve();
@@ -110,15 +148,29 @@ export default {
return new Promise(resolve => {
this.$nextTick(() => {
if (!this.$refs.map) return resolve(null);
const initial = this.buildInitialSelection();
const hasPoint =
Number.isFinite(initial.longitude) && Number.isFinite(initial.latitude);
const center = hasPoint
? [initial.longitude, initial.latitude]
: [116.40769, 39.89945];
const zoom = hasPoint ? 14 : 11;
if (!this.amap) {
this.amap = new window.AMap.Map(this.$refs.map, {
center: [116.40769, 39.89945],
zoom: 11,
center,
zoom,
});
this.amap.on('click', event => this.pickPoint(event.lnglat));
} else {
this.amap.resize();
this.clearMarker();
this.amap.setZoomAndCenter(zoom, center);
}
if (hasPoint) {
const point = new window.AMap.LngLat(initial.longitude, initial.latitude);
this.renderMarker(point);
this.selected = { ...initial };
this.status = initial.address || '已选点,可确认回填';
}
resolve(this.amap);
});
@@ -176,23 +228,17 @@ export default {
this.searchResults = [];
this.searching = true;
this.initMap()
.then(map => (map ? this.runGeocode('location', keyword) : null))
.then(result => {
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) {
.then(map => (map ? searchAmapPlaceText(keyword) : null))
.then(pois => {
if (!pois) return;
this.searchResults = normalizeMapSearchResults(pois, keyword);
const first = this.searchResults[0];
if (!first?.location) {
throw new Error('未找到匹配地址');
}
return this.$nextTick().then(() => {
this.amap?.resize();
this.pickPoint(point, geocodes[0]?.formattedAddress || keyword);
this.pickPoint(first.location, first.address || first.name || keyword);
});
})
.catch(() => {
@@ -209,8 +255,10 @@ export default {
}
},
pickPoint(point, fallbackAddress = '') {
const longitude = typeof point.getLng === 'function' ? point.getLng() : point.lng;
const latitude = typeof point.getLat === 'function' ? point.getLat() : point.lat;
const longitude =
typeof point.getLng === 'function' ? point.getLng() : point.lng ?? point.longitude;
const latitude =
typeof point.getLat === 'function' ? point.getLat() : point.lat ?? point.latitude;
if (!Number.isFinite(Number(longitude)) || !Number.isFinite(Number(latitude))) {
this.$message.warning('选点坐标无效');
return;
@@ -227,7 +275,12 @@ export default {
this.runGeocode('address', lnglat)
.then(result => {
const detailAddress = result.regeocode?.formattedAddress || fallbackAddress;
this.selected = { ...this.selected, address: detailAddress };
const region = this.resolveRegionFromGeocode(result);
this.selected = {
...this.selected,
address: detailAddress,
...region,
};
this.keyword = detailAddress || this.keyword;
this.status = detailAddress || '已选点,可确认回填';
})
@@ -241,6 +294,26 @@ export default {
this.resolving = false;
});
},
resolveRegionFromGeocode(result = {}) {
const component = result.regeocode?.addressComponent || {};
const city = Array.isArray(component.city) ? '' : String(component.city || '').trim();
const province = String(component.province || '').trim();
const district = String(component.district || '').trim();
const country = String(component.country || '').trim();
const adcode = String(component.adcode || '').trim();
return {
country,
province,
city: city || province,
district,
adcode,
regionCode: adcode,
provinceName: province,
cityName: city || province,
districtName: district,
countryName: country,
};
},
renderMarker(point) {
this.clearMarker();
this.marker = new window.AMap.Marker({ position: point });
@@ -253,12 +326,53 @@ export default {
this.marker = null;
}
},
syncSelectedFromMarker() {
// 确认时以当前标记点坐标为准,避免换地址后仍回填上一次经纬度
if (!this.marker || typeof this.marker.getPosition !== 'function') {
return;
}
const point = this.marker.getPosition();
if (!point) {
return;
}
const longitude =
typeof point.getLng === 'function' ? point.getLng() : point.lng ?? point.longitude;
const latitude =
typeof point.getLat === 'function' ? point.getLat() : point.lat ?? point.latitude;
if (!Number.isFinite(Number(longitude)) || !Number.isFinite(Number(latitude))) {
return;
}
this.selected = {
...this.selected,
longitude: Number(longitude),
latitude: Number(latitude),
};
},
confirm() {
this.syncSelectedFromMarker();
if (!this.hasCoordinateValue(this.selected.longitude) || !this.hasCoordinateValue(this.selected.latitude)) {
this.$message.warning('请先搜索或点击地图完成选点');
return;
}
if (!this.selected.address) {
this.$message.warning('请等待地址解析完成后再确认');
return;
}
this.$emit('confirm', this.selected.address);
this.$emit('confirm', {
address: this.selected.address,
longitude: Number(this.selected.longitude),
latitude: Number(this.selected.latitude),
country: this.selected.country || this.selected.countryName || '',
province: this.selected.province || this.selected.provinceName || '',
city: this.selected.city || this.selected.cityName || '',
district: this.selected.district || this.selected.districtName || '',
adcode: this.selected.adcode || this.selected.regionCode || '',
regionCode: this.selected.regionCode || this.selected.adcode || '',
countryName: this.selected.countryName || this.selected.country || '',
provinceName: this.selected.provinceName || this.selected.province || '',
cityName: this.selected.cityName || this.selected.city || '',
districtName: this.selected.districtName || this.selected.district || '',
});
this.visible = false;
},
},
@@ -278,7 +392,6 @@ export default {
}
&__map {
flex: 1 1 auto;
width: 100%;
min-width: 0;
height: 420px;
@@ -286,9 +399,19 @@ export default {
}
&__content {
display: flex;
gap: 12px;
position: relative;
display: block;
min-width: 0;
overflow: visible;
.address-map-picker__map {
position: relative;
z-index: 1;
}
:deep(.map-search-results) {
z-index: 2000;
}
}
&__info {
@@ -0,0 +1,108 @@
<template>
<el-dialog
:model-value="modelValue"
title="变更记录详情"
append-to-body
destroy-on-close
align-center
width="1100px"
class="change-record-detail-dialog"
@update:model-value="$emit('update:modelValue', $event)"
@open="resetPage"
>
<el-table
:data="pageRows"
border
max-height="60vh"
:show-overflow-tooltip="false"
>
<el-table-column prop="field" label="变更字段" min-width="180" />
<el-table-column
prop="before"
label="变更前"
min-width="360"
class-name="change-record-detail-value"
/>
<el-table-column
prop="after"
label="变更后"
min-width="500"
class-name="change-record-detail-value"
/>
</el-table>
<el-empty v-if="!total" description="暂无变更内容" :image-size="60" />
<div class="change-record-detail-dialog__pager">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
background
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
<template #footer>
<el-button type="primary" @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script>
export default {
name: 'ChangeRecordDetailDialog',
props: {
modelValue: {
type: Boolean,
default: false,
},
rows: {
type: Array,
default: () => [],
},
},
emits: ['update:modelValue'],
data() {
return {
currentPage: 1,
pageSize: 10,
};
},
computed: {
total() {
return this.rows.length;
},
pageRows() {
const start = (this.currentPage - 1) * this.pageSize;
return this.rows.slice(start, start + this.pageSize);
},
},
watch: {
rows() {
const maxPage = Math.max(1, Math.ceil(this.total / this.pageSize) || 1);
if (this.currentPage > maxPage) this.currentPage = 1;
},
},
methods: {
resetPage() {
this.currentPage = 1;
this.pageSize = 10;
},
},
};
</script>
<style lang="scss" scoped>
.change-record-detail-dialog__pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
:deep(.change-record-detail-value .cell) {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
text-overflow: clip;
line-height: 1.6;
}
</style>
+1 -1
View File
@@ -576,7 +576,7 @@ export default {
}
.mode-content :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.mode-content :deep(.el-form-item:last-child) {
+181 -22
View File
@@ -1,20 +1,55 @@
<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 class="map-search-results__list">
<div
v-for="(item, index) in pageResults"
:key="item.id || `${currentPage}-${index}`"
class="map-search-results__item"
:class="{ 'is-active': isActive(item, index) }"
@click="handleSelect(item, index)"
>
<img
v-if="item.photo || item.image"
class="map-search-results__thumb"
:src="item.photo || item.image"
alt=""
/>
<div class="map-search-results__body">
<div class="map-search-results__name">
{{ item.name || item.address || '未命名地址' }}
</div>
<div class="map-search-results__address">
地址:{{ item.address || item.name || '-' }}
</div>
</div>
</div>
</div>
<div v-if="totalPages > 1" class="map-search-results__pager">
<button
v-for="page in visiblePages"
:key="page"
type="button"
class="map-search-results__page"
:class="{ 'is-active': page === currentPage }"
@click="currentPage = page"
>
{{ page }}
</button>
<button
type="button"
class="map-search-results__page map-search-results__page--next"
:disabled="currentPage >= totalPages"
@click="goNext"
>
下一页
</button>
</div>
</div>
</template>
<script>
const PAGE_SIZE = 5;
export default {
props: {
results: {
@@ -23,43 +58,167 @@ export default {
},
},
emits: ['select'],
data() {
return {
currentPage: 1,
activeKey: '',
};
},
computed: {
totalPages() {
return Math.max(1, Math.ceil((this.results || []).length / PAGE_SIZE));
},
pageResults() {
const start = (this.currentPage - 1) * PAGE_SIZE;
return (this.results || []).slice(start, start + PAGE_SIZE);
},
visiblePages() {
const maxButtons = 3;
let start = Math.max(1, this.currentPage - 1);
let end = Math.min(this.totalPages, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
const pages = [];
for (let page = start; page <= end; page += 1) {
pages.push(page);
}
return pages;
},
},
watch: {
results() {
this.currentPage = 1;
this.activeKey = '';
},
},
methods: {
resultKey(item, index) {
return item?.id ?? `${this.currentPage}-${index}`;
},
isActive(item, index) {
return this.activeKey === this.resultKey(item, index);
},
handleSelect(item, index) {
this.activeKey = this.resultKey(item, index);
this.$emit('select', item);
},
goNext() {
if (this.currentPage < this.totalPages) {
this.currentPage += 1;
}
},
},
};
</script>
<style lang="scss" scoped>
.map-search-results {
width: 260px;
height: 420px;
overflow-y: auto;
border: 1px solid #eff1f7;
position: absolute;
top: 0;
left: 0;
z-index: 2000;
display: flex;
flex-direction: column;
width: 360px;
max-height: 360px;
overflow: hidden;
background: #fff;
flex: 0 0 260px;
border: 1px solid #dcdfe6;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
pointer-events: auto;
&__title {
padding: 10px 12px;
border-bottom: 1px solid #eff1f7;
font-weight: 600;
&__list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
&__item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
border-bottom: 1px solid #ebeef5;
}
&__item:hover {
background: #f5f9ff;
&__item:hover,
&__item.is-active {
background: #f5f5f5;
}
&__thumb {
flex: 0 0 48px;
width: 48px;
height: 48px;
object-fit: cover;
border: 1px solid #ebeef5;
background: #fafafa;
}
&__body {
flex: 1 1 auto;
min-width: 0;
}
&__name {
overflow: hidden;
color: #303133;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
text-overflow: ellipsis;
}
&__address {
margin-top: 4px;
color: #909399;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
}
&__pager {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 8px 10px;
border-top: 1px solid #ebeef5;
background: #fff;
}
&__page {
min-width: 28px;
height: 28px;
padding: 0 8px;
color: #606266;
font-size: 13px;
line-height: 26px;
text-align: center;
background: #fff;
border: 1px solid #dcdfe6;
cursor: pointer;
&:hover:not(:disabled) {
color: #409eff;
border-color: #409eff;
}
&.is-active {
color: #409eff;
border-color: #409eff;
}
&:disabled {
color: #c0c4cc;
cursor: not-allowed;
}
&--next {
min-width: 56px;
}
}
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div class="avue-crud__right table-header-tools">
<el-button v-if="showRefresh" icon="el-icon-refresh" circle @click="$emit('refresh')" />
<el-button
v-if="showColumn"
icon="el-icon-operation"
circle
@click="columnSettingVisible = true"
/>
<el-button
v-if="showSearch"
icon="el-icon-search"
circle
@click="$emit('update:searchVisible', !searchVisible)"
/>
<el-button v-if="showGrid" icon="el-icon-grid" circle @click="toggleTableSize" />
<el-dialog
v-if="showColumn"
v-model="columnSettingVisible"
title="列设置"
width="360px"
append-to-body
destroy-on-close
>
<el-checkbox-group
class="table-header-tools__column-setting"
:model-value="visibleColumnKeys"
@update:model-value="$emit('update:visibleColumnKeys', $event)"
>
<el-checkbox v-for="item in columns" :key="item.prop" :label="item.prop">
{{ item.label }}
</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="columnSettingVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
const TABLE_SIZE_OPTIONS = ['default', 'large', 'small'];
export default {
name: 'TableHeaderTools',
props: {
columns: {
type: Array,
default: () => [],
},
visibleColumnKeys: {
type: Array,
default: () => [],
},
tableSize: {
type: String,
default: 'default',
},
searchVisible: {
type: Boolean,
default: true,
},
showRefresh: {
type: Boolean,
default: true,
},
showColumn: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: true,
},
showGrid: {
type: Boolean,
default: true,
},
},
emits: ['refresh', 'update:visibleColumnKeys', 'update:tableSize', 'update:searchVisible'],
data() {
return {
columnSettingVisible: false,
};
},
deactivated() {
// keep-alive 缓存页面时关闭列设置弹窗,避免 Teleport 到 body 的弹窗切页后残留
this.columnSettingVisible = false;
},
methods: {
toggleTableSize() {
const index = TABLE_SIZE_OPTIONS.indexOf(this.tableSize);
const next = TABLE_SIZE_OPTIONS[(index + 1) % TABLE_SIZE_OPTIONS.length];
this.$emit('update:tableSize', next);
},
},
};
</script>
<style scoped lang="scss">
.table-header-tools.avue-crud__right {
width: auto;
margin: 0 0 0 auto;
flex: none;
white-space: nowrap;
text-align: right;
}
.table-header-tools__column-setting {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 360px;
overflow: auto;
}
</style>
@@ -9,18 +9,21 @@
<el-table :data="rows" border @selection-change="selectedRows = $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 label="文件名" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<span
class="vehicle-attachment-table__file-name"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>
{{ attachmentName(row) }}
</span>
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -308,16 +311,6 @@ export default {
margin-bottom: 16px;
}
&__file-name {
color: #409eff;
cursor: pointer;
}
&__file-name:hover,
&__file-name:focus-visible {
text-decoration: underline;
}
&__upload {
display: flex;
justify-content: flex-start;
@@ -212,7 +212,14 @@ export default {
.filter(Boolean);
},
acceptText() {
return this.acceptedList.join(',');
return this.acceptedList
.map(item => {
const value = String(item).trim();
if (!value || value.includes('/') || value.startsWith('.')) return value;
return `.${value}`;
})
.filter(Boolean)
.join(',');
},
tipText() {
return this.tip || `${UNIFIED_ATTACHMENT_TIP_PREFIX}${this.maxSize}M`;
@@ -304,12 +311,22 @@ export default {
});
},
handleSuccess(response, file, files) {
if (!response || response.success === false || (response.code && response.code !== 200)) {
const code = response?.code;
const codeInvalid = code !== undefined && code !== null && code !== '' && Number(code) !== 200;
if (!response || response.success === false || codeInvalid) {
this.$message.error((response && response.msg) || '上传失败');
return;
}
this.emitUploadFiles(this.multiple ? files : [file]);
this.$emit('success', this.normalizeUploadFile(file));
if (file && !file.response) {
file.response = response;
}
const normalized = this.normalizeUploadFile(file, response);
if (!normalized.url) {
this.$message.error('上传成功但未返回文件地址');
return;
}
this.$emit('success', normalized);
this.emitUploadFiles(this.multiple ? files : [file], normalized);
this.$message.success('上传成功');
},
handleChange(file, files) {
@@ -323,35 +340,89 @@ export default {
this.emitUploadFiles(files);
return true;
},
emitUploadFiles(files) {
const list = files
.filter(file => file.status === 'success' || this.getFileUrl(file))
.map(this.normalizeUploadFile)
emitUploadFiles(files, preferredFile) {
const list = (files || [])
.filter(file => {
if (file?.status === 'fail') return false;
return (
file?.status === 'success' ||
this.getFileUrl(file) ||
file?.response?.data ||
file?.response?.link ||
file?.response
);
})
.map(file => this.normalizeUploadFile(file))
.filter(item => item.url);
this.$emit('update:modelValue', list);
this.$emit('change', list);
if (preferredFile?.url) {
const exists = list.some(
item =>
(preferredFile.uid && item.uid && String(item.uid) === String(preferredFile.uid)) ||
item.url === preferredFile.url
);
if (!exists) list.push(preferredFile);
}
const current = this.parseValue(this.modelValue).map(item => ({
...item,
url: this.getFileUrl(item),
}));
const merged = [...current];
list.forEach(file => {
const index = merged.findIndex(
item =>
(file.uid && item.uid && String(item.uid) === String(file.uid)) ||
(file.url && this.getFileUrl(item) === file.url)
);
if (index >= 0) merged.splice(index, 1, { ...merged[index], ...file });
else merged.push(file);
});
const result = merged.filter(item => this.getFileUrl(item));
this.$emit('update:modelValue', result);
this.$emit('change', result);
},
normalizeUploadFile(file) {
const data = (file.response && file.response.data) || file.data || file;
const url = data.link || data.url || data.domain || file.url || '';
normalizeUploadFile(file, responseOverride) {
const response = responseOverride || file?.response;
let data = {};
if (response && typeof response === 'object') {
if (response.data && typeof response.data === 'object') {
data = response.data;
} else if (response.link || response.url || response.domain) {
data = response;
}
}
if (!data.link && !data.url && !data.domain) {
data = file?.data && typeof file.data === 'object' ? file.data : file || {};
}
const url =
data.link ||
data.url ||
data.domain ||
data.fileLink ||
data.fileUrl ||
file?.url ||
file?.link ||
'';
const originalName =
data.originalName ||
file.originalName ||
file.name ||
file?.originalName ||
file?.name ||
data.name ||
this.getFileName(url) ||
'附件';
const extension = this.getExtension({ name: originalName, url });
return {
...data,
uid: file.uid || data.uid,
uid: file?.uid || data.uid,
originalName,
name: originalName,
url,
link: data.link || url,
size: data.size || data.attachSize || file.size || '',
size: data.size || data.attachSize || file?.size || '',
extension,
mimeType: data.mimeType || data.contentType || file.raw?.type || MIME_MAP[extension] || '',
mimeType: data.mimeType || data.contentType || file?.raw?.type || MIME_MAP[extension] || '',
};
},
handlePreview(file) {
+11
View File
@@ -146,6 +146,13 @@ export const createOption = () => ({
showWordLimit: true,
rules: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
},
{
label: '创建人',
prop: 'createUserName',
minWidth: 120,
addDisplay: false,
editDisplay: false,
},
{
label: '更新时间',
prop: 'updateTime',
@@ -298,6 +305,10 @@ export const createOption = () => ({
searchOrder: 3,
searchPlaceholder: '请选择',
dicData: [],
props: {
label: 'label',
value: 'value',
},
filterable: true,
hide: true,
display: false,
+4
View File
@@ -2,6 +2,9 @@ const dimensionOptions = [
{ label: '重量', value: '重量' },
{ label: '体积', value: '体积' },
{ label: '数量', value: '数量' },
{ label: '整车', value: '整车' },
{ label: '里程', value: '里程' },
{ label: '吨·公里', value: '吨·公里' },
];
const statusOptions = [
@@ -42,6 +45,7 @@ export const createOption = () => ({
label: '计量单位编码',
prop: 'unitCode',
minWidth: 150,
span: 24,
search: true,
searchOrder: 4,
searchSpan: 6,
+143 -139
View File
@@ -35,26 +35,7 @@ export const formatCargoValue = value => {
};
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',
@@ -79,12 +60,24 @@ export const option = createCrudOption([
],
},
{
label: '一级货物类型编码',
prop: 'firstCargoTypeCode',
hide: true,
display: false,
addDisplay: false,
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: '二级货物类型编码',
@@ -92,9 +85,132 @@ export const option = createCrudOption([
display: false,
addDisplay: false,
editDisabled: true,
minWidth: 180,
minWidth: 160,
},
// 搜索字段与表格、表单字段独立,避免筛选配置影响数据展示和编辑布局。
{
label: '包装',
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),
},
{
label: '规格',
prop: 'specification',
order: 29,
span: 12,
minWidth: 120,
placeholder: '请输入',
rules: textRule('规格', 50),
},
{
label: '型号',
prop: 'model',
order: 19,
span: 12,
minWidth: 120,
placeholder: '请输入',
rules: textRule('型号', 50, true),
},
{
label: '单价',
prop: 'cargoValue',
formslot: true,
order: 30,
formatter: row => formatCargoValue(row.cargoValue),
minWidth: 120,
rules: [
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback();
return;
}
if (!/^\d+(\.\d{1,2})?$/.test(String(value))) {
callback(new Error('单价只能输入数字,最多保留2位小数'));
return;
}
callback();
},
trigger: 'blur',
},
],
},
{
label: '计价单位',
prop: 'priceUnit',
type: 'select',
dicData: [],
display: false,
minWidth: 120,
},
{
label: '尺寸',
prop: 'sizeText',
minWidth: 120,
addDisplay: false,
editDisplay: false,
rules: textRule('尺寸', 100),
},
{
label: '说明',
prop: 'descriptionOne',
order: 20,
span: 12,
minWidth: 160,
placeholder: '请输入',
rules: textRule('说明', 100),
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
order: 10,
minRows: 2,
span: 24,
maxlength: 200,
showWordLimit: true,
placeholder: '请输入',
rules: textRule('备注', 200),
},
{
label: '组织',
prop: 'deptName',
minWidth: 150,
addDisplay: false,
editDisplay: false,
},
...auditColumns
.filter(column => ['createUserName', 'createTime', 'updateTime'].includes(column.prop))
.sort(
(first, second) =>
['createUserName', 'createTime', 'updateTime'].indexOf(first.prop) -
['createUserName', 'createTime', 'updateTime'].indexOf(second.prop)
),
// ---------- 表单隐藏字段 ----------
{
label: '一级货物类型编码',
prop: 'firstCargoTypeCode',
hide: true,
display: false,
addDisplay: false,
editDisabled: true,
},
// ---------- 搜索字段(与表格、表单独立) ----------
{
label: '货物名称',
prop: 'searchCargoName',
@@ -186,118 +302,6 @@ export const option = createCrudOption([
editDisplay: false,
viewDisplay: false,
},
{
label: '包装',
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),
},
{
label: '规格',
prop: 'specification',
order: 29,
span: 12,
minWidth: 120,
placeholder: '请输入',
rules: textRule('规格', 50),
},
{
label: '货值',
prop: 'cargoValue',
formslot: true,
order: 30,
formatter: row => formatCargoValue(row.cargoValue),
minWidth: 120,
rules: [
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
callback();
return;
}
if (!/^\d+(\.\d{1,2})?$/.test(String(value))) {
callback(new Error('货值只能输入数字,最多保留2位小数'));
return;
}
callback();
},
trigger: 'blur',
},
],
},
{
label: '计价单位',
prop: 'priceUnit',
type: 'select',
dicData: [],
display: false,
minWidth: 120,
},
{
label: '型号',
prop: 'model',
order: 19,
span: 12,
minWidth: 120,
placeholder: '请输入',
rules: textRule('型号', 50, true),
},
{
label: '尺寸',
prop: 'sizeText',
minWidth: 120,
addDisplay: false,
editDisplay: false,
rules: textRule('尺寸', 100),
},
{
label: '说明',
prop: 'descriptionOne',
order: 20,
span: 12,
minWidth: 160,
placeholder: '请输入',
rules: textRule('说明', 100),
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
order: 10,
minRows: 2,
span: 24,
maxlength: 200,
showWordLimit: true,
placeholder: '请输入',
rules: textRule('备注', 200),
},
{
label: '组织',
prop: 'deptName',
minWidth: 150,
addDisplay: false,
editDisplay: false,
},
...auditColumns
.filter(column => ['updateTime', 'createTime'].includes(column.prop))
.sort(
(first, second) =>
['updateTime', 'createTime'].indexOf(first.prop) -
['updateTime', 'createTime'].indexOf(second.prop)
),
]);
// 搜索栏标签统一加宽,给较长的货物类型字段预留足够展示空间。
+2 -3
View File
@@ -281,14 +281,13 @@ export const option = {
filterable: true,
},
...auditColumns
.filter(column => column.prop !== 'createUserName')
.map(column =>
['createTime', 'updateTime'].includes(column.prop) ? { ...column, minWidth: 190 } : column
)
.sort(
(first, second) =>
['updateUserName', 'updateTime', 'createTime'].indexOf(first.prop) -
['updateUserName', 'updateTime', 'createTime'].indexOf(second.prop)
['createUserName', 'updateUserName', 'createTime', 'updateTime'].indexOf(first.prop) -
['createUserName', 'updateUserName', 'createTime', 'updateTime'].indexOf(second.prop)
),
]),
dialogCustomClass: 'avue-dialog--common-route',
+1
View File
@@ -117,6 +117,7 @@ export const contractCategoryOptions = [
export const signTypeOptions = [
{ label: '新签', value: '新签' },
{ label: '续签', value: '续签' },
{ label: '补充协议', value: '补充协议' },
];
export const contractStageOptions = [
+49 -13
View File
@@ -6,6 +6,7 @@ import {
contractStageOptions,
createCrudOption,
effectiveTypeOptions,
nonNegativeRule,
selectRule,
signTypeOptions,
textRule,
@@ -61,13 +62,14 @@ export const config = {
['contractNo', '合同编号'],
['contractName', '合同名称'],
['projectName', '所属项目'],
['organizationName', '所属组织'],
['organizationName', '合同负责部门'],
['contractCategory', '合同类别'],
['signType', '签约类型'],
['partyA', '甲方'],
['partyB', '乙方'],
['handlerUserName', '经办人'],
['signDate', '签订日期'],
['applyDate', '申请日期'],
['contractFormat', '合同格式'],
['legalSealFlag', '是否需要加盖法人章'],
['copyCount', '一式(份)'],
@@ -76,7 +78,8 @@ export const config = {
['templateFlag', '是否范本'],
['originalContractNo', '原件合同编号'],
['electronicSealFlag', '是否电子章'],
['remark', '备注', 2],
['mainContent', '合同主要内容', 2],
['remark', '其他事项说明', 2],
],
},
{
@@ -114,7 +117,7 @@ export const config = {
operations: [
{
action: 'flow',
label: '查看流程',
label: '流程',
statusProp: 'approvalStatus',
status: ['reviewing', 'change_reviewing'],
stage: ['temporary', 'formal'],
@@ -206,10 +209,10 @@ export const config = {
},
{
action: 'flow',
label: '查看流程',
label: '流程',
statusProp: 'approvalStatus',
status: ['approved', 'change_approved'],
stage: ['formal', 'terminated'],
stage: ['temporary', 'formal', 'terminated'],
permission: 'contract_manage_view',
},
{
@@ -294,17 +297,17 @@ export const option = createCrudOption([
display: false,
},
{
label: '所属组织',
label: '合同负责部门',
prop: 'organizationName',
search: true,
searchOrder: 3,
searchslot: true,
searchPlaceholder: '请选择 所属组织',
searchPlaceholder: '请选择 合同负责部门',
formslot: true,
order: 120,
dicData: [],
minWidth: 150,
rules: selectRule('所属组织'),
rules: selectRule('合同负责部门'),
},
{
label: '合同类别',
@@ -435,6 +438,17 @@ export const option = createCrudOption([
minWidth: 130,
rules: [{ required: true, message: '请选择签订日期', trigger: 'change' }],
},
{
label: '申请日期',
prop: 'applyDate',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
sortable: true,
minWidth: 130,
addDisplay: false,
editDisplay: false,
},
{
label: '结算类型',
prop: 'settlementMode',
@@ -468,11 +482,10 @@ export const option = createCrudOption([
{ label: '否', value: 0 },
{ label: '是', value: 1 },
],
value: 0,
clearable: true,
hide: true,
order: 70,
minWidth: 100,
rules: selectRule('是否需要加盖法人章'),
},
{
label: '一式(份)',
@@ -500,6 +513,10 @@ export const option = createCrudOption([
hide: true,
order: 45,
minWidth: 130,
rules: [
{ required: true, message: '请输入合同金额', trigger: 'blur' },
nonNegativeRule('合同金额'),
],
},
{
label: '是否范本',
@@ -532,6 +549,13 @@ export const option = createCrudOption([
order: 30,
minWidth: 110,
},
{
label: '创建人',
prop: 'createUserName',
minWidth: 120,
addDisplay: false,
editDisplay: false,
},
{
label: '创建时间',
prop: 'createTime',
@@ -610,7 +634,19 @@ export const option = createCrudOption([
minWidth: 110,
},
{
label: '备注',
label: '合同主要内容',
prop: 'mainContent',
type: 'textarea',
span: 24,
minRows: 4,
hide: true,
order: 41,
maxlength: 1000,
showWordLimit: true,
rules: textRule('合同主要内容', 1000),
},
{
label: '其他事项说明',
prop: 'remark',
type: 'textarea',
span: 24,
@@ -619,7 +655,7 @@ export const option = createCrudOption([
order: 40,
maxlength: 2000,
showWordLimit: true,
rules: textRule('备注', 2000),
rules: textRule('其他事项说明', 2000),
},
{
label: '',
@@ -730,7 +766,7 @@ export const option = createCrudOption([
labelWidth: 0,
},
...auditColumns
.filter(column => column.prop !== 'createTime')
.filter(column => !['createTime', 'createUserName'].includes(column.prop))
.map(column => ({ ...column, hide: true })),
]);
+2 -1
View File
@@ -31,7 +31,7 @@ export const loadingManageConfig = {
exportColumns: [
{ prop: 'loadingNo', label: '配载单号' },
{ prop: 'loadingSubNos', label: '配载子单号' },
{ prop: 'vehicleNo', label: '车牌号/航班号/船号/班列号' },
{ prop: 'vehicleNo', label: '车牌号/船班号/船次号/班次号' },
{ prop: 'driverName', label: '司机' },
{ prop: 'driverPhone', label: '联系电话' },
{ prop: 'carrierType', label: '承运类型' },
@@ -41,6 +41,7 @@ export const loadingManageConfig = {
{ prop: 'carrierName', label: '承运商' },
{ prop: 'transportType', label: '运输方式' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'currentProcessNode', label: '过程节点' },
+3 -3
View File
@@ -112,12 +112,12 @@ export const option = {
editDisplay: false,
},
...auditColumns
.filter(column => ['updateTime', 'createTime'].includes(column.prop))
.filter(column => ['createUserName', 'createTime', 'updateTime'].includes(column.prop))
.map(column => ({ ...column, minWidth: 190 }))
.sort(
(first, second) =>
['updateTime', 'createTime'].indexOf(first.prop) -
['updateTime', 'createTime'].indexOf(second.prop)
['createUserName', 'createTime', 'updateTime'].indexOf(first.prop) -
['createUserName', 'createTime', 'updateTime'].indexOf(second.prop)
),
{
label: '项目ID集合',
+17 -3
View File
@@ -57,16 +57,24 @@ export const config = {
action: 'submitApproval',
label: '提交',
statusProp: 'approvalStatus',
status: ['draft'],
status: ['draft', 'rejected', 'withdrawn'],
permission: 'project_apply_submit',
},
{
action: 'withdraw',
label: '撤回',
statusProp: 'approvalStatus',
status: ['reviewing'],
status: ['draft', 'reviewing'],
permission: 'project_apply_withdraw',
},
{
action: 'mkAudit',
label: '流程',
statusProp: 'approvalStatus',
status: ['reviewing', 'change_reviewing', 'approved', 'change_approved'],
permission: 'project_apply_approve',
mkOnly: true,
},
{
action: 'approve',
label: '通过',
@@ -74,6 +82,7 @@ export const config = {
statusProp: 'approvalStatus',
status: ['reviewing', 'change_reviewing'],
permission: 'project_apply_approve',
localOnly: true,
},
{
action: 'reject',
@@ -82,6 +91,7 @@ export const config = {
statusProp: 'approvalStatus',
status: ['reviewing', 'change_reviewing'],
permission: 'project_apply_reject',
localOnly: true,
},
{
action: 'startChange',
@@ -442,5 +452,9 @@ export const option = createCrudOption([
span: 24,
hide: true,
},
...auditColumns.map(column => ({ ...column, hide: true })),
...auditColumns.map(column =>
['createUserName', 'createTime'].includes(column.prop)
? column
: { ...column, hide: true }
),
]);
@@ -332,9 +332,13 @@ export const option = {
addDisplay: false,
editDisplay: false,
},
...auditColumns.map(column => ({ ...column, hide: true })),
...auditColumns.map(column =>
['createUserName', 'createTime'].includes(column.prop)
? column
: { ...column, hide: true }
),
])),
dialogWidth: '96%',
dialogWidth: 1100,
menuFixed: 'right',
menuWidth: 320,
index: false,
+5 -3
View File
@@ -249,9 +249,10 @@ export const config = {
title: '其它信息',
fields: [
['remark', '备注'],
['createUserName', '创建人'],
['createTime', '创建时间'],
['updateUserName', '更新人'],
['updateTime', '更新时间'],
['createTime', '创建时间'],
],
},
],
@@ -399,7 +400,6 @@ export const option = {
span: 6,
order: 350,
minWidth: 150,
rules: [{ required: true, message: '请选择计划开始日期', trigger: 'change' }],
},
{
label: '计划结束日期',
@@ -410,7 +410,6 @@ export const option = {
span: 6,
order: 340,
minWidth: 150,
rules: [{ required: true, message: '请选择计划结束日期', trigger: 'change' }],
},
{
label: '数据来源',
@@ -457,6 +456,9 @@ export const option = {
showWordLimit: true,
rules: textRule('备注', 200),
},
{
...auditColumn('createUserName'),
},
{
...auditColumn('updateUserName'),
},
+44 -3
View File
@@ -98,6 +98,24 @@ const getProcessConfigNodes = row => {
const isAcceptProcessNode = node => node.key === 'accept' || node.name === '接单';
/**
* 过程节点列显示文案。
*
* 该列在库里存的是「原始值」,且历史上写入了两种格式:司机打卡推进写的是配置的
* key(unload / dispatch / load …),配载单新建默认值写的是中文名(接单)。
* 故这里按两种格式都认一次,认不出就原样显示,不把值吞掉。
*
* 运单创建后、司机首次打卡前该字段为空,属正常——显示「-」,避免留白看不出是没值还是没渲染。
*/
const formatCurrentProcessNode = row => {
const value = String(getFirstValue(row, ['currentProcessNode', 'currentNode']) || '').trim();
if (!value) return '-';
const node = getProcessConfigNodes(row).find(
item => item.key === value || item.name === value
);
return node?.name || node?.key || value;
};
const isTruthyFlag = value => {
if (value === true || value === 1) return true;
if (value === false || value === 0 || value === null || value === undefined || value === '') {
@@ -543,7 +561,8 @@ export const option = {
prop: 'projectName',
formslot: true,
search: true,
searchLabel: '项目',
searchLabel: '项目名称',
searchPlaceholder: '请选择或输入',
searchOrder: 22,
span: 6,
order: 890,
@@ -555,6 +574,7 @@ export const option = {
prop: 'customerName',
search: true,
searchLabel: '客户名称',
searchPlaceholder: '请选择或输入',
searchOrder: 21,
minWidth: 150,
addDisplay: false,
@@ -563,10 +583,10 @@ export const option = {
display: false,
},
{
label: '车牌号/航班号/船号/班列号',
label: '车牌号/船班号/船次号/班次号',
prop: 'vehicleNo',
search: true,
searchLabel: '车/船/航班/班列',
searchLabel: '车牌号/船班号/船次号/班次号',
searchOrder: 13,
minWidth: 210,
display: false,
@@ -576,6 +596,7 @@ export const option = {
prop: 'driverName',
search: true,
searchLabel: '司机名称',
searchPlaceholder: '请选择或输入',
searchOrder: 14,
minWidth: 120,
display: false,
@@ -605,6 +626,7 @@ export const option = {
{
label: '过程节点',
prop: 'currentProcessNode',
formatter: row => formatCurrentProcessNode(row),
minWidth: 140,
addDisplay: false,
editDisplay: false,
@@ -626,6 +648,7 @@ export const option = {
prop: 'carrierName',
search: true,
searchLabel: '承运商名称',
searchPlaceholder: '请选择或输入',
searchOrder: 15,
minWidth: 150,
display: false,
@@ -729,15 +752,30 @@ export const option = {
prop: 'planName',
formslot: true,
search: true,
searchPlaceholder: '请选择或输入',
searchOrder: 7,
span: 6,
order: 870,
minWidth: 150,
formatter: row => {
const value = row?.planName;
if (
value === undefined ||
value === null ||
value === '' ||
String(value) === '-1' ||
Number(value) === -1
) {
return '';
}
return value;
},
},
{
label: '货物类型',
prop: 'cargoType',
search: true,
searchPlaceholder: '请选择或输入',
searchOrder: 18,
formatter: row => formatGoodsField(row, ['cargoType', 'goodsType', 'typeName']),
minWidth: 130,
@@ -864,6 +902,9 @@ export const option = {
viewDisplay: false,
display: false,
},
{
...auditColumn('createUserName'),
},
{
...auditColumn('createTime'),
},
+165 -20
View File
@@ -1,20 +1,165 @@
export const paymentApplicationTableColumns = [
{ prop: 'paymentNo', label: '单据号', minWidth: 140, link: true },
{ prop: 'paymentTypeName', label: '付款类型', minWidth: 110 },
{ prop: 'projectName', label: '所属项目', minWidth: 140 },
{ prop: 'deptName', label: '所属组织', minWidth: 120 },
{ prop: 'contractNo', label: '合同编号', minWidth: 130 },
{ prop: 'contractName', label: '合同名称', minWidth: 140 },
{ prop: 'payerName', label: '付款方', minWidth: 130 },
{ prop: 'payeeName', label: '收款方', minWidth: 130 },
{ prop: 'appliedAmount', label: '申请付款金额', minWidth: 140, money: true },
{ prop: 'paidAmount', label: '已付款金额', minWidth: 130, money: true },
{ prop: 'matchedInvoiceAmount', label: '匹配发票金额', minWidth: 130, money: true },
{ prop: 'applicantName', label: '申请人', minWidth: 100 },
{ prop: 'applyDate', label: '申请日期', minWidth: 120 },
{ prop: 'approvalStatusName', label: '审核状态', minWidth: 110, status: true },
{ prop: 'currentNode', label: '当前节点', minWidth: 120 },
{ prop: 'currentProcessor', label: '当前处理人', minWidth: 120 },
{ prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 130 },
{ prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 130 },
];
/**
* 付款申请列表(avue-crud)配置
* ------------------------------------------------------------------
* 列表页与其它模块统一使用 avue-crud,头部 4 个按钮(刷新 / 列显隐 / 搜索显隐 /
* 表格卡片切换)由 avue-crud 自带,不做二次封装。
*
* 搜索项与表格列 label 不一致的(如表格列「收款方」/ 搜索项「客户名称」),
* 单独定义一个 hide 的搜索列,并用 searchProp 映射到后端字段。
*
* ⚠️ avue 的 searchOrder 为降序排序,值越大越靠前(见 header-search2.js:
* `list.sort((a, b) => (b.searchOrder || 0) - (a.searchOrder || 0))`)。
*/
const textFormatter = (row, value) =>
value === null || value === undefined || value === '' ? '-' : value;
const moneyFormatter = (row, value) => Number(value || 0).toFixed(2);
// 仅用于搜索、不在表格中出现的列
const searchOnlyColumn = ({ label, prop, searchOrder, searchProp, dicData }) => {
const column = {
label,
prop,
search: true,
searchOrder,
searchPlaceholder: '请输入',
hide: true,
showColumn: false,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
};
if (searchProp) column.searchProp = searchProp;
if (dicData) {
column.type = 'select';
column.searchPlaceholder = '请选择';
column.dicData = dicData;
}
return column;
};
export const paymentApplicationOption = ({
paymentTypeOptions = [],
approvalStatusOptions = [],
kingdeeStatusOptions = [],
} = {}) => ({
height: 'auto',
calcHeight: 32,
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
searchShow: true,
searchIcon: true,
searchLabelWidth: 88,
searchMenuSpan: 24,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
indexFixed: 'left',
selection: true,
selectionWidth: 52,
selectionFixed: 'left',
addBtn: false,
viewBtn: false,
delBtn: false,
editBtn: false,
copyBtn: false,
menuWidth: 320,
menuFixed: 'right',
dialogClickModal: false,
column: [
// ---------- 搜索项(前 4 项默认展示,其余折叠;searchOrder 越大越靠前) ----------
{
label: '单据号',
prop: 'paymentNo',
slot: true,
search: true,
searchOrder: 9,
searchPlaceholder: '请输入',
minWidth: 150,
},
{
label: '日期',
prop: 'applyDateRange',
type: 'daterange',
searchType: 'daterange',
search: true,
searchOrder: 8,
hide: true,
showColumn: false,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
searchOnlyColumn({
label: '客户名称',
prop: 'payeeNameSearch',
searchOrder: 7,
searchProp: 'payeeName',
}),
searchOnlyColumn({
label: '项目名称',
prop: 'projectNameSearch',
searchOrder: 6,
searchProp: 'projectName',
}),
searchOnlyColumn({
label: '关联结算单',
prop: 'settlementNo',
searchOrder: 4,
}),
searchOnlyColumn({
label: '付款类型',
prop: 'paymentType',
searchOrder: 3,
dicData: paymentTypeOptions,
}),
searchOnlyColumn({
label: '审核状态',
prop: 'approvalStatus',
searchOrder: 2,
dicData: approvalStatusOptions,
}),
searchOnlyColumn({
label: '金蝶单据状态',
prop: 'kingdeeStatus',
searchOrder: 1,
dicData: kingdeeStatusOptions,
}),
// ---------- 表格列 ----------
{ label: '付款类型', prop: 'paymentTypeName', minWidth: 110, formatter: textFormatter },
{ label: '所属项目', prop: 'projectName', minWidth: 140, formatter: textFormatter },
{
label: '所属组织',
prop: 'deptName',
search: true,
searchOrder: 5,
searchslot: true,
minWidth: 120,
formatter: textFormatter,
},
{ label: '合同编号', prop: 'contractNo', minWidth: 130, formatter: textFormatter },
{ label: '合同名称', prop: 'contractName', minWidth: 140, formatter: textFormatter },
{ label: '付款方', prop: 'payerName', minWidth: 130, formatter: textFormatter },
{ label: '收款方', prop: 'payeeName', minWidth: 130, formatter: textFormatter },
{ label: '申请付款金额', prop: 'appliedAmount', minWidth: 140, formatter: moneyFormatter },
{ label: '已付款金额', prop: 'paidAmount', minWidth: 130, formatter: moneyFormatter },
{
label: '匹配发票金额',
prop: 'matchedInvoiceAmount',
minWidth: 130,
formatter: moneyFormatter,
},
{ label: '申请人', prop: 'applicantName', minWidth: 100, formatter: textFormatter },
{ label: '申请日期', prop: 'applyDate', minWidth: 120, formatter: textFormatter },
{ label: '审核状态', prop: 'approvalStatusName', slot: true, minWidth: 110 },
{ label: '当前节点', prop: 'currentNode', minWidth: 120, formatter: textFormatter },
{ label: '当前处理人', prop: 'currentProcessor', minWidth: 120, formatter: textFormatter },
{ label: '金蝶单据号', prop: 'kingdeeBillNo', minWidth: 130, formatter: textFormatter },
{ label: '金蝶单据状态', prop: 'kingdeeStatusName', minWidth: 130, formatter: textFormatter },
],
});
@@ -104,7 +104,7 @@ export const generateWaybillColumns = [
{ label: '运单号', prop: 'waybillNo', minWidth: 160, link: true },
{ label: '项目名称', prop: 'projectName', minWidth: 160 },
{ label: '客户/委托方', prop: 'customerName', minWidth: 160 },
{ label: '车牌号/航班号/船号/班列号', prop: 'vehicleNo', minWidth: 190 },
{ label: '车牌号/船班号/船次号/班次号', prop: 'vehicleNo', minWidth: 190 },
{ label: '司机', prop: 'driverName', minWidth: 120 },
{ label: '承运商', prop: 'carrierName', minWidth: 160 },
{ label: '运输类型', prop: 'transportType', minWidth: 130 },
+3 -2
View File
@@ -5,6 +5,7 @@ export const GRANT_TYPE_DIC = [
{ label: '社交登录', value: 'social' },
{ label: '客户端凭证', value: 'client_credentials' },
{ label: '刷新令牌', value: 'refresh_token' },
{ label: '退出登录', value: 'logout' },
];
export const authLogOption = {
@@ -38,7 +39,7 @@ export const authLogOption = {
prop: 'realName',
},
{
label: '授权类型',
label: '操作类型',
prop: 'grantType',
type: 'select',
search: true,
@@ -80,7 +81,7 @@ export const authLogOption = {
width: 120,
},
{
label: '登录时间',
label: '操作时间',
prop: 'loginTime',
sortable: true,
span: 24,
+205
View File
@@ -0,0 +1,205 @@
const identityDic = [
{ label: '司机', value: 'driver' },
{ label: '管理员', value: 'admin' },
];
const statusDic = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
];
const validatePhone = (rule, value, callback) => {
const phone = String(value || '').trim();
if (!phone) {
callback(new Error('请输入手机号'));
return;
}
if (!/^1\d{10}$/.test(phone)) {
callback(new Error('请输入正确的手机号'));
return;
}
callback();
};
const validateRemark = (rule, value, callback) => {
if (String(value || '').length > 200) {
callback(new Error('备注不能超过200字'));
} else {
callback();
}
};
export default {
height: 'auto',
calcHeight: 22,
dialogWidth: 760,
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
saveBtnText: '提交',
updateBtnText: '提交',
searchShow: true,
searchIcon: true,
searchLabelWidth: 160,
searchMenuSpan: 24,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
viewBtn: false,
delBtn: false,
editBtn: false,
addBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 320,
column: [
{
label: '手机号',
prop: 'phone',
minWidth: 130,
search: true,
searchOrder: 4,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 11,
showWordLimit: true,
order: 100,
rules: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ validator: validatePhone, trigger: 'blur' },
],
},
{
label: '账号',
prop: 'account',
minWidth: 130,
search: true,
searchOrder: 3,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 45,
order: 90,
placeholder: '默认与手机号相同',
},
{
label: '姓名',
prop: 'realName',
minWidth: 100,
search: true,
searchOrder: 2,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 10,
showWordLimit: true,
order: 80,
},
{
label: '昵称',
prop: 'name',
minWidth: 100,
maxlength: 20,
showWordLimit: true,
order: 70,
},
{
label: '小程序身份',
prop: 'identities',
type: 'select',
multiple: true,
dataType: 'string',
dicData: identityDic,
minWidth: 140,
search: true,
searchOrder: 1,
searchSpan: 6,
searchPlaceholder: '请选择',
order: 60,
rules: [{ required: true, message: '请选择小程序身份', trigger: 'change' }],
},
{
label: '性别',
prop: 'sex',
type: 'select',
dicUrl: '/blade-system/dict/dictionary?code=sex',
props: {
label: 'dictValue',
value: 'dictKey',
},
dataType: 'number',
minWidth: 80,
hide: true,
order: 50,
},
{
label: 'openid',
prop: 'openid',
minWidth: 180,
overHidden: true,
hide: true,
addDisplay: false,
editDisplay: true,
viewDisplay: true,
order: 40,
placeholder: '可清空以解绑微信',
},
{
label: '状态',
prop: 'status',
type: 'select',
dicData: statusDic,
dataType: 'number',
minWidth: 90,
search: true,
searchOrder: 0,
searchSpan: 6,
addDisplay: false,
editDisplay: false,
value: 1,
order: 30,
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
span: 24,
minRows: 2,
maxlength: 200,
showWordLimit: true,
hide: true,
order: 20,
rules: [{ validator: validateRemark, trigger: 'blur' }],
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 10,
},
{
label: '更新人',
prop: 'updateUserName',
minWidth: 100,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 5,
},
{
label: '更新时间',
prop: 'updateTime',
minWidth: 170,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 1,
},
],
};
+11 -7
View File
@@ -1,12 +1,9 @@
import { getDeptLazyTree } from '@/api/system/dept';
import { validateLoginPassword } from '@/utils/validate';
export const userOption = safe => {
const validatePass = (rule, value, callback) => {
if (value === '') {
callback(new Error('请输入密码'));
} else {
callback();
}
validateLoginPassword(rule, value, callback);
};
const validatePass2 = (rule, value, callback) => {
if (value === '') {
@@ -30,7 +27,8 @@ export const userOption = safe => {
index: true,
selection: true,
addBtn: false,
viewBtn: true,
viewBtn: false,
menuWidth: 160,
dialogType: 'dialog',
dialogWidth: '90%',
labelPosition: 'right',
@@ -40,8 +38,11 @@ export const userOption = safe => {
{
label: '账号名',
prop: 'account',
slot: true,
minWidth: 180,
search: true,
searchOrder: 2,
searchPlaceholder: '支持模糊搜索',
display: false,
},
{
@@ -49,6 +50,7 @@ export const userOption = safe => {
prop: 'realName',
search: true,
searchOrder: 4,
searchPlaceholder: '支持模糊搜索',
display: false,
},
{
@@ -56,6 +58,7 @@ export const userOption = safe => {
prop: 'phone',
search: true,
searchOrder: 3,
searchPlaceholder: '支持模糊搜索',
display: false,
},
{ label: '工号', prop: 'code', display: false },
@@ -63,7 +66,8 @@ export const userOption = safe => {
label: '所属部门',
prop: 'deptName',
slot: true,
overHidden: true,
minWidth: 200,
overHidden: false,
display: false,
},
{ label: '岗位', prop: 'postName', overHidden: true, display: false },
+51 -10
View File
@@ -1,7 +1,10 @@
// 一级 value 与二级 value 都用中文原文:库里 exception_type 存的是大类中文名、
// exception_reason 存子类中文名(小程序上报即如此),此处必须与之一致,
// 否则按异常类型做精确匹配的查询会一行都查不到。
export const exceptionCategoryOptions = [
{
label: '交通异常',
value: 'traffic',
value: '交通异常',
children: [
{ label: '道路拥堵', value: '道路拥堵' },
{ label: '交通事故', value: '交通事故' },
@@ -12,7 +15,7 @@ export const exceptionCategoryOptions = [
},
{
label: '车辆异常',
value: 'vehicle',
value: '车辆异常',
children: [
{ label: '车辆故障维修', value: '车辆故障维修' },
{ label: '车辆事故', value: '车辆事故' },
@@ -24,7 +27,7 @@ export const exceptionCategoryOptions = [
},
{
label: '货物异常',
value: 'cargo',
value: '货物异常',
children: [
{ label: '货损', value: '货损' },
{ label: '货差', value: '货差' },
@@ -35,7 +38,7 @@ export const exceptionCategoryOptions = [
},
{
label: '其他异常',
value: 'other',
value: '其他异常',
children: [
{ label: '变更路线地址', value: '变更路线地址' },
{ label: '其他', value: '其他异常-其他' },
@@ -43,6 +46,16 @@ export const exceptionCategoryOptions = [
},
];
/** 一级类型的中文名列表,供编辑表单的级联选择使用 */
export const exceptionTypeOptions = exceptionCategoryOptions.map(item => ({
label: item.label,
value: item.value,
}));
/** 按一级类型取二级选项 */
export const exceptionReasonOptions = typeValue =>
exceptionCategoryOptions.find(item => item.value === typeValue)?.children || [];
export const disposalStatusOptions = [
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
@@ -65,20 +78,30 @@ export const tableColumns = [
{ label: '项目', prop: 'projectName', minWidth: 160 },
{ label: '车辆', prop: 'vehicleNo', minWidth: 150, link: true },
{ label: '上报人', prop: 'reporterName', minWidth: 120 },
{ label: '上报时间', prop: 'reportTime', minWidth: 170 },
{ label: '异常类型', prop: 'exceptionTypeName', minWidth: 160 },
{ label: '上报说明', prop: 'reportDescription', minWidth: 180 },
{ label: '异常节点', prop: 'nodeName', minWidth: 120 },
{ label: '承运商', prop: 'carrierName', minWidth: 160 },
{ label: '最新跟进说明', prop: 'latestFollowContent', minWidth: 200 },
{ label: '状态', prop: 'disposalStatusName', minWidth: 120 },
];
export const detailFields = [
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo', link: true },
{ label: '运单号', prop: 'waybillNo', link: true },
{ label: '项目', prop: 'projectName', link: true },
{ label: '车牌号', prop: 'vehicleNo', link: true },
{ label: '上报人', prop: 'reporterName' },
// 详情/跟进弹窗的只读信息,按来源分两块:
// 「运单信息」是上报时从运单复制的字段,「异常信息」是司机上报的内容。
// 与编辑弹窗的分区口径一致,改动时两处要一起改。
export const detailWaybillFields = [
{ label: '运单号', prop: 'waybillNo' },
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo' },
{ label: '车牌号', prop: 'vehicleNo' },
{ label: '项目', prop: 'projectName' },
{ label: '承运商', prop: 'carrierName' },
];
export const detailExceptionFields = [
{ label: '上报人', prop: 'reporterName' },
{ label: '上报时间', prop: 'reportTime' },
{ label: '异常节点', prop: 'nodeName' },
{ label: '异常类型', prop: 'exceptionTypeName' },
{ label: '异常原因', prop: 'exceptionReason' },
{ label: '上报说明', prop: 'reportDescription', span: 2 },
@@ -89,3 +112,21 @@ export const followColumns = [
{ label: '跟进人', prop: 'followUserName', minWidth: 130 },
{ label: '跟进时间', prop: 'followTime', minWidth: 170 },
];
// 编辑表单只开放上报四件套。车牌、项目、承运商、配载单号由上报时从运单回填,
// 允许手改会让记录与运单脱钩;处置状态由跟进与完成流转,不在此处。
export const editFormFields = [
{ label: '异常类型', prop: 'exceptionType', type: 'select', required: true },
{ label: '异常原因', prop: 'exceptionReason', type: 'select' },
{ label: '上报说明', prop: 'reportDescription', type: 'textarea', required: true, span: 2 },
];
// 编辑弹窗顶部的运单信息,只读展示,帮助运管员确认改的是哪一单。
// 上报人与上报时间属上报行为,放在「异常信息」里,不在此处重复。
export const editWaybillFields = [
{ label: '运单号', prop: 'waybillNo' },
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo' },
{ label: '车牌号', prop: 'vehicleNo' },
{ label: '项目', prop: 'projectName' },
{ label: '承运商', prop: 'carrierName' },
];
@@ -0,0 +1,51 @@
export const option = {
height: 'auto',
calcHeight: 32,
tip: false,
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
selection: false,
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
columnBtn: false,
menuWidth: 110,
column: [
{
label: '司机姓名',
prop: 'driverName',
search: true,
searchOrder: 3,
minWidth: 120,
},
{
label: '司机手机号',
prop: 'driverMobile',
search: true,
searchOrder: 2,
minWidth: 130,
},
{
label: '审核状态',
prop: 'statusText',
slot: true,
width: 110,
align: 'center',
},
{
label: '提交时间',
prop: 'submitTime',
slot: true,
minWidth: 170,
align: 'center',
},
],
};
@@ -0,0 +1,58 @@
export const option = {
height: 'auto',
calcHeight: 32,
tip: false,
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
selection: false,
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
columnBtn: false,
menuWidth: 110,
column: [
{
label: '车牌号',
prop: 'bizKey',
search: true,
searchOrder: 3,
minWidth: 140,
},
{
label: '司机姓名',
prop: 'driverName',
search: true,
searchOrder: 2,
minWidth: 120,
},
{
label: '司机手机号',
prop: 'driverMobile',
search: true,
searchOrder: 1,
minWidth: 130,
},
{
label: '审核状态',
prop: 'statusText',
slot: true,
width: 110,
align: 'center',
},
{
label: '提交时间',
prop: 'submitTime',
slot: true,
minWidth: 170,
align: 'center',
},
],
};
+17
View File
@@ -125,6 +125,23 @@ export const option = {
slot: true,
minWidth: 160,
},
{
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: 'drivingType',
@@ -91,6 +91,23 @@ export const option = {
minWidth: 140,
align: 'center',
},
{
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: 'expireStatus',
@@ -95,6 +95,8 @@ export const option = {
align: 'center',
},
{
// 这里是「最新一次提交的状态」缓存,不等于该车当前是否已认证:
// 已认证由生效快照指针决定,曾审核通过即永久保持,重新提交不会让它退回审核中
label: '认证状态',
prop: 'certificationStatus',
slot: true,
@@ -104,9 +106,10 @@ export const option = {
type: 'select',
dicData: [
{ label: '全部', value: '' },
{ label: '认证中', value: 0 },
{ label: '已认证', value: 1 },
{ label: '审核中', value: 0 },
{ label: '已通过', value: 1 },
{ label: '已驳回', value: 2 },
{ label: '已作废', value: 3 },
],
minWidth: 110,
align: 'center',
@@ -151,6 +154,23 @@ export const option = {
minWidth: 130,
align: 'center',
},
{
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: 'expireStatus',
@@ -12,6 +12,9 @@ export const option = {
height: 'auto',
calcHeight: 32,
dialogWidth: 1200,
// 弹窗根节点 class:avue-crud 弹窗默认 append-to-body 到 body,
// 页面内 scoped 样式命中不到弹窗内容,故用该 class 在全局样式里做兜底(见 styles/element-ui.scss)
dialogCustomClass: 'vehicle-dispatch-dialog',
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
@@ -101,6 +104,7 @@ export const option = {
minWidth: 120,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
{
label: '当前节点',
@@ -108,6 +112,7 @@ export const option = {
minWidth: 120,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
{
label: '当前处理人',
@@ -115,6 +120,7 @@ export const option = {
minWidth: 130,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
{
label: '创建人',
@@ -122,6 +128,7 @@ export const option = {
minWidth: 120,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
{
label: '创建时间',
@@ -132,6 +139,7 @@ export const option = {
minWidth: 170,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
},
// ========== 新增/编辑表单专用(不进表格)==========
{
@@ -165,6 +173,7 @@ export const option = {
type: 'textarea',
hide: true,
span: 24,
minRows: 2,
maxlength: 200,
showWordLimit: true,
rules: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
+16 -1
View File
@@ -1,7 +1,7 @@
import { formatUpdateUserName } from '@/utils/audit';
const validateNonNegative = (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || Number(value) === -1) {
callback();
} else if (Number(value) < 0) {
callback(new Error('不能小于 0'));
@@ -10,6 +10,9 @@ const validateNonNegative = (rule, value, callback) => {
}
};
const formatEmptySentinel = (row, value) =>
value === undefined || value === null || value === '' || Number(value) === -1 ? '' : value;
export const vehicleTypeDic = [
{ label: '车辆', value: '车辆' },
{ label: '船舶', value: '船舶' },
@@ -135,6 +138,7 @@ export const option = {
minWidth: 140,
span: 12,
placeholder: '请输入',
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -146,6 +150,7 @@ export const option = {
minWidth: 140,
span: 12,
placeholder: '请输入',
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -204,6 +209,14 @@ export const option = {
editDisplay: false,
viewDisplay: false,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -281,6 +294,8 @@ export const exportColumns = [
{ prop: 'insuranceClaimAmount', label: '保险理赔金额' },
{ prop: 'accidentReasonDamage', label: '事故原因及损坏情况' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+15 -1
View File
@@ -1,7 +1,7 @@
import { formatUpdateUserName } from '@/utils/audit';
const validateNonNegative = (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || Number(value) === -1) {
callback();
} else if (Number(value) < 0) {
callback(new Error('不能小于 0'));
@@ -10,6 +10,9 @@ const validateNonNegative = (rule, value, callback) => {
}
};
const formatEmptySentinel = (row, value) =>
value === undefined || value === null || value === '' || Number(value) === -1 ? '' : value;
export const vehicleTypeDic = [
{ label: '车辆', value: '车辆' },
{ label: '船舶', value: '船舶' },
@@ -159,6 +162,7 @@ export const option = {
minWidth: 100,
span: 12,
placeholder: '请输入',
formatter: formatEmptySentinel,
rules: [
{ required: true, message: '请输入费用', trigger: 'blur' },
{ validator: validateNonNegative, trigger: 'blur' },
@@ -206,6 +210,14 @@ export const option = {
editDisplay: false,
viewDisplay: false,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -282,6 +294,8 @@ export const exportColumns = [
{ prop: 'fee', label: '费用(元)' },
{ prop: 'assessmentUnit', label: '评定(复核)单位' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+11
View File
@@ -147,6 +147,15 @@ export const option = {
viewDisplay: false,
minWidth: 170,
},
{
label: '创建人',
prop: 'createUserName',
display: false,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -224,6 +233,8 @@ export const exportColumns = [
{ prop: 'specificationModel', label: '规格型号' },
{ prop: 'remark', label: '备注' },
{ prop: 'originalEquipmentNo', label: '原厂设备号' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+10
View File
@@ -172,6 +172,14 @@ export const option = {
editDisplay: false,
viewDisplay: false,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -218,6 +226,8 @@ export const exportColumns = [
{ prop: 'exitStation', label: '出口站' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+15 -1
View File
@@ -1,7 +1,7 @@
import { formatUpdateUserName } from '@/utils/audit';
const validateNonNegative = (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || Number(value) === -1) {
callback();
} else if (Number(value) < 0) {
callback(new Error('不能小于 0'));
@@ -141,6 +141,7 @@ export const option = {
formslot: true,
append: '元',
minWidth: 120,
formatter: (row, value) => (value === undefined || value === null || value === '' || Number(value) === -1 ? '' : value),
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -150,6 +151,7 @@ export const option = {
formslot: true,
append: '元',
minWidth: 120,
formatter: (row, value) => (value === undefined || value === null || value === '' || Number(value) === -1 ? '' : value),
rules: [
{ required: true, message: '请输入保费', trigger: 'blur' },
{ validator: validateNonNegative, trigger: 'blur' },
@@ -159,6 +161,7 @@ export const option = {
label: '发票号',
prop: 'invoiceNo',
minWidth: 150,
formatter: (row, value) => (value === undefined || value === null || value === '' || String(value) === '-1' ? '' : value),
rules: [{ max: 80, message: '最多 80 个字符', trigger: 'blur' }],
},
{
@@ -168,6 +171,7 @@ export const option = {
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
minWidth: 120,
formatter: (row, value) => (value === undefined || value === null || value === '' || String(value) === '-1' ? '' : value),
},
{
label: '备注',
@@ -178,6 +182,14 @@ export const option = {
hide: true,
rules: [{ max: 200, message: '备注最多 200 个字', trigger: 'blur' }],
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -256,6 +268,8 @@ export const exportColumns = [
{ prop: 'invoiceNo', label: '发票号' },
{ prop: 'invoiceDate', label: '开票日期' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+18 -1
View File
@@ -1,6 +1,7 @@
import { formatUpdateUserName } from '@/utils/audit';
const isEmpty = value => value === undefined || value === null || value === '';
const isEmpty = value =>
value === undefined || value === null || value === '' || Number(value) === -1;
const validateNonNegative = (rule, value, callback) => {
if (isEmpty(value)) {
@@ -12,6 +13,8 @@ const validateNonNegative = (rule, value, callback) => {
}
};
const formatEmptySentinel = (row, value) => (isEmpty(value) ? '' : value);
export const option = {
height: 'auto',
calcHeight: 32,
@@ -60,6 +63,7 @@ export const option = {
append: 'km',
minWidth: 210,
slot: true,
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -70,6 +74,7 @@ export const option = {
append: 'km',
minWidth: 210,
slot: true,
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -80,6 +85,7 @@ export const option = {
append: 'km',
minWidth: 170,
slot: true,
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -90,6 +96,7 @@ export const option = {
append: 'km',
minWidth: 170,
slot: true,
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -133,6 +140,14 @@ export const option = {
editDisplay: false,
viewDisplay: false,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -208,6 +223,8 @@ export const exportColumns = [
{ prop: 'monthlyMileage', label: '本月行驶里程数' },
{ prop: 'totalMileage', label: '累计行驶里程数' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+18 -1
View File
@@ -1,6 +1,7 @@
import { formatUpdateUserName } from '@/utils/audit';
const isEmpty = value => value === undefined || value === null || value === '';
const isEmpty = value =>
value === undefined || value === null || value === '' || Number(value) === -1;
const validateNonNegative = (rule, value, callback) => {
if (isEmpty(value)) {
@@ -12,6 +13,8 @@ const validateNonNegative = (rule, value, callback) => {
}
};
const formatEmptySentinel = (row, value) => (isEmpty(value) ? '' : value);
export const vehicleTypeDic = [
{ label: '车辆', value: '车辆' },
{ label: '船舶', value: '船舶' },
@@ -124,6 +127,7 @@ export const option = {
type: 'input',
formslot: true,
minWidth: 110,
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -133,6 +137,7 @@ export const option = {
formslot: true,
append: '元/升',
minWidth: 120,
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -141,6 +146,7 @@ export const option = {
type: 'input',
formslot: true,
minWidth: 130,
formatter: formatEmptySentinel,
rules: [
{ required: true, message: '请输入交易金额', trigger: 'blur' },
{ validator: validateNonNegative, trigger: 'blur' },
@@ -152,6 +158,7 @@ export const option = {
type: 'input',
formslot: true,
minWidth: 110,
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -225,6 +232,14 @@ export const option = {
editDisplay: false,
viewDisplay: false,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -304,6 +319,8 @@ export const exportColumns = [
{ prop: 'balance', label: '余额' },
{ prop: 'station', label: '站点' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
@@ -165,6 +165,14 @@ export const option = {
editDisplay: false,
viewDisplay: false,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -209,6 +217,8 @@ export const exportColumns = [
{ prop: 'amount', label: '金额' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+17 -2
View File
@@ -1,7 +1,7 @@
import { formatUpdateUserName } from '@/utils/audit';
const validateNonNegative = (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || Number(value) === -1) {
callback();
} else if (Number(value) < 0) {
callback(new Error('不能小于 0'));
@@ -11,7 +11,7 @@ const validateNonNegative = (rule, value, callback) => {
};
const validatePositiveInteger = (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || Number(value) === -1) {
callback();
} else if (!Number.isInteger(Number(value)) || Number(value) <= 0) {
callback(new Error('换胎数量必须为正整数'));
@@ -20,6 +20,9 @@ const validatePositiveInteger = (rule, value, callback) => {
}
};
const formatEmptySentinel = (row, value) =>
value === undefined || value === null || value === '' || Number(value) === -1 ? '' : value;
export const option = {
height: 'auto',
calcHeight: 32,
@@ -98,6 +101,7 @@ export const option = {
minWidth: 110,
span: 12,
placeholder: '请输入',
formatter: formatEmptySentinel,
rules: [{ validator: validatePositiveInteger, trigger: 'blur' }],
},
{
@@ -108,6 +112,7 @@ export const option = {
minWidth: 120,
span: 12,
placeholder: '请输入',
formatter: formatEmptySentinel,
rules: [
{ required: true, message: '请输入换胎费用', trigger: 'blur' },
{ validator: validateNonNegative, trigger: 'blur' },
@@ -142,6 +147,14 @@ export const option = {
slot: true,
formslot: true,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -217,6 +230,8 @@ export const exportColumns = [
{ prop: 'replacementCost', label: '换胎费用' },
{ prop: 'replacementDescription', label: '换胎说明' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
@@ -97,6 +97,14 @@ export const option = {
slot: true,
formslot: true,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -169,6 +177,8 @@ export const exportColumns = [
{ prop: 'changeItem', label: '变更事项' },
{ prop: 'changeContent', label: '变更内容' },
{ prop: 'remark', label: '备注' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+17 -2
View File
@@ -1,7 +1,7 @@
import { formatUpdateUserName } from '@/utils/audit';
const validateNonNegative = (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || Number(value) === -1) {
callback();
} else if (Number(value) < 0) {
callback(new Error('不能小于 0'));
@@ -11,7 +11,7 @@ const validateNonNegative = (rule, value, callback) => {
};
const validateDeductPoints = (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || Number(value) === -1) {
callback();
} else if (!Number.isInteger(Number(value)) || Number(value) < 0 || Number(value) > 15) {
callback(new Error('被扣分数范围为 0-15 分'));
@@ -20,6 +20,9 @@ const validateDeductPoints = (rule, value, callback) => {
}
};
const formatEmptySentinel = (row, value) =>
value === undefined || value === null || value === '' || Number(value) === -1 ? '' : value;
export const vehicleTypeDic = [
{ label: '车辆', value: '车辆' },
{ label: '船舶', value: '船舶' },
@@ -150,6 +153,7 @@ export const option = {
minWidth: 100,
span: 12,
placeholder: '请输入',
formatter: formatEmptySentinel,
rules: [{ validator: validateNonNegative, trigger: 'blur' }],
},
{
@@ -160,6 +164,7 @@ export const option = {
minWidth: 110,
span: 12,
placeholder: '请输入',
formatter: formatEmptySentinel,
rules: [{ validator: validateDeductPoints, trigger: 'blur' }],
},
{
@@ -216,6 +221,14 @@ export const option = {
slot: true,
formslot: true,
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
display: false,
minWidth: 120,
},
{
label: '创建时间',
prop: 'createTime',
@@ -294,6 +307,8 @@ export const exportColumns = [
{ prop: 'processStatus', label: '状态' },
{ prop: 'processDescription', label: '过程描述' },
{ prop: 'processResult', label: '处理结果' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
];
export const excelOption = {
+2 -2
View File
@@ -13,8 +13,8 @@ export default {
data() {
return {
loginForm: {
username: 'admin',
password: '123456',
username: '',
password: '',
},
};
},
+3 -1
View File
@@ -24,6 +24,7 @@
-->
<userLogin v-if="activeName === 'user'"></userLogin>
<registerLogin v-else-if="activeName === 'register'"></registerLogin>
<!-- 暂时默认账号密码登录,IAM 选择入口先隐藏
<div v-else class="iam-login">
<el-button type="primary" class="login-submit" @click.prevent="handleIamLogin">
IAM统一身份认证
@@ -32,6 +33,7 @@
账号密码登录
</el-button>
</div>
-->
</div>
</div>
</div>
@@ -61,7 +63,7 @@ export default {
return {
website: website,
time: '',
activeName: 'iam',
activeName: 'user',
socialForm: {
tenantId: '000000',
source: '',
+2 -2
View File
@@ -93,9 +93,9 @@ export default {
//角色ID
roleId: '',
//用户名
username: 'admin',
username: '',
//密码
password: 'admin',
password: '',
//账号类型
type: 'account',
//验证码的值
+38 -16
View File
@@ -87,7 +87,7 @@ export default [
{
path: '/business/project-apply/public-view',
name: '查看项目信息',
component: () => import('@/views/mk/public-biz-view.vue'),
component: () => import('@/views/business/project-apply-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
@@ -98,7 +98,7 @@ export default [
{
path: '/business/contract-manage/public-view',
name: '查看合同信息',
component: () => import('@/views/mk/public-biz-view.vue'),
component: () => import('@/views/business/contract-manage-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
@@ -106,21 +106,10 @@ export default [
bizType: 'contract-manage',
},
},
{
path: '/business/waybill-manage/public-view',
name: '查看运单信息',
component: () => import('@/views/mk/public-biz-view.vue'),
meta: {
keepAlive: false,
isTab: false,
isAuth: false,
bizType: 'waybill-manage',
},
},
{
path: '/settlement/pre-settlement/public-view',
name: '查看预结算信息',
component: () => import('@/views/mk/public-biz-view.vue'),
component: () => import('@/views/settlement/pre-settlement-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
@@ -131,7 +120,7 @@ export default [
{
path: '/settlement/formal-settlement/public-view',
name: '查看正式结算信息',
component: () => import('@/views/mk/public-biz-view.vue'),
component: () => import('@/views/settlement/formal-settlement-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
@@ -142,7 +131,7 @@ export default [
{
path: '/payment/payment-application/public-view',
name: '查看付款申请信息',
component: () => import('@/views/mk/public-biz-view.vue'),
component: () => import('@/views/payment/payment-application-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
@@ -150,6 +139,39 @@ export default [
bizType: 'payment-application',
},
},
{
path: '/payment/invoice-application/public-view',
name: '查看开票申请信息',
component: () => import('@/views/payment/invoice-application-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
isAuth: false,
bizType: 'invoice-application',
},
},
{
path: '/payment/invoice-receipt/public-view',
name: '查看收票登记信息',
component: () => import('@/views/payment/invoice-receipt-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
isAuth: false,
bizType: 'invoice-receipt',
},
},
{
path: '/payment/bill-payment/public-view',
name: '查看汇票付款信息',
component: () => import('@/views/payment/bill-payment-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
isAuth: false,
bizType: 'bill-payment',
},
},
{
path: '/',
name: '主页',
+4
View File
@@ -1,8 +1,12 @@
import { isAdministrator as checkAdministrator } from '@/utils/auth';
const getters = {
tag: state => state.tags.tag,
language: state => state.common.language,
setting: state => state.common.setting,
userInfo: state => state.user.userInfo,
// 超级管理员:角色别名中包含 administrator
isAdministrator: state => checkAdministrator(state.user.userInfo),
colorName: state => state.common.colorName,
themeName: state => state.common.themeName,
isMacOs: (state, getters) => getters.themeName === 'mac-os',
+10 -3
View File
@@ -150,12 +150,15 @@ a {
bottom: 0;
}
.map-picker-content {
display: flex;
gap: 12px;
position: relative;
display: block;
min-width: 0;
overflow: visible;
> [class$='__map'],
> .address-map {
flex: 1 1 auto;
position: relative;
z-index: 1;
width: 100%;
min-width: 0;
box-sizing: border-box;
@@ -165,4 +168,8 @@ a {
height: 100% !important;
}
}
> .map-search-results {
z-index: 2000;
}
}
+103 -5
View File
@@ -173,7 +173,10 @@
}
.el-dialog .el-col {
// 取消列自身下边距,改由 form-item 统一控制行距;
// flow-root 避免子级 margin 折叠穿透导致换行后行距失效、校验提示被下一行遮挡
margin-bottom: 0;
display: flow-root;
}
.el-dialog .el-form-item,
@@ -181,7 +184,7 @@
.el-dialog .el-form-item--small.el-form-item,
.el-dialog .el-form-item--large.el-form-item,
.el-dialog .el-form-item--medium.el-form-item {
//margin-bottom: 20px;
margin-bottom: 24px;
}
/* 全站弹窗:底部操作栏浮动效果(参照 business/project-apply)
@@ -362,9 +365,14 @@
}
}
// 4) 弹窗内表单项默认上下间距 16px(覆盖默认 18px / 22px,与 customer-archive 对齐)
// 4) 弹窗内表单项上下间距:预留绝对定位校验提示(约 14~18px)的可视空间
.el-dialog .el-form-item {
//margin-bottom: 16px;
//margin-bottom: 24px;
}
// 校验提示浮在控件下方,提高层级避免被下一行输入框背景遮挡
.el-form-item__error {
z-index: 2;
}
// 5) 通用分组白卡组件(<section-card>):标题融入卡头
@@ -446,7 +454,7 @@
.dialog-form--carded {
.el-form-item {
align-items: center;
margin-bottom: 14px;
margin-bottom: 24px;
}
.el-form-item__label {
@@ -644,6 +652,28 @@
background: #fff;
}
// 车辆调度新增/编辑/查看弹窗:使用部门 el-cascader 默认 display: inline-block、宽度按内容收缩,
// 会比同行的 el-input(--el-input-width 默认 100%)窄一大截,需显式拉满列宽。
// 两个必须注意的点(踩过):
// 1) avue-crud 弹窗默认 append-to-body 到 body,页面内 `.vehicle-dispatch-page :deep(...)`
// 这类以页面容器为祖先的选择器匹配不到弹窗内容,必须用弹窗自身 class(dialogCustomClass)在全局兜底;
// 2) avue 表单项在 el-form-item__content 与控件之间还有两层包裹 div
// (form/index3.js 渲染:content > div > [div|elTooltip] > 控件),
// 所以要用后代选择器,不能写 `>` 直接子级。
.vehicle-dispatch-dialog .el-form-item__content .el-cascader {
width: 100%;
max-width: 100%;
}
// 车辆调度弹窗 label 宽度统一:option 用 labelWidth: 'auto' 时 label 宽度随字数变化
// (3 字 64px / 2 字 40px / 4 字带星号 78px),控件可用宽度随之变化,
// 于是同一行两列控件宽度不一致(如车牌号 486px vs 使用部门 472px)。
// 本弹窗左右两列最长 label 均为 4 个汉字(所属组织 / 使用部门 / 申请时间),故统一按 4em + 24px 取宽。
.vehicle-dispatch-dialog .el-form-item__label {
flex: 0 0 calc(4em + 24px) !important;
width: calc(4em + 24px) !important;
}
// 过程配置新增/编辑/查看弹窗(手写 el-dialog,参照 vehicle/credit-score-quantification):
// 灰底弹窗体(全站 .el-dialog__body 已统一)内堆叠 <section-card> 分组白卡,
// 卡间 12px 间距由 section-card 自身 margin-bottom 保证,与项目其他弹窗风格统一
@@ -995,6 +1025,66 @@ td.el-table-fixed-column--right {
text-overflow: clip;
}
// 全站统一:内容型长文本列(文件名 / 单号 / 备注等)单元格内也放了 el-link / span.attachment-file-name,
// 会被上面两条「含 el-link 的单元格」规则一并命中,导致:
// ① 上面第 950 行的 display: flex 让链接成为 flex item,flex item 默认 min-width: auto 无法收缩,
// 超长文本(如未规范命名的附件名)会直接把列撑破;
// ② 豁免规则的 overflow: visible 让溢出内容不再被单元格裁切,直接压到相邻列上。
// 因此对「内容列」做显式兜底:单元格自身恢复块级 + 省略号截断,链接参与收缩并显示省略号。
// 用法:在 <el-table-column> 上配置 class-name="is-ellipsis-link"(动态列可 :class-name 绑定)。
// 操作列不要加此类,否则会失去自动换行能力。
.el-table .el-table__body .cell.is-ellipsis-link {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.el-link {
display: inline-block;
min-width: 0;
max-width: 100%;
overflow: hidden;
vertical-align: middle;
// Element Plus 的 el-link__inner 是 inline-flex,flex 容器上的 text-overflow 不生效,
// 必须降级为块级容器,省略号才会真正渲染出来。
.el-link__inner {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.attachment-file-name {
display: inline-block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
}
// 附件上传表格:文件名统一用 span,视觉与可点击行为对齐原 el-link primary
.attachment-file-name {
color: var(--el-color-primary, #409eff);
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
&.is-disabled {
color: var(--el-text-color-placeholder, #c0c4cc);
cursor: not-allowed;
text-decoration: none;
}
}
// 全站统一:Avue 内置行操作按钮(查看 / 编辑 / 删除 / 复制等)视觉对齐为 el-link 链接样式。
// 选择器仅命中表格行操作列 .avue-crud__menu 内的 is-text 按钮,不误伤顶部工具栏(.avue-crud__header)
// 与弹窗按钮(.avue-crud__dialog__menu)。事件(rowView/rowEdit/rowDel)与权限(permission 指令)
@@ -1073,7 +1163,12 @@ td.el-table-fixed-column--right {
align-items: normal;
}
.el-form-item--default {
margin-bottom: 14px;
margin-bottom: 24px;
}
// 独立表单页(非弹窗)同样避免 el-col 折叠吃掉行距
.el-form .el-col {
display: flow-root;
}
// 配套:星号必须保持「高度 auto + 继承行高」,stretch 才会按首行排布;
@@ -1248,3 +1343,6 @@ td.el-table-fixed-column--right {
cursor: not-allowed;
}
}
.is-ellipsis-link {
overflow: hidden;
}
+24 -1
View File
@@ -6,8 +6,31 @@ const RefreshTokenKey = website.refreshTokenKey;
const SessionId = 'JSESSIONID';
const UserId = 'b-user-id';
/** 超级管理员角色别名 */
export const ADMINISTRATOR_ROLE_ALIAS = 'administrator';
/**
* 是否超级管理员:角色别名中包含 administrator 即为超级管理员
*
* @param {object|string|string[]} [userInfoOrAuthority] 用户信息,或 authority / role_name 字符串/数组
* @returns {boolean}
*/
export function isAdministrator(userInfoOrAuthority) {
let authority = userInfoOrAuthority;
if (authority && typeof authority === 'object' && !Array.isArray(authority)) {
authority = authority.authority ?? authority.role_name ?? authority.roleName ?? '';
}
const roles = Array.isArray(authority)
? authority
: String(authority || '')
.split(/[,,]/)
.map(role => role.trim())
.filter(Boolean);
return roles.some(role => String(role).trim() === ADMINISTRATOR_ROLE_ALIAS);
}
export function getToken() {
return Cookies.get(TokenKey);
return Cookies.get(TokenKey);
}
export function setToken(token) {
+140
View File
@@ -0,0 +1,140 @@
import { getDetail, getList as getCustomerList } from '@/api/vehicle/customer-archive';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { getList as getVehicleList } from '@/api/transportCapacity/transport-vehicle';
const orgNameCache = new Map();
const extractRecords = res => {
const data = res?.data?.data ?? res?.data ?? res;
if (Array.isArray(data)) return data;
if (Array.isArray(data?.records)) return data.records;
if (Array.isArray(data?.data)) return data.data;
return [];
};
const normalizeOrgName = value => String(value || '').trim();
const uniqueOrgNames = (values = []) => {
const seen = new Set();
const result = [];
values.forEach(value => {
const name = normalizeOrgName(value);
if (!name || seen.has(name)) return;
seen.add(name);
result.push(name);
});
return result;
};
/**
* 解析承运商客商档案上联系人的所属组织(branchName),无联系人时回退客商所属组织。
*/
export const resolveCarrierOrganizationNames = async ({ carrierId, carrierName } = {}) => {
const id = String(carrierId || '').trim();
const name = normalizeOrgName(carrierName);
const cacheKey = id || name;
if (!cacheKey) return [];
if (orgNameCache.has(cacheKey)) return orgNameCache.get(cacheKey);
let detail = null;
if (id) {
try {
const res = await getDetail(id);
detail = res?.data?.data || res?.data || null;
} catch (error) {
detail = null;
}
}
if (!detail && name) {
try {
const res = await getCustomerList(1, 20, { fullName: name });
const records = extractRecords(res);
const matched =
records.find(item => normalizeOrgName(item.fullName || item.customerName) === name) ||
records[0];
if (matched?.id) {
const detailRes = await getDetail(matched.id);
detail = detailRes?.data?.data || detailRes?.data || matched;
}
} catch (error) {
detail = null;
}
}
const contacts = Array.isArray(detail?.contacts) ? detail.contacts : [];
const orgNames = uniqueOrgNames([
...contacts.map(item => item.branchName),
detail?.deptName,
detail?.organizationName,
]);
orgNameCache.set(cacheKey, orgNames);
if (id && name) orgNameCache.set(name, orgNames);
return orgNames;
};
export const clearCarrierOrganizationCache = (carrier = {}) => {
const id = String(carrier.carrierId || '').trim();
const name = normalizeOrgName(carrier.carrierName);
if (id) orgNameCache.delete(id);
if (name) orgNameCache.delete(name);
};
export const matchOrganizationName = (value, orgNames = []) => {
const text = normalizeOrgName(value);
if (!text || !orgNames.length) return false;
return orgNames.some(org => text === org || text.includes(org) || org.includes(text));
};
const filterByOrganizations = (records = [], orgNames = []) => {
if (!orgNames.length) return [];
return records.filter(item => matchOrganizationName(item.organizationName, orgNames));
};
/**
* 按承运商联系人所属组织筛选司机。
* 未选择承运商时返回空列表(需先选承运商)。
*/
export const fetchDriversByCarrierOrganizations = async (
query = {},
{ carrierId, carrierName } = {}
) => {
if (!String(carrierId || '').trim() && !normalizeOrgName(carrierName)) {
return [];
}
const orgNames = await resolveCarrierOrganizationNames({ carrierId, carrierName });
if (!orgNames.length) return [];
const size = Math.max(Number(query.size) || 50, 50);
const params = { ...query };
delete params.size;
// 单组织时用后端模糊条件缩小范围;多组织再前端精确过滤
if (orgNames.length === 1) {
params.organizationName = orgNames[0];
}
const res = await getDriverList(1, Math.min(size * 5, 200), params);
return filterByOrganizations(extractRecords(res), orgNames).slice(0, size);
};
/**
* 按承运商联系人所属组织筛选车辆。
* 未选择承运商时返回空列表。
*/
export const fetchVehiclesByCarrierOrganizations = async (
query = {},
{ carrierId, carrierName } = {}
) => {
if (!String(carrierId || '').trim() && !normalizeOrgName(carrierName)) {
return [];
}
const orgNames = await resolveCarrierOrganizationNames({ carrierId, carrierName });
if (!orgNames.length) return [];
const size = Math.max(Number(query.size) || 50, 50);
const params = { ...query };
delete params.size;
if (orgNames.length === 1) {
params.organizationName = orgNames[0];
}
const res = await getVehicleList(1, Math.min(size * 5, 200), params);
return filterByOrganizations(extractRecords(res), orgNames).slice(0, size);
};
+434
View File
@@ -0,0 +1,434 @@
/**
* 认证审核通用工具
*
* 认证对象(司机 / 车辆)每次提交产生一条不可变快照记录,「审核」针对的是**一条记录**。
* 记录分两侧:本次**待审快照**与该对象**当前生效快照**(最后一次审核通过的那条)。
* 待审内容在审核通过前不影响生效值,所以审核页必须逐字段对比两侧,而不是只看一条当前值。
*
* 本文件提供三件事:
* 1. 审核状态文案与标签(与后端 statusText 同一套词表);
* 2. 快照字段的展示元数据(中文名、控件类型、分组);
* 3. 两侧快照的字段级 diff 计算,含「从未认证通过、无对比基准」的处理。
*
* 两侧快照字段名完全一致(同一套结构),故可逐键比较。
*/
const EMPTY_TEXT = '—';
const TYPE_IMAGE = 'image';
const TYPE_DATE = 'date';
const TYPE_LONG_TERM = 'longTerm';
/** 审核状态取值 */
export const CERT_RECORD_STATUS = {
PENDING: 0,
APPROVED: 1,
REJECTED: 2,
VOIDED: 3,
};
/** 状态文案统一为:审核中 / 已通过 / 已驳回 / 已作废 */
const CERT_STATUS_TAG = {
0: { label: '审核中', type: 'warning' },
1: { label: '已通过', type: 'success' },
2: { label: '已驳回', type: 'danger' },
3: { label: '已作废', type: 'info' },
};
/** 变更类型 → 差异标签 */
export const CERT_CHANGE_TAG = {
added: { label: '新增', type: 'success' },
modified: { label: '已修改', type: 'warning' },
cleared: { label: '已清空', type: 'danger' },
};
/**
* 「最新提交状态」展示缓存 → 标签
*
* 缓存为空表示该对象从未提交过认证(即没有生效快照)。
* 注意它只是最近一次提交的状态,不等于「是否已认证」——后者由生效快照指针决定。
*/
export const certStatusTag = status => {
if (status === null || status === undefined || status === '') {
return { label: '未认证', type: 'info' };
}
return CERT_STATUS_TAG[status] || { label: EMPTY_TEXT, type: 'info' };
};
const pad = value => String(value).padStart(2, '0');
/** 后端若改用时间戳毫秒序列化日期,也能落成日期文案 */
const EPOCH_PATTERN = /^\d{13}$/;
const isEpochText = text => EPOCH_PATTERN.test(text);
const toDate = text => {
const date = new Date(Number(text));
return Number.isNaN(date.getTime()) ? null : date;
};
/** 提交时间等日期时间展示,兼容 `yyyy-MM-dd HH:mm:ss`、ISO 串与时间戳毫秒 */
export const formatCertDateTime = value => {
if (value === null || value === undefined || value === '') return EMPTY_TEXT;
const text = String(value).trim();
const matched = text.match(
/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/
);
if (matched) {
return (
`${matched[1]}-${pad(matched[2])}-${pad(matched[3])} ` +
`${pad(matched[4])}:${pad(matched[5])}:${pad(matched[6] || 0)}`
);
}
const date = isEpochText(text) ? toDate(text) : null;
if (!date) return text;
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
);
};
/**
* 快照字段元数据
*
* 字段名与后端提交 DTO 的 JSON 键一致(司机 PersonCertSubmitRequest / 车辆 VehicleCertSubmitRequest),
* 两侧快照共用同一套结构,故同时用于标签与比较规则。
*
* `longTermKey` 只挂在**被长期有效作废掉的那个字段**上,也就是各组的「有效期止」与强制报废日期:
* 勾了长期有效,它们显示「长期有效」、比较时按同一个值算(避免每次审核都报「已变更」)。
* 「有效期起」不挂:起期是一个独立的日期,长期有效并不会让它失效,
* 挂上去等于把它自己的值藏起来、还让审核员看不到起期的改动。
*/
const CERT_SNAPSHOT_GROUPS = {
driver: [
{
title: '身份信息',
fields: [
{ key: 'realName', label: '真实姓名' },
{ key: 'idCardNo', label: '身份证号' },
{ key: 'idCardFrontUrl', label: '身份证正面照', type: TYPE_IMAGE },
{ key: 'idCardBackUrl', label: '身份证反面照', type: TYPE_IMAGE },
{ key: 'idCardValidStart', label: '身份证有效期起', type: TYPE_DATE },
{
key: 'idCardValidEnd',
label: '身份证有效期止',
type: TYPE_DATE,
longTermKey: 'idCardLongTerm',
},
{ key: 'idCardLongTerm', label: '身份证长期有效', type: TYPE_LONG_TERM },
{ key: 'headPhoto', label: '大头照', type: TYPE_IMAGE },
],
},
{
title: '驾驶证信息',
fields: [
{ key: 'driverFileNo', label: '驾驶证档案编号' },
{ key: 'driverClass', label: '准驾车型' },
{ key: 'driverLicenseFrontUrl', label: '驾驶证主页照片', type: TYPE_IMAGE },
{ key: 'driverLicenseBackUrl', label: '驾驶证副页照片', type: TYPE_IMAGE },
{ key: 'driverValidStart', label: '驾驶证有效期起', type: TYPE_DATE },
{
key: 'driverValidEnd',
label: '驾驶证有效期止',
type: TYPE_DATE,
longTermKey: 'driverLongTerm',
},
{ key: 'driverLongTerm', label: '驾驶证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '从业资格证信息',
fields: [
{ key: 'qualificationType', label: '从业资格认证类型' },
{ key: 'qualificationNo', label: '从业资格证号' },
{
key: 'qualificationFront',
label: '从业资格证封面页',
type: TYPE_IMAGE,
legacyKeys: ['qualificationUrl'],
},
{ key: 'qualificationBack', label: '从业资格证内容页', type: TYPE_IMAGE },
{ key: 'qualificationValidStart', label: '从业资格证有效期起', type: TYPE_DATE },
{
key: 'qualificationValidEnd',
label: '从业资格证有效期止',
type: TYPE_DATE,
longTermKey: 'qualificationLongTerm',
},
{ key: 'qualificationLongTerm', label: '从业资格证长期有效', type: TYPE_LONG_TERM },
],
},
],
/*
* 车辆字段名以 VehicleCertSubmitRequest 为准,与 PersonCertSubmitRequest 不同。
* 键名对不上不报错——每个字段都算「没有值」,两组全显示「—」且 diff 不出变更。
*/
vehicle: [
{
title: '车辆基本信息',
fields: [
{ key: 'plateNo', label: '车牌号' },
{ key: 'vehicleType', label: '车辆类型' },
{ key: 'plateColor', label: '车牌颜色' },
{ key: 'ownerName', label: '所有人' },
{ key: 'vin', label: '车辆识别代号(车架号)' },
{ key: 'outerLength', label: '外廓长度(mm)' },
{ key: 'outerWidth', label: '外廓宽度(mm)' },
{ key: 'outerHeight', label: '外廓高度(mm)' },
{ key: 'approvedLoadKg', label: '核定载质量(KG)' },
{ key: 'tractionMassKg', label: '准牵引总质量(KG)' },
{ key: 'energyType', label: '能源类型' },
{ key: 'businessRelation', label: '业务关系' },
],
},
{
title: '行驶证信息',
fields: [
{ key: 'compulsoryScrapDate', label: '强制报废日期', type: TYPE_DATE, longTermKey: 'compulsoryScrapLongTerm' },
{ key: 'compulsoryScrapLongTerm', label: '强制报废长期有效', type: TYPE_LONG_TERM },
{ key: 'drivingLicenseNo', label: '行驶证档案编号' },
{ key: 'drivingLicenseImage', label: '行驶证主页正面', type: TYPE_IMAGE },
{ key: 'drivingLicenseMainBack', label: '行驶证主页反面', type: TYPE_IMAGE },
{ key: 'drivingLicenseViceFront', label: '行驶证副页正面', type: TYPE_IMAGE },
{ key: 'drivingLicenseViceBack', label: '行驶证副页反面', type: TYPE_IMAGE },
{ key: 'drivingLicenseStartDate', label: '行驶证有效期起', type: TYPE_DATE },
{
key: 'drivingLicenseEndDate',
label: '行驶证有效期止',
type: TYPE_DATE,
longTermKey: 'drivingLicenseLongTerm',
},
{ key: 'drivingLicenseLongTerm', label: '行驶证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '道路运输证信息',
fields: [
{ key: 'roadTransportCertNo', label: '道路运输证号' },
{ key: 'roadTransportCertImage', label: '道路运输证照片', type: TYPE_IMAGE },
{ key: 'roadTransportCertStartDate', label: '道路运输证有效期起', type: TYPE_DATE },
{
key: 'roadTransportCertEndDate',
label: '道路运输证有效期止',
type: TYPE_DATE,
longTermKey: 'roadTransportCertLongTerm',
},
{ key: 'roadTransportCertLongTerm', label: '道路运输证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '机动车登记本信息',
fields: [
{ key: 'registrationNo', label: '机动车登记编号' },
{ key: 'registrationImage', label: '机动车登记本照片', type: TYPE_IMAGE },
],
},
],
};
/** 提交元信息与主键不参与对比(两侧必相同,展示出来只是噪音) */
const IGNORED_KEYS = [
'id',
'bizType',
'bizId',
'bizKey',
'certificationStatus',
'snapshotJson',
'pendingSnapshotJson',
'effectiveSnapshotJson',
];
/**
* 解析快照 JSON
*
* 后端把 null 序列化为空串,故空串 / 'null' 都按「没有快照」处理,不算解析失败。
*/
const readSnapshot = json => {
if (json && typeof json === 'object') {
return { value: Object.keys(json).length ? json : null, invalid: false };
}
const text = String(json === null || json === undefined ? '' : json).trim();
if (!text || text === 'null') {
return { value: null, invalid: false };
}
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object') {
return { value: parsed, invalid: false };
}
return { value: null, invalid: true };
} catch (error) {
return { value: null, invalid: true };
}
};
/** 解析快照 JSON,无快照或解析失败时返回 null */
export const parseSnapshot = json => readSnapshot(json).value;
const isEmptyValue = value => value === null || value === undefined || value === '';
const isLongTermOn = value => value === 1 || value === '1' || value === true;
/** 日期归一化到 yyyy-MM-dd,便于 `2026-01-01` 与 `2026-01-01T00:00:00` 判等 */
const normalizeDate = value => {
if (isEmptyValue(value)) return '';
const text = String(value).trim();
const matched = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (matched) return `${matched[1]}-${pad(matched[2])}-${pad(matched[3])}`;
const date = isEpochText(text) ? toDate(text) : null;
if (date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
return text;
};
/**
* 取字段值,兼容字段改名前的旧键
*
* 后端把某字段改名后,改名之前提交的记录里存的仍是旧键(例:从业资格证封面页
* 由 `qualificationUrl` 改为 `qualificationFront`)。只读新键会得到空,
* 审核页就把已填的封面页显示成「—」,误导审核员以为对方没传。
* 新旧键都存在时以新键为准。
*/
const readFieldValue = (snapshot, field) => {
if (!snapshot) return undefined;
const value = snapshot[field.key];
if (!isEmptyValue(value)) return value;
const legacyKeys = field.legacyKeys || [];
for (let i = 0; i < legacyKeys.length; i += 1) {
if (!isEmptyValue(snapshot[legacyKeys[i]])) return snapshot[legacyKeys[i]];
}
return value;
};
/** 比较用的归一化取值:展示相同即视为未变更 */
const canonicalOf = (snapshot, field) => {
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_LONG_TERM) {
if (isEmptyValue(value)) return '';
return isLongTermOn(value) ? '1' : '0';
}
if (field.type === TYPE_DATE) {
if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) {
return '长期有效';
}
return normalizeDate(value);
}
return isEmptyValue(value) ? '' : String(value).trim();
};
/** 展示文案:日期型受「长期有效」影响,图片型返回 URL 由组件渲染缩略图 */
const textOf = (snapshot, field) => {
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_IMAGE) {
return isEmptyValue(value) ? '' : String(value);
}
if (field.type === TYPE_LONG_TERM) {
if (isEmptyValue(value)) return EMPTY_TEXT;
return isLongTermOn(value) ? '是' : '否';
}
if (field.type === TYPE_DATE) {
if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) {
return '长期有效';
}
return normalizeDate(value) || EMPTY_TEXT;
}
return isEmptyValue(value) ? EMPTY_TEXT : String(value);
};
const buildRow = (field, pendingSnapshot, effectiveSnapshot, hasBaseline) => {
const type = field.type || 'text';
const target = { ...field, type };
const pendingCanonical = canonicalOf(pendingSnapshot, target);
const effectiveCanonical = canonicalOf(effectiveSnapshot, target);
let changeType = '';
if (hasBaseline && pendingCanonical !== effectiveCanonical) {
if (!effectiveCanonical) {
changeType = 'added';
} else if (!pendingCanonical) {
changeType = 'cleared';
} else {
changeType = 'modified';
}
}
return {
key: field.key,
label: field.label,
type,
effective: {
raw: readFieldValue(effectiveSnapshot, target),
text: textOf(effectiveSnapshot, target),
},
pending: {
raw: readFieldValue(pendingSnapshot, target),
text: textOf(pendingSnapshot, target),
},
changed: Boolean(changeType),
changeType,
};
};
const unionKeys = (pendingSnapshot, effectiveSnapshot) => {
const keys = [];
[pendingSnapshot, effectiveSnapshot].forEach(snapshot => {
Object.keys(snapshot || {}).forEach(key => {
if (!keys.includes(key)) keys.push(key);
});
});
return keys;
};
/**
* 构造「待审快照 vs 当前生效值」的字段级 diff
*
* @param {string} bizType 认证对象类型:driver / vehicle
* @param {string} pendingJson 本次待审快照 JSON
* @param {string} effectiveJson 该对象当前生效快照 JSON,从未认证通过时为空
* @returns {Object} { hasBaseline, groups, totalCount, changedCount, ... }
* hasBaseline 为 false 表示无对比基准(从未认证通过),此时不产出任何变更标记
*/
export const buildSnapshotDiff = (bizType, pendingJson, effectiveJson) => {
const pending = readSnapshot(pendingJson);
const effective = readSnapshot(effectiveJson);
const hasBaseline = effective.value !== null;
const groupsConfig = CERT_SNAPSHOT_GROUPS[bizType] || [];
const knownKeys = [];
const groups = groupsConfig.map(group => {
const rows = group.fields.map(field => {
// 旧键已由所属字段并入展示,不能再作为独立字段出现,否则同一张封面页会出现两行
knownKeys.push(field.key, ...(field.legacyKeys || []));
return buildRow(field, pending.value, effective.value, hasBaseline);
});
return {
title: group.title,
rows,
changedCount: rows.filter(row => row.changed).length,
};
});
// 元数据未登记的字段照样展示,避免后端新增字段时审核员看不到变化
const extraKeys = unionKeys(pending.value, effective.value).filter(
key => !knownKeys.includes(key) && !IGNORED_KEYS.includes(key)
);
if (extraKeys.length) {
const rows = extraKeys.map(key =>
buildRow({ key, label: key }, pending.value, effective.value, hasBaseline)
);
groups.push({
title: '其他字段',
rows,
changedCount: rows.filter(row => row.changed).length,
});
}
const rows = groups.reduce((all, group) => all.concat(group.rows), []);
return {
hasBaseline,
pendingInvalid: pending.invalid,
effectiveInvalid: effective.invalid,
groups,
totalCount: rows.length,
changedCount: rows.filter(row => row.changed).length,
};
};
+14
View File
@@ -0,0 +1,14 @@
/**
* 合同下拉/选择展示:合同编号/合同名称
*
* @param {Object} contract 合同对象
* @returns {string}
*/
export function formatContractOptionLabel(contract = {}) {
const contractNo = String(contract?.contractNo ?? '').trim();
const contractName = String(contract?.contractName ?? '').trim();
if (contractNo && contractName) {
return `${contractNo}/${contractName}`;
}
return contractNo || contractName || '';
}
+86
View File
@@ -13,6 +13,92 @@ const coordinateRanges = {
},
};
/** WGS-84 → GCJ-02(高德)偏移算法常量 */
const WGS84_TO_GCJ02_PI = Math.PI;
const WGS84_TO_GCJ02_A = 6378245.0;
const WGS84_TO_GCJ02_EE = 0.00669342162296594323;
const isOutOfChina = (longitude, latitude) =>
longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
const transformLatitude = (longitude, latitude) => {
let result =
-100.0 +
2.0 * longitude +
3.0 * latitude +
0.2 * latitude * latitude +
0.1 * longitude * latitude +
0.2 * Math.sqrt(Math.abs(longitude));
result +=
((20.0 * Math.sin(6.0 * longitude * WGS84_TO_GCJ02_PI) +
20.0 * Math.sin(2.0 * longitude * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((20.0 * Math.sin(latitude * WGS84_TO_GCJ02_PI) +
40.0 * Math.sin((latitude / 3.0) * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((160.0 * Math.sin((latitude / 12.0) * WGS84_TO_GCJ02_PI) +
320 * Math.sin((latitude * WGS84_TO_GCJ02_PI) / 30.0)) *
2.0) /
3.0;
return result;
};
const transformLongitude = (longitude, latitude) => {
let result =
300.0 +
longitude +
2.0 * latitude +
0.1 * longitude * longitude +
0.1 * longitude * latitude +
0.1 * Math.sqrt(Math.abs(longitude));
result +=
((20.0 * Math.sin(6.0 * longitude * WGS84_TO_GCJ02_PI) +
20.0 * Math.sin(2.0 * longitude * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((20.0 * Math.sin(longitude * WGS84_TO_GCJ02_PI) +
40.0 * Math.sin((longitude / 3.0) * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((150.0 * Math.sin((longitude / 12.0) * WGS84_TO_GCJ02_PI) +
300.0 * Math.sin((longitude / 30.0) * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
return result;
};
/**
* WGS-84(GPS / LBS)转 GCJ-02(高德/国测局),供高德地图绘制使用。
* 中国境外坐标不做偏移,原样返回。
* @param {number} longitude
* @param {number} latitude
* @returns {[number, number]} [经度, 纬度]
*/
export const wgs84ToGcj02 = (longitude, latitude) => {
const lng = Number(longitude);
const lat = Number(latitude);
if (!Number.isFinite(lng) || !Number.isFinite(lat) || isOutOfChina(lng, lat)) {
return [lng, lat];
}
let dLat = transformLatitude(lng - 105.0, lat - 35.0);
let dLng = transformLongitude(lng - 105.0, lat - 35.0);
const radLat = (lat / 180.0) * WGS84_TO_GCJ02_PI;
let magic = Math.sin(radLat);
magic = 1 - WGS84_TO_GCJ02_EE * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat =
(dLat * 180.0) /
(((WGS84_TO_GCJ02_A * (1 - WGS84_TO_GCJ02_EE)) / (magic * sqrtMagic)) * WGS84_TO_GCJ02_PI);
dLng = (dLng * 180.0) / ((WGS84_TO_GCJ02_A / sqrtMagic) * Math.cos(radLat) * WGS84_TO_GCJ02_PI);
return [lng + dLng, lat + dLat];
};
export const normalizeCoordinateInput = value => {
const rawValue = String(value ?? '').replace(/[^\d.-]/g, '');
const isNegative = rawValue.startsWith('-');
+109
View File
@@ -0,0 +1,109 @@
const CURRENCY_NAME_MAP = {
CNY: '人民币',
RMB: '人民币',
USD: '美元',
EUR: '欧元',
JPY: '日元',
GBP: '英镑',
};
const CURRENCY_UNIT_MAP = {
CNY: '元',
RMB: '元',
USD: '美元',
EUR: '欧元',
JPY: '日元',
GBP: '英镑',
};
const CURRENCY_NAME_TO_UNIT = {
人民币: '元',
美元: '美元',
欧元: '欧元',
日元: '日元',
英镑: '英镑',
};
/**
* 归一化币种代码(CNY → RMB)
*/
export function normalizeCurrencyCode(value, fallback = 'RMB') {
const raw =
typeof value === 'object'
? value?.dictKey || value?.value || value?.code || value?.dictValue || ''
: value;
const normalized = String(raw || fallback)
.trim()
.toUpperCase();
const code = normalized.includes(' - ') ? normalized.split(' - ')[0].trim() : normalized;
if (!code) return fallback;
return code === 'CNY' ? 'RMB' : code;
}
/**
* 币种中文名称
*/
export function currencyName(value) {
const text = String(value || '');
if (text.includes(' - ')) return text.slice(text.indexOf(' - ') + 3);
return CURRENCY_NAME_MAP[text.toUpperCase()] || text;
}
/**
* 金额单位文案:RMB/CNY → 元,USD → 美元
*/
export function currencyUnit(value) {
const text = String(value || '');
let code = text;
if (text.includes(' - ')) {
code = text.split(' - ')[0].trim();
}
const upperCode = code.toUpperCase();
if (CURRENCY_UNIT_MAP[upperCode]) {
return CURRENCY_UNIT_MAP[upperCode];
}
if (CURRENCY_NAME_TO_UNIT[code]) {
return CURRENCY_NAME_TO_UNIT[code];
}
return text || '元';
}
/**
* 将计价单位前缀替换为当前币种单位,如 元/吨 → 美元/吨
*/
export function remapPriceUnitCurrency(priceUnit, currency) {
const text = String(priceUnit || '').trim();
if (!text || !text.includes('/')) return text;
const suffix = text.slice(text.indexOf('/'));
return `${currencyUnit(currency)}${suffix}`;
}
/**
* 按数量单位生成默认计价单位
*/
export function resolvePriceUnitByQuantityUnit(quantityUnit, currency = 'RMB') {
const unit = currencyUnit(currency);
return (
{
吨: `${unit}/吨`,
方: `${unit}/立方`,
件: `${unit}/件`,
}[String(quantityUnit || '').trim()] || ''
);
}
/**
* 将字典中的计价单位选项映射到当前币种
*/
export function mapFeeUnitOptions(options = [], currency = 'RMB') {
const unit = currencyUnit(currency);
return (options || []).map(item => {
const label = String(item.label || item.value || '');
const value = String(item.value || item.label || '');
return {
...item,
label: label.replace(/^元/, unit).replace(/^人民币/, unit),
value: value.replace(/^元/, unit).replace(/^人民币/, unit),
};
});
}
+99 -22
View File
@@ -6,7 +6,31 @@ import 'nprogress/nprogress.css';
const excelPattern = /\.(xls|xlsx)$/;
/** 是否为浏览器 Blob(含跨 realm 的 Blob-like) */
const isBlobLike = value =>
value != null &&
typeof value === 'object' &&
typeof value.size === 'number' &&
typeof value.type === 'string' &&
(typeof value.text === 'function' || typeof value.arrayBuffer === 'function');
/** axios 拦截器已把 JSON Blob 解析成的业务对象 */
const isBusinessJson = value =>
value != null &&
typeof value === 'object' &&
!isBlobLike(value) &&
!(value instanceof ArrayBuffer) &&
('code' in value || 'success' in value || 'msg' in value);
const isExcelResponse = res => {
// 已是业务 JSON(导入成功/失败提示)时,绝不能当失败明细 Excel 处理
if (isBusinessJson(res?.data)) {
return false;
}
// 只有文件流才可能是失败明细;普通对象没有 .text/.arrayBuffer
if (!isBlobLike(res?.data)) {
return false;
}
const headers = res.headers || {};
const contentType = String(headers['content-type'] || res.data?.type || '').toLowerCase();
const contentDisposition = String(headers['content-disposition'] || '').toLowerCase();
@@ -23,8 +47,34 @@ const isExcelResponse = res => {
);
};
const parseBlobJson = async blob => {
const text = await blob.text();
/**
* 解析导入接口响应体为 JSON 对象。
* <p>
* axios 拦截器在 Content-Type 为 JSON 时,会把 `responseType: 'blob'` 的 Blob
* 提前解析成普通对象;此处需同时兼容 Blob / 字符串 / 已解析对象,避免再调
* `blob.text()` 时出现「blob.text is not a function」。
*/
const parseBlobJson = async data => {
if (data == null) {
return {};
}
if (isBusinessJson(data)) {
return data;
}
if (typeof data === 'object' && !isBlobLike(data) && !(data instanceof ArrayBuffer)) {
return data;
}
if (typeof data === 'string') {
return data ? JSON.parse(data) : {};
}
let text = '';
if (typeof data.text === 'function') {
text = await data.text();
} else if (typeof data.arrayBuffer === 'function') {
text = new TextDecoder().decode(await data.arrayBuffer());
} else if (data instanceof ArrayBuffer) {
text = new TextDecoder().decode(data);
}
return text ? JSON.parse(text) : {};
};
@@ -39,18 +89,21 @@ const parseBlobJson = async blob => {
* 这里补上这一步:从错误响应的 Blob 里取出后端 `msg`,取不到再退回通用文案。
*/
const resolveErrorMessage = async error => {
const blob = error?.response?.data ?? error?.data;
if (blob instanceof Blob) {
const payload = error?.response?.data ?? error?.data;
if (isBusinessJson(payload) && payload.msg) {
return payload.msg;
}
if (isBlobLike(payload) || typeof payload === 'string') {
try {
const result = await parseBlobJson(blob);
const result = await parseBlobJson(payload);
if (result?.msg) return result.msg;
} catch (ignored) {
// 响应体不是 JSON(如网关返回的 HTML 错误页),退回通用文案
}
}
const fallback = error?.response?.data?.msg || error?.message;
const fallback = payload?.msg || error?.message;
// axios 的英文通用文案对用户没有意义,统一换成中文提示
if (!fallback || /Request failed with status code/i.test(fallback)) {
if (!fallback || /Request failed with status code|\.text is not a function/i.test(fallback)) {
return '导入失败,请检查导入文件后重试';
}
return fallback;
@@ -68,8 +121,11 @@ const resolveErrorMessage = async error => {
*/
const decorateFailExcel = async (blob, businessName, failDetailDecorator) => {
if (typeof failDetailDecorator !== 'function') return blob;
if (!isBlobLike(blob) && !(blob instanceof ArrayBuffer)) {
return blob;
}
const ExcelJS = await import('exceljs');
const buf = await blob.arrayBuffer();
const buf = typeof blob.arrayBuffer === 'function' ? await blob.arrayBuffer() : blob;
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buf);
await failDetailDecorator(workbook, { businessName });
@@ -77,6 +133,31 @@ const decorateFailExcel = async (blob, businessName, failDetailDecorator) => {
return new Blob([out], { type: 'application/vnd.ms-excel' });
};
/** 通知上传组件成功:只传业务响应体,避免其误调 axios 响应上的 .text() */
const notifyUploadSuccess = (option, res) => {
if (typeof option?.onSuccess !== 'function') {
return;
}
try {
const body = isBusinessJson(res?.data) ? res.data : { code: 200, success: true };
option.onSuccess(body);
} catch (callbackError) {
console.warn('upload onSuccess callback failed', callbackError);
}
};
/** 通知上传组件失败 */
const notifyUploadError = (option, error) => {
if (typeof option?.onError !== 'function') {
return;
}
try {
option.onError(error instanceof Error ? error : new Error(String(error?.message || error || '导入失败')));
} catch (callbackError) {
console.warn('upload onError callback failed', callbackError);
}
};
/**
* 打开导入弹窗
* @param {Object} vm 组件实例
@@ -108,9 +189,7 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
const fileName = file && file.name ? file.name.toLowerCase() : '';
if (!excelPattern.test(fileName)) {
vm.$message.error('请上传 .xls,.xlsx 标准格式文件');
if (typeof option.onError === 'function') {
option.onError(new Error('请上传 .xls,.xlsx 标准格式文件'));
}
notifyUploadError(option, new Error('请上传 .xls,.xlsx 标准格式文件'));
return;
}
@@ -130,12 +209,11 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
);
vm.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const result = await parseBlobJson(res.data);
if (result.code !== 200) {
// 成功体常被 axios 拦截器提前解析成对象;优先走业务 JSON,避免再调 Blob.text
const result = isBusinessJson(res?.data) ? res.data : await parseBlobJson(res?.data);
if (Number(result.code) !== 200) {
vm.$message.error(result.msg || '导入失败');
if (typeof option.onError === 'function') {
option.onError(new Error(result.msg || '导入失败'));
}
notifyUploadError(option, new Error(result.msg || '导入失败'));
return;
}
vm.$message.success('导入完成');
@@ -149,18 +227,17 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
} else if (typeof vm.initTree === 'function') {
vm.initTree();
}
if (typeof option.onSuccess === 'function') {
option.onSuccess(res);
}
// 上传组件 onSuccess 约定接收「响应体」,不是完整 axios 响应。
// 若传入 axios 响应对象,部分环境下会按 Fetch Response 调用 .text(),
// 从而在导入已成功后仍弹出「e.text is not a function」。
notifyUploadSuccess(option, res);
} catch (error) {
// 拦截器已提示过的错误(如后端 400 返回的业务提示)不再重复弹一次,
// 只保留一次清晰的中文提示。
if (!error?._notified) {
vm.$message.error(await resolveErrorMessage(error));
}
if (typeof option.onError === 'function') {
option.onError(error);
}
notifyUploadError(option, error);
} finally {
loading.close();
NProgress.done();
+162
View File
@@ -0,0 +1,162 @@
/** 地点搜索 v5/place/text 使用的「Web 服务」类型 Key */
export const AMAP_WEB_SERVICE_KEY = 'b43016a0d1f5b0c32211560392059708';
/**
* 高德地点关键词搜索(Web 服务 v5)。
* @see https://restapi.amap.com/v5/place/text
* @param {string} keyword 地点关键字
* @param {{ key?: string, pageSize?: number, pageNum?: number, region?: string, cityLimit?: boolean }} options
* @returns {Promise<Array>}
*/
export function searchAmapPlaceText(keyword, options = {}) {
const key = options.key || AMAP_WEB_SERVICE_KEY;
const pageSize = options.pageSize || 20;
const pageNum = options.pageNum || 1;
if (!key) {
return Promise.reject(new Error('高德地图 Key 未配置'));
}
const trimmed = String(keyword || '').trim();
if (!trimmed) {
return Promise.reject(new Error('请输入地址关键词'));
}
const params = new URLSearchParams({
key,
keywords: trimmed,
page_size: String(pageSize),
page_num: String(pageNum),
show_fields: 'photos',
});
if (options.region) {
params.set('region', options.region);
}
if (options.cityLimit != null) {
params.set('city_limit', options.cityLimit ? 'true' : 'false');
}
return fetch(`https://restapi.amap.com/v5/place/text?${params.toString()}`)
.then(response => {
if (!response.ok) {
throw new Error('高德地点搜索请求失败');
}
return response.json();
})
.then(data => {
if (String(data?.status) === '1') {
return Array.isArray(data.pois) ? data.pois : [];
}
const error = new Error(data?.info || '高德地点搜索失败');
error.code = data?.infocode;
throw error;
});
}
/**
* 将高德 Geocoder / place/text 结果规范化为地图选址浮层列表数据。
* @param {Array|Object} geocodesOrResult geocodes/pois 数组,或含 geocodes/pois/location 的结果对象
* @param {string} keyword 搜索关键词兜底文案
* @returns {Array<{id: string|number, name: string, address: string, location: any, photo: string}>}
*/
export function normalizeMapSearchResults(geocodesOrResult, keyword = '') {
const fallback = String(keyword || '').trim();
let list = [];
if (Array.isArray(geocodesOrResult)) {
list = geocodesOrResult;
} else if (geocodesOrResult && typeof geocodesOrResult === 'object') {
if (Array.isArray(geocodesOrResult.pois)) {
list = geocodesOrResult.pois;
} else if (Array.isArray(geocodesOrResult.geocodes)) {
list = geocodesOrResult.geocodes;
} else if (geocodesOrResult.location) {
list = [geocodesOrResult];
}
}
return list
.map((item, index) => {
if (!item) return null;
if (isPlacePoi(item)) {
const address = buildPoiAddress(item) || fallback || '-';
return {
id: item.id || index,
name: String(item.name || '').trim() || address,
address,
location: parseMapLocation(item.location),
photo: pickPoiPhoto(item),
};
}
const buildingName = pickNamedField(item?.building);
const neighborhoodName = pickNamedField(item?.neighborhood);
const address = item?.formattedAddress || item?.address || fallback || '-';
return {
id: item?.id || index,
name: buildingName || neighborhoodName || address,
address,
location: parseMapLocation(item?.location),
photo: item?.photo || item?.image || '',
};
})
.filter(item => item && item.location);
}
export function parseMapLocation(location) {
if (!location) return null;
if (typeof location.getLng === 'function' && typeof location.getLat === 'function') {
return location;
}
if (typeof location === 'string') {
const parts = location.split(',');
const lng = Number(parts[0]);
const lat = Number(parts[1]);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
return { lng, lat };
}
if (Number.isFinite(Number(location.lng)) && Number.isFinite(Number(location.lat))) {
return { lng: Number(location.lng), lat: Number(location.lat) };
}
if (
Number.isFinite(Number(location.longitude)) &&
Number.isFinite(Number(location.latitude))
) {
return { lng: Number(location.longitude), lat: Number(location.latitude) };
}
return null;
}
function isPlacePoi(item) {
return Boolean(item && item.name && item.location && (item.id || item.typecode || item.adname));
}
function buildPoiAddress(item) {
const detail = String(item.address || '').trim();
const region = [item.pname, item.cityname, item.adname]
.map(value => String(value || '').trim())
.filter(Boolean);
// 直辖市等场景省市名可能重复
const uniqueRegion = region.filter((value, index) => region.indexOf(value) === index);
const prefix = uniqueRegion.join('');
if (prefix && detail) {
if (detail.startsWith(prefix)) return detail;
return `${prefix}${detail}`;
}
return detail || prefix || String(item.name || '').trim();
}
function pickPoiPhoto(item) {
const photos = item?.photos;
if (Array.isArray(photos) && photos.length) {
return photos[0]?.url || '';
}
if (photos && typeof photos === 'object') {
return photos.url || '';
}
return item?.photo || item?.image || '';
}
function pickNamedField(value) {
if (!value) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'object' && value.name) return String(value.name).trim();
return '';
}
+27
View File
@@ -0,0 +1,27 @@
import { getList as getMeasurementUnitList } from '@/api/base/measurement-unit';
/**
* 加载启用状态的计量单位选项,展示/取值均为「计量单位」字段(unitName)。
* @returns {Promise<Array<{ label: string, value: string, dimension: string, id: string|number }>>}
*/
export const loadMeasurementUnitOptions = () => {
return getMeasurementUnitList(1, 9999, { status: 1 }).then(res => {
const data = res?.data?.data || res?.data || {};
const records = Array.isArray(data) ? data : data.records || [];
const names = new Set();
return records
.filter(item => item.status === undefined || item.status === null || Number(item.status) === 1)
.map(item => {
const name = String(item.unitName || '').trim();
if (!name || names.has(name)) return null;
names.add(name);
return {
id: item.id,
label: name,
value: name,
dimension: String(item.dimension || '').trim(),
};
})
.filter(Boolean);
});
};
+86 -15
View File
@@ -1,5 +1,5 @@
import { getDictionary } from '@/api/system/dictbiz';
import { processDelete, processSubmit } from '@/api/system/business-process';
import { hasMkProcessInstance, processDelete, processSubmit } from '@/api/system/business-process';
import { ElMessage } from 'element-plus';
export const MK_BIZ = {
@@ -7,43 +7,80 @@ export const MK_BIZ = {
dictName: '提交客商审核流',
subjectPrefix: '客商准入审批',
rejected: ['rejected'],
approved: ['approved'],
},
'project-apply': {
dictName: '提交项目审核流',
subjectPrefix: '项目审批',
rejected: ['rejected', 'change_rejected', 'withdrawn'],
approved: ['approved', 'change_approved'],
},
'contract-manage': {
dictName: '提交合同审核流',
subjectPrefix: '合同审批',
rejected: ['rejected', 'change_rejected', 'withdrawn'],
},
'waybill-manage': {
dictName: '提交运单审核流',
subjectPrefix: '运单审批',
rejected: ['rejected', 'returned'],
approved: ['approved', 'change_approved'],
},
'pre-settlement': {
dictName: '提交预结算审核流',
subjectPrefix: '预结算审批',
rejected: ['returned'],
approved: ['approved'],
},
'formal-settlement': {
dictName: '提交正式结算审核流',
subjectPrefix: '正式结算审批',
rejected: ['returned'],
approved: ['approved'],
},
'payment-application': {
dictName: '提交付款审核流',
subjectPrefix: '付款审批',
rejected: ['returned'],
approved: ['approved'],
},
'invoice-application': {
dictName: '提交开票审核流',
subjectPrefix: '开票审批',
rejected: ['returned'],
approved: ['approved'],
},
'invoice-receipt': {
dictName: '提交收票审核流',
subjectPrefix: '收票审批',
rejected: ['returned'],
approved: ['approved'],
},
'bill-payment': {
dictName: '提交汇票付款审核流',
subjectPrefix: '汇票付款审批',
rejected: ['returned'],
approved: ['approved'],
},
};
export async function resolveMkTemplateCode(dictName) {
const MK_SWITCH_NAME = '开启MK';
async function loadMkTemplateList() {
const res = await getDictionary({ code: 'mk_template' });
const list = res?.data?.data || [];
const matched = list.find(item => String(item.dictValue || '').trim() === dictName);
return res?.data?.data || [];
}
/** 业务字典 mk_template:名称「开启MK」且键值为 1 时才请求 MK */
export function isMkEnabled(list = []) {
const matched = list.find(item => String(item.dictValue || '').trim() === MK_SWITCH_NAME);
return String(matched?.dictKey ?? '').trim() === '1';
}
/** 加载业务字典并判断是否开启 MK */
export async function loadMkEnabled() {
const list = await loadMkTemplateList();
return isMkEnabled(list);
}
export async function resolveMkTemplateCode(dictName, list) {
const dictList = list || (await loadMkTemplateList());
const matched = dictList.find(item => String(item.dictValue || '').trim() === dictName);
const templateCode = matched?.dictKey;
if (!templateCode) {
ElMessage.warning(`未配置业务字典 mk_template「${dictName}」,无法提交审核流`);
@@ -52,9 +89,26 @@ export async function resolveMkTemplateCode(dictName) {
return String(templateCode);
}
/**
* 是否属于「审核通过后再提交」需要新建表单实例的状态
*/
export function isMkApprovedStatus(bizType, approvalStatus) {
const conf = MK_BIZ[bizType];
if (!conf) return false;
return (conf.approved || []).includes(approvalStatus);
}
/**
* 提交 MK 审核流
* @param {Object} options
* @param {string} options.bizType 业务类型
* @param {string|number} options.bizId 业务主键(后端据此生成/复用 formInstanceId)
* @param {string} [options.subjectName] 标题后缀
* @param {string} [options.approvalStatus] 提交前的审批状态(须在业务 submit 改状态前取值;通过态强制新建实例)
*/
export async function submitMkApprovalFlow({
bizType,
formInstanceId,
bizId,
subjectName = '',
approvalStatus = '',
}) {
@@ -62,17 +116,34 @@ export async function submitMkApprovalFlow({
if (!conf) {
return Promise.reject(new Error(`不支持的MK业务类型:${bizType}`));
}
if ((conf.rejected || []).includes(approvalStatus)) {
await processDelete({ formInstanceId: String(formInstanceId) });
if (bizId == null || bizId === '') {
return Promise.reject(new Error('业务id不能为空'));
}
const templateCode = await resolveMkTemplateCode(conf.dictName);
const list = await loadMkTemplateList();
if (!isMkEnabled(list)) {
return;
}
const bizIdText = String(bizId);
const forceNewFormInstance = isMkApprovedStatus(bizType, approvalStatus);
// 审核通过后再提交必须新建流程;其余场景已有有效实例则跳过
if (!forceNewFormInstance) {
const existsRes = await hasMkProcessInstance({ bizId: bizIdText });
if (existsRes?.data?.data) {
return;
}
}
if ((conf.rejected || []).includes(approvalStatus)) {
await processDelete({ bizId: bizIdText });
}
const templateCode = await resolveMkTemplateCode(conf.dictName, list);
const subject = subjectName
? `${conf.subjectPrefix}:${subjectName}`
: `${conf.subjectPrefix}:${formInstanceId}`;
: `${conf.subjectPrefix}:${bizIdText}`;
return processSubmit({
templateCode,
formInstanceId: String(formInstanceId),
bizId: bizIdText,
subject,
bizType,
forceNewFormInstance,
});
}
+28
View File
@@ -283,3 +283,31 @@ export function validatejson(val) {
// 非对象、非数组、非字符串,或者字符串不是 JSON
return false;
}
/** 登录密码规则提示 */
export const LOGIN_PASSWORD_RULE_MESSAGE =
'密码须大于8位,且同时包含字母、数字和特殊字符(.!@#$%^&*)';
/**
* 校验登录密码强度:大于8位,同时包含字母、数字、特殊字符(.!@#$%^&*)
* @param {string} password
* @returns {boolean}
*/
export function isValidLoginPassword(password) {
return /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.!@#$%^&*]).{9,}$/.test(String(password || ''));
}
/**
* Element Plus / Avue 表单校验器:登录密码强度
*/
export function validateLoginPassword(rule, value, callback) {
if (value === undefined || value === null || String(value).trim() === '') {
callback(new Error('请输入登录密码'));
return;
}
if (!isValidLoginPassword(value)) {
callback(new Error(LOGIN_PASSWORD_RULE_MESSAGE));
return;
}
callback();
}
+5 -5
View File
@@ -24,7 +24,7 @@
type="danger"
icon="el-icon-delete"
plain
v-if="userInfo.authority.includes('admin')"
v-if="isAdministrator"
@click="handleDelete"
>删除
</el-button>
@@ -43,19 +43,19 @@
<template #menu="{ row }">
<el-link
type="primary"
v-if="row.status === -1 && userInfo.authority.includes('admin')"
v-if="row.status === -1 && isAdministrator"
@click.stop="handleEnable(row)"
>启用
</el-link>
<el-link
type="primary"
v-if="row.status === 1 && userInfo.authority.includes('admin')"
v-if="row.status === 1 && isAdministrator"
@click.stop="handleDisable(row)"
>禁用
</el-link>
<el-link
type="primary"
v-if="userInfo.authority.includes('admin')"
v-if="isAdministrator"
@click.stop="handleRowLog(row)"
>日志
</el-link>
@@ -522,7 +522,7 @@ sortable: true, span: 24, width: 180 },
};
},
computed: {
...mapGetters(['userInfo']),
...mapGetters(['userInfo', 'isAdministrator']),
ids() {
let ids = [];
this.selectionList.forEach(ele => {
+5 -5
View File
@@ -24,7 +24,7 @@
type="danger"
icon="el-icon-delete"
plain
v-if="userInfo.authority.includes('administrator')"
v-if="isAdministrator"
@click="handleDelete"
>删除
</el-button>
@@ -54,19 +54,19 @@
<template #menu="{ row }">
<el-link
type="primary"
v-if="row.status !== 1 && userInfo.authority.includes('administrator')"
v-if="row.status !== 1 && isAdministrator"
@click.stop="handleEnable(row)"
>启用
</el-link>
<el-link
type="primary"
v-if="row.status === 1 && userInfo.authority.includes('administrator')"
v-if="row.status === 1 && isAdministrator"
@click.stop="handleDisable(row)"
>禁用
</el-link>
<el-link
type="primary"
v-if="userInfo.authority.includes('administrator')"
v-if="isAdministrator"
@click.stop="handleRowLog(row)"
>日志
</el-link>
@@ -401,7 +401,7 @@ sortable: true, span: 24, width: 170 },
};
},
computed: {
...mapGetters(['userInfo']),
...mapGetters(['userInfo', 'isAdministrator']),
ids() {
let ids = [];
this.selectionList.forEach(ele => {
+3 -3
View File
@@ -31,7 +31,7 @@
<el-button
icon="el-icon-setting"
@click="handleRole"
v-if="userInfo.authority.includes('admin')"
v-if="isAdministrator"
plain
>权限设置
</el-button>
@@ -39,7 +39,7 @@
<template #menu="{ row }">
<el-link
type="primary"
v-if="userInfo.authority.includes('admin')"
v-if="isAdministrator"
style="border: 0; background-color: transparent !important"
@click.stop="handleRowRole(row)"
>权限设置
@@ -354,7 +354,7 @@ export default {
};
},
computed: {
...mapGetters(['userInfo', 'permission']),
...mapGetters(['userInfo', 'permission', 'isAdministrator']),
permissionList() {
return {
addBtn: this.validData(this.permission.role_add, false),
+365 -30
View File
@@ -94,6 +94,17 @@
/>
</el-select>
</template>
<template #detailAddress-form>
<el-input
v-model="form.detailAddress"
class="detail-address-input"
readonly
maxlength="255"
show-word-limit
placeholder="点击选择地图地址"
@click="openAddressMapPicker"
/>
</template>
<template #longitude-form>
<el-input
v-model="form.longitude"
@@ -121,19 +132,27 @@
</template>
</avue-form>
</el-dialog>
<address-map-picker
v-model="addressMapPickerVisible"
:address="form.detailAddress"
:longitude="form.longitude"
:latitude="form.latitude"
@confirm="handleAddressMapConfirm"
/>
</basic-container>
</template>
<script>
import { getList, getDetail, submit, remove, changeStatus } from '@/api/base/airport-master';
import { getLazyTree } from '@/api/base/region';
import { getDetail as getRegionDetail, getLazyTree } from '@/api/base/region';
import { exportBlob } from '@/api/common';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { formatUpdateUserName } from '@/utils/audit';
import { getToken } from '@/utils/auth';
import { getToken, isAdministrator } from '@/utils/auth';
import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate';
import AddressMapPicker from '@/components/address-map-picker/main.vue';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
@@ -181,6 +200,9 @@ const addExcelRequiredHeaderMarks = async blob => {
};
export default {
components: {
AddressMapPicker,
},
data() {
const validateIataCode = (rule, value, callback) => {
if (!/^[A-Z]{3}$/.test(String(value || '').toUpperCase())) {
@@ -220,6 +242,7 @@ export default {
loading: true,
data: [],
excelBox: false,
addressMapPickerVisible: false,
excelForm: {},
provinceOptions: [],
cityOptions: [],
@@ -299,6 +322,20 @@ export default {
prop: 'shortName',
minWidth: 130,
},
{
label: '详细地址',
prop: 'detailAddress',
formslot: true,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请选择详细地址', trigger: 'change' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '所属省份',
prop: 'provinceCode',
@@ -365,21 +402,6 @@ export default {
addDisplay: false,
editDisplay: false,
},
{
label: '详细地址',
prop: 'detailAddress',
type: 'textarea',
minRows: 2,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请输入详细地址', trigger: 'blur' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '经度',
prop: 'longitude',
@@ -438,6 +460,13 @@ export default {
showWordLimit: true,
rules: [{ max: 200, message: '备注不能超过200字', trigger: 'blur' }],
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
minWidth: 120,
},
{
label: '更新人',
prop: 'updateUserName',
@@ -526,10 +555,7 @@ export default {
};
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
ids() {
let ids = [];
@@ -552,33 +578,51 @@ export default {
return targetColumns.find(item => item.prop === targetProp) || null;
},
loadProvinceOptions() {
getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
return getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
this.provinceOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('provinceCode').dicData = this.provinceOptions;
const provinceColumn = this.findColumn('provinceCode');
if (provinceColumn) {
provinceColumn.dicData = this.provinceOptions;
}
return this.provinceOptions;
});
},
loadCityOptions(provinceCode) {
if (!provinceCode) {
this.cityOptions = [];
this.findColumn('cityCode').dicData = [];
return Promise.resolve();
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = [];
}
return Promise.resolve([]);
}
return getLazyTree(provinceCode).then(res => {
this.cityOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('cityCode').dicData = this.cityOptions;
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = this.cityOptions;
}
return this.cityOptions;
});
},
loadDistrictOptions(cityCode) {
if (!cityCode) {
this.districtOptions = [];
this.findColumn('districtCode').dicData = [];
return Promise.resolve();
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = [];
}
return Promise.resolve([]);
}
this.districtLoading = true;
return getLazyTree(cityCode)
.then(res => {
this.districtOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('districtCode').dicData = this.districtOptions;
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = this.districtOptions;
}
return this.districtOptions;
})
.finally(() => {
this.districtLoading = false;
@@ -605,6 +649,22 @@ export default {
: String(row.districtCode),
};
},
ensureCityOption(row) {
if (!row.cityCode || !row.cityName) return;
const exists = this.cityOptions.some(item => item.id === row.cityCode);
if (exists) return;
this.cityOptions = [
...this.cityOptions,
{
id: row.cityCode,
title: row.cityName,
},
];
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = this.cityOptions;
}
},
ensureDistrictOption(row) {
if (!row.districtCode || !row.districtName) return;
const exists = this.districtOptions.some(item => item.id === row.districtCode);
@@ -616,7 +676,10 @@ export default {
title: row.districtName,
},
];
this.findColumn('districtCode').dicData = this.districtOptions;
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = this.districtOptions;
}
},
handleIataChange(value) {
this.form.iataCode = String(value || '')
@@ -669,6 +732,269 @@ export default {
handleCoordinateInput(prop, value) {
this.form[prop] = normalizeCoordinateInput(value);
},
openAddressMapPicker() {
this.addressMapPickerVisible = true;
},
formatMapCoordinate(value) {
if (value === undefined || value === null || value === '') {
return '';
}
const number = Number(value);
if (!Number.isFinite(number)) {
return '';
}
return normalizeCoordinateInput(number.toFixed(6));
},
patchDialogForm(patch = {}) {
Object.assign(this.form, patch);
const tableForm = this.$refs.crud?.tableForm;
if (tableForm && tableForm !== this.form) {
Object.assign(tableForm, patch);
}
},
handleAddressMapConfirm(payload) {
const selection = typeof payload === 'string' ? { address: payload } : payload || {};
const longitude = this.formatMapCoordinate(selection.longitude);
const latitude = this.formatMapCoordinate(selection.latitude);
const patch = {};
if (selection.address) {
patch.detailAddress = selection.address;
}
if (longitude !== '' && latitude !== '') {
patch.longitude = longitude;
patch.latitude = latitude;
}
if (Object.keys(patch).length) {
this.patchDialogForm(patch);
}
this.applyMapRegionSelection(selection);
},
normalizeAmapCityName(city, province) {
if (Array.isArray(city)) {
return String(province || '').trim();
}
const cityName = String(city || '').trim();
return cityName || String(province || '').trim();
},
buildRegionLookupCodes(adcode) {
const code = String(adcode || '').trim();
if (!code) {
return [];
}
const codes = [code];
if (/^\d{6}$/.test(code)) {
if (code.endsWith('00')) {
const cityCode = code.slice(0, 4);
if (cityCode && !codes.includes(cityCode)) {
codes.push(cityCode);
}
}
if (code.endsWith('0000')) {
const provinceCode = code.slice(0, 2);
if (provinceCode && !codes.includes(provinceCode)) {
codes.push(provinceCode);
}
}
}
return codes;
},
fetchRegionDetailByCodes(codes = []) {
const tryNext = index => {
if (index >= codes.length) {
return Promise.resolve(null);
}
return getRegionDetail(codes[index])
.then(res => {
const detail = res.data && res.data.data;
if (detail && detail.code) {
return detail;
}
return tryNext(index + 1);
})
.catch(() => tryNext(index + 1));
};
return tryNext(0);
},
resolveProvinceCode(provinceName) {
const name = String(provinceName || '').trim();
if (!name) {
return '';
}
const province = this.provinceOptions.find(
item => item.title === name || item.id === name || String(item.title).includes(name)
);
return province ? province.id : '';
},
resolveCityCode(cityName, regionCode) {
const name = String(cityName || '').trim();
const code = String(regionCode || '').trim();
const city = this.cityOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return city ? city.id : '';
},
resolveDistrictCode(districtName, regionCode) {
const name = String(districtName || '').trim();
const code = String(regionCode || '').trim();
const district = this.districtOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return district ? district.id : code || '';
},
applyRegionDetail(detail = {}) {
const provinceName = detail.provinceName || '';
const cityName = detail.cityName || '';
const districtName = detail.districtName || detail.name || '';
return this.loadProvinceOptions()
.then(() => {
const provinceCode =
String(detail.provinceCode || '').trim() || this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode =
String(detail.cityCode || '').trim() ||
this.resolveCityCode(cityName, detail.districtCode || detail.code);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(
districtName,
detail.districtCode || detail.code
);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionByNames(selection = {}) {
const provinceName = String(selection.province || selection.provinceName || '').trim();
const cityName = this.normalizeAmapCityName(
selection.city || selection.cityName,
provinceName
);
const districtName = String(selection.district || selection.districtName || '').trim();
const adcode = String(selection.adcode || selection.regionCode || '').trim();
// 本页无国家字段:国内 adcode 走中国省份树;无匹配则不强制回填
if (
!provinceName &&
!cityName &&
!districtName &&
!(/^\d{6}$/.test(adcode) || String(selection.country || selection.countryName || '').includes('中国'))
) {
return Promise.resolve();
}
return this.loadProvinceOptions()
.then(() => {
const provinceCode = this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode = this.resolveCityCode(cityName, adcode);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(districtName, adcode);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionSelection(selection = {}) {
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const hasNameHint = Boolean(
selection.country ||
selection.countryName ||
selection.province ||
selection.provinceName ||
selection.city ||
selection.cityName ||
selection.district ||
selection.districtName
);
if (!adcode && !hasNameHint) {
return Promise.resolve();
}
this.regionInitializing = true;
const lookupCodes = this.buildRegionLookupCodes(adcode);
return this.fetchRegionDetailByCodes(lookupCodes)
.then(detail => {
if (detail) {
return this.applyRegionDetail(detail);
}
return this.applyMapRegionByNames(selection);
})
.catch(() => this.applyMapRegionByNames(selection))
.finally(() => {
this.$nextTick(() => {
window.setTimeout(() => {
this.regionInitializing = false;
}, 0);
});
});
},
normalizeRow(row) {
row.code = row.iataCode ? `JC-${row.iataCode}` : row.code;
row.regionCode = String(row.regionCode || '').trim();
@@ -917,4 +1243,13 @@ export default {
white-space: nowrap;
}
}
.detail-address-input {
cursor: pointer;
:deep(.el-input__wrapper),
:deep(.el-input__inner) {
cursor: pointer;
}
}
</style>
+61 -41
View File
@@ -182,8 +182,8 @@
<el-input
v-model="form.regionName"
readonly
disabled
placeholder="行政区划自动带出"
:disabled="dialogReadonly"
/>
<el-input
v-model="form.detailAddress"
@@ -233,8 +233,8 @@
</el-button>
</div>
<div class="map-picker-content">
<map-search-results :results="mapSearchResults" @select="selectMapSearchResult" />
<div ref="amap" class="common-address-page__map"></div>
<map-search-results :results="mapSearchResults" @select="selectMapSearchResult" />
</div>
<div class="common-address-page__map-info">
<span>{{ mapStatus }}</span>
@@ -269,7 +269,8 @@ import { exportBlob } from '@/api/common';
import { createOption } from '@/option/base/common-address';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { getToken } from '@/utils/auth';
import { getToken, isAdministrator } from '@/utils/auth';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { isMobile } from '@/utils/validate';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
@@ -323,16 +324,16 @@ export default {
};
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
},
created() {
if (this.isAdmin) {
this.allDept = 1;
}
this.initDeptTree();
},
methods: {
@@ -343,15 +344,21 @@ export default {
getDeptTree(this.userInfo.tenantId).then(res => {
this.deptOptions = this.flattenDept(res.data.data || []);
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
deptColumn.dicData = this.deptOptions;
if (deptColumn) {
deptColumn.dicData = this.deptOptions;
}
});
},
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
// 雪花 ID 必须保持字符串,避免 JS Number 精度丢失导致筛错组织。
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return;
result.push({
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: item.id,
value: id,
rawLabel: item.title || item.deptName || item.name,
});
if (item.children && item.children.length) {
@@ -360,6 +367,19 @@ export default {
});
return result;
},
normalizeListQuery(params = {}) {
// 搜索条件优先于缓存的 query,避免旧组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
} else {
delete query.deptId;
}
query.allDept = this.isAdmin ? 1 : 0;
return query;
},
getRecords(res) {
const data = res && res.data && res.data.data;
if (Array.isArray(data)) return data;
@@ -966,13 +986,15 @@ export default {
},
searchReset() {
this.query = {};
this.allDept = this.isAdmin ? 1 : 0;
this.page.currentPage = 1;
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.allDept = this.query.allDept;
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -993,7 +1015,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, { ...params, ...this.query, allDept: this.allDept })
const query = this.normalizeListQuery(params);
this.query = query;
this.allDept = query.allDept;
getList(page.currentPage, page.pageSize, query)
.then(res => {
const data = res.data.data;
this.page.total = data.total;
@@ -1026,8 +1051,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: this.allDept,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
@@ -1111,36 +1135,32 @@ export default {
this.$message.warning('请输入地址关键词');
return;
}
if (this.mapLoading) return;
this.mapSearchResults = [];
this.mapLoading = true;
this.loadAmap()
.then(() => {
this.mapLoading = true;
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 = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
return;
}
this.pickMapPoint(point, keyword);
})
.catch(() => {
this.mapStatus = '地图搜索失败';
this.$message.error('地图搜索失败,请稍后重试');
})
.finally(() => {
this.mapLoading = false;
});
.then(() => searchAmapPlaceText(keyword))
.then(pois => {
this.mapSearchResults = normalizeMapSearchResults(pois, keyword);
const first = this.mapSearchResults[0];
if (!first?.location) {
this.mapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
return;
}
return this.$nextTick().then(() => {
if (typeof this.amap?.resize === 'function') {
this.amap.resize();
}
this.pickMapPoint(first.location, first.address || first.name || keyword);
});
})
.catch(() => {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
this.mapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
})
.finally(() => {
this.mapLoading = false;
});
},
selectMapSearchResult(item) {
+7
View File
@@ -238,6 +238,13 @@ export default {
value: 'value',
},
},
{
label: '创建人',
prop: 'createUserName',
minWidth: 120,
addDisplay: false,
editDisplay: false,
},
{
label: '更新人',
prop: 'updateUserName',
+2 -1
View File
@@ -143,6 +143,7 @@
import { mapGetters } from 'vuex';
import { getDetail, getList, remove, submit } from '@/api/base/insurance-ocr-template';
import SectionCard from '@/components/section-card/main.vue';
import { isAdministrator } from '@/utils/auth';
const insuranceFieldKeys = [
'保险类型',
@@ -203,7 +204,7 @@ export default {
computed: {
...mapGetters(['permission', 'userInfo']),
isAdmin() {
return String(this.userInfo.authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
selectionIds() {
return this.selectionList.map(item => item.id).join(',');
+2 -1
View File
@@ -82,6 +82,7 @@
import { mapGetters } from 'vuex';
import { changeStatus, getDetail, getList, remove, submit } from '@/api/base/measurement-unit';
import { createOption } from '@/option/base/measurement-unit';
import { isAdministrator } from '@/utils/auth';
export default {
data() {
@@ -111,7 +112,7 @@ export default {
return this.selectionList.map(item => item.id).join(',');
},
isAdmin() {
return String(this.userInfo?.authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
},
methods: {
+372 -88
View File
@@ -194,10 +194,11 @@
<el-input
v-model="form.detailAddress"
class="detail-address-input"
clearable
readonly
maxlength="255"
show-word-limit
placeholder="请输入"
placeholder="点击选择地图地址"
@click="openAddressMapPicker"
/>
</template>
<template #remark-form>
@@ -244,6 +245,13 @@
</template>
</avue-form>
</el-dialog>
<address-map-picker
v-model="addressMapPickerVisible"
:address="form.detailAddress"
:longitude="form.longitude"
:latitude="form.latitude"
@confirm="handleAddressMapConfirm"
/>
</basic-container>
</template>
@@ -256,20 +264,24 @@ import {
changeStatus,
getPortSelect,
} from '@/api/base/port-terminal';
import { getLazyTree } from '@/api/base/region';
import { getDetail as getRegionDetail, getLazyTree } from '@/api/base/region';
import { exportBlob } from '@/api/common';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { formatUpdateUserName } from '@/utils/audit';
import { getToken } from '@/utils/auth';
import { getToken, isAdministrator } from '@/utils/auth';
import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate';
import AddressMapPicker from '@/components/address-map-picker/main.vue';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
const DEFAULT_COUNTRY_CODE = '+86';
const newLocal = '请选择类型';
export default {
components: {
AddressMapPicker,
},
data() {
const validateCode = (rule, value, callback) => {
const code = String(value || '').toUpperCase();
@@ -315,6 +327,7 @@ export default {
loading: true,
data: [],
excelBox: false,
addressMapPickerVisible: false,
excelForm: {},
portOptions: [],
countryOptions: [],
@@ -571,12 +584,12 @@ export default {
span: 12,
minWidth: 220,
overHidden: false,
order: 85,
placeholder: '请输入',
order: 90,
placeholder: '点击选择地图地址',
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请输入详细地址', trigger: 'blur' },
{ required: true, message: '请选择详细地址', trigger: 'change' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
@@ -608,6 +621,13 @@ export default {
showWordLimit: true,
rules: [{ max: 200, message: '备注不能超过200个字', trigger: 'blur' }],
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
minWidth: 120,
},
{
label: '更新人',
prop: 'updateUserName',
@@ -695,10 +715,7 @@ export default {
};
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
isParentRegionLocked() {
return this.form.category === '码头';
@@ -824,10 +841,60 @@ export default {
},
handleCountryNameChange(value) {
const country = this.countryOptions.find(item => item.title === value);
this.setProvinceOptions([]);
this.setCityOptions([]);
this.setDistrictOptions([]);
this.loadProvinceOptions(country ? country.id : '');
this.clearSearchCityAndDistrict();
// 搜索栏为国家 → 城市 → 区县(无省份),选中国家后需直接加载该国下全部城市
this.loadCityOptionsByCountry(country ? country.id : '');
},
clearSearchCityAndDistrict() {
const searchForm = this.$refs.crud && this.$refs.crud.searchForm;
if (searchForm) {
searchForm.city = undefined;
searchForm.districtName = undefined;
}
if (this.query) {
this.query.city = undefined;
this.query.districtName = undefined;
}
},
loadCityOptionsByCountry(countryCode) {
if (!countryCode) {
this.setCityOptions([]);
return Promise.resolve([]);
}
this.cityLoading = true;
return this.loadProvinceOptions(countryCode)
.then(provinces =>
Promise.all((provinces || []).map(province => this.fetchCityOptions(province.id)))
)
.then(cityGroups => {
const cityMap = new Map();
cityGroups.flat().forEach(city => {
if (city && city.id && !cityMap.has(city.id)) {
cityMap.set(city.id, city);
}
});
const cities = Array.from(cityMap.values());
this.setCityOptions(cities);
return cities;
})
.finally(() => {
this.cityLoading = false;
});
},
fetchCityOptions(provinceCode) {
if (!provinceCode) {
return Promise.resolve([]);
}
if (this.cityOptionCache[provinceCode]) {
return Promise.resolve(this.cityOptionCache[provinceCode]);
}
return getLazyTree(provinceCode).then(res => {
const options = (res.data.data || []).map(this.normalizeRegionOption);
this.cityOptionCache[provinceCode] = options;
return options;
});
},
syncCountryName(countryCode) {
const country = this.countryOptions.find(item => item.id === countryCode);
@@ -908,10 +975,8 @@ export default {
return Promise.resolve(this.cityOptionCache[provinceCode]);
}
this.cityLoading = true;
return getLazyTree(provinceCode)
.then(res => {
const options = (res.data.data || []).map(this.normalizeRegionOption);
this.cityOptionCache[provinceCode] = options;
return this.fetchCityOptions(provinceCode)
.then(options => {
this.setCityOptions(options);
return options;
})
@@ -1030,6 +1095,13 @@ export default {
handleCityNameChange(value) {
const city = this.cityOptions.find(item => item.title === value);
this.setDistrictOptions([]);
const searchForm = this.$refs.crud && this.$refs.crud.searchForm;
if (searchForm) {
searchForm.districtName = undefined;
}
if (this.query) {
this.query.districtName = undefined;
}
this.loadDistrictOptions(city ? city.id : '');
},
handleDistrictChange(value) {
@@ -1040,6 +1112,228 @@ export default {
handleCoordinateInput(prop, value) {
this.form[prop] = normalizeCoordinateInput(value);
},
openAddressMapPicker() {
this.addressMapPickerVisible = true;
},
formatMapCoordinate(value) {
if (value === undefined || value === null || value === '') {
return '';
}
const number = Number(value);
if (!Number.isFinite(number)) {
return '';
}
return normalizeCoordinateInput(number.toFixed(6));
},
patchDialogForm(patch = {}) {
// 同步页面 form 与 Avue 弹窗内部 tableForm,避免只改了一边导致经纬度不刷新
Object.assign(this.form, patch);
const tableForm = this.$refs.crud?.tableForm;
if (tableForm && tableForm !== this.form) {
Object.assign(tableForm, patch);
}
},
handleAddressMapConfirm(payload) {
const selection = typeof payload === 'string' ? { address: payload } : payload || {};
const longitude = this.formatMapCoordinate(selection.longitude);
const latitude = this.formatMapCoordinate(selection.latitude);
const patch = {};
if (selection.address) {
patch.detailAddress = selection.address;
}
// 换地址时必须同步覆盖经纬度,不能保留上一次选点
if (longitude !== '' && latitude !== '') {
patch.longitude = longitude;
patch.latitude = latitude;
}
if (Object.keys(patch).length) {
this.patchDialogForm(patch);
}
this.applyMapRegionSelection(selection);
},
normalizeAmapCityName(city, province) {
if (Array.isArray(city)) {
return String(province || '').trim();
}
const cityName = String(city || '').trim();
return cityName || String(province || '').trim();
},
buildRegionLookupCodes(adcode) {
const code = String(adcode || '').trim();
if (!code) {
return [];
}
const codes = [code];
if (/^\d{6}$/.test(code)) {
if (code.endsWith('00')) {
const cityCode = code.slice(0, 4);
if (cityCode && !codes.includes(cityCode)) {
codes.push(cityCode);
}
}
if (code.endsWith('0000')) {
const provinceCode = code.slice(0, 2);
if (provinceCode && !codes.includes(provinceCode)) {
codes.push(provinceCode);
}
}
}
return codes;
},
fetchRegionDetailByCodes(codes = []) {
const tryNext = index => {
if (index >= codes.length) {
return Promise.resolve(null);
}
return getRegionDetail(codes[index])
.then(res => {
const detail = res.data && res.data.data;
if (detail && detail.code) {
return detail;
}
return tryNext(index + 1);
})
.catch(() => tryNext(index + 1));
};
return tryNext(0);
},
resolveCountryFromRegionDetail(detail = {}) {
const ancestors = String(detail.ancestors || '')
.split(',')
.map(item => String(item || '').trim())
.filter(Boolean);
for (let index = 0; index < ancestors.length; index += 1) {
const ancestorCode = ancestors[index];
if (this.countryOptions.some(item => item.id === ancestorCode)) {
return ancestorCode;
}
}
if (
detail.parentCode &&
Number(detail.regionLevel) === 1 &&
this.countryOptions.some(item => item.id === String(detail.parentCode))
) {
return String(detail.parentCode);
}
if (/^\d{6}$/.test(String(detail.code || ''))) {
return DEFAULT_COUNTRY_CODE;
}
return '';
},
applyRegionDetail(detail = {}) {
const countryHint = this.resolveCountryFromRegionDetail(detail);
return this.resolveCountryCode(countryHint || DEFAULT_COUNTRY_CODE)
.then(countryCode => {
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(() => {
this.form.provinceCode =
String(detail.provinceCode || '') || this.resolveProvinceCode(detail.provinceName);
const province = this.provinceOptions.find(item => item.id === this.form.provinceCode);
this.form.provinceName = province ? province.title : detail.provinceName || '';
return this.loadCityOptions(this.form.provinceCode);
})
.then(() => {
this.form.cityCode =
String(detail.cityCode || '') ||
this.resolveCityCode(detail.cityName, detail.districtCode || detail.code);
const city = this.cityOptions.find(item => item.id === this.form.cityCode);
this.form.city = city ? city.title : detail.cityName || '';
return this.loadDistrictOptions(this.form.cityCode);
})
.then(() => {
this.form.districtCode = this.resolveDistrictCode(
detail.districtName || detail.name,
detail.districtCode || detail.code
);
this.handleDistrictChange(this.form.districtCode);
});
},
applyMapRegionByNames(selection = {}) {
const provinceName = String(selection.province || selection.provinceName || '').trim();
const cityName = this.normalizeAmapCityName(
selection.city || selection.cityName,
provinceName
);
const districtName = String(selection.district || selection.districtName || '').trim();
const countryName = String(selection.country || selection.countryName || '').trim();
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const countryPromise = countryName
? this.resolveCountryCode(countryName)
: Promise.resolve(/^\d{6}$/.test(adcode) ? DEFAULT_COUNTRY_CODE : '');
return countryPromise
.then(countryCode => {
if (!countryCode) {
return null;
}
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(provinces => {
if (!provinces) {
return null;
}
this.form.provinceCode = this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === this.form.provinceCode);
this.form.provinceName = province ? province.title : provinceName;
if (!this.form.provinceCode) {
this.clearCityValue();
return null;
}
return this.loadCityOptions(this.form.provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
this.form.cityCode = this.resolveCityCode(cityName, adcode);
const city = this.cityOptions.find(item => item.id === this.form.cityCode);
this.form.city = city ? city.title : cityName;
if (!this.form.cityCode) {
this.clearDistrictValue();
return null;
}
return this.loadDistrictOptions(this.form.cityCode);
})
.then(districts => {
if (!districts) {
return;
}
this.form.districtCode = this.resolveDistrictCode(districtName, adcode);
this.handleDistrictChange(this.form.districtCode);
});
},
applyMapRegionSelection(selection = {}) {
if (this.isParentRegionLocked) {
return Promise.resolve();
}
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const hasNameHint = Boolean(
selection.country ||
selection.countryName ||
selection.province ||
selection.provinceName ||
selection.city ||
selection.cityName ||
selection.district ||
selection.districtName
);
if (!adcode && !hasNameHint) {
return Promise.resolve();
}
const lookupCodes = this.buildRegionLookupCodes(adcode);
return this.fetchRegionDetailByCodes(lookupCodes)
.then(detail => {
if (detail) {
return this.applyRegionDetail(detail);
}
return this.applyMapRegionByNames(selection);
})
.catch(() => this.applyMapRegionByNames(selection));
},
resolveCountryCode(country) {
if (!country) {
return Promise.resolve('');
@@ -1107,7 +1401,7 @@ export default {
return false;
}
if (isBlank(row.detailAddress)) {
this.$message.warning('请输入详细地址');
this.$message.warning('请选择详细地址');
return false;
}
if (isBlank(row.longitude)) {
@@ -1219,13 +1513,50 @@ export default {
this.$message({ type: 'success', message: '操作成功!' });
});
},
createEmptyPortTerminalForm() {
return {
category: '港口',
dataSource: '手动录入',
status: 1,
parentId: undefined,
parentCode: '',
parentName: '',
name: '',
code: '',
terminalCode: '',
country: '',
countryCode: '',
provinceCode: undefined,
provinceName: '',
cityCode: undefined,
city: '',
districtCode: undefined,
districtName: '',
regionCode: '',
detailAddress: '',
longitude: '',
latitude: '',
remark: '',
};
},
resetAddForm() {
// 新增必须整表重置,避免沿用上一次编辑/查看残留
this.form = this.createEmptyPortTerminalForm();
this.setProvinceOptions([]);
this.setCityOptions([]);
this.setDistrictOptions([]);
},
beforeOpen(done, type) {
if (type === 'add') {
this.form.category = this.form.category || '港口';
this.form.dataSource = this.form.dataSource || '手动录入';
this.form.status = this.form.status || 1;
this.resetAddForm();
this.handleCategoryChange('港口', false);
done();
this.$nextTick(() => {
this.$refs.crud?.clearValidate?.();
setTimeout(() => this.disableDialogAutocomplete(), 50);
});
return;
}
this.handleCategoryChange(this.form.category || '港口');
if (['edit', 'view'].includes(type)) {
getDetail(this.form.id).then(res => {
this.form = res.data.data;
@@ -1260,35 +1591,7 @@ export default {
setTimeout(() => this.disableDialogAutocomplete(), 50);
});
});
return;
}
this.resolveCountryCode(this.form.country)
.then(countryCode => {
this.form.countryCode = countryCode;
this.syncCountryName(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);
return this.loadDistrictOptions(this.form.cityCode);
})
.then(() => {
this.form.districtCode = this.resolveDistrictCode(
this.form.districtName,
this.form.regionCode
);
if (!this.form.regionCode) {
this.handleDistrictChange(this.form.districtCode);
}
done();
setTimeout(() => this.disableDialogAutocomplete(), 50);
});
},
disableDialogAutocomplete() {
this.$nextTick(() => {
@@ -1399,45 +1702,26 @@ export default {
line-height: 20px;
padding: 4px 0;
}
.detail-address-input {
cursor: pointer;
:deep(.el-input__wrapper),
:deep(.el-input__inner) {
cursor: pointer;
}
}
</style>
<style lang="scss">
/* 港口码头编辑/新增弹窗:对齐 railway-station 统一风格(灰底 + 白卡表单 + 浮动底栏,padding 24px 与全局弹窗标准一致) */
//.port-terminal-edit-dialog {
// /* 灰底内容区 */
// .el-dialog__body {
// background-color: #f2f2f2;
// padding: 8px;
// }
// /* 表单白卡(与全局 .el-dialog .avue-form 一致,padding 24px 对齐 customer-archive / railway-station) */
// .avue-form {
// background: #fff;
// border-radius: 6px;
// box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
// padding: 24px !important;
// margin-bottom: 0;
// }
// /* 底部按钮区白卡(Avue 弹窗 footer 在 body 内,接续白卡) */
// .avue-dialog__footer {
// background: #fff;
// border-radius: 0 0 6px 6px;
// box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
// padding: 16px 24px !important;
// display: flex;
// justify-content: flex-end;
// }
// /* label 宽度统一 108px(对齐 railway-station,标签对齐更整齐) */
// .el-form-item__label {
// width: 108px !important;
// }
// /* 所有 input 文本框、select 下拉控件统一宽度 250px */
// .el-input,
// .el-select {
// width: 250px !important;
// }
// /* 备注宽度 85% */
// .remark-input {
// width: 85% !important;
// }
//}
/* 港口码头编辑/新增弹窗:与全局表单项行距保持一致 */
.port-terminal-edit-dialog {
.el-form-item,
.el-form-item--default.el-form-item,
.el-form-item--small.el-form-item,
.el-form-item--large.el-form-item,
.el-form-item--medium.el-form-item {
margin-bottom: 20px;
}
}
</style>
+359 -41
View File
@@ -94,6 +94,17 @@
/>
</el-select>
</template>
<template #detailAddress-form>
<el-input
v-model="form.detailAddress"
class="detail-address-input"
readonly
maxlength="255"
show-word-limit
placeholder="点击选择地图地址"
@click="openAddressMapPicker"
/>
</template>
<template #longitude-form>
<el-input
v-model="form.longitude"
@@ -121,25 +132,36 @@
</template>
</avue-form>
</el-dialog>
<address-map-picker
v-model="addressMapPickerVisible"
:address="form.detailAddress"
:longitude="form.longitude"
:latitude="form.latitude"
@confirm="handleAddressMapConfirm"
/>
</basic-container>
</template>
<script>
import { getList, getDetail, submit, remove, changeStatus } from '@/api/base/railway-station';
import { getLazyTree } from '@/api/base/region';
import { getDetail as getRegionDetail, getLazyTree } from '@/api/base/region';
import { exportBlob } from '@/api/common';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { formatUpdateUserName } from '@/utils/audit';
import { getToken } from '@/utils/auth';
import { getToken, isAdministrator } from '@/utils/auth';
import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate';
import AddressMapPicker from '@/components/address-map-picker/main.vue';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
const DEFAULT_COUNTRY_CODE = '+86';
export default {
components: {
AddressMapPicker,
},
data() {
const validateTmisCode = (rule, value, callback) => {
if (!/^\d{5}$/.test(value || '')) {
@@ -149,7 +171,12 @@ export default {
}
};
const validateTelegraphCode = (rule, value, callback) => {
if (!/^[A-Z]{3}$/.test(String(value || '').toUpperCase())) {
const telegraphCode = String(value || '').trim();
if (!telegraphCode) {
callback();
return;
}
if (!/^[A-Z]{3}$/.test(telegraphCode.toUpperCase())) {
callback(new Error('电报略码格式不正确或已存在'));
} else {
callback();
@@ -185,6 +212,7 @@ export default {
loading: true,
data: [],
excelBox: false,
addressMapPickerVisible: false,
excelForm: {},
provinceOptions: [],
cityOptions: [],
@@ -250,10 +278,7 @@ export default {
prop: 'telegraphCode',
minWidth: 100,
maxlength: 3,
rules: [
{ required: true, message: '电报略码格式不正确或已存在', trigger: 'blur' },
{ validator: validateTelegraphCode, trigger: 'blur' },
],
rules: [{ validator: validateTelegraphCode, trigger: 'blur' }],
change: ({ value }) => this.handleTelegraphChange(value),
blur: ({ value }) => this.handleTelegraphChange(value),
},
@@ -270,6 +295,20 @@ export default {
// prop: 'railwayLine',
// minWidth: 130,
// },
{
label: '详细地址',
prop: 'detailAddress',
formslot: true,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请选择详细地址', trigger: 'change' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '所属省份',
prop: 'provinceCode',
@@ -337,21 +376,6 @@ export default {
addDisplay: false,
editDisplay: false,
},
{
label: '详细地址',
prop: 'detailAddress',
type: 'textarea',
minRows: 2,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请输入详细地址', trigger: 'blur' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '经度',
prop: 'longitude',
@@ -411,6 +435,13 @@ export default {
showWordLimit: true,
rules: [{ max: 200, message: '备注不能超过200字', trigger: 'blur' }],
},
{
label: '创建人',
prop: 'createUserName',
addDisplay: false,
editDisplay: false,
minWidth: 120,
},
{
label: '更新人',
prop: 'updateUserName',
@@ -497,10 +528,7 @@ export default {
};
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
ids() {
let ids = [];
@@ -523,33 +551,51 @@ export default {
return targetColumns.find(item => item.prop === targetProp) || null;
},
loadProvinceOptions() {
getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
return getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
this.provinceOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('provinceCode').dicData = this.provinceOptions;
const provinceColumn = this.findColumn('provinceCode');
if (provinceColumn) {
provinceColumn.dicData = this.provinceOptions;
}
return this.provinceOptions;
});
},
loadCityOptions(provinceCode) {
if (!provinceCode) {
this.cityOptions = [];
this.findColumn('cityCode').dicData = [];
return Promise.resolve();
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = [];
}
return Promise.resolve([]);
}
return getLazyTree(provinceCode).then(res => {
this.cityOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('cityCode').dicData = this.cityOptions;
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = this.cityOptions;
}
return this.cityOptions;
});
},
loadDistrictOptions(cityCode) {
if (!cityCode) {
this.districtOptions = [];
this.findColumn('districtCode').dicData = [];
return Promise.resolve();
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = [];
}
return Promise.resolve([]);
}
this.districtLoading = true;
return getLazyTree(cityCode)
.then(res => {
this.districtOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('districtCode').dicData = this.districtOptions;
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = this.districtOptions;
}
return this.districtOptions;
})
.finally(() => {
this.districtLoading = false;
@@ -652,8 +698,272 @@ export default {
handleCoordinateInput(prop, value) {
this.form[prop] = normalizeCoordinateInput(value);
},
openAddressMapPicker() {
this.addressMapPickerVisible = true;
},
formatMapCoordinate(value) {
if (value === undefined || value === null || value === '') {
return '';
}
const number = Number(value);
if (!Number.isFinite(number)) {
return '';
}
return normalizeCoordinateInput(number.toFixed(6));
},
patchDialogForm(patch = {}) {
Object.assign(this.form, patch);
const tableForm = this.$refs.crud?.tableForm;
if (tableForm && tableForm !== this.form) {
Object.assign(tableForm, patch);
}
},
handleAddressMapConfirm(payload) {
const selection = typeof payload === 'string' ? { address: payload } : payload || {};
const longitude = this.formatMapCoordinate(selection.longitude);
const latitude = this.formatMapCoordinate(selection.latitude);
const patch = {};
if (selection.address) {
patch.detailAddress = selection.address;
}
if (longitude !== '' && latitude !== '') {
patch.longitude = longitude;
patch.latitude = latitude;
}
if (Object.keys(patch).length) {
this.patchDialogForm(patch);
}
this.applyMapRegionSelection(selection);
},
normalizeAmapCityName(city, province) {
if (Array.isArray(city)) {
return String(province || '').trim();
}
const cityName = String(city || '').trim();
return cityName || String(province || '').trim();
},
buildRegionLookupCodes(adcode) {
const code = String(adcode || '').trim();
if (!code) {
return [];
}
const codes = [code];
if (/^\d{6}$/.test(code)) {
if (code.endsWith('00')) {
const cityCode = code.slice(0, 4);
if (cityCode && !codes.includes(cityCode)) {
codes.push(cityCode);
}
}
if (code.endsWith('0000')) {
const provinceCode = code.slice(0, 2);
if (provinceCode && !codes.includes(provinceCode)) {
codes.push(provinceCode);
}
}
}
return codes;
},
fetchRegionDetailByCodes(codes = []) {
const tryNext = index => {
if (index >= codes.length) {
return Promise.resolve(null);
}
return getRegionDetail(codes[index])
.then(res => {
const detail = res.data && res.data.data;
if (detail && detail.code) {
return detail;
}
return tryNext(index + 1);
})
.catch(() => tryNext(index + 1));
};
return tryNext(0);
},
resolveProvinceCode(provinceName) {
const name = String(provinceName || '').trim();
if (!name) {
return '';
}
const province = this.provinceOptions.find(
item => item.title === name || item.id === name || String(item.title).includes(name)
);
return province ? province.id : '';
},
resolveCityCode(cityName, regionCode) {
const name = String(cityName || '').trim();
const code = String(regionCode || '').trim();
const city = this.cityOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return city ? city.id : '';
},
resolveDistrictCode(districtName, regionCode) {
const name = String(districtName || '').trim();
const code = String(regionCode || '').trim();
const district = this.districtOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return district ? district.id : code || '';
},
applyRegionDetail(detail = {}) {
const provinceName = detail.provinceName || '';
const cityName = detail.cityName || '';
const districtName = detail.districtName || detail.name || '';
return this.loadProvinceOptions()
.then(() => {
const provinceCode =
String(detail.provinceCode || '').trim() || this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode =
String(detail.cityCode || '').trim() ||
this.resolveCityCode(cityName, detail.districtCode || detail.code);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(
districtName,
detail.districtCode || detail.code
);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionByNames(selection = {}) {
const provinceName = String(selection.province || selection.provinceName || '').trim();
const cityName = this.normalizeAmapCityName(
selection.city || selection.cityName,
provinceName
);
const districtName = String(selection.district || selection.districtName || '').trim();
const adcode = String(selection.adcode || selection.regionCode || '').trim();
// 本页无国家字段:国内 adcode 走中国省份树;无匹配则不强制回填
if (
!provinceName &&
!cityName &&
!districtName &&
!(/^\d{6}$/.test(adcode) || String(selection.country || selection.countryName || '').includes('中国'))
) {
return Promise.resolve();
}
return this.loadProvinceOptions()
.then(() => {
const provinceCode = this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode = this.resolveCityCode(cityName, adcode);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(districtName, adcode);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionSelection(selection = {}) {
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const hasNameHint = Boolean(
selection.country ||
selection.countryName ||
selection.province ||
selection.provinceName ||
selection.city ||
selection.cityName ||
selection.district ||
selection.districtName
);
if (!adcode && !hasNameHint) {
return Promise.resolve();
}
this.regionInitializing = true;
const lookupCodes = this.buildRegionLookupCodes(adcode);
return this.fetchRegionDetailByCodes(lookupCodes)
.then(detail => {
if (detail) {
return this.applyRegionDetail(detail);
}
return this.applyMapRegionByNames(selection);
})
.catch(() => this.applyMapRegionByNames(selection))
.finally(() => {
this.$nextTick(() => {
window.setTimeout(() => {
this.regionInitializing = false;
}, 0);
});
});
},
normalizeRow(row) {
row.code = row.tmisCode ? `TL${row.tmisCode}` : row.code;
row.telegraphCode = String(row.telegraphCode || '').trim() || undefined;
row.regionCode = String(row.regionCode || '').trim();
row.regionName = String(row.regionName || '').trim();
row.districtCode = String(row.districtCode || '').trim();
@@ -686,14 +996,15 @@ export default {
return records.some(item => item[prop] === row[prop] && String(item.id || '') !== rowId);
});
};
return Promise.all([
hasDuplicate({ tmisCode: row.tmisCode }, 'tmisCode'),
hasDuplicate({ telegraphCode: row.telegraphCode }, 'telegraphCode'),
]).then(([tmisExists, telegraphExists]) => {
if (tmisExists) {
const uniqueChecks = [hasDuplicate({ tmisCode: row.tmisCode }, 'tmisCode')];
if (row.telegraphCode) {
uniqueChecks.push(hasDuplicate({ telegraphCode: row.telegraphCode }, 'telegraphCode'));
}
return Promise.all(uniqueChecks).then(results => {
if (results[0]) {
return Promise.reject(new Error('该TMIS编码已存在'));
}
if (telegraphExists) {
if (results[1]) {
return Promise.reject(new Error('电报略码格式不正确或已存在'));
}
return Promise.resolve();
@@ -910,9 +1221,16 @@ export default {
:deep(.el-table td .cell) {
white-space: nowrap;
}
}
.detail-address-input {
cursor: pointer;
:deep(.el-input__wrapper),
:deep(.el-input__inner) {
cursor: pointer;
}
}
</style>
<style lang="scss">
+55 -42
View File
@@ -26,6 +26,7 @@
</el-form-item>
<el-form-item label="区域层级">
<el-select v-model="searchForm.regionLevel" clearable placeholder="请选择" style="width: 200px;">
<el-option label="国家" :value="0" />
<el-option label="省份" :value="1" />
<el-option label="省市" :value="2" />
<el-option label="区县" :value="3" />
@@ -42,6 +43,13 @@
</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"
@@ -112,14 +120,7 @@
placeholder="请输入国家编码,如 +86"
v-model="regionForm.code"
/>
<el-input
v-else-if="isProvinceRegion"
placeholder="请输入省级区划编号"
v-model="regionForm.subCode"
/>
<el-input v-else placeholder="请输入 区划子编号" v-model="regionForm.subCode">
<template #prepend>{{ regionForm.parentCode }}</template>
</el-input>
<el-input v-else placeholder="请输入区划编号" v-model="regionForm.subCode" />
</template>
</avue-form>
<el-dialog title="行政区划数据导入" append-to-body v-model="excelBox" width="555px">
@@ -253,9 +254,10 @@ export default {
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) }));
return (Array.isArray(list) ? list : []).map(item => ({
...item,
dictKey: Number(item.dictKey),
}));
},
props: {
label: 'dictValue',
@@ -410,9 +412,6 @@ export default {
isCountryRegion() {
return Number(this.regionForm.regionLevel) === 0;
},
isProvinceRegion() {
return Number(this.regionForm.regionLevel) === 1;
},
isEditRegion() {
return !validatenull(this.regionForm.originalCode);
},
@@ -476,10 +475,7 @@ export default {
getDetail(this.treeCode).then(res => {
this.regionForm = res.data.data;
this.regionForm.originalCode = this.regionForm.code;
this.regionForm.subCode =
this.regionForm.parentCode === this.topCode
? this.regionForm.code
: this.regionForm.code.replace(this.regionForm.parentCode, '');
this.regionForm.subCode = this.regionForm.code;
this.loadParentOptions(this.regionForm.parentCode, this.regionForm.parentName);
});
},
@@ -500,25 +496,31 @@ export default {
};
},
addChildren() {
if (validatenull(this.regionForm.code) || validatenull(this.regionForm.name)) {
const current = this.regionForm || {};
const parentCode = current.originalCode || current.code;
if (validatenull(parentCode) || validatenull(current.name)) {
this.$message.warning('请先选择一项区划');
return;
}
if (Number(this.regionForm.regionLevel) >= 3) {
if (Number(current.regionLevel) >= 3) {
this.$message.warning('区县已是末级,不能新增下级');
return;
}
const parentCode = this.regionForm.code;
const parentName = this.regionForm.name;
const parentGroupCode = this.regionForm.parentCode || this.topCode;
const parentName = current.name;
const parentGroupCode = current.parentCode || this.topCode;
this.setRegionColumnDisplay('code', true);
this.regionForm.parentCode = parentCode;
this.regionForm.parentName = parentName;
this.regionForm.code = '';
this.regionForm.subCode = '';
this.regionForm.originalCode = '';
this.regionForm.name = '';
this.regionForm.regionLevel = Number(this.regionForm.regionLevel) + 1;
this.regionForm = {
parentCode,
parentName,
code: '',
subCode: '',
originalCode: '',
name: '',
regionLevel: Number(current.regionLevel) + 1,
sort: 1,
remark: '',
status: 1,
};
this.loadParentOptionsByGroup(parentGroupCode, parentCode, parentName);
},
setRegionColumnDisplay(prop, display) {
@@ -536,8 +538,7 @@ export default {
if (this.isCountryRegion) {
return;
}
const parentCode = this.isProvinceRegion ? '' : this.regionForm.parentCode || '';
this.regionForm.code = parentCode + (this.regionForm.subCode || '');
this.regionForm.code = this.regionForm.subCode || '';
},
getRootParentOptions() {
return [
@@ -586,16 +587,28 @@ export default {
this.parentOptions = this.ensureSelectedParent(options, selectedCode, selectedName);
});
},
handleSubmit(form, done) {
if (Number(form.regionLevel) === 0) {
form.parentCode = this.topCode;
form.ancestors = this.topCode;
form.subCode = form.code;
} else {
const parentCode = Number(form.regionLevel) === 1 ? '' : form.parentCode;
form.code = parentCode + form.subCode;
buildRegionPayload(form) {
const source = validatenull(this.regionForm) ? form || {} : this.regionForm;
const regionLevel = Number(source.regionLevel);
const isNew = validatenull(source.originalCode);
const payload = {
code: regionLevel === 0 ? source.code : source.subCode,
parentCode: regionLevel === 0 ? this.topCode : source.parentCode,
name: source.name,
regionLevel,
sort: source.sort,
remark: source.remark,
status: source.status == null || source.status === '' ? 1 : source.status,
};
if (!isNew) {
payload.originalCode = source.originalCode;
payload.dataSource = source.dataSource;
}
submit(form).then(
return payload;
},
handleSubmit(form, done) {
const payload = this.buildRegionPayload(form);
submit(payload).then(
() => {
this.$message({
type: 'success',
@@ -774,6 +787,6 @@ export default {
margin-bottom: 12px !important;
}
.el-form-item{
margin-bottom: 9px !important;
margin-bottom: 22px !important;
}
</style>
+62 -13
View File
@@ -204,7 +204,7 @@ import { exportBlob } from '@/api/common';
import { getDeptTree } from '@/api/system/dept';
import { config, excelOption, formatCargoValue, option } from '@/option/business/common-cargo';
import { getDictionary } from '@/api/system/dictbiz';
import { getToken } from '@/utils/auth';
import { getToken, isAdministrator } from '@/utils/auth';
import { openImportDialog } from '@/utils/import-excel';
import { downloadXls } from '@/utils/util';
import { mapGetters } from 'vuex';
@@ -250,16 +250,14 @@ export default {
};
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
},
created() {
this.applyDefaultDeptSearch();
this.initDeptTree();
this.loadPriceUnitOptions();
},
@@ -273,6 +271,26 @@ export default {
if (Array.isArray(data)) return data;
return (data && data.records) || [];
},
defaultDeptId() {
// 用户可能绑定多个组织(逗号分隔),搜索下拉是单选,只取当前会话第一个组织 ID。
const raw = (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
return String(raw)
.split(',')
.map(item => item.trim())
.filter(Boolean)[0] || '';
},
applyDefaultDeptSearch() {
const deptId = this.defaultDeptId();
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
if (!deptId || !deptColumn) return;
deptColumn.searchValue = deptId;
this.query = { ...this.query, deptId };
this.$nextTick(() => {
if (this.$refs.crud?.searchForm) {
this.$refs.crud.searchForm.searchDeptId = deptId;
}
});
},
initDeptTree() {
getDeptTree(this.userInfo.tenantId).then(res => {
this.deptOptions = this.flattenDept(res.data.data || []);
@@ -280,6 +298,18 @@ export default {
if (deptColumn) {
deptColumn.dicData = this.deptOptions;
}
// dicData 异步到位后再回填,避免下拉选项为空时清空已选组织,导致请求丢失 deptId。
const deptId = String(this.query.deptId || this.defaultDeptId() || '');
if (!deptId) return;
if (deptColumn) {
deptColumn.searchValue = deptId;
}
this.query = { ...this.query, deptId };
this.$nextTick(() => {
if (this.$refs.crud?.searchForm) {
this.$refs.crud.searchForm.searchDeptId = deptId;
}
});
});
},
loadPriceUnitOptions() {
@@ -307,9 +337,13 @@ export default {
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
// 雪花 ID 必须保持字符串,避免 JS Number 精度丢失导致筛错组织。
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return;
result.push({
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: item.id,
value: id,
});
if (item.children && item.children.length) {
result.push(...this.flattenDept(item.children, level + 1));
@@ -317,6 +351,19 @@ export default {
});
return result;
},
normalizeListQuery(params = {}) {
// 搜索条件优先于缓存的 query,避免默认组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
} else {
delete query.deptId;
}
query.allDept = this.isAdmin ? 1 : 0;
return query;
},
normalizeCargoTypeOptions(list = []) {
return list
.map(item => ({
@@ -485,7 +532,7 @@ export default {
return false;
}
if (row.cargoValue && !/^\d+(\.\d{1,2})?$/.test(String(row.cargoValue))) {
this.$message.warning('货值只能输入数字,最多保留2位小数');
this.$message.warning('单价只能输入数字,最多保留2位小数');
return false;
}
if (!row.model) {
@@ -619,13 +666,14 @@ export default {
},
searchReset() {
this.query = {};
this.applyDefaultDeptSearch();
this.page.currentPage = 1;
this.onLoad(this.page);
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -648,8 +696,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeListQuery(params);
this.query = query;
api
.getList(page.currentPage, page.pageSize, { ...params, ...this.query, allDept: 0 })
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data;
this.page.total = result.total;
@@ -662,8 +712,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: 0,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
+62 -37
View File
@@ -289,8 +289,8 @@
</el-button>
</div>
<div class="map-picker-content">
<map-search-results :results="routeMapSearchResults" @select="selectRouteMapSearchResult" />
<div ref="routeAmap" class="common-route-page__map"></div>
<map-search-results :results="routeMapSearchResults" @select="selectRouteMapSearchResult" />
</div>
<div class="common-route-page__map-info">
<span>{{ routeMapStatus }}</span>
@@ -321,8 +321,9 @@ import { exportBlob } from '@/api/common';
import { getDeptTree } from '@/api/system/dept';
import { addressTypeOptions } from '@/option/base/common-address';
import { config, excelOption, option } from '@/option/business/common-route';
import { getToken } from '@/utils/auth';
import { getToken, isAdministrator } from '@/utils/auth';
import { openImportDialog } from '@/utils/import-excel';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
import { List, Search } from '@element-plus/icons-vue';
@@ -389,10 +390,7 @@ export default {
};
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
return isAdministrator(this.userInfo);
},
ids() {
return this.selectionList.map(item => item.id).join(',');
@@ -417,19 +415,27 @@ export default {
});
},
buildDeptTree(tree) {
return tree.map(item => ({
label: item.title || item.deptName || item.name,
value: item.id,
children:
item.children && item.children.length ? this.buildDeptTree(item.children) : undefined,
}));
return tree.map(item => {
// 雪花 ID 必须保持字符串,避免 JS Number 精度丢失导致筛错组织。
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
return {
label: item.title || item.deptName || item.name,
value: id,
children:
item.children && item.children.length ? this.buildDeptTree(item.children) : undefined,
};
}).filter(item => item.value);
},
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return;
result.push({
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: item.id,
value: id,
});
if (item.children && item.children.length) {
result.push(...this.flattenDept(item.children, level + 1));
@@ -438,7 +444,26 @@ export default {
return result;
},
defaultDeptId() {
return (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
const raw = (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
return (
String(raw)
.split(',')
.map(item => item.trim())
.filter(Boolean)[0] || ''
);
},
normalizeListQuery(params = {}) {
// 搜索条件优先于缓存的 query,避免默认组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
} else {
delete query.deptId;
}
query.allDept = this.isAdmin ? 1 : 0;
return query;
},
applyDefaultDeptSearch() {
const deptId = this.defaultDeptId();
@@ -489,7 +514,7 @@ export default {
this.addressLoading = true;
getCommonAddressList(this.addressPage.currentPage, this.addressPage.pageSize, {
...this.addressQuery,
allDept: 0,
allDept: this.isAdmin ? 1 : 0,
})
.then(res => {
const data = res.data.data || {};
@@ -609,30 +634,29 @@ export default {
this.$message.warning('请输入地址关键词');
return;
}
if (this.routeMapLoading) return;
this.routeMapSearchResults = [];
this.routeMapLoading = true;
this.loadAmap()
.then(() => {
this.routeMapLoading = true;
return this.ensureRouteAmapGeocoder();
})
.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) {
.then(() => searchAmapPlaceText(keyword))
.then(pois => {
this.routeMapSearchResults = normalizeMapSearchResults(pois, keyword);
const first = this.routeMapSearchResults[0];
if (!first?.location) {
this.routeMapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
return;
}
this.pickRouteMapPoint(point, keyword);
return this.$nextTick().then(() => {
if (typeof this.routeAmap?.resize === 'function') {
this.routeAmap.resize();
}
this.pickRouteMapPoint(first.location, first.address || first.name || keyword);
});
})
.catch(() => {
this.routeMapStatus = '地图搜索失败';
this.$message.error('地图搜索失败,请稍后重试');
this.routeMapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
})
.finally(() => {
this.routeMapLoading = false;
@@ -1002,9 +1026,9 @@ export default {
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -1027,8 +1051,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeListQuery(params);
this.query = query;
api
.getList(page.currentPage, page.pageSize, { ...params, ...this.query, allDept: 0 })
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data;
this.page.total = result.total;
@@ -1041,8 +1067,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: 0,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
@@ -162,16 +162,16 @@
v-model="row.billingUnit"
clearable
filterable
:disabled="!row.billingElement"
:disabled="!canSelectBillingUnit(row)"
:loading="unitLoading"
:placeholder="row.billingElement ? '请选择' : '请先选择计费要素'"
:placeholder="billingUnitPlaceholder(row)"
><el-option
v-for="item in unitOptionsFor(row)"
:key="item.id || item.value"
:label="item.label"
:value="item.value" /></el-select></template
></el-table-column>
<el-table-column label="单价(元)" width="180"
<el-table-column :label="unitPriceColumnLabel" width="180"
><template #default="{ row }"
><div v-if="readonly && usesRangeUnitPrice(row)" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
@@ -379,13 +379,24 @@ import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { InfoFilled } from '@element-plus/icons-vue';
import { getDictionary } from '@/api/system/dictbiz';
import SectionCard from '@/components/section-card/main.vue';
import { currencyUnit } from '@/utils/currency';
const clone = value => JSON.parse(JSON.stringify(value));
/** 计费要素与计量单位维度的对应关系 */
/** 计费要素与计量单位维度的对应关系(这些要素可选计费单位) */
const BILLING_ELEMENT_DIMENSION_MAP = {
按重量: '重量',
按体积: '体积',
按车辆: '数量',
按数量: '数量',
按里程: '里程',
'按吨·公里': '吨·公里',
'固定金额(整单一口价)': '整车',
};
const BILLING_UNIT_SELECTABLE_ELEMENTS = Object.keys(BILLING_ELEMENT_DIMENSION_MAP);
/** 对应维度下无计量单位时的默认计费单位 */
const DEFAULT_BILLING_UNIT_MAP = {
按里程: '公里',
'按吨·公里': '吨·公里',
'固定金额(整单一口价)': '单',
};
const defaultRule = () => ({
feeType: '',
@@ -421,6 +432,7 @@ export default {
value: { type: Object, default: () => ({}) },
index: { type: Number, default: -1 },
readonly: Boolean,
settlementCurrency: { type: String, default: 'RMB' },
},
emits: ['update:modelValue', 'save'],
data() {
@@ -436,7 +448,6 @@ export default {
billingElements: [
'按重量',
'按体积',
'按车辆',
'按里程',
'按吨·公里',
'固定金额(整单一口价)',
@@ -445,7 +456,6 @@ export default {
typeMap: {
按重量: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
按体积: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
按车辆: ['固定单价'],
按里程: ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
'按吨·公里': ['固定单价', '区间单价', '阶梯单价', '区间阶梯一口价'],
'固定金额(整单一口价)': ['固定一口价'],
@@ -492,6 +502,9 @@ export default {
emitPath: true,
};
},
unitPriceColumnLabel() {
return `单价(${currencyUnit(this.settlementCurrency)})`;
},
},
mounted() {
this.loadDictionaries();
@@ -560,6 +573,7 @@ export default {
next.limitRanges = next.limitRanges.map(item => ({ ...item, minimumBillingWeight: '' }));
this.syncLegacyLimit(next);
next.matchCondition = { ...defaultRule().matchCondition, ...(rule.matchCondition || {}) };
this.syncBillingUnit(next);
return next;
},
normalizeRanges(row) {
@@ -646,36 +660,61 @@ export default {
}),
]).finally(() => {
this.unitLoading = false;
(this.draft.rules || []).forEach(row => this.syncBillingUnit(row));
});
},
measurementDimension(billingElement) {
return BILLING_ELEMENT_DIMENSION_MAP[billingElement] || '';
},
defaultBillingUnit(billingElement) {
return DEFAULT_BILLING_UNIT_MAP[String(billingElement || '').trim()] || '';
},
canSelectBillingUnit(row) {
return BILLING_UNIT_SELECTABLE_ELEMENTS.includes(String(row?.billingElement || '').trim());
},
billingUnitPlaceholder(row) {
if (!row?.billingElement) return '请先选择计费要素';
if (!this.canSelectBillingUnit(row)) return '当前计费要素无需选择';
return '请选择';
},
unitOptionsFor(row) {
const dimension = this.measurementDimension(row?.billingElement);
if (dimension) {
return this.measurementUnits
.filter(item => String(item.dimension || '').trim() === dimension)
.map(item => ({
id: item.id,
label: item.unitName,
value: item.unitName,
}))
.filter(item => item.value);
if (!this.canSelectBillingUnit(row)) {
return [];
}
return (this.unitOptions || [])
.map(item => ({
id: item.id || item.dictKey || item.dictValue,
label: item.dictValue,
value: item.dictValue,
}))
.filter(item => item.value);
const dimension = this.measurementDimension(row?.billingElement);
const defaultUnit = this.defaultBillingUnit(row?.billingElement);
const options = dimension
? this.measurementUnits
.filter(item => String(item.dimension || '').trim() === dimension)
.map(item => ({
id: item.id,
label: item.unitName,
value: item.unitName,
}))
.filter(item => item.value)
: [];
// 对应维度未配置计量单位时,下拉仅展示默认值
if (!options.length && defaultUnit) {
return [{ id: `default-${defaultUnit}`, label: defaultUnit, value: defaultUnit }];
}
return options;
},
syncBillingUnit(row) {
const options = this.unitOptionsFor(row);
if (!options.some(item => String(item.value) === String(row.billingUnit || ''))) {
if (!this.canSelectBillingUnit(row)) {
row.billingUnit = '';
return;
}
const options = this.unitOptionsFor(row);
const defaultUnit = this.defaultBillingUnit(row?.billingElement);
const current = String(row.billingUnit || '').trim();
if (options.some(item => String(item.value) === current)) {
return;
}
if (defaultUnit && options.some(item => String(item.value) === defaultUnit)) {
row.billingUnit = defaultUnit;
return;
}
row.billingUnit = options[0]?.value || defaultUnit || '';
},
feeTypeKey(row) {
const option = this.feeCategories.find(
@@ -892,7 +931,6 @@ export default {
['taxRate', '税率'],
['billingElement', '计费要素'],
['billingType', '计费类型'],
['billingUnit', '计费单位'],
];
for (const [i, row] of this.draft.rules.entries()) {
const empty = required.find(
@@ -902,6 +940,15 @@ export default {
this.$message.warning(`第${i + 1}行${empty[1]}不能为空`);
return false;
}
if (
this.canSelectBillingUnit(row) &&
(row.billingUnit === undefined ||
row.billingUnit === null ||
String(row.billingUnit).trim() === '')
) {
this.$message.warning(`第${i + 1}行计费单位不能为空`);
return false;
}
const feeItem = String(row.feeItem).trim();
if (feeItemSet.has(feeItem)) {
this.$message.warning(`费用项“${feeItem}”不能重复`);
File diff suppressed because it is too large Load Diff
@@ -1,94 +1,108 @@
<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"
<div class="contract-attachment-section">
<section
v-if="!dialogOnly"
class="contract-manage-form__section contract-manage-form__section--panel contract-manage-form__section--attachment"
>
<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
<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"
>
<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>
<el-table-column 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, $index }">
<span v-if="readonly || isRowLocked(row)">{{ row.fileType || '-' }}</span>
<el-select
v-else
:model-value="row.fileType"
placeholder="请选择"
style="width: 100%"
teleported
clearable
@update:model-value="value => updateRowFileType($index, value)"
>
<el-option
v-for="item in contractAttachmentTypeOptions"
:key="`${item.value}-${item.label}`"
: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
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="preview?.(row, rows)"
@keydown.enter="preview?.(row, rows)"
@keydown.space.prevent="preview?.(row, rows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column v-if="description" label="附件描述" min-width="220">
<template #default="{ row, $index }">
<span v-if="readonly || isRowLocked(row)">{{ row.description || '-' }}</span>
<el-input
v-else
:model-value="row.description"
maxlength="200"
@update:model-value="value => updateRowDescription($index, 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">
支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过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>
<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">
支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过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="上传附件"
@change="handleChange"
@success="handleUploadSuccess"
/>
</div>
</section>
<el-dialog
v-model="uploadDialogVisible"
@@ -103,8 +117,18 @@
<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
v-model="uploadDialogLocation"
placeholder="请选择附件位置"
style="width: 100%"
@change="syncUploadDialogTypeByLocation"
>
<el-option
v-for="item in resolvedLocationOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</div>
<div class="contract-attachment-upload-dialog__field">
@@ -144,7 +168,7 @@
</div>
</template>
</el-dialog>
</section>
</div>
</template>
<script>
@@ -154,14 +178,42 @@ import { getUploadHeaders } from '@/utils/upload';
import { downloadFileByUrl } from '@/utils/util';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
const CONTRACT_ATTACHMENT_TYPE_OTHER = '其它文件';
const CONTRACT_ATTACHMENT_TYPE_OTHER = '其他文件';
/** 用印材料历史取值兼容别名 */
export const CONTRACT_SEAL_MATERIAL_LOCATION = '用印材料';
export const CONTRACT_SEAL_MATERIAL_TYPE_ALIASES = {
合同文件: CONTRACT_SEAL_MATERIAL_LOCATION,
};
/** 佐证材料附件位置(历史:其它附件) */
export const CONTRACT_SUPPORTING_ATTACHMENT_LOCATION = '附件(佐证材料等)';
export const CONTRACT_ATTACHMENT_TYPE_DICT = 'contract_attachment_type';
export const CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT = 'contract_supporting_attachment_type';
export const isContractSupportingAttachmentLocation = location => {
const text = String(location || '');
return (
text === CONTRACT_SUPPORTING_ATTACHMENT_LOCATION ||
text === '其它附件' ||
text === '其他附件'
);
};
export const resolveAttachmentTypeDictCode = (location, fallback = CONTRACT_ATTACHMENT_TYPE_DICT) =>
isContractSupportingAttachmentLocation(location)
? CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
: fallback || CONTRACT_ATTACHMENT_TYPE_DICT;
export const defaultContractAttachmentTypeOptions = [
{ label: '合同文件', value: '合同文件' },
{ label: '用印材料', value: '用印材料' },
{ label: '双章归档文件', value: '双章归档文件' },
{ label: '其它文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典 contract_attachment_type 动态加载 */
export const defaultContractSupportingAttachmentTypeOptions = [
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典动态加载 */
export const contractAttachmentTypeOptions = defaultContractAttachmentTypeOptions;
export const isContractSealMaterialLocation = location => {
const text = String(location || '');
return text === CONTRACT_SEAL_MATERIAL_LOCATION || text === '合同文件';
};
export const attachmentFileTypes = [
'pdf',
'bmp',
@@ -201,21 +253,35 @@ const normalizeAttachmentTypeText = value => {
.replace(/[\s_\-—–·•()()\[\]【】{}《》<>“”"'、,,。.]/g, '');
};
const isOtherAttachmentTypeOption = item => {
const text = `${item?.label || ''}${item?.value || ''}`;
return (
String(item?.value) === CONTRACT_ATTACHMENT_TYPE_OTHER ||
String(item?.value) === '其它文件' ||
text.includes('其他文件') ||
text.includes('其它文件') ||
text.includes('其他') ||
text.includes('其它')
);
};
const attachmentTypeKeywords = fileType => {
if (fileType === '双章归档文件') return ['双章归档文件', '双章归档', '双章'];
if (fileType === '合同文件') return ['合同文件', '合同'];
return [fileType];
const text = String(fileType || '');
if (text.includes('双章')) return ['双章归档文件', '双章归档', '双章'];
if (
text.includes('用印') ||
(text.includes('合同') && !text.includes('其他') && !text.includes('其它'))
) {
return ['用印材料', '合同文件', '用印', '合同'];
}
if (isOtherAttachmentTypeOption({ label: text, value: text })) return [];
return text ? [text] : [];
};
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;
const matched = list.find(isOtherAttachmentTypeOption);
return matched?.value || CONTRACT_ATTACHMENT_TYPE_OTHER;
};
export const resolveContractAttachmentType = (
@@ -223,22 +289,32 @@ export const resolveContractAttachmentType = (
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);
const values = list.map(item => item.value);
const rawFileType = String(row.fileType || '');
const aliasedFileType = CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawFileType] || rawFileType;
if (values.includes(aliasedFileType)) return aliasedFileType;
if (values.includes(rawFileType)) return rawFileType;
const matchedByLabel = list.find(
item => String(item.label) === aliasedFileType || String(item.label) === rawFileType
);
if (matchedByLabel) return matchedByLabel.value;
const fileName = normalizeAttachmentTypeText(attachmentName(row));
if (!fileName) return otherType;
const matched = list
.filter(item => item.value !== otherType)
.filter(item => !isOtherAttachmentTypeOption(item))
.flatMap(item =>
attachmentTypeKeywords(item.value).map(keyword => ({
value: item.value,
keyword: normalizeAttachmentTypeText(keyword),
}))
attachmentTypeKeywords(item.value)
.concat(attachmentTypeKeywords(item.label))
.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;
};
@@ -249,17 +325,32 @@ export const normalizeContractFileRows = (rows, options = defaultContractAttachm
description: row.description || '',
}));
const mapDictOptions = response => {
const mapDictOptions = (response, dictCode = CONTRACT_ATTACHMENT_TYPE_DICT) => {
const data = response?.data?.data || response?.data || [];
const records = Array.isArray(data) ? data : data.records || [];
const applySealAlias = dictCode === CONTRACT_ATTACHMENT_TYPE_DICT;
return records
.map(item => ({
label: item.dictValue,
value: item.dictKey,
}))
.map(item => {
const rawLabel = String(item.dictValue || '');
const rawValue = String(item.dictKey || '');
const label = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawLabel] ||
(rawLabel === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawLabel)
: rawLabel;
const value = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawValue] ||
(rawValue === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawValue)
: rawValue;
return { label, value };
})
.filter(item => item.label && item.value);
};
const defaultOptionsByDictCode = dictCode =>
dictCode === CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
? [...defaultContractSupportingAttachmentTypeOptions]
: [...defaultContractAttachmentTypeOptions];
export default {
name: 'ContractAttachmentSection',
components: { UploadFilled },
@@ -272,16 +363,25 @@ export default {
lockApproved: Boolean,
markApprovedOnUpload: Boolean,
useUploadDialog: Boolean,
attachmentLocation: { type: String, default: '合同文件' },
dialogOnly: Boolean,
attachmentLocation: { type: String, default: CONTRACT_SEAL_MATERIAL_LOCATION },
attachmentLocationOptions: { type: Array, default: null },
/** 附件类型业务字典 code,默认用印材料字典;佐证材料传 contract_supporting_attachment_type */
attachmentTypeDictCode: {
type: String,
default: CONTRACT_ATTACHMENT_TYPE_DICT,
},
preview: { type: Function, default: null },
},
emits: ['update:rows'],
emits: ['update:rows', 'upload-to-location', 'upload-confirmed'],
data() {
return {
selected: [],
attachmentFileTypes,
contractAttachmentTypeOptions: [...defaultContractAttachmentTypeOptions],
activeAttachmentTypeDictCode: CONTRACT_ATTACHMENT_TYPE_DICT,
uploadDialogVisible: false,
uploadDialogLocation: CONTRACT_SEAL_MATERIAL_LOCATION,
uploadDialogType: resolveOtherAttachmentType(defaultContractAttachmentTypeOptions),
uploadDialogFiles: [],
uploadDialogFileList: [],
@@ -300,47 +400,186 @@ export default {
defaultAttachmentType() {
return resolveOtherAttachmentType(this.contractAttachmentTypeOptions);
},
resolvedAttachmentTypeDictCode() {
return this.attachmentTypeDictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
},
resolvedLocationOptions() {
const options = (this.attachmentLocationOptions || []).filter(Boolean);
if (options.length) {
return options.map(item => {
if (item === '合同文件') return CONTRACT_SEAL_MATERIAL_LOCATION;
if (item === '其它附件' || item === '其他附件') {
return CONTRACT_SUPPORTING_ATTACHMENT_LOCATION;
}
return item;
});
}
return [CONTRACT_SEAL_MATERIAL_LOCATION, CONTRACT_SUPPORTING_ATTACHMENT_LOCATION];
},
},
watch: {
attachmentTypeDictCode: {
immediate: false,
handler() {
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
},
},
created() {
this.loadAttachmentTypeOptions();
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
methods: {
attachmentName,
loadAttachmentTypeOptions() {
return getBizDictionary({ code: 'contract_attachment_type' })
currentUploadUserName() {
const raw = this.$store.getters.userInfo;
const userInfo = raw && !Array.isArray(raw) ? raw : {};
// 与顶部栏一致优先 real_name;用印材料 / 佐证材料共用
return (
userInfo.real_name ||
userInfo.realName ||
userInfo.nick_name ||
userInfo.nickName ||
userInfo.user_name ||
userInfo.userName ||
userInfo.account ||
''
);
},
loadAttachmentTypeOptions(dictCode = this.resolvedAttachmentTypeDictCode, alignRows = false) {
const code = dictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
this.activeAttachmentTypeDictCode = code;
const fallbackOptions = defaultOptionsByDictCode(code);
return getBizDictionary({ code })
.then(res => {
const options = mapDictOptions(res);
// 切换字典期间若已发起更新请求,忽略过期响应
if (this.activeAttachmentTypeDictCode !== code) return;
const options = mapDictOptions(res, code);
if (options.length) {
this.contractAttachmentTypeOptions = options;
if (
!options.some(item => String(item.value) === String(this.uploadDialogType))
) {
this.uploadDialogType = this.defaultAttachmentType;
}
// 字典缺少「其他文件」时补上,保证未匹配文件名可默认选中
this.contractAttachmentTypeOptions = options.some(isOtherAttachmentTypeOption)
? options
: [...options, { label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER }];
} else {
this.contractAttachmentTypeOptions = fallbackOptions;
}
if (
!this.contractAttachmentTypeOptions.some(
item => String(item.value) === String(this.uploadDialogType)
)
) {
this.uploadDialogType = this.defaultAttachmentType;
}
// 字典加载后,把已有行的中文类型名对齐到 dictKey,保证下拉可选中
if (alignRows && this.attachmentType && (this.rows || []).length) {
const aligned = (this.rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, this.contractAttachmentTypeOptions),
}));
const changed = aligned.some(
(row, index) => row.fileType !== this.rows[index]?.fileType
);
if (changed) this.update(aligned);
}
})
.catch(() => {
// 字典加载失败时保留默认选项
if (this.activeAttachmentTypeDictCode !== code) return;
this.contractAttachmentTypeOptions = fallbackOptions;
this.uploadDialogType = this.defaultAttachmentType;
});
},
isRowLocked(row) {
return this.lockApproved && isAttachmentApproved(row);
},
update(rows) {
this.$emit('update:rows', rows);
this.$emit('update:rows', Array.isArray(rows) ? rows.map(item => ({ ...item })) : []);
},
updateRowFileType(row, value) {
if (this.isRowLocked(row)) return;
row.fileType = value;
this.update([...this.rows]);
enrichUploadedRow(row, existing) {
const uploadUserName = this.currentUploadUserName();
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const next = {
...existing,
...row,
description: row.description || existing?.description || '',
// 新上传行始终写入当前用户;已有行保留原上传人
uploadUserName: existing?.uploadUserName || uploadUserName || row.uploadUserName || '',
uploadTime: existing?.uploadTime || row.uploadTime || uploadTime,
approved: existing?.approved || row.approved || false,
};
if (this.attachmentType) {
next.fileType = existing?.fileType
? existing.fileType
: resolveContractAttachmentType(
{ ...next, fileType: '' },
this.contractAttachmentTypeOptions
);
}
if (this.markApprovedOnUpload && !existing) next.approved = true;
return next;
},
updateRowDescription(row, value) {
if (this.isRowLocked(row)) return;
row.description = value;
this.update([...this.rows]);
findExistingRow(row, rows = this.rows) {
const fileUrl = attachmentUrl(row);
return (rows || []).find(item => {
const sameUid = row.uid && item.uid && String(row.uid) === String(item.uid);
const sameUrl = fileUrl && attachmentUrl(item) === fileUrl;
return sameUid || sameUrl;
});
},
updateRowFileType(index, value) {
const rows = [...(this.rows || [])];
const row = rows[index];
if (!row || this.isRowLocked(row)) return;
rows[index] = { ...row, fileType: value };
this.update(rows);
},
updateRowDescription(index, value) {
const rows = [...(this.rows || [])];
const row = rows[index];
if (!row || this.isRowLocked(row)) return;
rows[index] = { ...row, description: value };
this.update(rows);
},
resolveTypeByLocation(location) {
const options = this.contractAttachmentTypeOptions || [];
const locationText = String(location || '');
if (
isContractSupportingAttachmentLocation(locationText) ||
locationText.includes('佐证') ||
locationText.includes('其它') ||
locationText.includes('其他')
) {
return this.defaultAttachmentType;
}
const matched = options.find(
item =>
String(item.value) === locationText ||
String(item.label) === locationText ||
String(item.value).includes('用印') ||
String(item.label).includes('用印') ||
String(item.value).includes('合同') ||
String(item.label).includes('合同')
);
return matched?.value || options[0]?.value || this.defaultAttachmentType;
},
syncUploadDialogTypeByLocation() {
const dictCode = resolveAttachmentTypeDictCode(
this.uploadDialogLocation,
this.resolvedAttachmentTypeDictCode
);
const applyType = () => {
this.uploadDialogType = this.resolveTypeByLocation(this.uploadDialogLocation);
};
if (dictCode !== this.activeAttachmentTypeDictCode) {
this.loadAttachmentTypeOptions(dictCode, false).finally(applyType);
return;
}
applyType();
},
openUploadDialog() {
this.uploadDialogType = this.defaultAttachmentType;
const options = this.resolvedLocationOptions;
this.uploadDialogLocation = options.includes(this.attachmentLocation)
? this.attachmentLocation
: options[0] || this.attachmentLocation;
this.syncUploadDialogTypeByLocation();
this.uploadDialogFiles = [];
this.uploadDialogFileList = [];
this.uploadDialogVisible = true;
@@ -348,7 +587,8 @@ export default {
resetUploadDialog() {
this.uploadDialogFiles = [];
this.uploadDialogFileList = [];
this.uploadDialogType = this.defaultAttachmentType;
this.uploadDialogLocation = this.attachmentLocation;
this.syncUploadDialogTypeByLocation();
},
beforeUpload(file) {
const extension = String(file.name || '')
@@ -391,13 +631,18 @@ export default {
...this.uploadDialogFiles.filter(item => String(item.uid) !== String(file.uid)),
normalized,
];
this.uploadDialogType = resolveContractAttachmentType(
{
...normalized,
fileType: '',
},
this.contractAttachmentTypeOptions
);
const locationText = String(this.uploadDialogLocation || '');
if (!(locationText.includes('其它') || locationText.includes('其他'))) {
this.uploadDialogType = resolveContractAttachmentType(
{
...normalized,
fileType: '',
},
this.contractAttachmentTypeOptions
);
} else {
this.uploadDialogType = this.defaultAttachmentType;
}
this.$message.success('上传成功');
},
handleDialogError() {
@@ -416,57 +661,57 @@ export default {
this.$message.warning('请先上传附件');
return;
}
if (!this.uploadDialogLocation) {
this.$message.warning('请选择附件位置');
return;
}
if (!this.uploadDialogType) {
this.$message.warning('请选择附件类型');
return;
}
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadUserName = this.currentUploadUserName();
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,
uploadUserName: uploadUserName || row.uploadUserName || '',
uploadTime: row.uploadTime || uploadTime,
...(this.markApprovedOnUpload ? { approved: true } : {}),
}));
this.update([...(this.rows || []), ...appended]);
// 统一交给父组件按附件位置写入对应列表,避免同位置双写或跨位置不显示
this.$emit('upload-to-location', {
location: this.uploadDialogLocation,
rows: appended,
});
this.uploadDialogVisible = false;
this.$emit('upload-confirmed', {
location: this.uploadDialogLocation,
rows: appended,
});
},
handleChange(rows) {
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
handleChange(list) {
if (!Array.isArray(list)) return;
// 上传组件偶发回传空列表时,禁止清空已有表格数据
if (!list.length) return;
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;
})
);
const nextRows = list.map(row => {
const existing = this.findExistingRow(row, existingRows);
if (existing && this.isRowLocked(existing)) return { ...existing };
return this.enrichUploadedRow(row, existing);
});
existingRows.forEach(item => {
if (!this.isRowLocked(item)) return;
if (this.findExistingRow(item, nextRows)) return;
nextRows.unshift({ ...item });
});
this.update(nextRows);
},
handleUploadSuccess(file) {
const url = attachmentUrl(file);
if (!file || !url) return;
if (this.findExistingRow(file)) return;
this.update([...(this.rows || []), this.enrichUploadedRow(file)]);
},
remove(index) {
const row = this.rows[index];
@@ -474,7 +719,7 @@ export default {
this.$message.warning('已审核通过的附件不允许删除');
return;
}
const rows = [...this.rows];
const rows = [...(this.rows || [])];
rows.splice(index, 1);
this.update(rows);
},
@@ -487,7 +732,15 @@ export default {
downloadFileByUrl(url, attachmentName(row));
},
batchDownload() {
(this.selected.length ? this.selected : this.rows).forEach(this.download);
const sourceRows = this.selected.length ? this.selected : this.rows || [];
const downloadableRows = sourceRows.filter(row => attachmentUrl(row));
if (!downloadableRows.length) {
this.$message.warning(this.selected.length ? '所选附件暂无可下载地址' : '暂无可下载附件');
return;
}
downloadableRows.forEach((row, index) => {
window.setTimeout(() => this.download(row), index * 400);
});
},
formatSize(size) {
const value = Number(size);
@@ -504,7 +757,7 @@ export default {
.contract-manage-form__section {
margin: 12px 0 0;
padding: 14px 16px 16px;
overflow: hidden;
overflow: visible;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
@@ -527,6 +780,15 @@ export default {
}
}
.contract-manage-form__attachment-table {
:deep(.el-table__body-wrapper),
:deep(.el-table__header-wrapper),
:deep(.el-table__cell),
:deep(.cell) {
overflow: visible;
}
}
.contract-manage-form__attachment-head {
display: flex;
align-items: center;
@@ -1,7 +1,17 @@
<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></div>
<div class="detail-heading">
<div>
<h2>多联总单详情 <span>|</span> {{ master.masterNo }}</h2>
<el-tag :type="statusType(master.businessStatus)">{{
statusName(master.businessStatus)
}}</el-tag>
<el-tag v-if="transportFlowLabel" type="primary" class="transport-flow-tag">{{
transportFlowLabel
}}</el-tag>
</div>
</div>
<dl class="detail-meta">
<div>
<dt>客户</dt>
@@ -34,19 +44,22 @@
<dt>附件</dt>
<dd>
<template v-if="attachments.length">
<el-link
<span
v-for="file in attachments"
:key="file.url || file.link || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>{{ attachmentName(file) }}</el-link
>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
</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>
<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>{{ formatRegionAddress(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>{{ formatRegionAddress(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">
@@ -69,14 +82,21 @@
<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="60" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="150" />
<el-table-column prop="cargoType" label="类型" min-width="130" />
<el-table-column prop="cargoType" min-width="130">
<template #header
><span>货物类型<span class="required-asterisk">*</span></span></template
>
</el-table-column>
<el-table-column prop="packageType" label="包装" min-width="120" />
<el-table-column label="数量" min-width="100"><template #default="{ row }">{{ row.quantity ?? '-' }}{{ row.quantityUnit ? ` ${row.quantityUnit}` : '' }}</template></el-table-column>
<el-table-column prop="quantity" label="数量" min-width="100" />
<el-table-column prop="quantityUnit" label="数量单位" width="100" />
<el-table-column prop="brand" label="品牌" min-width="120" />
<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-column prop="specification" label="规格" min-width="120" />
<el-table-column prop="model" label="型号" min-width="120" />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="100" align="center" fixed="right" />
</el-table>
<el-empty v-if="!(master.goods || []).length" description="暂无货物信息" :image-size="56" />
</section>
@@ -90,6 +110,7 @@ import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archiv
import { CaretBottom, CaretTop } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { formatRegionAddress } from '@/utils/region-address';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
@@ -132,6 +153,7 @@ export default {
dateRange() { return this.master?.planStartTime && this.master?.planEndTime ? `${String(this.master.planStartTime).slice(0, 10)} ~ ${String(this.master.planEndTime).slice(0, 10)}` : '-'; },
},
async mounted() {
if (!this.id) return;
this.loading = true;
try {
const res = await api.getDetail(this.id);
@@ -143,6 +165,7 @@ export default {
} finally { this.loading = false; }
},
methods: {
formatRegionAddress,
quantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
segmentNumber(route, index) { return String(route.segmentNo || '').replace(/^段/, '') || index + 1; },
isSegmentExpanded(segmentNo) { return Boolean(this.expandedSegments[segmentNo]); },
@@ -282,9 +305,37 @@ export default {
<style scoped lang="scss">
.master-detail { padding-bottom: 20px; color: #303133; }
.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: #303133; font-size: 14px; word-break: break-all; } :deep(.el-link) { font-size: 14px; } }
.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;
}
:deep(.el-tag) {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 8px;
font-size: 13px;
line-height: 28px;
}
}
.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: #303133; font-size: 14px; word-break: break-all; } .attachment-file-name { font-size: 14px; max-width: 100%; } }
.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: #303133 !important; }
@@ -305,5 +356,7 @@ export default {
.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; }
.master-goods-card__heading { padding: 20px 24px 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } }
.master-goods-card :deep(.el-table) { margin: 0 24px 20px; width: auto; }
.required-asterisk { margin-left: 4px; color: #f56c6c; }
@media screen and (max-width: 1200px) { .detail-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
@@ -21,10 +21,10 @@
<div class="route-overview__node">
<span :class="['route-badge', index === 0 ? 'start' : 'middle']">{{ index === 0 ? '起' : '经' }}</span>
<el-tooltip :content="route.departureName || '-'" placement="top" :show-after="120">
<strong>{{ route.departureName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'departure') }}</strong>
</el-tooltip>
<el-tooltip :content="route.departureAddress || '-'" placement="top" :show-after="120">
<small>{{ route.departureAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.departureAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.departureContact, route.departurePhone)" placement="top" :show-after="120">
<small>{{ contactText(route.departureContact, route.departurePhone) }}</small>
@@ -34,10 +34,10 @@
<div v-if="index === routes.length - 1" class="route-overview__node">
<span class="route-badge end">终</span>
<el-tooltip :content="route.arrivalName || '-'" placement="top" :show-after="120">
<strong>{{ route.arrivalName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'arrival') }}</strong>
</el-tooltip>
<el-tooltip :content="route.arrivalAddress || '-'" placement="top" :show-after="120">
<small>{{ route.arrivalAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.arrivalAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.arrivalContact, route.arrivalPhone)" placement="top" :show-after="120">
<small>{{ contactText(route.arrivalContact, route.arrivalPhone) }}</small>
@@ -51,9 +51,29 @@
<section v-for="(route, index) in routes" :key="route.segmentNo" class="dispatch-section" :data-segment="route.segmentNo">
<header class="segment-header">
<div>
<el-checkbox v-model="route.selected">
<el-tooltip
v-if="!canDispatchSegment(route)"
content="需等上一段运单完成到达后才能调度"
placement="top"
>
<span class="segment-checkbox-wrap">
<el-checkbox :model-value="false" disabled>
<span class="segment-checkbox-label">
<span class="segment-checkbox-no">{{ route.segmentNo }}:</span>
<span class="segment-checkbox-path"
>{{ dispatchLocationName(route, 'departure') }} → {{ dispatchLocationName(route, 'arrival') }}</span
>
</span>
</el-checkbox>
</span>
</el-tooltip>
<el-checkbox
v-else
v-model="route.selected"
@change="value => handleSegmentSelect(route, value)"
>
<span class="segment-checkbox-label">
<span>{{ route.segmentNo }}:</span>
<span class="segment-checkbox-no">{{ route.segmentNo }}:</span>
<el-tooltip
:content="`${dispatchLocationName(route, 'departure')} → ${dispatchLocationName(route, 'arrival')}`"
placement="top"
@@ -65,7 +85,15 @@
</span>
</el-checkbox>
</div>
<div>总量 {{ formatQuantity(totalQuantity) }} {{ quantityUnitLabel }},已调度 <em>{{ formatQuantity(dispatchedQuantity(route)) }} {{ quantityUnitLabel }}</em>,剩余 <em>{{ formatQuantity(remaining(route)) }} {{ quantityUnitLabel }}</em></div>
<div>
总量 {{ formatQuantity(totalQuantity) }} {{ quantityUnitLabel }},已调度
<em>{{ formatQuantity(dispatchedQuantity(route)) }} {{ quantityUnitLabel }}</em>,剩余
<em>{{ formatQuantity(remaining(route)) }} {{ quantityUnitLabel }}</em>
<template v-if="index > 0">
,上一段已到达
<em>{{ formatQuantity(previousArrivedQuantity(route)) }} {{ quantityUnitLabel }}</em>
</template>
</div>
</header>
<div v-show="route.selected" class="segment-content">
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form">
@@ -78,16 +106,28 @@
<el-col :span="12">
<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>
<el-col :span="4"
><el-form-item label="联系人" label-width="68px" class="dispatch-form__compact-label"
><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="4"
><el-form-item label="联系方式" label-width="80px" class="dispatch-form__compact-label"
><el-input v-model="route.departurePhone" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="4"><el-form-item :label="dateStartLabel(route)"><el-date-picker v-model="route.estimatedStartTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></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.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>
<el-col :span="4"
><el-form-item label="联系人" label-width="68px" class="dispatch-form__compact-label"
><el-input v-model="route.arrivalContact" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="4"
><el-form-item label="联系方式" label-width="80px" class="dispatch-form__compact-label"
><el-input v-model="route.arrivalPhone" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="4"><el-form-item :label="dateEndLabel(route)"><el-date-picker v-model="route.estimatedEndTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></el-form-item></el-col>
</el-row>
</el-form>
@@ -156,16 +196,59 @@
<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-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>
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="挂车车牌号"><el-input v-model="route.trailerVehicleNo" placeholder="请输入" /></el-form-item></el-col>
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人"><el-autocomplete v-model="route.escortName" :debounce="300" :fetch-suggestions="fetchEscortSuggestions" clearable placeholder="请输入" :loading="escortLoading" @select="item => handleEscortSuggestionSelect(route, item)" /></el-form-item></el-col>
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人手机号"><el-input v-model="route.escortPhone" placeholder="请输入" /></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"
clearable
trigger-on-focus
:debounce="300"
:fetch-suggestions="fetchDriverSuggestions"
:loading="driverLoading"
placeholder="请输入司机"
@mousedown="() => handleDriverFocus(route)"
@focus="() => handleDriverFocus(route)"
@select="handleDriverSuggestionSelect"
/></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>
<el-col v-if="route.carrierType !== '承运商'" :span="6"
><el-form-item label="挂车车牌号"
><el-input v-model="route.trailerVehicleNo" placeholder="请输入" /></el-form-item
></el-col>
<el-col v-if="route.carrierType !== '承运商'" :span="6"
><el-form-item label="押运人"
><el-autocomplete
v-model="route.escortName"
clearable
trigger-on-focus
:debounce="300"
:fetch-suggestions="fetchEscortSuggestions"
:loading="escortLoading"
placeholder="请输入"
@mousedown="() => handleDriverFocus(route)"
@focus="() => handleDriverFocus(route)"
@select="handleEscortSuggestionSelect"
/></el-form-item
></el-col>
<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><el-input v-model="route.vehicleNo" 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>
<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="联系电话"><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>
@@ -183,7 +266,7 @@
<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>
<template #append><el-select :model-value="freightGroupPriceUnit(group)" class="freight-unit-select" @change="value => handleFreightGroupPriceUnitChange(group, value)"><el-option v-for="item in priceUnitOptions(route)" :key="item.value" :label="item.label" :value="item.value" /></el-select></template>
</el-input>
</el-form-item>
</el-col>
@@ -233,7 +316,15 @@
<script>
import * as api from '@/api/business/master-order';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { fetchDriversByCarrierOrganizations } from '@/utils/carrier-org-resource';
import { formatContractOptionLabel } from '@/utils/contract';
import {
currencyUnit,
remapPriceUnitCurrency,
resolvePriceUnitByQuantityUnit,
} from '@/utils/currency';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { formatRegionAddress } from '@/utils/region-address';
import { isMobile } from '@/utils/validate';
const unwrapRecords = res => {
@@ -249,7 +340,24 @@ 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, quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'] };
return {
loading: false,
submitting: false,
master: null,
routes: [],
pending: [],
pendingExpanded: false,
editingId: null,
driverInputs: {},
activeSuggestRoute: null,
carrierOptions: [],
driverOptions: [],
carrierLoading: false,
driverLoading: false,
escortLoading: false,
quantityUnitOptions: [],
quantityUnitLoading: false,
};
},
computed: {
totalQuantity() { return (this.master?.goods || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0); },
@@ -290,11 +398,26 @@ export default {
},
},
async mounted() {
await Promise.all([this.load(), this.loadCarrierOptions(), this.loadDriverOptions()]);
await this.loadQuantityUnitOptions();
await Promise.all([this.load(), this.loadCarrierOptions()]);
this.applySelfOperatedCarrierDefaults();
},
methods: {
loadQuantityUnitOptions() {
if (this.quantityUnitLoading) return this.quantityUnitLoadPromise;
this.quantityUnitLoading = true;
this.quantityUnitLoadPromise = loadMeasurementUnitOptions()
.then(options => {
this.quantityUnitOptions = options.map(item => item.value);
return this.quantityUnitOptions;
})
.finally(() => {
this.quantityUnitLoading = false;
});
return this.quantityUnitLoadPromise;
},
async load() {
if (!this.id) return;
this.loading = true;
try {
const res = await api.getDetail(this.id);
@@ -312,8 +435,10 @@ export default {
},
async loadContractCurrency() {
const directCurrency = this.master?.settlementCurrency || this.master?.settlementCurrencyCode || this.master?.currency;
const previousCurrency = this.master?.settlementCurrency;
if (!this.master?.projectId || !this.master?.contractId) {
this.master.settlementCurrency = this.normalizeCurrencyValue(directCurrency || 'RMB');
this.syncRouteCurrency(previousCurrency, this.master.settlementCurrency);
return;
}
try {
@@ -327,6 +452,20 @@ export default {
this.master.customerContractPartyB = '';
this.master.settlementCurrency = 'RMB';
}
this.syncRouteCurrency(previousCurrency, this.master.settlementCurrency);
},
syncRouteCurrency(_previousCurrency, nextCurrency) {
const currency = this.normalizeCurrencyValue(nextCurrency || 'RMB');
(this.routes || []).forEach(route => {
route.currency = currency;
(route.freightItems || []).forEach(item => {
if (item.priceUnit) {
item.priceUnit = remapPriceUnitCurrency(item.priceUnit, currency);
} else if (item.quantityUnit) {
item.priceUnit = resolvePriceUnitByQuantityUnit(item.quantityUnit, currency);
}
});
});
},
dispatchRouteNodes() {
const routes = Array.isArray(this.master?.routes) ? this.master.routes : [];
@@ -452,12 +591,29 @@ export default {
dispatchedQuantity(route) {
return this.quantityNumber(route.dispatchedQuantity) + this.pendingSegmentQuantity(route);
},
previousArrivedQuantity(route) {
const routeIndex = this.routes.indexOf(route);
if (routeIndex <= 0) return null;
return this.quantityNumber(this.routes[routeIndex - 1]?.arrivedQuantity);
},
canDispatchSegment(route) {
const previousArrived = this.previousArrivedQuantity(route);
if (previousArrived === null) return true;
return previousArrived > 0;
},
remaining(route) {
return Math.max(
0,
this.totalQuantity -
this.dispatchedQuantity(route)
);
const masterRemaining = Math.max(0, this.totalQuantity - this.dispatchedQuantity(route));
const previousArrived = this.previousArrivedQuantity(route);
if (previousArrived === null) return masterRemaining;
return Math.max(0, Math.min(masterRemaining, previousArrived - this.dispatchedQuantity(route)));
},
handleSegmentSelect(route, value) {
if (value && !this.canDispatchSegment(route)) {
route.selected = false;
this.$message.warning(
`${route.segmentNo}需等上一段运单完成到达后才能调度(上一段已到达 ${this.formatQuantity(this.previousArrivedQuantity(route))} ${this.quantityUnitLabel})`
);
}
},
formatQuantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
formatAmount(value) { return Number(value || 0).toFixed(2); },
@@ -484,8 +640,23 @@ export default {
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}` : ''); },
freightGroupPriceUnit(group = {}) {
if (group.rows?.[0]?.priceUnit) return group.rows[0].priceUnit;
if (!group.quantityUnit) return '';
return (
resolvePriceUnitByQuantityUnit(group.quantityUnit, this.master?.settlementCurrency || 'RMB') ||
`${currencyUnit(this.master?.settlementCurrency || 'RMB')}/${group.quantityUnit}`
);
},
freightGroupAmount(group = {}) { return (group.rows || []).reduce((sum, item) => sum + this.freightAmount(item), 0); },
priceUnitOptions(route = {}) {
const unit = currencyUnit(route.currency || this.master?.settlementCurrency || 'RMB');
return [
{ label: `${unit}/吨`, value: `${unit}/吨` },
{ label: `${unit}/件`, value: `${unit}/件` },
{ label: `${unit}/方`, value: `${unit}/方` },
];
},
handleFreightGroupUnitPriceInput(group, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
const normalized = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
@@ -495,6 +666,10 @@ export default {
handleFreightGroupQuantityUnitChange(route, group, value) {
(group.rows || []).forEach(item => {
item.quantityUnit = value || '';
item.priceUnit = resolvePriceUnitByQuantityUnit(
value,
route.currency || this.master?.settlementCurrency || 'RMB'
) || item.priceUnit;
const goods = (route.goods || []).find(row => String(row.sourceIndex) === String(item.sourceIndex));
if (goods) goods.quantityUnit = value || '';
});
@@ -517,7 +692,7 @@ export default {
})),
});
},
currencyLabel(value) { return ({ CNY: '人民币', RMB: '人民币', USD: '美元', EUR: '欧元' })[value] || value || '人民币'; },
currencyLabel(value) { return currencyUnit(value); },
quantityNumber(value) { return Number(value || 0); },
goodsKey(goods = {}, includeUnit = true) {
return [
@@ -562,13 +737,14 @@ export default {
}, 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 previousCompletedQuantity = this.previousArrivedQuantity(route);
if (previousCompletedQuantity === null) return true;
if (previousCompletedQuantity <= 0) {
this.$message.warning(`${route.segmentNo}需等上一段运单完成到达后才能调度`);
return false;
}
const currentDispatchQuantity = (goods || []).reduce(
(sum, item) => sum + this.quantityNumber(item.dispatchQuantity),
(sum, item) => sum + this.quantityNumber(item.dispatchQuantity ?? item.quantity),
0
);
const dispatchTotal =
@@ -577,7 +753,7 @@ export default {
currentDispatchQuantity;
if (dispatchTotal <= previousCompletedQuantity) return true;
this.$message.warning(
`${route.segmentNo}调度总量不能大于上一段已完成数量(${this.formatQuantity(previousCompletedQuantity)} ${this.quantityUnitLabel})`
`${route.segmentNo}调度总量不能大于上一段已到达数量(${this.formatQuantity(previousCompletedQuantity)} ${this.quantityUnitLabel})`
);
return false;
},
@@ -676,7 +852,12 @@ export default {
const mileage = Number(value);
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
},
addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; },
formatRegionAddress,
addressText(name, address) {
// 名称与详细地址都要按「市 区县」展示;两者格式化后相同时只展示一次
const values = [this.formatRegionAddress(name), this.formatRegionAddress(address)].filter(Boolean);
return [...new Set(values)].join(' / ') || '-';
},
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
dateStartLabel(route) { return route.documentType === '计划单' ? '计划开始日期' : '预计发货日期'; },
dateEndLabel(route) { return route.documentType === '计划单' ? '计划结束日期' : '预计完成日期'; },
@@ -706,6 +887,7 @@ export default {
removeGoods(route, index) { route.goods.splice(index, 1); this.syncFreightItems(route); },
syncFreightItems(route) {
const oldItems = route.freightItems || [];
const currency = route.currency || this.master?.settlementCurrency || 'RMB';
route.freightItems = (route.goods || [])
.filter(item => item.sourceIndex !== undefined && item.sourceIndex !== '')
.map(item => {
@@ -717,7 +899,10 @@ export default {
quantity: item.dispatchQuantity || '',
quantityUnit: item.quantityUnit || '',
unitPrice: old.unitPrice || '',
priceUnit: old.priceUnit || `元/${item.quantityUnit || '吨'}`,
priceUnit:
old.priceUnit ||
resolvePriceUnitByQuantityUnit(item.quantityUnit || '吨', currency) ||
`${currencyUnit(currency)}/${item.quantityUnit || '吨'}`,
};
});
},
@@ -726,6 +911,7 @@ export default {
route.carrierName = '';
route.carrierId = '';
route.carrierContractId = '';
this.clearRouteDriverVehicleFields(route);
if (value === '承运商') {
route.trailerVehicleNo = '';
route.escortName = '';
@@ -745,15 +931,35 @@ export default {
route.carrierContractId = route.carrierType !== '自运' ? contract?.contractId || '' : '';
route.carrierId = route.carrierType !== '自运' ? contract?.carrierId || '' : '';
route.carrierName = contract?.carrierName || '';
this.clearRouteDriverVehicleFields(route);
},
clearRouteDriverVehicleFields(route) {
if (!route) return;
route.driverId = '';
route.driverName = '';
route.driverPhone = '';
route.vehicleNo = '';
route.trailerVehicleNo = '';
route.escortName = '';
route.escortPhone = '';
this.driverOptions = [];
},
getRouteCarrierFilter(route = {}) {
return {
carrierId: route.carrierId || '',
carrierName: route.carrierName || '',
};
},
carrierOptionValue(item = {}) {
return item.contractId || item.id || item.value || item.carrierName || '';
},
carrierOptionLabel(item) {
const duplicateCount = this.carrierOptions.filter(option => option.carrierName === item.carrierName).length;
return duplicateCount > 1
? `${item.carrierName}(${item.contractName || item.contractId})`
: item.carrierName;
if (duplicateCount <= 1) {
return item.carrierName;
}
const contractLabel = formatContractOptionLabel(item);
return contractLabel ? `${item.carrierName}(${contractLabel})` : item.carrierName;
},
driverOptionLabel(item) { return item.driverName || item.name || ''; },
driverOptionKey(item) { return item.id || item.driverId || this.driverOptionLabel(item) || item.mobile; },
@@ -816,29 +1022,57 @@ export default {
route.carrierContractId = '';
});
},
async loadDriverOptions(keyword = '') {
handleDriverFocus(route) {
this.activeSuggestRoute = route;
},
async loadDriverOptions(route = {}, keyword = '') {
const carrier = this.getRouteCarrierFilter(route);
if (!carrier.carrierId && !carrier.carrierName) {
this.driverOptions = [];
return [];
}
this.driverLoading = true;
try {
this.driverOptions = unwrapRecords(
await getDriverList(1, 20, { driverName: keyword, posts: '司机' })
this.driverOptions = await fetchDriversByCarrierOrganizations(
{ size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '司机' },
carrier
);
return this.driverOptions;
} finally {
this.driverLoading = false;
}
},
fetchDriverSuggestions(queryString, callback) {
this.loadDriverOptions(String(queryString || '').trim())
.then(() =>
const route = this.activeSuggestRoute || {};
const carrier = this.getRouteCarrierFilter(route);
const keyword = String(queryString || '').trim();
if (!carrier.carrierId && !carrier.carrierName) {
callback([]);
if (!keyword) this.$message.warning('请先选择承运商');
return;
}
this.driverLoading = true;
fetchDriversByCarrierOrganizations(
{ size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '司机' },
carrier
)
.then(records => {
this.driverOptions = records;
callback(
this.driverOptions.map(item => ({
records.map(item => ({
...item,
value: this.driverOptionLabel(item),
}))
)
)
.catch(() => callback([]));
);
})
.catch(() => callback([]))
.finally(() => {
this.driverLoading = false;
});
},
handleDriverSuggestionSelect(route, item = {}) {
handleDriverSuggestionSelect(item = {}) {
const route = this.activeSuggestRoute;
if (!route) return;
route.driverName = this.driverOptionLabel(item);
route.driverId = item.id || item.driverId || '';
route.driverPhone = item.mobile || item.driverPhone || item.phone || route.driverPhone || '';
@@ -846,12 +1080,22 @@ export default {
if (drivingVehicle) route.vehicleNo = drivingVehicle;
},
fetchEscortSuggestions(queryString, callback) {
const route = this.activeSuggestRoute || {};
const keyword = String(queryString || '').trim();
const carrier = this.getRouteCarrierFilter(route);
if (!carrier.carrierId && !carrier.carrierName) {
callback([]);
if (!keyword) this.$message.warning('请先选择承运商');
return;
}
this.escortLoading = true;
getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '押运员' })
.then(res => {
fetchDriversByCarrierOrganizations(
{ size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '押运员' },
carrier
)
.then(records => {
callback(
unwrapRecords(res).map(item => ({
records.map(item => ({
...item,
value: this.driverOptionLabel(item),
}))
@@ -862,7 +1106,9 @@ export default {
this.escortLoading = false;
});
},
handleEscortSuggestionSelect(route, item = {}) {
handleEscortSuggestionSelect(item = {}) {
const route = this.activeSuggestRoute;
if (!route) return;
route.escortName = this.driverOptionLabel(item);
const phone = item.mobile || item.driverPhone || item.phone || '';
if (phone) route.escortPhone = phone;
@@ -887,6 +1133,10 @@ export default {
addToPending(route, continueDispatch) {
const goods = route.goods.filter(item => Number(item.dispatchQuantity) > 0);
if (!goods.length) return this.$message.warning('请填写本次数量');
if (!this.canDispatchSegment(route)) {
return this.$message.warning(`${route.segmentNo}需等上一段运单完成到达后才能调度`);
}
if (!this.validatePreviousSegmentCompletedQuantity(route, goods)) return;
if (!this.validateDispatchGoods(route, goods)) return;
this.syncFreightItems(route);
if (!this.validateFreightItems(route)) return;
@@ -1001,9 +1251,11 @@ export default {
.pending-list-spacer { height: min(436px, calc(100vh - 130px)); }
.master-overview, .dispatch-section { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; }
.overview-heading, .segment-header, .pending-bar { display: flex; align-items: center; justify-content: space-between; }
.segment-header :deep(.el-checkbox.is-checked .el-checkbox__label) { font-size: 16px; font-weight: 700; }
.segment-checkbox-label { display: inline-flex; align-items: center; min-width: 0; }
.segment-checkbox-path { display: inline-block; white-space: nowrap; }
.segment-checkbox-wrap { display: inline-flex; max-width: 100%; }
.segment-checkbox-label { display: inline-flex; align-items: center; min-width: 0; font-size: 14px; font-weight: 400; color: #303133; }
/* 仅「段X:」加粗加大,冒号右侧路线保持常规字号 */
.segment-checkbox-no { font-size: 16px; font-weight: 700; color: #303133; }
.segment-checkbox-path { display: inline-block; white-space: nowrap; font-size: 14px; font-weight: 400; }
.overview-heading { padding: 18px 24px 12px; h2 { display: inline-block; margin: 0 16px 0 0; font-size: 20px; } h2 span { margin: 0 8px; color: #909399; font-weight: 400; } }
.overview-content { display: grid; grid-template-columns: minmax(400px, .9fr) minmax(620px, 1.6fr); gap: 28px; padding: 8px 24px 20px; }
.overview-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 24px; margin: 0; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { margin: 0; color: #409eff; font-size: 14px; word-break: break-all; } }
@@ -1014,7 +1266,13 @@ export default {
.route-overview__line { flex: 0 1 56px; min-width: 24px; height: 32px; margin-top: 0; border-bottom: 6px solid #e4e7ed; }
.segment-header { min-height: 46px; padding: 0 20px; border-bottom: 1px solid #eff1f7; font-weight: 600; em { color: #409eff; font-style: normal; } }
.segment-content { padding: 16px 24px; }
.dispatch-form { :deep(.el-form-item) { margin-bottom: 14px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } :deep(.el-input-group__append .el-select) { width: auto; } }
.dispatch-form { :deep(.el-form-item) { margin-bottom: 24px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } :deep(.el-input-group__append .el-select) { width: auto; } }
/* 短标签按自身宽度,避免被「预计发货日期」等长标签把左侧撑开 */
.dispatch-form__compact-label {
:deep(.el-form-item__label) {
justify-content: flex-end;
}
}
.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; } }
@@ -9,6 +9,7 @@
><el-select
v-model="form.projectId"
filterable
:disabled="isBasicRouteReadonly"
placeholder="请输入并选择"
@change="projectChange"
><el-option
@@ -20,13 +21,13 @@
><el-form-item label="客户合同" prop="contractName"
><el-select
v-model="form.contractId"
:disabled="!form.projectId"
:disabled="isBasicRouteReadonly || !form.projectId"
placeholder="选择项目后带出"
@change="contractChange"
><el-option
v-for="contract in contracts"
:key="contract.id"
:label="contract.contractName"
:label="formatContractOptionLabel(contract)"
:value="contract.id" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="总单号" required
@@ -41,6 +42,7 @@
><el-form-item label="备注"
><el-input
v-model="form.remark"
:disabled="isBasicRouteReadonly"
maxlength="200"
show-word-limit
placeholder="请输入" /></el-form-item></el-col></el-row
@@ -49,7 +51,7 @@
<section>
<div class="section-head">
<h3>收发货信息</h3>
<div class="section-head__actions">
<div v-if="!isBasicRouteReadonly" class="section-head__actions">
<el-link type="primary" @click="addRoute">新增途经点</el-link>
<el-link type="primary" @click="openRouteDialog">选择线路</el-link>
</div>
@@ -62,13 +64,13 @@
<div class="route-node-form">
<el-form-item label="发货地" required class="route-address-form-item"
><div class="route-address-inputs"
><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-input v-if="isNonRoadAddress('departure')" v-model="form.departureName" class="route-address-name" readonly :disabled="isBasicRouteReadonly" placeholder="请选择站点" /><el-cascader v-else v-model="addressRegionPaths.departure" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="isBasicRouteReadonly" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('departure', value)" /><el-input v-model="form.departureAddress" class="route-address-detail" readonly :disabled="isBasicRouteReadonly" placeholder="请输入详细地址" @click="handleAddressDetailClick('departure')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="isBasicRouteReadonly" @click="openCommonAddress('departure')" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人" class="route-contact-form-item"
><el-input v-model="form.departureContact" placeholder="请输入"
><el-input v-model="form.departureContact" :disabled="isBasicRouteReadonly" placeholder="请输入"
/></el-form-item>
<el-form-item label="联系方式" class="route-phone-form-item"
><el-input v-model="form.departurePhone" placeholder="请输入"
><el-input v-model="form.departurePhone" :disabled="isBasicRouteReadonly" placeholder="请输入"
/></el-form-item>
<el-form-item label="计划开始" class="route-date-form-item"
><el-date-picker
@@ -76,6 +78,7 @@
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled="isBasicRouteReadonly"
placeholder="请选择"
/></el-form-item>
</div>
@@ -83,7 +86,7 @@
</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 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" :disabled="isBasicRouteReadonly" 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>
@@ -91,16 +94,16 @@
<div class="route-node-form">
<el-form-item label="途经地" required class="route-address-form-item"
><div class="route-address-inputs"
><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-input v-if="isNonRoadAddress(`route-${index}`)" v-model="route.departureName" class="route-address-name" readonly :disabled="isBasicRouteReadonly || !route.transportType" placeholder="请先选择运输方式" /><el-cascader v-else v-model="addressRegionPaths[`route-${index}`]" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="isBasicRouteReadonly || !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="isBasicRouteReadonly || !route.transportType" placeholder="请先选择运输方式" @click="handleAddressDetailClick(`route-${index}`)" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="isBasicRouteReadonly || !route.transportType" @click="openCommonAddress(`route-${index}`)" /></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>
<el-form-item label="联系人" class="route-contact-form-item"><el-input v-model="route.departureContact" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式" class="route-phone-form-item"><div class="route-contact-actions"><el-input v-model="route.departurePhone" :disabled="isBasicRouteReadonly" placeholder="请输入" /><template v-if="!isBasicRouteReadonly"><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></template></div></el-form-item>
</div>
</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 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" :disabled="isBasicRouteReadonly" 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>
@@ -108,18 +111,19 @@
<div class="route-node-form">
<el-form-item label="收货地" required class="route-address-form-item"
><div class="route-address-inputs"
><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-input v-if="isNonRoadAddress('arrival')" v-model="form.arrivalName" class="route-address-name" readonly :disabled="isBasicRouteReadonly || !form.finalTransportType" placeholder="请先选择运输方式" /><el-cascader v-else v-model="addressRegionPaths.arrival" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="isBasicRouteReadonly || !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="isBasicRouteReadonly || !form.finalTransportType" placeholder="请先选择运输方式" @click="handleAddressDetailClick('arrival')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="isBasicRouteReadonly || !form.finalTransportType" @click="openCommonAddress('arrival')" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人" class="route-contact-form-item"
><el-input v-model="form.arrivalContact" placeholder="请输入" /></el-form-item>
><el-input v-model="form.arrivalContact" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式" class="route-phone-form-item"
><el-input v-model="form.arrivalPhone" placeholder="请输入" /></el-form-item>
><el-input v-model="form.arrivalPhone" :disabled="isBasicRouteReadonly" placeholder="请输入" /></el-form-item>
<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"
:disabled="isBasicRouteReadonly"
placeholder="请选择"
/></el-form-item>
</div>
@@ -144,6 +148,7 @@
><el-autocomplete
v-model="row.cargoName"
clearable
:disabled="isGoodsCategoryLocked(row)"
placeholder="请输入货物名称"
:debounce="300"
:fetch-suggestions="
@@ -168,22 +173,29 @@
:placeholder="row.cargoType || '请选择'"
clearable
filterable
:disabled="isGoodsCategoryLocked(row)"
:loading="cargoTypeLoading"
@visible-change="visible => visible && loadCargoTypeOptions()"
@change="value => handleCargoTypeChange(row, value)"
/></template></el-table-column
><el-table-column label="数量" width="180" class-name="master-editor__quantity-cell"
><el-table-column label="数量" width="200" class-name="master-editor__quantity-cell"
><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
><div class="master-editor__quantity-field"
><el-input
v-model="row.quantity"
inputmode="decimal"
placeholder="请输入"
@input="value => handleQuantityInput(row, value)"
@blur="() => enforceMinDispatchedQuantity(row)"
/><small v-if="dispatchedQuantityOf(row) > 0" class="master-editor__quantity-hint"
>已调度 {{ formatDispatchedQuantity(dispatchedQuantityOf(row)) }},不可低于此值</small
></div
></template></el-table-column
><el-table-column label="数量单位" width="120"
><template #header><span>数量单位<span class="required-asterisk">*</span></span></template>
<template #default="{ row }"
><el-select v-model="row.quantityUnit"
><el-select v-model="row.quantityUnit" :disabled="isGoodsCategoryLocked(row)"
><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"
><template #default="{ row }"
@@ -218,7 +230,7 @@
>新增</el-link
><el-link
type="danger"
:disabled="form.goods.length === 1"
:disabled="form.goods.length === 1 || isGoodsCategoryLocked(form.goods[$index])"
@click="removeGoods($index)"
>删除</el-link
></div
@@ -249,11 +261,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="240" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="240"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -272,10 +295,9 @@
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column prop="uploadTime" label="上传时间" width="170" align="center" sortable />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row, $index }">
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-link type="danger" @click="removeAttachment($index)">删除</el-link>
<el-link type="primary" @click="downloadAttachment(row)">下载</el-link>
</template>
</el-table-column>
</el-table>
@@ -402,8 +424,8 @@
>
</div>
<div class="map-picker-content">
<map-search-results :results="mapSearchResults" @select="selectAddressMapSearchResult" />
<div ref="addressMap" class="address-map" />
<map-search-results :results="mapSearchResults" @select="selectAddressMapSearchResult" />
</div>
<div class="address-map-status">
{{ mapStatus
@@ -578,6 +600,7 @@ import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
import { exportBlob, importBlob } from '@/api/common';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { OfficeBuilding, Search } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
@@ -586,10 +609,10 @@ import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { mapGetters } from 'vuex';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
const quantityUnitOptions = ['吨', '件', '方'];
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
@@ -601,6 +624,7 @@ let amapLoader;
import { getList as getCommonRouteList } from '@/api/business/common-route';
import * as api from '@/api/business/master-order';
import { formatUpdateUserName } from '@/utils/audit';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { isMobile } from '@/utils/validate';
export default {
components: {
@@ -691,10 +715,11 @@ export default {
'zip',
],
transports: ['公路运输', '铁路运输', '水路运输', '航空运输'],
quantityUnitOptions,
quantityUnitOptions: [],
quantityUnitLoading: false,
form: {
routes: [{ segmentNo: '段1', transportType: '' }],
goods: [{ quantityUnit: quantityUnitOptions[0] }],
goods: [{ quantityUnit: '吨' }],
attachmentsJson: '[]',
transportOrganizationType: '多式联运',
},
@@ -706,6 +731,31 @@ export default {
},
computed: {
...mapGetters(['userInfo']),
// 非草稿编辑:基本信息与收发货路线只读
isBasicRouteReadonly() {
return Boolean(this.id) && Boolean(this.form.businessStatus) && this.form.businessStatus !== 'draft';
},
// 品类 → 已调度量(各分段取最大值;优先用详情返回的 dispatchedGoods)
dispatchedGoodsMap() {
const map = {};
const source = this.form.dispatchedGoods;
if (source && typeof source === 'object' && !Array.isArray(source)) {
Object.entries(source).forEach(([key, qty]) => {
const number = Number(qty);
if (Number.isFinite(number)) map[key] = number;
});
return map;
}
(this.form.routeProgress || []).forEach(route => {
const goods = route?.dispatchedGoods || {};
Object.entries(goods).forEach(([key, qty]) => {
const number = Number(qty);
if (!Number.isFinite(number)) return;
map[key] = Math.max(map[key] || 0, number);
});
});
return map;
},
lastRouteName() {
return this.form.routes[this.form.routes.length - 1]?.departureName || '途经地';
},
@@ -736,6 +786,7 @@ export default {
async mounted() {
this.loading = true;
try {
await this.loadQuantityUnitOptions();
await Promise.all([this.loadProjects(), this.loadCargoTypeOptions(), this.loadRegionOptions()]);
if (this.id) {
await this.loadDetail(this.id);
@@ -745,6 +796,23 @@ export default {
}
},
methods: {
formatContractOptionLabel,
defaultQuantityUnit() {
return this.quantityUnitOptions[0] || '吨';
},
loadQuantityUnitOptions() {
if (this.quantityUnitLoading) return this.quantityUnitLoadPromise;
this.quantityUnitLoading = true;
this.quantityUnitLoadPromise = loadMeasurementUnitOptions()
.then(options => {
this.quantityUnitOptions = options.map(item => item.value);
return this.quantityUnitOptions;
})
.finally(() => {
this.quantityUnitLoading = false;
});
return this.quantityUnitLoadPromise;
},
responseData(response) {
return response?.data?.data || response?.data || response || {};
},
@@ -921,7 +989,7 @@ export default {
finalTransportType: hasFinalSegment
? finalSegment.transportType
: detail.finalTransportType || '',
goods: detail.goods || [{ quantityUnit: quantityUnitOptions[0] }],
goods: detail.goods || [{ quantityUnit: this.defaultQuantityUnit() }],
attachmentsJson: detail.attachmentsJson || '[]',
transportOrganizationType: '多式联运',
};
@@ -1104,6 +1172,7 @@ export default {
return row.regionCode || row.districtCode || row.areaCode || row.adcode || '';
},
openCommonAddress(target) {
if (this.isBasicRouteReadonly) return;
this.addressTarget = target;
if (this.addressTransportMode(target) !== 'road') {
this.stationQuery = {};
@@ -1209,6 +1278,7 @@ export default {
this.loadRegionOptions().then(() => this.syncAddressRegionPath(this.addressTarget));
},
handleAddressDetailClick(target) {
if (this.isBasicRouteReadonly) return;
if (this.addressTransportMode(target) !== 'road') {
this.openCommonAddress(target);
return;
@@ -1216,6 +1286,7 @@ export default {
this.openAddressMap(target);
},
openAddressMap(target) {
if (this.isBasicRouteReadonly) return;
if (this.addressTransportMode(target) !== 'road') {
this.$message.info('非公路运输地址不能使用地图选择');
return;
@@ -1273,30 +1344,31 @@ export default {
.catch(() => this.$message.error('高德地图组件加载失败'));
},
searchAddressMap() {
if (!this.mapKeyword.trim()) return this.$message.warning('请输入地址关键词');
const keyword = String(this.mapKeyword || '').trim();
if (!keyword) return this.$message.warning('请输入地址关键词');
if (this.mapLoading) return;
this.mapSearchResults = [];
this.mapLoading = true;
this.loadAmap()
.then(() => this.ensureAddressGeocoder())
.then(
() =>
new Promise((resolve, reject) =>
this.addressMapGeocoder.getLocation(this.mapKeyword, (status, result) =>
status === 'complete' ? resolve(result) : reject(result)
)
)
)
.then(result => {
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);
.then(() => searchAmapPlaceText(keyword))
.then(pois => {
this.mapSearchResults = normalizeMapSearchResults(pois, keyword);
const first = this.mapSearchResults[0];
if (!first?.location) {
this.mapStatus = '未找到匹配地址';
return this.$message.warning('地图搜索无匹配地址');
}
return this.$nextTick().then(() => {
this.pickAddressMap(first.location, first.address || first.name || keyword);
});
})
.catch(() => this.$message.error('地图搜索失败'));
.catch(() => {
this.mapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
})
.finally(() => {
this.mapLoading = false;
});
},
selectAddressMapSearchResult(item) {
if (item && item.location) {
@@ -1618,7 +1690,7 @@ export default {
'',
cargoTypePath,
quantity: row.quantity || '',
quantityUnit: row.quantityUnit || quantityUnitOptions[0],
quantityUnit: row.quantityUnit || this.defaultQuantityUnit(),
packageType: row.packageType || row.package || '',
brand: row.brand || '',
specification: row.specification || row.spec || '',
@@ -1662,6 +1734,7 @@ export default {
});
},
openRouteDialog() {
if (this.isBasicRouteReadonly) return;
this.routeQuery = {};
this.routePage.current = 1;
this.routeDialogVisible = true;
@@ -1719,21 +1792,44 @@ export default {
return { segmentNo: `段${index + 1}`, transportType: '' };
},
addRoute() {
if (this.isBasicRouteReadonly) return;
this.form.routes.push(this.createRoute());
},
removeRoute(index) {
if (this.isBasicRouteReadonly) return;
this.form.routes.splice(index, 1);
this.syncAddressRegionPaths();
},
goodsKey(row = {}) {
return `${row.cargoName || ''}\u0000${row.cargoType || ''}`;
},
dispatchedQuantityOf(row = {}) {
return Number(this.dispatchedGoodsMap[this.goodsKey(row)] || 0);
},
formatDispatchedQuantity(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '0';
return String(number);
},
isGoodsCategoryLocked(row = {}) {
return this.dispatchedQuantityOf(row) > 0;
},
addGoods(index) {
this.form.goods.splice(index + 1, 0, {
quantityUnit: quantityUnitOptions[0],
quantityUnit: this.defaultQuantityUnit(),
materialCode: '',
deviceCode: '',
remark: '',
});
},
removeGoods(index) {
const row = this.form.goods[index];
if (this.isGoodsCategoryLocked(row)) {
this.$message.warning(
`货物【${row.cargoName || ''}】已有调度量,不能删除`
);
return;
}
if (this.form.goods.length > 1) this.form.goods.splice(index, 1);
},
handleQuantityInput(row, value) {
@@ -1741,6 +1837,42 @@ export default {
const [integer = '', ...decimals] = text.split('.');
row.quantity = decimals.length ? `${integer}.${decimals.join('').slice(0, 3)}` : integer;
},
enforceMinDispatchedQuantity(row = {}) {
const min = this.dispatchedQuantityOf(row);
if (min <= 0) return true;
const current = Number(row.quantity);
if (!Number.isFinite(current) || current < min) {
row.quantity = String(min);
this.$message.warning(
`货物【${row.cargoName || ''}】数量不能低于已调度量(${this.formatDispatchedQuantity(min)})`
);
return false;
}
return true;
},
validateGoodsDispatchedQuantity() {
const totals = {};
(this.form.goods || []).forEach(row => {
const key = this.goodsKey(row);
totals[key] = (totals[key] || 0) + (Number(row.quantity) || 0);
});
for (const [key, qty] of Object.entries(this.dispatchedGoodsMap)) {
const dispatched = Number(qty) || 0;
if (dispatched <= 0) continue;
const cargoName = String(key).split('\u0000')[0] || '未知货物';
if (!(key in totals)) {
this.$message.warning(`货物【${cargoName}】已有调度量,不能删除或变更品类`);
return false;
}
if ((totals[key] || 0) < dispatched) {
this.$message.warning(
`货物【${cargoName}】数量不能低于已调度量(${this.formatDispatchedQuantity(dispatched)})`
);
return false;
}
}
return true;
},
syncAttachmentsJson() {
this.form.attachmentsJson = JSON.stringify(this.attachmentRows);
},
@@ -1863,6 +1995,7 @@ export default {
return this.$message.warning(`请完整填写第${invalidRouteIndex + 1}段途经地及运输方式`);
if (!this.form.finalTransportType) return this.$message.warning('请填写最后一段运输方式');
}
if (!this.validateGoodsDispatchedQuantity()) return false;
const payload = {
...this.form,
planStartTime: this.normalizeDateTimeValue(this.form.planStartTime),
@@ -1945,9 +2078,19 @@ export default {
font-size: 16px;
}
.el-form-item {
margin-bottom: 14px;
margin-bottom: 24px;
}
}
.master-editor__quantity-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.master-editor__quantity-hint {
color: #e6a23c;
font-size: 12px;
line-height: 1.2;
}
.goods-heading {
display: flex;
margin-bottom: 8px;
@@ -2199,8 +2342,15 @@ export default {
display: flex;
gap: 8px;
margin-bottom: 12px;
.el-input {
flex: 1;
min-width: 0;
}
}
.address-map {
width: 100%;
min-width: 0;
height: 440px;
}
.address-map-status {
@@ -0,0 +1,305 @@
<template>
<div v-if="groups.length" class="punch-record-photos">
<section v-for="group in groups" :key="group.type" class="punch-record-photos__group">
<div class="punch-record-photos__head">
<span class="punch-record-photos__title">{{ group.type }}</span>
<span v-if="editable" class="punch-record-photos__count">{{ countText(group) }}</span>
</div>
<div
class="punch-record-photos__grid"
:class="{ 'punch-record-photos__grid--edit': editable }"
>
<div
v-for="(item, groupIndex) in group.items"
:key="`${item.photo.url}-${item.index}`"
class="punch-record-photos__item"
@click="handlePreview(item.index)"
>
<el-image
:src="item.photo.url"
:preview-src-list="nativePreview ? previewUrls : []"
:initial-index="nativePreview ? item.index : 0"
:preview-teleported="nativePreview"
:lazy="lazy"
fit="cover"
/>
<!-- 主图 = 该类型的第一张,按上传顺序取;没有独立字段,也不可切换 -->
<div v-if="groupIndex === 0" class="punch-record-photos__badge">主图</div>
<el-link
v-if="editable"
class="punch-record-photos__remove"
type="danger"
:underline="false"
@click.stop="$emit('remove', item.index)"
>删除</el-link
>
</div>
<el-upload
v-if="editable && !group.full"
class="punch-record-photos__upload"
:action="uploadAction"
name="file"
:headers="uploadHeaders"
:show-file-list="false"
:before-upload="file => handleBeforeUpload(file, group.type)"
:on-success="res => $emit('upload-success', res, group.type)"
:on-error="error => $emit('upload-error', error, group.type)"
>
<div class="punch-record-photos__add">+ 添加</div>
</el-upload>
<div v-else-if="editable" class="punch-record-photos__add punch-record-photos__add--full">
已满 {{ maxPerType }} 张
</div>
</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue';
// 打卡照片是每个打卡节点必有的一个照片类型,不由过程配置的凭证类型决定;
// 它与各凭证类型各自最多 9 张,同一类型下第一张即界面上的「主图」。
const PUNCH_PHOTO_TYPE = '打卡照片';
// 照片没带类型时的兜底名称,与后端落库时的兜底口径一致
const PHOTO_TYPE_FALLBACK = '凭证';
const MAX_PHOTOS_PER_TYPE = 9;
const props = defineProps({
// 扁平照片数组:管理端回显用 voucherType,司机端回显与旧数据用 type
photos: {
type: Array,
default: () => [],
},
// 恒定展示的照片类型:「打卡照片」+ 该节点过程配置勾选的凭证类型。
// 留空则只按照片自身带的类型分组(只读回显用)。
groupTypes: {
type: Array,
default: () => [],
},
// 编辑态:每个类型自带「添加」格与删除链接,并显示已选/上限
editable: {
type: Boolean,
default: false,
},
// 每个类型的张数上限;0 表示不限制(在途打卡只有一个照片字段,张数由「只保留第一张」约束)
maxPerType: {
type: Number,
default: MAX_PHOTOS_PER_TYPE,
},
// 编辑态用 el-image 自带预览;只读态不发预览事件,把点击交给调用方开自己的看图器
nativePreview: {
type: Boolean,
default: false,
},
lazy: {
type: Boolean,
default: false,
},
uploadAction: {
type: String,
default: '',
},
uploadHeaders: {
type: Object,
default: () => ({}),
},
// 返回 false 即中止本次上传;类型、体积、张数校验都在调用方
beforeUpload: {
type: Function,
default: null,
},
});
const emit = defineEmits(['preview', 'remove', 'upload-success', 'upload-error']);
const photoType = photo =>
String(photo?.voucherType || photo?.type || '').trim() || PHOTO_TYPE_FALLBACK;
const groups = computed(() => {
const photos = Array.isArray(props.photos) ? props.photos : [];
const names = [];
const buckets = new Map();
const ensure = name => {
if (!buckets.has(name)) {
buckets.set(name, []);
names.push(name);
}
return buckets.get(name);
};
// 恒定展示的类型先占位:一张照片都没有的类型也要出现,否则编辑态下没有添加入口
props.groupTypes.forEach(type => {
const name = String(type || '').trim();
if (name) ensure(name);
});
// 按上传顺序入组,组间顺序即类型首次出现的顺序,不重排照片
photos.forEach((photo, index) => {
if (!photo?.url) return;
ensure(photoType(photo)).push({ photo, index });
});
const list = names.map(type => {
const items = buckets.get(type);
return {
type,
items,
full: props.editable && props.maxPerType > 0 && items.length >= props.maxPerType,
};
});
// 编辑态至少要有一个分组,否则记录里还没有照片时就没有添加入口
if (props.editable && !list.length) {
return [{ type: props.groupTypes[0] || PUNCH_PHOTO_TYPE, items: [], full: false }];
}
return list;
});
// 预览列表按原始下标对齐,删除/分组都不改变点击某张时打开的是哪一张
const previewUrls = computed(() =>
(Array.isArray(props.photos) ? props.photos : []).map(photo => photo?.url || '')
);
const handlePreview = index => {
if (props.nativePreview) return;
emit('preview', index);
};
const handleBeforeUpload = (file, type) => {
if (typeof props.beforeUpload !== 'function') return true;
return props.beforeUpload(file, type);
};
const countText = group =>
props.maxPerType > 0 ? `${group.items.length}/${props.maxPerType}` : String(group.items.length);
</script>
<style lang="scss" scoped>
.punch-record-photos {
// 弹窗里本组件挂在 el-form-item__content 下,那是个 flex 容器:
// 不给宽度就会收缩成内容宽,里面的网格跟着塌成一小块
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
&__group {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
&__head {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 20px;
}
&__title {
font-weight: 600;
color: #303133;
word-break: break-all;
}
&__count {
font-size: 12px;
color: #909399;
}
// 只读回显:撑满容器宽度的五列网格
&__grid {
width: 100%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
// 编辑态沿用弹窗原本的固定 96px 方图 + 自动换行:尺寸不随容器宽窄变化
&__grid--edit {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 10px;
}
&__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
.el-image {
display: block;
width: 100%;
aspect-ratio: 1;
border: 1px solid #eff1f7;
border-radius: 4px;
overflow: hidden;
background: #f5f7fa;
}
}
&__grid--edit &__item {
width: 96px;
.el-image {
width: 96px;
height: 96px;
aspect-ratio: auto;
}
}
&__badge {
position: absolute;
top: 0;
left: 0;
padding: 0 6px;
border-radius: 4px 0 4px 0;
background: rgba(64, 158, 255, 0.9);
font-size: 12px;
line-height: 18px;
color: #fff;
}
&__remove {
align-self: center;
font-size: 12px;
}
&__upload {
width: 100%;
}
&__grid--edit &__upload {
width: 96px;
}
&__add {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
aspect-ratio: 1;
border: 1px dashed #dcdfe6;
border-radius: 4px;
font-size: 13px;
color: #909399;
cursor: pointer;
}
&__grid--edit &__add {
width: 96px;
height: 96px;
aspect-ratio: auto;
}
&__add--full {
color: #c0c4cc;
cursor: not-allowed;
}
}
</style>

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