diff --git a/.workbuddy/memory/2026-10-13.md b/.workbuddy/memory/2026-10-13.md new file mode 100644 index 0000000..4f0252e --- /dev/null +++ b/.workbuddy/memory/2026-10-13.md @@ -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,完全一致。 diff --git a/.workbuddy/memory/2026-10-14.md b/.workbuddy/memory/2026-10-14.md new file mode 100644 index 0000000..2cde316 --- /dev/null +++ b/.workbuddy/memory/2026-10-14.md @@ -0,0 +1,126 @@ +# 2026-10-14 + +## 运单管理:草稿行「查看」改为与其它状态一致的「详情」 +- 现象:`/business/waybill-manage` 列表操作列,草稿行显示「查看」→ 走 avue 自带 `$refs.crud.rowView()` 表单弹窗;其它状态显示「详情」→ `openDetail()` 跳独立详情页 `/business/waybill-manage/detail?id=x`(新标签「运单管理详情」)。同一列两种弹窗形态。 +- 根因:`waybill-manage-page.vue` 的 `showDetailButton(row)` 显式 `return status !== 'draft'`,把草稿排除在详情按钮外,于是落到 `#menu` 里 `!showDetailButton(row)` 的「查看」分支。 +- 修法:`showDetailButton()` 去掉草稿判断,仅保留 `config.detailButton` + `_view` 权限判断;「查看」分支保留,仅在 `detailButton:false` 时兜底。 +- 备注:`business-crud-page.vue:7043`、`transport-plan-page.vue:4111` 有同款 `status !== 'draft'` 逻辑(运输计划另有 `showViewButton`);两个 option 文件里的 `detailButtonIgnoreDraftStatus` 目前是死配置,组件未读取。 + +## 收付款管理模块:列表页右上角统一为 4 键工具组(最终方案 = 改 avue-crud) +- 现象:付款管理页右上角只有 1 个刷新按钮,与其它模块不一致;参考模块的 4 键工具组其实是 **avue-crud 内置头部**,不是可引用的公共组件。 +- 方案演进: + 1. 过渡方案(已落地 7 页):手写页引入标准件 `src/components/table-header-tools/main.vue`,改法为搜索区 `v-if="searchVisible"`、el-table 加 `:size="tableSize"`、列 `v-for="column in visibleColumns"`、data 加 `searchVisible/tableSize/visibleColumnKeys`;汇票两页表格在子组件,子组件透传 `update:searchVisible` + `refresh`。已接入 `invoice-application.vue`、`invoice-receipt.vue`、`receipt-flow.vue`、`receipt-claim-record.vue`、`bill-ledger.vue`+`bill-ledger-table.vue`、`bill-payment.vue`+`bill-payment-table.vue`。 + 但用户实测反馈:第 2 键(列设置)与第 4 键(表格尺寸)效果仍与参考模块不同 —— 参考模块第 2 键是 `el-drawer` 列设置抽屉(列名/隐藏/冻结/过滤/排序),第 4 键是「表格 ↔ 卡片」视图切换,两者都嵌在 avue 内部,**无法脱离 avue-crud 复用**。 + 2. 最终方案(用户选定,先做样板):列表页改写成 ``,4 键 = 刷新 / 列显隐抽屉 / 搜索显隐 / 网格切换,与其它模块 100% 一致。 +- 样板:`src/views/payment/payment-application.vue` + `src/option/payment/paymentApplication.js`(`paymentApplicationOption()` 工厂)。 + - 搜索项与表格列 label 不一致的(表格列「收款方」vs 搜索项「客户名称」)用 `searchProp` 映射;属搜索专用列用 `searchOnlyColumn()` 统一生成(`hide + showColumn:false + add/edit/viewDisplay:false`)。 + - ⚠️ **avue 的 searchOrder 是降序**(`header-search2.js`:`(b.searchOrder||0)-(a.searchOrder||0)`),值越大越靠前 —— 首次做反了,9 个搜索项 score 全部反转。 + - ⚠️ **column 数组顺序即表格列顺序**,搜索项夹在中间会把该列挤到表格前面(`deptName` 踩过),搜索专用列必须紧跟在同位置或用 `hide` 隔离。 + - `on-load` 在 created / currentChange / sizeChange 都会触发(`table-page2.js`),因此 `currentChange`/`sizeChange` 只改 page、**不要再调 onLoad**;`onLoad(page)` 也不能把 page 里的 total/pageSizes spread 进查询参数。 +- 顺带加固:`table-header-tools` 加 `deactivated()` 关闭列设置弹窗(keep-alive 切页防 Teleport 弹窗残留);清理 invoice-application / invoice-receipt 里失效的 `__toolbar-right` 样式规则。 +- 验证:项目根建临时 html 挂真组件 + `app.use(Avue)`,`agent-browser` 实测 —— 4 个按钮均渲染图标、刷新触发、搜索区切换、表格尺寸 default→large、列设置弹窗勾选双向生效;avue-crud 样板另测:4 键数量=4、默认 4 项搜索 + 展开 9 项、18 列表格顺序与原页一致、列显隐抽屉表头 5 列 18 行、网格按钮切出卡片视图;临时文件用完即删。 +- 踩坑:`el-icon-*` 字符串图标靠 Avue 的全局映射,验证页不装 Avue 时 4 个按钮是空圆(差点误判)。 +- 待办:用户确认样板效果后,把其余 6 页(invoice-application / invoice-receipt / receipt-flow / receipt-claim-record / bill-ledger / bill-payment)也改成 avue-crud,并回退其 table-header-tools 改动。 + +## 正式结算单表单:结算明细区按钮/筛选行对齐预结算单 +- 现象:`formal-settlement-editor.vue` 的「结算明细」区把 导出/选择结算明细 塞在筛选表单的 actions 里(`grid-column:1/-1` 换行右对齐,出现两个蓝实心),与 `pre-settlement-editor.vue` 不一致(后者把这两个按钮放 ``,筛选项与查询/重置同行)。 +- 改法(只动 formal 一个文件,72+/17-): + 1. 模板 `section-card#extra` 加 导出(`v-if="hasPermission('formal_settlement_export')" type=primary plain`)+ 选择结算明细(`v-if="editable" type=primary plain`),与「收起明细」并列;actions 里只留 查询/重置。 + 2. el-form 去掉 `inline`、`label-width` 88px→auto,4 个输入补 `placeholder="请输入"`。 + 3. 样式 `grid-template-columns: repeat(4, minmax(0,1fr)) auto` + `align-items:center`;actions 去掉 `grid-column:1/-1` 改 `width:auto;margin-left:0;white-space:nowrap;justify-self:end`;媒体查询 ≤1200px 同步补 `auto` 列。 + 4. 补 `hasPermission`:`...mapGetters(['permission','userInfo'])` + `isAdmin`(`isAdministrator` from `@/utils/auth`)+ methods `hasPermission(code){ return this.isAdmin || this.validData(this.permission?.[code], false) }`(`validData` 是项目全局属性,main.js 未显式挂载但全站通用)。 +- ⚠️ 关键坑:文件末尾 `.formal-editor :deep(.el-form-item){margin-bottom:24px}` 与筛选区 `:deep(.el-form-item){margin-bottom:0}` **特异性相同**,靠源序决定胜负 → 覆盖规则必须写在文件尾部(`@media` 之前)。pre 用的也是同索引结构。 +- 验证:`agent-browser` + mock 注入实测 —— grid 列 `188.25×4 + 132`、5 个 form-item 同一行 top 相同、form-item margin-bottom=0;正式单与预结算单**同视口截图逐像素一致**。 + +## 「变更运输路线」弹窗 · 变更记录列表:变更前/后地址分行展示 +- 需求:变更内容原为单行 `YD-xxx:发货地 A → B;到货地 C → D`(多条以 `;` 拼接),地址太长挤成一坨,要求「变更前地址 / 变更后地址」换行。 +- 落地:新增 `src/utils/route-change.js` 导出 `formatRouteChangeContent(content, emptyText='-')` —— 仅**展示层**格式化(不动 taskInfoJson 里已存的 content,历史数据同样生效):`\r\n` 归一 → `;` 转 `\n` → `→` 转 ` →\n`(箭头留上一行行尾)→ 压缩空行;空值返回 `-`。 +- 接入 3 处同一弹窗(都在 methods 里以 ES6 简写挂载 `formatRouteChangeContent`,与 `formatContractOptionLabel` 同款): + - `views/business/loading-manage.vue`(表格列 `record-content`) + - `views/business/components/waybill-manage-page.vue` + - `views/business/components/business-crud-page.vue` +- ⚠️ 关键坑:`white-space: pre-wrap` 下模板里插值前后的**换行+缩进空白会被渲染出来**(内容前面凭空多一行缩进)。所以要么把 `>{{ ... }}` 写成紧贴一行(loading-manage),要么改用 `v-text="formatRouteChangeContent(row.content)"` 多行写法(另两处)——文本节点不存在才安全。CSS 里 `white-space:normal` 需改 `pre-wrap`(loading-manage 原为 normal),另两处补 `overflow-wrap:anywhere; word-break:break-word; line-height:22px`。 +- 效果:`YD-20260904-0022:发货地 广西…公司 →` / 换行 / `广东省…西瓜洲` / `到货地 山东…公司 →` / `山东省…公司`。 +- 验证:node 直跑函数(多条 / 单条 / 路线顺序 / 空值)输出正确;dev `localhost:2889` 三个 .vue 与 scoped style 块均 200,编译产物各含 3 处 `formatRouteChangeContent`。 +- 未做(如需再说):存储格式与导出 Excel 仍是单行文本;`vehicle/transport-change-record.vue` 的 `changeContent` 是人工填写的自由文本,未改。 + + +## 发货模板"选择常用地址"改图标(对齐运输计划) +- 需求:`/business/shipping-template/form` 收发货信息行,把文字「选择」按钮改成运输计划同款图标方式。 +- 改动(`src/views/business/components/shipping-template-page.vue`): + 1. `import { Location, OfficeBuilding } from '@element-plus/icons-vue'`,并把 `OfficeBuilding` 加进 `data()`; + 2. departure/arrival 两个 form 插槽里,把 `选择` 从行尾移到第二个地址输入框之后,改成 `:icon="OfficeBuilding"` + `link` 图标按钮(class `shipping-template-page__address-pick-btn`); + 3. suffix 的 `` 补 class `shipping-template-page__map-suffix`(cursor/18px/#909399/hover #409eff),与运输计划同名同款; + 4. `.shipping-template-page__route-row` grid 第 3 列插入 `40px` 图标列(原末尾的 `auto`),并把 `__address-pick-btn .el-icon { font-size: 22px }` 写进该行容器内。 +- 关键坑(差点误判成 bug):Element Plus 图标两种用法机制不同 —— + - 标签形式 `` 靠 `main.js` 里 `app.component(key, ...)` **全局注册**解析,与 `data()` 无关; + - 表达式形式 `:icon="OfficeBuilding"` 编译成 `_ctx.OfficeBuilding`,**必须把组件对象放进 data()**(放 `components` 注册里模板取不到,会静默渲染不出图标)。 + 验证页漏了全局注册,曾误判 `` 不渲染。 +- 验证:临时验证页(项目根 html + 真 .vue,走 Vite + 真 element-plus + 全局图标注册)实测:地址框内 Location 18px、按钮 40px 内 svg 22px 居中、7 列 184/368/40/48/184/64/184 铺满 1120px;截图视觉与运输计划一致。用完已删临时文件。 +- dev 自检:`shipping-template-page.vue` 与 scoped style 均 200。 + +## 多联总单:调度页顶部路线节点 / 详情页路线图 —— 省市区按「市 区县」分段展示 +- 现象:`/business/master-order?mode=dispatch&id=xxx` 顶部 `route-overview` 的起/终节点直接绑定 `route.departureName/arrivalName` 原始值 → 显示成「上海市浦东新区」「广西壮族自治区南宁市邕宁区」挤成一坨(同页段1~段3 的表头早已是「上海市 浦东新区 → 上海市 静安区」,用户截图圈出顶部这行说"怎么还没改")。 +- 根因:顶部节点是唯一没走格式化方法的绑定(段头走 `dispatchLocationName`,详情页走 `segmentLocationName`),不是格式化逻辑有问题。 +- 落地:新增 `src/utils/region-address.js` 导出 `formatRegionAddress(value)` + `splitRegionTokens(value)`(按行政区划后缀切分,`州`+`市`、`县`+`区` 连写特例合并;**整串都是行政区划**才重排,站点名「南宁站」「广州南站」和含街道门牌的详细地址原样返回)→「市 区县」的空格展示,省/自治区不重复(与全站既有 `formatRoadLocation`/`formatRouteCityDistrict` 规则一致,`广西壮族自治区南宁市邕宁区` → `南宁市 邕宁区`)。 +- 接入 4 处:调度页 `route-overview` 起/终节点的强文本改用 `dispatchLocationName(route, 'departure'|'arrival')`、第二行地址改用 `formatRegionAddress(...)`,`addressText()` 名称+地址都格式化并去重(`南宁市 邕宁区` 不再重复两遍);详情页 `route-map` 两处 `` 地址改用 `formatRegionAddress(...)`。 +- 验证:项目根临时验证页(真组件 + `axios.defaults.adapter` 全量 mock 后端,复刻截图数据 `routes=[上海市静安区, 南宁站, 广西壮族自治区南宁市邕宁区]`)+ agent-browser 读 DOM —— 调度页顶部 4 节点 =「上海市 浦东新区 / 上海市 静安区 / 南宁站 / 南宁市 邕宁区」,与段1~段3 表头、详情页路线图**逐字一致**;终节点宽 101px 未被省略号截断(改前原始串超宽被截);两页 innerText 再扫 `省|自治区` 已无未加空格的残留。用完已删临时文件。 + +## 同事拉代码编译报错(漏提交新文件)→ 已修复 +- 现象:同事 pull 后 `[vite:load-fallback] Could not load .../src/utils/route-change (imported by src/views/business/loading-manage.vue): ENOENT`。 +- 根因:`src/utils/route-change.js`、`src/utils/region-address.js` 是**新增文件且一直 untracked**,本地能跑、远端没有。 + 提交时用了只带已跟踪文件的写法(`git commit -am` 之类),.vue 的引用被提交了、新 .js 没进去(12:44 的 6de87ed 就是这种情况)。region-address 是第二个雷:route-change 修好后会接着报它。 +- 修复:`git add src/utils/region-address.js src/utils/route-change.js` → commit `749b314 :bug: 补充提交遗漏的 region-address 与 route-change 工具文件` → push(期间远端有同事新提交 74780b8,先 `git pull --rebase` 无冲突)。三处 tree(本地 HEAD / origin/master / 远端真实 SHA)核对一致。 +- 复查:写脚本扫 `src/**` 所有 `@/`、相对路径引用(含 scss @use/@import),比对 `git ls-files`,**无其它"文件存在但未跟踪"的引用目标**,暂时不会再踩同类坑。 +- 教训:本仓库提交前建议 `git status` 扫一眼 `??`,新增文件必须显式 `git add`。 + +## payment 模块 6 个手写列表页:搜索栏「展开/收起」统一(对齐 customer-archive / settlement) +- 起因:`/payment/invoice-application`、`/payment/bill-payment` 的展开收起控件与别的模块不一致。排查发现 payment 模块 6 个手写搜索栏各写各的:5 个是 `el-tooltip`+`el-button text`+纯箭头图标(文案「折叠」,tooltip 才显示),`receipt-flow.vue` 更离谱 —— 裸 `el-button`(带边框)且排在**查询/重置左边**。 +- 统一标准(两处参照物写法完全一致): + - `views/settlement/{formal-settlement,pre-settlement,transport-reconciliation,receivable-payable-detail,settlement-adjustment}.vue` 已有写法: + ```html + + {{ searchExpanded ? '收起' : '展开' }} + + ``` + 放在「查询 / 重置」之后(最右)。 + - `views/vehicle/customer-archive.vue` 是 avue-crud 内建,`searchIcon: true` + `searchIndex: 4`,渲染出的也是同一形态。 +- avue 显示规则(`node_modules/@smallwei/avue/lib/packages/element-plus/crud/menu/header-search3.js` 的 `isSearchIcon`): + `searchIcon === true && columnLen > searchIndex` → **搜索项不超过 4 项时根本不渲染展开/收起按钮**。故各页 data 加 `searchIndex: 4` + `searchItemCount: N`,computed `showSearchToggle = searchItemCount > searchIndex`。 +- 逐页落地:invoice-application(5项) / invoice-receipt(6项) / receipt-claim-record(11项) / receipt-flow(7项) / components/bill-ledger-search(6项) 均显示;**components/bill-payment-search 仅 4 项(后端 `BillPaymentServiceImpl.selectPage` 也只支持 paymentNo/deptName/paymentDate 区间/approvalStatus 这 4 个条件)→ showSearchToggle=false,不渲染按钮**,避免留一个"点了没反应"的假按钮。 +- `receipt-flow.vue`:补 `import { ArrowDown, ArrowUp }`、按钮从最左移到最右、文案「折叠」→「收起」。 +- gap 不动:`business/`、`settlement/` 手写页的 `__search-actions` 都写了 `gap: 8px`(与本次统一无关,别顺手删)。 +- 校验:6 个文件 `curl localhost:2889/src/...vue` 与 `?vue&type=style&index=0&scoped=true&lang.scss` 全 200;`grep 折叠|el-tooltip src/views/payment/` 已无残留。 + +## 15:55 路线变更「变更内容」列排布问题(待用户确认方案,未动代码) +- 位置:`变更运输路线` 弹窗 → `变更记录` tab → 表格 `变更内容` 列,共 **3 处同款**:`views/business/loading-manage.vue:1688`(`__record-content`)、`views/business/components/waybill-manage-page.vue:3027`、`views/business/components/business-crud-page.vue:2998`。 +- 存储格式为**单行文本**:`YD-xxx:发货地 A → B;到货地 C → D;YD-yyy:…`,展示层由 `src/utils/route-change.js` 的 `formatRouteChangeContent()` 切行(`;`→换行、`→`→` →\n`),再以 `white-space: pre-wrap` 平铺。 +- 病因:`:` 不带换行 → 运单号与「发货地 原地址」挤同一行;前/后无标签;多条记录无分隔。一个单元格可堆 3 条运单 × 2 字段 × 2 行 ≈ 12+ 行。 +- 已给 3 个方案(A 单元格内分组对齐:运单号独占行主色 + 字段标签列 + 前值/后值缩进对齐,改 formatter 返回结构 + 模板渲染;B 列只留「运单号 · N 项」摘要 + 查看详情弹窗,复用同类组件 `src/components/change-record-detail-dialog/main.vue`(变更字段/变更前/变更后三列 + 分页);C 按「运单 × 字段」拆行 + `span-method` 合并时间/人)。**推荐 A,B 备选**。等用户确认后再改。 + +## 16:20 已按方案 A 落地(变更内容列分组渲染) +- **`src/utils/route-change.js` 重写**:删掉 `formatRouteChangeContent`,改为导出 `parseRouteChangeContent(content)`,返回分组数组 `[{ type, title, text, fields:[{label,before,after,single,changed,unchanged}] }]`。 + - ⚠️ **关键坑:存储格式里「到货地」前面只有 `;`、没有冒号**(真实格式 `${waybillNo}:发货地 A → B;到货地 C → D`)。第一版按「冒号切 head/tail」写,导致「到货地」整段落进纯文本兜底 → 必须"先判断 segment 是否以字段名开头",再把无标题字段行合并进上一条 waybill 分组。 + - 字段名正则的**长标签必须排在短标签前**(`发货地址` 早于 `发货地`),否则被前缀吃掉。字段白名单:发货地址/到货地址/发货地/到货地/装货地/卸货地/收货地。 + - 三种分组类型:`waybill`(`运单号:发货地…`)、`field`(`;到货地…`,合并进上一个非 text 分组)、`group`(`路线顺序:X 调整为 Y`,按「调整为」拆前后,不走箭头切分,因为路线文本本身含 `→`)、`text`(兜底原文)。 + - 箭头正则 `/\s*(?:→|➔|➜|[\-–—]{1,2}\s*>)\s*/`;空值归一为 `-`。 +- **三处模板统一改为分组渲染**(运单号主色标题 + 左侧 2px 主色竖条;字段 label 列 54px;`changed` 两行「旧值(灰) / → 新值(主色)」,`unchanged` 单行 + 灰字「未变更」):`loading-manage.vue`(`loading-route-change-dialog__change-*`)、`waybill-manage-page.vue`、`business-crud-page.vue`。样式块也同步替换(`__route-change-record-content` 从 `white-space: pre-wrap` 改为 flex column;箭头 `text-align: right`,grid item 会被 blockify 所以 span 上生效)。 +- 校验:node 跑 `parseRouteChangeContent` 7 组用例(真实截图数据 / 路线顺序 / 混合 / 半角箭头分号 / 无箭头单值 / 纯文本 / 空)全部符合预期;`@vue/compiler-sfc` 编译三个 .vue 的 template + scss 全过;SSR 渲染真实模板 + agent-browser 截图确认观感(临时文件已删)。 +- 已知代价:窄列(360px)下地址被 54px 标签列占位,单元格比原来高约一倍;换来的是一条运单/一个字段的分组边界一目了然。 + +## 16:05 /base/measurement-unit 创建时间/更新时间列截断修复 +- 现象:表格里「创建时…」「更新时…」列被挤到 ~80px,`2026-0...` 截断;用户猜测要固定操作列。 +- 根因:`option/base/measurement-unit.js` 两列只写了 `type/format/sortable`,**没有 width/minWidth** → el-table 未设宽列按剩余空间均分,被压到低于内容宽度 → 省略号。与 `menuFixed` 无关。 +- 修法:`createTime`/`updateTime` 加 `width: 180`(与同模块 `option/base/common-address.js` 完全一致),`createUserName`/`updateUserName` 加 `minWidth: 110`。 +- 结论(重要经验):**操作列不需要额外"固定"**,`menuFixed: 'right'` 早在 option 里(`main.js` 全局 `crudOption.menuWidth: 180`,avue 库默认 220)。不要为了"防截断"去加宽操作列,加宽只会增加横向滚动;时间列截断一律是缺 width。 +- 验证方式(可复用):项目根建临时 html `__mu-check.html` → Vite 直接服务;`import { createOption } from '/src/option/base/measurement-unit.js'` + `app.use(Avue, { crudOption: { menuWidth: 180 } })`,用 `h(resolveComponent('avue-crud'), {...}, { menu: () => [...] })` 渲染(避免运行时模板编译)。 + 实测(容器 1200px):td 宽 `[50,70,150,150,130,200,100,110,110,180,180,180]` 合计 1610 → 横向滚动 410px;`scrollWidth > clientWidth` 的单元格 **0 个**;`操作` th 为 `position: sticky`,滚到最右后右边缘 = 容器右边缘 → fixed 生效。用完删文件 + `agent-browser close`。 + +## 16:25 变更内容列 hover 提示去除(分组改造的连带问题) +- 用户反馈:改完分组后,变更内容单元格 hover 出现提示浮层,要求去掉。 +- 根因:`main.js:84` `app.use(ElementPlus, { table: { showOverflowTooltip: true } })` 全局开启 → EP 给每列 `.cell` 加 `.el-tooltip` 类(`white-space: nowrap`)。**我上一次改造把内层 `white-space: pre-wrap` 换成了 flex column、忘了补 white-space** → 继承 nowrap → 长地址横向溢出 → tooltip 激活。改动前靠 pre-wrap 自保所以没暴露。 +- 修法(三处,列级豁免、不动全局配置):① 变更内容列显式 `:show-overflow-tooltip="false"`;② `__route-change-record-content` 补 `white-space: normal` 兜底。 +- ⚠️ 验证踩坑:用「项目根临时 html + `node_modules/element-plus/dist/index.full.min.js` 全局构建 + `app.use(ElementPlus, { table: { showOverflowTooltip: true } })`」复现时,**el-table 表头/列宽渲染错乱**(表头逐列堆叠、每行只渲染出 2 个 cell),该环境不可用于布局验证;但单元格内容渲染正常,足以确认分组结构无误。要验证真实表格布局还是得走 Vite 服务真实页面。 + 另:自定义组件接模板字符串时,切出的模板里 `row.content` 要替换成 `content`(插槽作用域不存在于独立组件里),否则报 `Cannot read properties of undefined (reading 'content')`。 +- 校验:编译三个 .vue 的 template + scss 全过;临时验证页 `__route-change-verify.html` 与 8899 端口静态服务已删除/停止。 +- 未处理(等用户确认):备注列的 `show-overflow-tooltip` 保持原样(属全站约定),用户若要求连备注一起去掉再单独改。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 6be373c..ef1ee3f 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -12,16 +12,72 @@ **坑二:宿主 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 表单全部失效**(手写 `` 才生效),一律用后代选择器。 + +**坑六:`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 循环)。 + +- 接入范式:``;配套:搜索区 `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`,否则会混进列显隐抽屉。 +- ⚠️ **`createTime`/`updateTime` 必须显式给 `width: 180`**(全站统一,参考 `option/base/common-address.js`)。不写宽度的列参与"剩余空间均分",容易被压到 80px 出现 `2026-0...` 截断 —— 这是**列缺 width**,不是操作列没固定;`menuFixed: 'right'` 各页早已配好(`main.js` 全局 `crudOption.menuWidth: 180`,avue 默认 220),别用"加宽操作列"来处理时间列截断。创建人/更新人给 `minWidth: 110`。 +- ⚠️ **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`(禁绿、禁两个蓝实心)。顺序 `[辅助][取消][保存草稿][提交]`。 - 独立页标题 `.archive-page-form__title`;分组用全局 ``;上传证件区 `width:100%; max-width:240px` + `.el-upload{height:151px}`。 - ⚠️ ` diff --git a/src/components/vehicle-attachment-table/main.vue b/src/components/vehicle-attachment-table/main.vue index 66bc248..15e7f1d 100644 --- a/src/components/vehicle-attachment-table/main.vue +++ b/src/components/vehicle-attachment-table/main.vue @@ -9,18 +9,21 @@ - + diff --git a/src/option/base/common-address.js b/src/option/base/common-address.js index 6860fb7..2b8f6d3 100644 --- a/src/option/base/common-address.js +++ b/src/option/base/common-address.js @@ -305,6 +305,10 @@ export const createOption = () => ({ searchOrder: 3, searchPlaceholder: '请选择', dicData: [], + props: { + label: 'label', + value: 'value', + }, filterable: true, hide: true, display: false, diff --git a/src/option/base/measurement-unit.js b/src/option/base/measurement-unit.js index 7c68b87..84609cc 100644 --- a/src/option/base/measurement-unit.js +++ b/src/option/base/measurement-unit.js @@ -2,6 +2,9 @@ const dimensionOptions = [ { label: '重量', value: '重量' }, { label: '体积', value: '体积' }, { label: '数量', value: '数量' }, + { label: '整车', value: '整车' }, + { label: '里程', value: '里程' }, + { label: '吨·公里', value: '吨·公里' }, ]; const statusOptions = [ @@ -35,7 +38,6 @@ export const createOption = () => ({ delBtn: false, selection: true, dialogClickModal: false, - menuWidth: 240, menuFixed: 'right', column: [ { @@ -109,6 +111,7 @@ export const createOption = () => ({ { label: '创建人', prop: 'createUserName', + minWidth: 110, addDisplay: false, editDisplay: false, viewDisplay: false, @@ -117,6 +120,7 @@ export const createOption = () => ({ { label: '更新人', prop: 'updateUserName', + minWidth: 110, addDisplay: false, editDisplay: false, viewDisplay: false, @@ -128,6 +132,7 @@ export const createOption = () => ({ type: 'datetime', format: 'YYYY-MM-DD HH:mm:ss', valueFormat: 'YYYY-MM-DD HH:mm:ss', + width: 180, sortable: true, addDisplay: false, editDisplay: false, @@ -139,6 +144,7 @@ export const createOption = () => ({ type: 'datetime', format: 'YYYY-MM-DD HH:mm:ss', valueFormat: 'YYYY-MM-DD HH:mm:ss', + width: 180, sortable: true, addDisplay: false, editDisplay: false, diff --git a/src/option/business/common.js b/src/option/business/common.js index 490c9b4..78c2570 100644 --- a/src/option/business/common.js +++ b/src/option/business/common.js @@ -117,6 +117,7 @@ export const contractCategoryOptions = [ export const signTypeOptions = [ { label: '新签', value: '新签' }, { label: '续签', value: '续签' }, + { label: '补充协议', value: '补充协议' }, ]; export const contractStageOptions = [ diff --git a/src/option/business/contract-manage.js b/src/option/business/contract-manage.js index 8789e72..1b9bb99 100644 --- a/src/option/business/contract-manage.js +++ b/src/option/business/contract-manage.js @@ -6,6 +6,7 @@ import { contractStageOptions, createCrudOption, effectiveTypeOptions, + nonNegativeRule, hcsSyncStatusOptions, selectRule, signTypeOptions, @@ -62,13 +63,14 @@ export const config = { ['contractNo', '合同编号'], ['contractName', '合同名称'], ['projectName', '所属项目'], - ['organizationName', '所属组织'], + ['organizationName', '合同负责部门'], ['contractCategory', '合同类别'], ['signType', '签约类型'], ['partyA', '甲方'], ['partyB', '乙方'], ['handlerUserName', '经办人'], ['signDate', '签订日期'], + ['applyDate', '申请日期'], ['contractFormat', '合同格式'], ['legalSealFlag', '是否需要加盖法人章'], ['copyCount', '一式(份)'], @@ -77,7 +79,8 @@ export const config = { ['templateFlag', '是否范本'], ['originalContractNo', '原件合同编号'], ['electronicSealFlag', '是否电子章'], - ['remark', '备注', 2], + ['mainContent', '合同主要内容', 2], + ['remark', '其他事项说明', 2], ], }, { @@ -210,7 +213,7 @@ export const config = { label: '流程', statusProp: 'approvalStatus', status: ['approved', 'change_approved'], - stage: ['formal', 'terminated'], + stage: ['temporary', 'formal', 'terminated'], permission: 'contract_manage_view', }, { @@ -295,17 +298,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: '合同类别', @@ -436,6 +439,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', @@ -469,11 +483,10 @@ export const option = createCrudOption([ { label: '否', value: 0 }, { label: '是', value: 1 }, ], - value: 0, + clearable: true, hide: true, order: 70, minWidth: 100, - rules: selectRule('是否需要加盖法人章'), }, { label: '一式(份)', @@ -501,6 +514,10 @@ export const option = createCrudOption([ hide: true, order: 45, minWidth: 130, + rules: [ + { required: true, message: '请输入合同金额', trigger: 'blur' }, + nonNegativeRule('合同金额'), + ], }, { label: '是否范本', @@ -631,7 +648,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, @@ -640,7 +669,7 @@ export const option = createCrudOption([ order: 40, maxlength: 2000, showWordLimit: true, - rules: textRule('备注', 2000), + rules: textRule('其他事项说明', 2000), }, { label: '', diff --git a/src/option/business/project-apply.js b/src/option/business/project-apply.js index c3a23e5..8801fc4 100644 --- a/src/option/business/project-apply.js +++ b/src/option/business/project-apply.js @@ -72,7 +72,7 @@ export const config = { action: 'mkAudit', label: '流程', statusProp: 'approvalStatus', - status: ['reviewing', 'change_reviewing'], + status: ['reviewing', 'change_reviewing', 'approved', 'change_approved'], permission: 'project_apply_approve', mkOnly: true, }, @@ -186,7 +186,7 @@ export const option = createCrudOption([ searchPlaceholder: '请输入', searchOrder: 8, minWidth: 150, - rules: textRule('平台公司', 50, true), + rules: textRule('平台公司', 500, true), }, { label: '平台公司ID', diff --git a/src/option/business/transport-plan.js b/src/option/business/transport-plan.js index 152054f..f395e93 100644 --- a/src/option/business/transport-plan.js +++ b/src/option/business/transport-plan.js @@ -400,7 +400,6 @@ export const option = { span: 6, order: 350, minWidth: 150, - rules: [{ required: true, message: '请选择计划开始日期', trigger: 'change' }], }, { label: '计划结束日期', @@ -411,7 +410,6 @@ export const option = { span: 6, order: 340, minWidth: 150, - rules: [{ required: true, message: '请选择计划结束日期', trigger: 'change' }], }, { label: '数据来源', diff --git a/src/option/business/waybill-manage.js b/src/option/business/waybill-manage.js index 49fa17b..9f617d3 100644 --- a/src/option/business/waybill-manage.js +++ b/src/option/business/waybill-manage.js @@ -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 === '') { @@ -608,6 +626,7 @@ export const option = { { label: '过程节点', prop: 'currentProcessNode', + formatter: row => formatCurrentProcessNode(row), minWidth: 140, addDisplay: false, editDisplay: false, diff --git a/src/option/payment/invoiceApplication.js b/src/option/payment/invoiceApplication.js index 2e7d138..fa5ed87 100644 --- a/src/option/payment/invoiceApplication.js +++ b/src/option/payment/invoiceApplication.js @@ -13,6 +13,7 @@ export const invoiceApplicationTableColumns = [ { prop: 'currentProcessor', label: '当前处理人', minWidth: 130 }, { prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 150 }, { prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 140 }, + { prop: 'kingdeeLockStatus', label: '金蝶锁状态', minWidth: 150 }, { prop: 'attachmentSyncStatusName', label: '附件同步', minWidth: 100 }, ]; diff --git a/src/option/payment/paymentApplication.js b/src/option/payment/paymentApplication.js index fdb7227..9f3b688 100644 --- a/src/option/payment/paymentApplication.js +++ b/src/option/payment/paymentApplication.js @@ -1,21 +1,167 @@ -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 }, - { prop: 'attachmentSyncStatusName', label: '附件同步', minWidth: 100 }, -]; +/** + * 付款申请列表(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 }, + { label: '金蝶锁状态', prop: 'kingdeeLockStatus', slot: true, minWidth: 150 }, + { label: '附件同步', prop: 'attachmentSyncStatusName', minWidth: 100, formatter: textFormatter }, + ], +}); diff --git a/src/option/settlement/formalSettlementTable.js b/src/option/settlement/formalSettlementTable.js index 4cb9012..83e9f1f 100644 --- a/src/option/settlement/formalSettlementTable.js +++ b/src/option/settlement/formalSettlementTable.js @@ -21,6 +21,7 @@ export const formalSettlementTableColumns = [ { label: '当前处理人', prop: 'currentProcessor', minWidth: 130 }, { label: '金蝶单据号', prop: 'kingdeeBillNo', minWidth: 150 }, { label: '金蝶单据状态', prop: 'kingdeeSyncStatus', minWidth: 135 }, + { label: '金蝶锁状态', prop: 'kingdeeLockStatus', minWidth: 150 }, { label: '附件同步', prop: 'attachmentSyncStatusName', minWidth: 100 }, { label: '创建人', prop: 'createUserName', minWidth: 110 }, { label: '创建时间', prop: 'createTime', minWidth: 170 }, diff --git a/src/option/system/mpUser.js b/src/option/system/mpUser.js new file mode 100644 index 0000000..994f76f --- /dev/null +++ b/src/option/system/mpUser.js @@ -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, + }, + ], +}; diff --git a/src/option/system/user.js b/src/option/system/user.js index 9cc07c3..93e3152 100644 --- a/src/option/system/user.js +++ b/src/option/system/user.js @@ -27,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', @@ -37,8 +38,11 @@ export const userOption = safe => { { label: '账号名', prop: 'account', + slot: true, + minWidth: 180, search: true, searchOrder: 2, + searchPlaceholder: '支持模糊搜索', display: false, }, { @@ -46,6 +50,7 @@ export const userOption = safe => { prop: 'realName', search: true, searchOrder: 4, + searchPlaceholder: '支持模糊搜索', display: false, }, { @@ -53,6 +58,7 @@ export const userOption = safe => { prop: 'phone', search: true, searchOrder: 3, + searchPlaceholder: '支持模糊搜索', display: false, }, { label: '工号', prop: 'code', display: false }, @@ -60,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 }, diff --git a/src/option/transit/exception-disposal.js b/src/option/transit/exception-disposal.js index 6f0ba58..a93c3c0 100644 --- a/src/option/transit/exception-disposal.js +++ b/src/option/transit/exception-disposal.js @@ -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' }, +]; diff --git a/src/option/transportCapacity/cert-audit-driver.js b/src/option/transportCapacity/cert-audit-driver.js new file mode 100644 index 0000000..71b9856 --- /dev/null +++ b/src/option/transportCapacity/cert-audit-driver.js @@ -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', + }, + ], +}; diff --git a/src/option/transportCapacity/cert-audit-vehicle.js b/src/option/transportCapacity/cert-audit-vehicle.js new file mode 100644 index 0000000..5e93caf --- /dev/null +++ b/src/option/transportCapacity/cert-audit-vehicle.js @@ -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', + }, + ], +}; diff --git a/src/option/transportCapacity/transport-vehicle.js b/src/option/transportCapacity/transport-vehicle.js index df05326..ffe1bae 100644 --- a/src/option/transportCapacity/transport-vehicle.js +++ b/src/option/transportCapacity/transport-vehicle.js @@ -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', diff --git a/src/option/transportCapacity/vehicle-dispatch.js b/src/option/transportCapacity/vehicle-dispatch.js index 0e50813..d2313cd 100644 --- a/src/option/transportCapacity/vehicle-dispatch.js +++ b/src/option/transportCapacity/vehicle-dispatch.js @@ -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' }], diff --git a/src/styles/element-ui.scss b/src/styles/element-ui.scss index 61bfe89..d8f73fe 100644 --- a/src/styles/element-ui.scss +++ b/src/styles/element-ui.scss @@ -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) 通用分组白卡组件():标题融入卡头 @@ -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 已统一)内堆叠 分组白卡, // 卡间 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 让溢出内容不再被单元格裁切,直接压到相邻列上。 +// 因此对「内容列」做显式兜底:单元格自身恢复块级 + 省略号截断,链接参与收缩并显示省略号。 +// 用法:在 上配置 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; +} diff --git a/src/utils/cert-audit.js b/src/utils/cert-audit.js new file mode 100644 index 0000000..331eced --- /dev/null +++ b/src/utils/cert-audit.js @@ -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, + }; +}; diff --git a/src/utils/contract.js b/src/utils/contract.js new file mode 100644 index 0000000..302996f --- /dev/null +++ b/src/utils/contract.js @@ -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 || ''; +} diff --git a/src/utils/coordinate.js b/src/utils/coordinate.js index b4654fb..01e7df3 100644 --- a/src/utils/coordinate.js +++ b/src/utils/coordinate.js @@ -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('-'); diff --git a/src/utils/currency.js b/src/utils/currency.js new file mode 100644 index 0000000..ea06789 --- /dev/null +++ b/src/utils/currency.js @@ -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), + }; + }); +} diff --git a/src/utils/import-excel.js b/src/utils/import-excel.js index 2ad6d55..4d69c81 100644 --- a/src/utils/import-excel.js +++ b/src/utils/import-excel.js @@ -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 对象。 + *

+ * 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(); diff --git a/src/utils/measurement-unit.js b/src/utils/measurement-unit.js new file mode 100644 index 0000000..a238460 --- /dev/null +++ b/src/utils/measurement-unit.js @@ -0,0 +1,27 @@ +import { getList as getMeasurementUnitList } from '@/api/base/measurement-unit'; + +/** + * 加载启用状态的计量单位选项,展示/取值均为「计量单位」字段(unitName)。 + * @returns {Promise>} + */ +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); + }); +}; diff --git a/src/utils/region-address.js b/src/utils/region-address.js new file mode 100644 index 0000000..28c8ef5 --- /dev/null +++ b/src/utils/region-address.js @@ -0,0 +1,89 @@ +/** + * 行政区划展示格式化(仅展示层,不改动后端存储值)。 + * + * 需求:省 / 市 / 区县之间要有空格隔开,避免「上海市浦东新区」「广西壮族自治区南宁市邕宁区」挤成一坨。 + * 与全站既有规则保持一致(公路地址按「市 区县」展示,省/自治区不重复展示): + * 上海市浦东新区 -> 上海市 浦东新区 + * 广西壮族自治区南宁市邕宁区 -> 南宁市 邕宁区 + * 梅州市梅县区 -> 梅州市 梅县区 + * + * 非纯行政区划串(站点名「南宁站」「广州南站」、含街道门牌的详细地址等)原样返回,不做任何改动。 + */ + +// 行政区划后缀:长后缀在前,避免「自治区」被截成「区」、「自治州」被截成「州」 +const REGION_SUFFIXES = [ + '特别行政区', + '自治区', + '自治州', + '自治县', + '自治旗', + '开发区', + '新区', + '林区', + '矿区', + '地区', + '省', + '市', + '州', + '盟', + '区', + '县', + '旗', +]; + +// 省级区划:展示时不在区划链首重复(「广西壮族自治区南宁市邕宁区」-> 「南宁市 邕宁区」) +const PROVINCE_SUFFIX = /(?:省|自治区|特别行政区)$/; + +/** + * 从文本头部匹配第一个行政区划片段。 + * 特例:「梅州市」州后紧跟市,并入同一片段;「梅县区」县/旗后紧跟区,并入同一片段。 + * @param {string} text 已去除空白的文本 + * @returns {string} 片段,未匹配到返回 '' + */ +function matchRegionToken(text) { + for (let end = 0; end < text.length; end += 1) { + const prefix = text.slice(0, end + 1); + const suffix = REGION_SUFFIXES.find(item => prefix.endsWith(item)); + if (!suffix) continue; + const next = text[end + 1] || ''; + if ((suffix === '州' || suffix === '盟') && next === '市') continue; + if ((suffix === '县' || suffix === '旗') && next === '区') return text.slice(0, end + 2); + return prefix; + } + return ''; +} + +/** + * 按行政区划后缀把文本切成区划片段。 + * @param {string} value 原值 + * @returns {string[]|null} 整串都是行政区划时返回片段数组,否则返回 null + */ +export function splitRegionTokens(value) { + const text = String(value ?? '').replace(/\s+/g, ''); + if (!text) return null; + const tokens = []; + let rest = text; + while (rest) { + const token = matchRegionToken(rest); + if (!token) return null; + tokens.push(token); + rest = rest.slice(token.length); + } + return tokens; +} + +/** + * 行政区划串按「市 区县」展示,省/自治区不重复展示。 + * @param {string} value 原值 + * @returns {string} 含空格的展示值;非纯行政区划串原样返回 + */ +export function formatRegionAddress(value) { + const text = String(value ?? '').trim(); + if (!text) return ''; + const tokens = splitRegionTokens(text); + if (!tokens || !tokens.length) return text; + const display = PROVINCE_SUFFIX.test(tokens[0]) ? tokens.slice(1) : tokens; + return (display.length ? display : tokens).join(' '); +} + +export default formatRegionAddress; diff --git a/src/utils/route-change.js b/src/utils/route-change.js new file mode 100644 index 0000000..3e0a780 --- /dev/null +++ b/src/utils/route-change.js @@ -0,0 +1,133 @@ +/** + * 路线变更记录内容解析 + * + * 存储格式(单行): + * YD-20260904-0022:发货地 A → B;到货地 C → D;YD-20260904-0025:发货地 E → F;到货地 G → H + * 路线顺序:A → B → C 调整为 B → A → C + * + * 解析结果(分组,供变更记录列表按「分组标题 - 字段 - 变更前/后」渲染): + * [ + * { + * title: 'YD-20260904-0022', + * text: '', + * fields: [{ label: '发货地', before: 'A', after: 'B', single: 'B', changed: true, unchanged: false }], + * }, + * { title: '', text: '无法识别的原文', fields: [] }, + * ] + * + * 说明:仅用于展示层,不改变已存储的 content 文本(历史数据同样适用)。 + */ +// 长标签必须排在短标签前(「发货地址」优先于「发货地」),否则会被前缀吃掉 +const FIELD_LABELS = ['发货地址', '到货地址', '发货地', '到货地', '装货地', '卸货地', '收货地']; +const FIELD_LABEL_PATTERN = new RegExp(`^(${FIELD_LABELS.join('|')})\\s*([\\s\\S]*)$`); +// 变更前 / 变更后分隔符:→、➔、➜、->、-->、–>、—> +const ARROW_PATTERN = /\s*(?:→|➔|➜|[\-–—]{1,2}\s*>)\s*/; +// 运单地址之外的变更描述,例如「A → B → C 调整为 B → A → C」 +const ADJUST_PATTERN = /^([\s\S]*?)\s调整为\s*([\s\S]*)$/; +const EMPTY_TEXT = ['', '-', '--', '—', 'null', 'undefined']; +// 冒号前内容超过该长度时不视为分组标题(运单号通常长于该值) +const TITLE_MAX_LENGTH = 12; + +const normalizeValue = value => { + const text = String(value === undefined || value === null ? '' : value).trim(); + return EMPTY_TEXT.includes(text.toLowerCase()) ? '-' : text; +}; + +const buildField = (label, rawValue) => { + const value = String(rawValue === undefined || rawValue === null ? '' : rawValue).trim(); + const adjustMatched = value.match(ADJUST_PATTERN); + let before = value; + let after = ''; + let hasPair = false; + + if (adjustMatched) { + before = adjustMatched[1]; + after = adjustMatched[2]; + hasPair = true; + } else { + const parts = value.split(ARROW_PATTERN); + if (parts.length > 1) { + before = parts[0]; + after = parts[1]; + hasPair = true; + } + } + + const beforeText = normalizeValue(before); + const afterText = normalizeValue(after); + + return { + label, + before: beforeText, + after: afterText, + // 单值展示(未拆分出前后值时使用) + single: hasPair ? afterText : beforeText, + changed: hasPair && beforeText !== afterText, + unchanged: hasPair && beforeText === afterText, + }; +}; + +const parseSegment = segment => { + // 形如「发货地 A → B」(前面只有「;」分隔,无分组标题)→ 归入上一条运单 + const leadingMatched = segment.match(FIELD_LABEL_PATTERN); + if (leadingMatched && leadingMatched[2].trim()) { + return { + type: 'field', + title: '', + text: '', + fields: [buildField(leadingMatched[1], leadingMatched[2])], + }; + } + + const colonIndex = segment.search(/[::]/); + if (colonIndex === -1) { + return { type: 'text', title: '', text: segment, fields: [] }; + } + + const head = segment.slice(0, colonIndex).trim(); + const tail = segment.slice(colonIndex + 1).trim(); + const fieldMatched = tail.match(FIELD_LABEL_PATTERN); + + // 形如「YD-20260904-0022:发货地 A → B」 + if (fieldMatched && fieldMatched[2].trim()) { + return { + type: 'waybill', + title: head, + text: '', + fields: [buildField(fieldMatched[1], fieldMatched[2])], + }; + } + + // 形如「路线顺序:A → B → C 调整为 B → A → C」 + if (head && head.length <= TITLE_MAX_LENGTH) { + return { type: 'group', title: head, text: '', fields: [buildField('', tail)] }; + } + + return { type: 'text', title: '', text: segment, fields: [] }; +}; + +export const parseRouteChangeContent = content => { + if (content === undefined || content === null) return []; + const text = String(content).replace(/\r\n?/g, '\n').trim(); + if (!text) return []; + + const segments = text + .split(/[;;\n]+/) + .map(item => item.trim()) + .filter(Boolean); + + const groups = []; + + segments.forEach(segment => { + const group = parseSegment(segment); + const last = groups[groups.length - 1]; + // 同一运单下的「到货地…」等无标题字段,合并进上一个运单分组 + if (group.type === 'field' && last && (last.type === 'waybill' || last.type === 'field')) { + last.fields.push(...group.fields); + return; + } + groups.push(group); + }); + + return groups; +}; diff --git a/src/views/base/airport-master.vue b/src/views/base/airport-master.vue index 8ef4edd..3c94260 100644 --- a/src/views/base/airport-master.vue +++ b/src/views/base/airport-master.vue @@ -144,7 +144,7 @@ + + diff --git a/src/views/business/components/shipping-template-page.vue b/src/views/business/components/shipping-template-page.vue index 9226f36..21f0015 100644 --- a/src/views/business/components/shipping-template-page.vue +++ b/src/views/business/components/shipping-template-page.vue @@ -113,7 +113,7 @@ @@ -153,11 +153,24 @@ @click="handleTransportSecondaryAddressInputClick('departure')" > + + + 联系人 联系方式 - - - 选择 - - @@ -197,25 +200,28 @@ @click="handleTransportSecondaryAddressInputClick('arrival')" > + + + 联系人 联系方式 - - - 选择 - - @@ -454,11 +460,20 @@ > - {{ attachmentName(row) }} - + @@ -1098,10 +1123,12 @@ import { import { getDictionary as getSystemDictionary } from '@/api/system/dict'; import { getDictionary } from '@/api/system/dictbiz'; import { packageOptions } from '@/option/business/common'; +import { formatContractOptionLabel } from '@/utils/contract'; +import { loadMeasurementUnitOptions } from '@/utils/measurement-unit'; import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search'; import { downloadFileByUrl, downloadXls } from '@/utils/util'; import { isMobile } from '@/utils/validate'; -import { Location } from '@element-plus/icons-vue'; +import { Location, OfficeBuilding } from '@element-plus/icons-vue'; import { ElImageViewer } from 'element-plus'; import { OpenFileViewer } from '@open-file-viewer/vue'; import { @@ -1252,6 +1279,7 @@ export default { data() { return { Location, + OfficeBuilding, form: {}, query: {}, loading: true, @@ -1278,7 +1306,8 @@ export default { cargoTypeRequest: null, transportCargoRows: [], packageOptions, - quantityUnitOptions: ['吨', '千克', '立方米', '立方', '公里', '单', '件', '车', '箱', '托盘'], + quantityUnitOptions: [], + quantityUnitLoading: false, feeUnitOptions: [], currencyOptions: [], currencyLoading: false, @@ -1525,11 +1554,26 @@ export default { created() { this.loadProjectOptions(); this.ensureCargoTypeOptions(); + this.loadQuantityUnitOptions(); this.loadFeeUnitOptions(); this.loadCurrencyOptions(); if (this.isStandaloneFormPage) this.$nextTick(() => this.initStandaloneFormPage()); }, methods: { + formatContractOptionLabel, + 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; + }, buildOption(option) { const next = { ...option, column: (option.column || []).map(column => ({ ...column })) }; const tableOrder = this.config.tableColumnOrder || []; @@ -3384,10 +3428,25 @@ export default { .shipping-template-page__route-row { display: grid; grid-template-columns: - minmax(160px, 1fr) minmax(220px, 2fr) auto minmax(140px, 1fr) auto minmax(160px, 1fr) - auto; + minmax(160px, 1fr) minmax(220px, 2fr) 40px auto minmax(140px, 1fr) auto + minmax(160px, 1fr); gap: 8px; align-items: center; + + .shipping-template-page__address-pick-btn { + :deep(.el-icon) { + font-size: 22px; + } + } +} +.shipping-template-page__map-suffix { + cursor: pointer; + color: #909399; + font-size: 18px; + + &:hover { + color: #409eff; + } } .shipping-template-page__address-map-input { cursor: pointer; @@ -3506,7 +3565,7 @@ export default { } .shipping-template-page__detail-form :deep(.el-form-item) { min-width: 0; - margin-bottom: 16px; + margin-bottom: 24px; } .shipping-template-page__detail-form :deep(.el-form-item__label) { color: #909399; @@ -3695,7 +3754,7 @@ export default { } :global(.shipping-template-form-page-dialog .avue-group .el-form-item) { - margin-bottom: 16px; + margin-bottom: 24px; } :global(.shipping-template-form-page-dialog .avue-form__menu) { diff --git a/src/views/business/components/transport-plan-page.vue b/src/views/business/components/transport-plan-page.vue index 27e37f4..4d12b86 100644 --- a/src/views/business/components/transport-plan-page.vue +++ b/src/views/business/components/transport-plan-page.vue @@ -149,7 +149,7 @@ @@ -492,11 +492,22 @@ > - + @@ -673,14 +684,16 @@

附件
- - {{ attachmentName(file) }} - + @keydown.enter="previewAttachment(file)" + @keydown.space.prevent="previewAttachment(file)" + >{{ attachmentName(file) }} -
@@ -855,11 +868,21 @@ - + @@ -1744,11 +1767,11 @@ > - + @@ -2741,7 +2775,15 @@ import { addressTypeOptions } from '@/option/base/common-address'; import { packageOptions } from '@/option/business/common'; import { formatUpdateUserName } from '@/utils/audit'; import { getToken, isAdministrator } from '@/utils/auth'; +import { + currencyUnit, + mapFeeUnitOptions, + remapPriceUnitCurrency, + resolvePriceUnitByQuantityUnit, +} from '@/utils/currency'; +import { formatContractOptionLabel } from '@/utils/contract'; import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search'; +import { loadMeasurementUnitOptions } from '@/utils/measurement-unit'; import { applyTableMenuWidth } from '@/utils/table-menu'; import { downloadFileByUrl, downloadXls } from '@/utils/util'; import { isMobile } from '@/utils/validate'; @@ -3206,7 +3248,7 @@ export default { billingCargoTypeLoading: false, billingCargoTypeRequest: null, packageOptions, - quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'], + quantityUnitOptions: [], attachmentFileTypes: [ 'pdf', 'bmp', @@ -3245,6 +3287,7 @@ export default { } else { this.consumeTemplateCreatePayload(); } + this.loadTaskQuantityUnitOptions(); }, computed: { ...mapGetters(['permission', 'userInfo']), @@ -3609,6 +3652,22 @@ export default { const value = this.dispatchItemForm.freightCurrency || 'RMB'; return this.currencyRemark(value); }, + taskFeeUnitSelectOptions() { + const currency = + this.dispatchItemForm.freightCurrency || + this.dispatchContractCurrency || + this.form.settlementCurrency || + 'RMB'; + return mapFeeUnitOptions(this.taskFeeUnitOptions, currency); + }, + defaultPriceUnit() { + return `${currencyUnit( + this.dispatchItemForm.freightCurrency || + this.dispatchContractCurrency || + this.form.settlementCurrency || + 'RMB' + )}/吨`; + }, isAdmin() { return isAdministrator(this.userInfo); }, @@ -3719,6 +3778,7 @@ export default { }, }, methods: { + formatContractOptionLabel, initStandaloneFormPage() { if (!this.isStandaloneFormPage) return; const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add'; @@ -4307,8 +4367,6 @@ export default { ['contractName', '客户合同'], ['planName', '计划名称'], ['transportType', '运输方式'], - ['planStartDate', '计划开始日期'], - ['planEndDate', '计划结束日期'], ['departureAddress', '发货地址'], ['arrivalAddress', '收货地址'], ]; @@ -4804,19 +4862,49 @@ export default { contract.customerNames || this.form.customerName || ''; + const nextCurrency = this.getContractCurrency(contract); + this.remapTransportPlanPriceUnits(nextCurrency); this.$refs.crud?.validateField?.('contractName'); }, + remapTransportPlanPriceUnits(currency) { + if (this.form.priceUnit) { + this.form.priceUnit = remapPriceUnitCurrency(this.form.priceUnit, currency); + } + if (this.dispatchItemForm.priceUnit) { + this.dispatchItemForm.priceUnit = remapPriceUnitCurrency( + this.dispatchItemForm.priceUnit, + currency + ); + } + this.dispatchItemForm.freightCurrency = currency; + (this.dispatchItemCargoRows || []).forEach(cargo => { + if (cargo.priceUnit) { + cargo.priceUnit = remapPriceUnitCurrency(cargo.priceUnit, currency); + } + }); + (this.transportCargoRows || []).forEach(cargo => { + if (cargo.priceUnit) { + cargo.priceUnit = remapPriceUnitCurrency(cargo.priceUnit, currency); + } + }); + }, loadDispatchContractCurrency(plan = {}) { const findContract = () => this.contractOptions.find(item => String(item.id) === String(plan.contractId)) || this.contractOptions.find(item => item.contractName === plan.contractName); const applyCurrency = contract => { + const previousCurrency = this.dispatchContractCurrency; this.dispatchContractCurrency = this.getContractCurrency( contract && (contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency) ? contract : { settlementCurrency: plan.settlementCurrency } ); + if (previousCurrency && previousCurrency !== this.dispatchContractCurrency) { + this.remapTransportPlanPriceUnits(this.dispatchContractCurrency); + } else { + this.dispatchItemForm.freightCurrency = this.dispatchContractCurrency; + } return this.dispatchContractCurrency; }; const current = findContract(); @@ -5076,15 +5164,40 @@ export default { return Promise.resolve(this.taskQuantityUnitOptions); } this.taskQuantityUnitLoading = true; - return getDictionary({ code: 'quantity_unit' }) - .then(res => { - this.taskQuantityUnitOptions = this.normalizeDictOptions(res.data.data || []); + return loadMeasurementUnitOptions() + .then(options => { + this.taskQuantityUnitOptions = options; + this.quantityUnitOptions = options.map(item => item.value); + this.ensureQuantityUnitOptions([ + this.dispatchItemForm?.quantityUnit, + ...(this.dispatchItemCargoRows || []).map(row => row.quantityUnit), + ...(this.transportCargoRows || []).map(row => row.quantityUnit), + ]); return this.taskQuantityUnitOptions; }) .finally(() => { this.taskQuantityUnitLoading = false; }); }, + ensureQuantityUnitOptions(units = []) { + const pending = [...new Set( + (Array.isArray(units) ? units : [units]) + .map(item => String(item || '').trim()) + .filter(Boolean) + )]; + if (!pending.length) return; + pending.forEach(unit => { + if (!this.quantityUnitOptions.includes(unit)) { + this.quantityUnitOptions = [...this.quantityUnitOptions, unit]; + } + if (!this.taskQuantityUnitOptions.some(item => item.value === unit)) { + this.taskQuantityUnitOptions = [ + ...this.taskQuantityUnitOptions, + { label: unit, value: unit }, + ]; + } + }); + }, loadTaskFeeUnitOptions() { if (this.taskFeeUnitOptions.length || this.taskFeeUnitLoading) { return Promise.resolve(this.taskFeeUnitOptions); @@ -5146,6 +5259,9 @@ export default { }); }, normalizeTransportCargoRow(row = {}) { + const quantityUnit = String( + row.quantityUnit || row.goodsQuantityUnit || row.unit || '' + ).trim(); const cargo = { ...defaultTransportCargo(), ...row, @@ -5162,10 +5278,14 @@ export default { packageType: row.packageType || row.package || '', specification: row.specification || row.spec || '', freightAmount: row.freightAmount ?? row.amount ?? row.totalAmount ?? '', + quantityUnit, }; ['quantity', 'mileage', 'unitPrice', 'freightAmount'].forEach(prop => { if (Number(cargo[prop]) === -1) cargo[prop] = ''; }); + if (quantityUnit) { + this.ensureQuantityUnitOptions(quantityUnit); + } return cargo; }, hasMultipleConfiguredGoods(source = {}, rows = []) { @@ -5183,7 +5303,7 @@ export default { }, addTransportCargoRow(index = -1, row = {}) { const nextRow = this.normalizeTransportCargoRow({ - priceUnit: this.form.priceUnit || '元/吨', + priceUnit: this.form.priceUnit || this.defaultPriceUnit, ...row, }); if ( @@ -7594,6 +7714,9 @@ export default { ...baseRow, ...(index >= 0 ? row : {}), }; + this.dispatchItemForm.quantityUnit = + this.dispatchItemForm.quantityUnit || defaultUnit || TRANSPORT_PLAN_QUANTITY_UNIT; + this.ensureQuantityUnitOptions(this.dispatchItemForm.quantityUnit); this.dispatchItemForm.otherFeeTotal = this.normalizeDispatchFeeValue( this.dispatchItemForm.otherFeeTotal ); @@ -7603,7 +7726,11 @@ export default { }); const goodsRows = this.parseJsonArray(this.dispatchItemForm.goodsJson); this.dispatchItemCargoRows = (goodsRows.length ? goodsRows : [this.dispatchItemForm]).map( - item => this.normalizeTransportCargoRow(item) + item => + this.normalizeTransportCargoRow({ + ...item, + quantityUnit: item.quantityUnit || this.dispatchItemForm.quantityUnit || defaultUnit, + }) ); const hasMultiplePlanGoods = this.dispatchPlanGoodsRows.length > 1; const shouldAutoFillFullGoods = @@ -7613,11 +7740,18 @@ export default { const unscheduledGoodsRows = this.getDispatchUnscheduledGoodsRows(); this.dispatchItemForm.taskEntryMode = 'full'; this.dispatchItemForm.quantity = ''; - this.dispatchItemCargoRows = unscheduledGoodsRows.length - ? unscheduledGoodsRows - : this.dispatchPlanGoodsRows.map(goods => - this.normalizeTransportCargoRow({ ...goods, quantity: '' }) - ); + this.dispatchItemCargoRows = ( + unscheduledGoodsRows.length + ? unscheduledGoodsRows + : this.dispatchPlanGoodsRows.map(goods => + this.normalizeTransportCargoRow({ ...goods, quantity: '' }) + ) + ).map(goods => + this.normalizeTransportCargoRow({ + ...goods, + quantityUnit: goods.quantityUnit || defaultUnit || TRANSPORT_PLAN_QUANTITY_UNIT, + }) + ); } if (!this.dispatchItemCargoRows.length) { this.dispatchItemCargoRows = [this.normalizeTransportCargoRow()]; @@ -7632,7 +7766,10 @@ export default { this.dispatchItemCargoRows = this.dispatchItemCargoRows.map((cargo, itemIndex) => ({ ...cargo, unitPrice: cargo.unitPrice || freightInfo.freightItems[itemIndex]?.unitPrice || '', - priceUnit: cargo.priceUnit || freightInfo.freightItems[itemIndex]?.priceUnit || '元/吨', + priceUnit: + cargo.priceUnit || + freightInfo.freightItems[itemIndex]?.priceUnit || + this.defaultPriceUnit, freightAmount: cargo.freightAmount || freightInfo.freightItems[itemIndex]?.freightAmount || '', })); @@ -7881,7 +8018,7 @@ export default { return value; }, addDispatchCargoRow(index = -1) { - const nextRow = this.normalizeTransportCargoRow({ priceUnit: '元/吨' }); + const nextRow = this.normalizeTransportCargoRow({ priceUnit: this.defaultPriceUnit }); if (index > -1) this.dispatchItemCargoRows.splice(index + 1, 0, nextRow); else this.dispatchItemCargoRows.push(nextRow); }, @@ -7925,7 +8062,14 @@ export default { return prices[0]; }, dispatchFreightGroupPriceUnit(group = {}) { - return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.quantityUnit}` : ''); + if (group.rows?.[0]?.priceUnit) return group.rows[0].priceUnit; + if (!group.quantityUnit) return ''; + return ( + resolvePriceUnitByQuantityUnit( + group.quantityUnit, + this.dispatchItemForm.freightCurrency || this.dispatchContractCurrency || 'RMB' + ) || `${currencyUnit(this.dispatchContractCurrency || 'RMB')}/${group.quantityUnit}` + ); }, dispatchFreightGroupAmount(group = {}) { const amount = (group.rows || []).reduce( @@ -8309,7 +8453,10 @@ export default { this.dispatchItemForm.cargoName || '', quantity: quantityText, - quantityUnit: firstGoods.quantityUnit || this.dispatchItemForm.quantityUnit || '', + quantityUnit: + firstGoods.quantityUnit || + this.dispatchItemForm.quantityUnit || + TRANSPORT_PLAN_QUANTITY_UNIT, unitPrice: firstGoods.unitPrice || '', priceUnit: firstGoods.priceUnit || '', freightTotal: this.dispatchItemFreightTotal, @@ -8324,7 +8471,7 @@ export default { unitPrice: cargo.unitPrice || '', priceUnit: cargo.priceUnit || '', quantity: cargo.quantity || '', - quantityUnit: cargo.quantityUnit || '', + quantityUnit: cargo.quantityUnit || firstGoods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT, freightAmount: this.dispatchCargoFreightAmount(cargo), })), }), @@ -8413,26 +8560,40 @@ export default { return { id: this.dispatchRow.id, mode, - waybills: rows.map(row => ({ - ...row, - taskEntryMode: row.taskEntryMode || 'simple', - taskRemark: row.remark || '', - estimatedStartTime: this.formatDispatchDate(row.estimatedStartTime), - estimatedEndTime: this.formatDispatchDate(row.estimatedEndTime), - goodsJson: - row.goodsJson || - JSON.stringify([ + waybills: rows.map(row => { + const quantityUnit = this.getDispatchQuantityUnit(row); + let goodsJson = row.goodsJson; + const goodsRows = this.parseJsonArray(goodsJson); + if (goodsRows.length) { + goodsJson = JSON.stringify( + goodsRows.map(goods => ({ + ...goods, + quantityUnit: goods.quantityUnit || quantityUnit, + })) + ); + } else { + goodsJson = JSON.stringify([ { cargoName: row.cargoName, cargoType: row.cargoType, quantity: row.quantity, - quantityUnit: row.quantityUnit, + quantityUnit, specification: row.specification, model: row.model, packageType: row.packageType, }, - ]), - })), + ]); + } + return { + ...row, + quantityUnit, + taskEntryMode: row.taskEntryMode || 'simple', + taskRemark: row.remark || '', + estimatedStartTime: this.formatDispatchDate(row.estimatedStartTime), + estimatedEndTime: this.formatDispatchDate(row.estimatedEndTime), + goodsJson, + }; + }), }; }, formatDispatchDate(value) { @@ -8680,7 +8841,7 @@ export default { &__dispatch-shipping-form { :deep(.el-form-item) { - margin-bottom: 14px; + margin-bottom: 24px; } } @@ -9871,7 +10032,7 @@ export default { } :global(.transport-plan-form-page-dialog .avue-group .el-form-item) { - margin-bottom: 16px; + margin-bottom: 24px; } :global(.transport-plan-form-page-dialog .avue-group .dialog-section-title) { diff --git a/src/views/business/components/voucher-execution-detail.vue b/src/views/business/components/voucher-execution-detail.vue index 0de4939..8b5623f 100644 --- a/src/views/business/components/voucher-execution-detail.vue +++ b/src/views/business/components/voucher-execution-detail.vue @@ -1,7 +1,7 @@ + {{ record.punched ? '编辑打卡' : '补卡' }}
暂未打卡
@@ -2356,6 +2488,424 @@ + +
+ + + + + {{ waybillExceptionValue(field.prop) }} + + + + + + + + + {{ + waybillExceptionValue('reporterName') + }} + + + + + {{ + waybillExceptionValue('reportTime') + }} + + + + + {{ + waybillExceptionValue('nodeName') + }} + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + 删除 +
+ +
+ 添加
+
+
+
+
+
+
+
+ + +
+ +
+ + + + + + + + {{ + waybillPunchEditRecord.nodeName || waybillPunchEditRecord.nodeCode || '-' + }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 在途打卡只保留 1 张照片 +
+
+
+
+
+
+ +
+ + + - + @@ -3086,8 +3682,15 @@ import { getList as getProcessConfigList, getVoucherImages as getProcessConfigVoucherImages, } from '@/api/business/process-config'; -import { getPunchRecords as getWaybillPunchRecords, locateVehicle, trackVehicle } from '@/api/business/waybill-manage'; +import { + getPunchRecords as getWaybillPunchRecords, + locateVehicle, + savePunchRecord, + trackVehicle, +} from '@/api/business/waybill-manage'; import VoucherExecutionDetail from './voucher-execution-detail.vue'; +import PunchRecordPhotos from './punch-record-photos.vue'; +import AddressMapPicker from '@/components/address-map-picker/main.vue'; import { getList as getDriverList } from '@/api/transportCapacity/driver'; import { fetchDriversByCarrierOrganizations, @@ -3098,8 +3701,18 @@ import { addressTypeOptions } from '@/option/base/common-address'; import { packageOptions } from '@/option/business/common'; import { formatUpdateUserName } from '@/utils/audit'; import { getToken, isAdministrator } from '@/utils/auth'; +import { + currencyUnit, + mapFeeUnitOptions, + remapPriceUnitCurrency, + resolvePriceUnitByQuantityUnit, +} from '@/utils/currency'; +import { formatContractOptionLabel } from '@/utils/contract'; +import { wgs84ToGcj02 } from '@/utils/coordinate'; import { openImportDialog } from '@/utils/import-excel'; import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search'; +import { loadMeasurementUnitOptions } from '@/utils/measurement-unit'; +import { parseRouteChangeContent } from '@/utils/route-change'; import { applyTableMenuWidth } from '@/utils/table-menu'; import { downloadFileByUrl, downloadXls } from '@/utils/util'; import { isMobile } from '@/utils/validate'; @@ -3119,6 +3732,12 @@ import { h, resolveComponent } from 'vue'; import { mapGetters } from 'vuex'; import NProgress from 'nprogress'; import 'nprogress/nprogress.css'; +import { + disposalStatusOptions, + exceptionCategoryOptions, +} from '@/option/transit/exception-disposal'; +import { getUploadHeaders } from '@/utils/upload'; +import * as exceptionDisposalApi from '@/api/transit/exception-disposal'; const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315'; const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a'; @@ -3129,6 +3748,9 @@ const standaloneWaybillFormPaths = [standaloneWaybillListRoute, standaloneWaybil const standaloneWaybillDetailRoute = '/business/waybill-manage/detail'; const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills'; const waybillManageListRefreshKey = 'waybill-manage-list-refresh'; +// 视为「已经处理完」的异常处置状态,与后端打卡门禁同一口径(已完成、已撤销都放行), +// 所以「处理中」仍算未完成,节点上继续显示异常。 +const settledExceptionStatuses = ['completed', 'revoked']; const attachmentViewerPlugins = [ imagePlugin(), @@ -3284,6 +3906,8 @@ export default { ElImageViewer, OpenFileViewer, VoucherExecutionDetail, + PunchRecordPhotos, + AddressMapPicker, }, name: 'WaybillManagePage', props: { @@ -3348,6 +3972,49 @@ export default { waybillVoucherFolderView: false, waybillVoucherImagesLoading: false, waybillVoucherImagesLoaded: false, + waybillExceptionRows: [], + waybillExceptionLoading: false, + waybillExceptionDialog: { + visible: false, + mode: 'follow', + loading: false, + submitting: false, + }, + waybillExceptionDetail: {}, + waybillExceptionFollowForm: { followContent: '', attachments: [] }, + waybillExceptionFollowRules: { + followContent: [{ max: 500, message: '跟进说明不能超过500个字符', trigger: 'blur' }], + }, + waybillExceptionEditForm: { + exceptionType: '', + exceptionReason: '', + reportDescription: '', + scenePhotoList: [], + }, + waybillExceptionEditRules: { + exceptionType: [{ required: true, message: '请选择异常类型', trigger: 'change' }], + reportDescription: [ + { required: true, message: '请输入上报说明', trigger: 'blur' }, + { max: 500, message: '上报说明不能超过500个字符', trigger: 'blur' }, + ], + }, + waybillPunchRecordEditDialog: { + visible: false, + submitting: false, + }, + waybillPunchEditRecord: {}, + waybillPunchEditForm: { + punchTime: '', + address: '', + longitude: '', + latitude: '', + weight: '', + volume: '', + quantity: '', + remark: '', + photos: [], + }, + waybillPunchAddressMapVisible: false, waybillRouteChangeBox: false, waybillRouteChangeTab: 'change', waybillRouteChangeRows: [], @@ -3556,7 +4223,7 @@ export default { cargoTypeLoading: false, cargoTypeRequest: null, packageOptions, - quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'], + quantityUnitOptions: [], attachmentFileTypes: [ 'pdf', 'bmp', @@ -3584,6 +4251,8 @@ export default { }, selectionList: [], attachmentUploadMode: false, + /** 复制来源运单业务状态(表单字段 addDisplay:false,提交时需显式带回) */ + copySourceBusinessStatus: '', standaloneFormKey: '', // 实例所属路由的路径快照:用于判断当前 $route 是否还是「本实例自己的路由」。 // 必须在 data 里初始化,不能放 created —— Vue 的 Options API 执行顺序是 @@ -3613,7 +4282,6 @@ export default { this.loadProjectOptions(); } if (this.config.enableTaskInfoForm) { - this.loadTaskQuantityUnitOptions(); this.loadTaskFeeUnitOptions(); this.ensureCargoTypeOptions(); } @@ -3628,6 +4296,7 @@ export default { } else { this.consumeTemplateCreatePayload(); } + this.loadTaskQuantityUnitOptions(); }, // 标签切走(实例被 keep-alive 缓存)或销毁时,收起本页所有 append-to-body 的弹窗。 // 否则 Teleport 出去的弹窗 DOM 会一直留在 body 上,盖住后续打开的页面。 @@ -3647,11 +4316,114 @@ export default { }, computed: { ...mapGetters(['permission', 'userInfo']), + waybillExceptionDialogTitle() { + return ( + { detail: '异常详情', follow: '异常跟进', edit: '编辑异常' }[ + this.waybillExceptionDialog.mode + ] || '异常详情' + ); + }, + waybillExceptionWaybillFields() { + return [ + { label: '运单号', prop: 'waybillNo' }, + { label: '配载单号/总单号', prop: 'loadingOrMasterNo' }, + { label: '车牌号', prop: 'vehicleNo' }, + { label: '项目', prop: 'projectName' }, + { label: '承运商', prop: 'carrierName' }, + ]; + }, + waybillExceptionDetailFields() { + return [ + { label: '上报人', prop: 'reporterName' }, + { label: '上报时间', prop: 'reportTime' }, + { label: '异常节点', prop: 'nodeName' }, + { label: '异常类型', prop: 'exceptionTypeName' }, + { label: '异常原因', prop: 'exceptionReason' }, + { label: '上报说明', prop: 'reportDescription', span: 2 }, + ]; + }, + waybillExceptionFollowColumns() { + return [ + { label: '跟进说明', prop: 'followContent', minWidth: 260 }, + { label: '跟进人', prop: 'followUserName', minWidth: 130 }, + { label: '跟进时间', prop: 'followTime', minWidth: 170 }, + ]; + }, + waybillExceptionTypeOptions() { + return exceptionCategoryOptions.map(item => ({ label: item.label, value: item.value })); + }, + waybillExceptionReasonOptions() { + return ( + exceptionCategoryOptions.find( + item => item.value === this.waybillExceptionEditForm.exceptionType + )?.children || [] + ); + }, + waybillExceptionPhotoUrls() { + return this.parseWaybillExceptionPhotos(this.waybillExceptionDetail.scenePhotos); + }, + waybillExceptionUploadHeaders() { + return getUploadHeaders(); + }, permissionList() { return { addBtn: this.canCreate, }; }, + // 打卡记录编辑入口与运单编辑同权限:能改运单的人才能订正它的打卡流水 + canEditWaybillPunchRecord() { + return this.hasPermission(`${this.config.permission}_edit`) && !this.readonlyScope; + }, + waybillPunchEditNode() { + return this.findWaybillPunchEditNodeConfig(this.waybillPunchEditRecord); + }, + // 编辑弹窗的字段按过程配置取舍:配置里没采的项不展示; + // 记录里已有值的项即使配置后来改小也保留——否则历史数据里的货量/定位再也改不掉。 + // 打卡照片不按配置取舍:过程配置的「上传凭证」是司机端的要求,管理端补录不看它, + // 否则配置没勾凭证的节点,记录里又还没有照片时,这个节点就再也传不了附件。 + waybillPunchEditFields() { + const record = this.waybillPunchEditRecord || {}; + const node = this.waybillPunchEditNode; + const hasValue = value => value !== undefined && value !== null && String(value).trim() !== ''; + // 接单/回单是确认节点,过程配置里没有「打卡定位」开关,其 location 是模板默认值,不采信 + const isConfirmNode = ['accept', 'return'].includes(String(node?.key || '')); + const cargoTypes = Array.isArray(node?.cargoTypes) ? node.cargoTypes : []; + const cargoFields = [ + { prop: 'weight', label: '重量(吨)', cargoType: '重量' }, + { prop: 'volume', label: '体积(方)', cargoType: '体积' }, + { prop: 'quantity', label: '数量(件)', cargoType: '数量' }, + ].filter(item => cargoTypes.includes(item.cargoType) || hasValue(record[item.prop])); + return { + location: + !isConfirmNode && + (Boolean(node?.location) || + hasValue(record.address) || + hasValue(record.longitude) || + hasValue(record.latitude)), + cargoFields, + }; + }, + // 过程配置为「不打卡」的节点,补录只是数据订正,司机端不会因此出现该节点的打卡 + waybillPunchEditPunchDisabled() { + return this.waybillPunchEditNode?.punch === false; + }, + waybillPunchEditRules() { + return { punchTime: [{ required: true, message: '请选择打卡时间', trigger: 'change' }] }; + }, + // 照片类型只有两个来源:固定的「打卡照片」,和该节点过程配置勾选的凭证类型; + // 不接受配置外的类型。在途打卡表只有一个照片字段、也没有类型列,不按凭证类型分组 + waybillPunchEditPhotoTypes() { + if (this.waybillPunchEditRecord.type === 'enroute') return []; + const voucherTypes = Array.isArray(this.waybillPunchEditNode?.voucherTypes) + ? this.waybillPunchEditNode.voucherTypes + : []; + return ['打卡照片', ...voucherTypes]; + }, + // 打卡照片与各凭证类型各自最多 9 张,不另设节点总张数上限; + // 在途打卡用 0 表示不限制——它保存时只保留第一张,张数由那一步约束 + waybillPunchEditPhotoLimit() { + return this.waybillPunchEditRecord.type === 'enroute' ? 0 : 9; + }, // 打卡时间轴按打卡时间先后展示:已打卡记录按打卡时间升序在前, // 未打卡记录保持流程节点原顺序排在其后。 orderedWaybillPunchRecords() { @@ -3671,6 +4443,47 @@ export default { }) .map(item => item.record); }, + // 打卡记录 → 该节点的异常处置。异常跟节点走,不跟打卡走:节点未打卡也可能有异常。 + // 数据源是异常处置表(waybillExceptionRows),不是打卡行上的 exception_flag + // ——后者由打卡接口写入且未随异常上报维护,实测有异常的节点它的值仍是 0。 + // 匹配优先用 nodeCode,回退 nodeName:两者各有一批历史数据只填了一边。 + waybillPunchRecordExceptions() { + const rows = Array.isArray(this.waybillExceptionRows) ? this.waybillExceptionRows : []; + const index = new Map(); + rows.forEach(row => { + const nodeCode = String(row.nodeCode || '').trim().toLowerCase(); + const nodeName = String(row.nodeName || '').trim(); + if (nodeCode) { + if (!index.has(`code:${nodeCode}`)) index.set(`code:${nodeCode}`, []); + index.get(`code:${nodeCode}`).push(row); + } + if (nodeName) { + if (!index.has(`name:${nodeName}`)) index.set(`name:${nodeName}`, []); + index.get(`name:${nodeName}`).push(row); + } + }); + const result = {}; + (Array.isArray(this.waybillPunchRecords) ? this.waybillPunchRecords : []).forEach( + (record, position) => { + const nodeCode = String(record.nodeCode || '').trim().toLowerCase(); + const nodeName = String(record.nodeName || '').trim(); + const matched = + (nodeCode && index.get(`code:${nodeCode}`)) || + (nodeName && index.get(`name:${nodeName}`)) || + []; + if (!matched.length) return; + const isSettled = item => settledExceptionStatuses.includes(item.disposalStatus); + result[this.waybillPunchRecordKey(record, position)] = { + total: matched.length, + // 只要还有一条没处理完,这个节点就算「有事没完」,链接送去跟进 + unfinished: matched.filter(item => !isSettled(item)).length, + // 取未完成的那条(没有则取第一条)作为跟进/详情的打开对象 + target: matched.find(item => !isSettled(item)) || matched[0], + }; + } + ); + return result; + }, isStandaloneWaybillPage() { return this.standaloneFormPage && standaloneWaybillFormPaths.includes(this.$route.path); }, @@ -3836,6 +4649,12 @@ export default { if (!currency) return ''; return this.currencyRemark(currency); }, + taskFeeUnitSelectOptions() { + return mapFeeUnitOptions(this.taskFeeUnitOptions, this.taskFreightCurrency || 'RMB'); + }, + defaultPriceUnit() { + return `${currencyUnit(this.taskFreightCurrency || 'RMB')}/吨`; + }, isAdmin() { return isAdministrator(this.userInfo); }, @@ -3980,6 +4799,8 @@ export default { }, }, methods: { + formatContractOptionLabel, + parseRouteChangeContent, // 页面被 keep-alive 缓存/卸载时调用,关闭本页所有 append-to-body 的弹窗, // 避免 Teleport 出去的 DOM 残留在 body 上盖住后续页面(详见 data 里的说明)。 closeInnerDialogs() { @@ -3998,6 +4819,7 @@ export default { this.cargoImportBox = false; this.waybillProcessConfigBox = false; this.excelBox = false; + this.waybillExceptionDialog.visible = false; }, initStandaloneFormPage() { if (!this.isStandaloneWaybillFormPage) return; @@ -4428,7 +5250,7 @@ export default { quantity: goods.quantity ?? goods.cargoQuantity ?? goods.goodsQuantity ?? '', quantityUnit: goods.quantityUnit || goods.goodsQuantityUnit || goods.unit || '吨', unitPrice: goods.unitPrice, - priceUnit: goods.priceUnit || '元/吨', + priceUnit: goods.priceUnit || this.defaultPriceUnit, }); }, canOperation(operation, row) { @@ -4444,10 +5266,11 @@ export default { return true; }, showDetailButton(row) { + // 草稿与其它状态统一走「详情」(独立详情页 / 详情弹窗), + // 不再对草稿单独降级为 avue 的「查看」表单弹窗,避免同一列出现两种弹窗形态。 if (!this.config.detailButton || !this.hasPermission(`${this.config.permission}_view`)) return false; - const status = this.statusValue(row); - return status !== 'draft'; + return true; }, formatDetailValue(row, prop) { if (!row) return '-'; @@ -4628,6 +5451,7 @@ export default { this.waybillVoucherFolder = null; this.waybillVoucherFolderView = false; this.waybillVoucherImagesLoaded = false; + this.waybillExceptionRows = []; const request = typeof this.api.getDetail === 'function' ? this.api.getDetail(row.id) @@ -4638,6 +5462,7 @@ export default { this.detailRow = detail; this.loadWaybillPunchRecords(); this.loadWaybillVoucherImages(); + this.loadWaybillExceptions(detail); if (detail.contractId) { return getContractDetail(detail.contractId) .then(contractRes => { @@ -4667,6 +5492,209 @@ export default { this.detailLoading = false; }); }, + // 异常处置列表接口不支持按 waybillId 精确过滤,只有 waybillNo 模糊匹配, + // 而运单号存在前缀包含(YD001 会命中 YD0011),故取回后必须再按 waybillId 精确筛一次。 + // 多取一页降低「同前缀运单占满首页」导致漏数据的概率。 + // 注意 String() 比较只是避免类型不一致,挡不住 int64 精度:运单 id 超出 JS 安全整数范围, + // 两个相差很小的 id 在响应 JSON 解析时就已被解析成同一个浮点值,此处无从分辨。 + async loadWaybillExceptions(detail) { + const waybillId = detail?.id; + const waybillNo = detail?.waybillNo; + if (!waybillId || !waybillNo) { + this.waybillExceptionRows = []; + return; + } + this.waybillExceptionLoading = true; + try { + const res = await exceptionDisposalApi.getList(1, 100, { waybillNo }); + const data = res?.data?.data || res?.data || res || {}; + this.waybillExceptionRows = (data.records || []) + .filter(item => String(item.waybillId) === String(waybillId)) + .map(this.decorateWaybillExceptionRow); + } catch (error) { + this.waybillExceptionRows = []; + } finally { + this.waybillExceptionLoading = false; + } + }, + decorateWaybillExceptionRow(row) { + return { + ...row, + exceptionTypeName: this.waybillExceptionName(row.exceptionType), + disposalStatusName: + disposalStatusOptions.find(item => item.value === row.disposalStatus)?.label || + row.disposalStatus || + '-', + }; + }, + waybillExceptionName(value) { + if (!value) return '-'; + // 一级存中文大类名(如「交通异常」),二级存子类名(如「货损」),两级都查 + const group = exceptionCategoryOptions.find(item => item.value === value); + if (group) return group.label; + for (const item of exceptionCategoryOptions) { + const match = item.children.find(child => child.value === value); + if (match) return match.label; + } + return value; + }, + async openWaybillException(row, mode) { + this.waybillExceptionDialog = { + visible: true, + mode, + loading: true, + submitting: false, + }; + this.waybillExceptionFollowForm = { followContent: '', attachments: [] }; + try { + const res = await exceptionDisposalApi.getDetail(row.id); + const detail = res?.data?.data || res?.data || res || {}; + this.waybillExceptionDetail = this.decorateWaybillExceptionRow(detail); + if (mode === 'edit') { + this.waybillExceptionEditForm = { + exceptionType: this.waybillExceptionDetail.exceptionType || '', + exceptionReason: this.waybillExceptionDetail.exceptionReason || '', + reportDescription: this.waybillExceptionDetail.reportDescription || '', + scenePhotoList: this.parseWaybillExceptionPhotos( + this.waybillExceptionDetail.scenePhotos + ), + }; + } + } finally { + this.waybillExceptionDialog.loading = false; + } + }, + waybillExceptionValue(prop) { + const value = this.waybillExceptionDetail[prop]; + return value === null || value === undefined || value === '' ? '-' : value; + }, + handleWaybillExceptionTypeChange() { + // 一级类型变了,旧二级可能不属于新类型 + const stillValid = this.waybillExceptionReasonOptions.some( + item => item.value === this.waybillExceptionEditForm.exceptionReason + ); + if (!stillValid) { + this.waybillExceptionEditForm.exceptionReason = ''; + } + }, + beforeWaybillExceptionPhotoUpload(file) { + const validType = ['image/jpeg', 'image/png', 'image/jpg', 'image/bmp'].includes(file.type); + if (!validType) { + this.$message.error('仅支持 JPG、PNG、BMP 图片'); + return false; + } + if (file.size / 1024 / 1024 >= 5) { + this.$message.error('图片大小不能超过 5MB'); + return false; + } + return true; + }, + handleWaybillExceptionPhotoSuccess(res) { + if (res.code === 200 && res.data) { + const url = res.data.link || res.data.url || res.data.domain || ''; + if (url) { + this.waybillExceptionEditForm.scenePhotoList = [ + ...this.waybillExceptionEditForm.scenePhotoList, + url, + ]; + } + } else { + this.$message.error(res.msg || '上传失败'); + } + }, + handleWaybillExceptionPhotoError() { + this.$message.error('上传失败'); + }, + removeWaybillExceptionPhoto(index) { + const list = [...this.waybillExceptionEditForm.scenePhotoList]; + list.splice(index, 1); + this.waybillExceptionEditForm.scenePhotoList = list; + }, + parseWaybillExceptionPhotos(value) { + if (!value) return []; + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.filter(Boolean); + } catch (error) { + return String(value) + .split(',') + .map(item => item.trim()) + .filter(Boolean); + } + return []; + }, + parseWaybillExceptionAttachments(value) { + if (!value) return []; + if (Array.isArray(value)) return value; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter(item => item && item.url) : []; + } catch (error) { + return []; + } + }, + previewWaybillExceptionAttachment(file, row) { + this.previewAttachment(file, this.parseWaybillExceptionAttachments(row.attachmentsJson)); + }, + async submitWaybillExceptionEdit() { + await this.$refs.waybillExceptionEditFormRef.validate(); + this.waybillExceptionDialog.submitting = true; + try { + await exceptionDisposalApi.update({ + id: this.waybillExceptionDetail.id, + exceptionType: this.waybillExceptionEditForm.exceptionType, + exceptionReason: this.waybillExceptionEditForm.exceptionReason, + reportDescription: this.waybillExceptionEditForm.reportDescription, + scenePhotos: this.waybillExceptionEditForm.scenePhotoList.join(','), + }); + this.$message.success('编辑成功'); + this.waybillExceptionDialog.visible = false; + this.refreshWaybillExceptions(); + } finally { + this.waybillExceptionDialog.submitting = false; + } + }, + async submitWaybillExceptionFollow() { + await this.$refs.waybillExceptionFollowFormRef.validate(); + const followContent = (this.waybillExceptionFollowForm.followContent || '').trim(); + const attachmentsJson = this.waybillExceptionFollowForm.attachments.length + ? JSON.stringify(this.waybillExceptionFollowForm.attachments) + : ''; + // 后端要求二者至少有其一 + if (!followContent && !attachmentsJson) { + this.$message.warning('请填写跟进说明或上传附件'); + return; + } + this.waybillExceptionDialog.submitting = true; + try { + await exceptionDisposalApi.follow({ + id: this.waybillExceptionDetail.id, + followContent, + attachmentsJson, + }); + this.$message.success('跟进成功'); + this.waybillExceptionDialog.visible = false; + this.refreshWaybillExceptions(); + } finally { + this.waybillExceptionDialog.submitting = false; + } + }, + async completeWaybillExceptionRow(row) { + await this.$confirm('确认完成该异常处置?', '提示', { type: 'warning' }); + await exceptionDisposalApi.complete({ id: row.id }); + this.$message.success('完成成功'); + this.refreshWaybillExceptions(); + }, + async completeWaybillExceptionFromDialog() { + await this.$confirm('确认完成该异常处置?', '提示', { type: 'warning' }); + await exceptionDisposalApi.complete({ id: this.waybillExceptionDetail.id }); + this.$message.success('完成成功'); + this.waybillExceptionDialog.visible = false; + this.refreshWaybillExceptions(); + }, + refreshWaybillExceptions() { + this.loadWaybillExceptions(this.detailRow); + }, closeDetail() { this.destroyWaybillLocateMap(); this.destroyWaybillTrackMap(); @@ -4827,6 +5855,16 @@ export default { const fallback = new Date(text.replace(/-/g, '/')).getTime(); return Number.isNaN(fallback) ? null : fallback; }, + // 时间轴 key:只认节点标识,不带序号。异常索引与模板 v-for 共用它, + // 带上序号的话,编辑打卡时间会重排时间轴,序号一变异常标签就挂错节点或整个消失。 + waybillPunchRecordKey(record, index) { + return String(record?.nodeCode || record?.id || `punch-${index}`); + }, + // 未处理完 → 跟进;全处理完 → 只读详情(与异常处置列表的操作口径一致) + openWaybillPunchRecordException(exception) { + if (!exception?.target) return; + this.openWaybillException(exception.target, exception.unfinished ? 'follow' : 'detail'); + }, loadWaybillPunchRecords() { const waybillId = this.detailRow?.id; if (!waybillId || this.waybillPunchRecordsLoading) return; @@ -4851,6 +5889,150 @@ export default { this.waybillPunchRecordsLoading = false; }); }, + // 打卡记录 → 过程配置节点:nodeCode 命中优先,回退 nodeName(与后端 listPunchRecords 同口径) + findWaybillPunchEditNodeConfig(record = {}) { + const nodes = Array.isArray(this.waybillDetailProcessNodes) + ? this.waybillDetailProcessNodes + : []; + const nodeCode = String(record?.nodeCode || '') + .trim() + .toLowerCase(); + const nodeName = String(record?.nodeName || '').trim(); + return ( + nodes.find(node => nodeCode && String(node.key || '').trim().toLowerCase() === nodeCode) || + nodes.find(node => nodeName && String(node.name || '').trim() === nodeName) || + null + ); + }, + // 接单节点没有打卡流水,它的完成由运单的接单事实决定:补录或取消打卡都改不动它, + // 放出去只会造出一条与运单事实打架的流水。判据与后端一致(key=accept / name=接单)。 + isAcceptPunchRecord(record = {}) { + const node = this.findWaybillPunchEditNodeConfig(record); + const key = String(node?.key || record?.nodeCode || '') + .trim() + .toLowerCase(); + const name = String(node?.name || record?.nodeName || '').trim(); + return key === 'accept' || name === '接单'; + }, + openWaybillPunchRecordEdit(record = {}) { + if (!this.detailRow?.id || !record) return; + this.waybillPunchEditRecord = { ...record }; + // 弹窗不提供打卡状态开关:未打卡的记录点「补卡」进来,保存即补出一条打卡流水 + this.waybillPunchEditForm = { + punchTime: record.punchTime || this.$dayjs().format('YYYY-MM-DD HH:mm:ss'), + address: record.address || '', + longitude: record.longitude || '', + latitude: record.latitude || '', + weight: record.weight || '', + volume: record.volume || '', + quantity: record.quantity || '', + remark: record.remark || '', + photos: (record.photos || []) + .filter(photo => photo?.url) + .map(photo => ({ url: photo.url, type: photo.voucherType || photo.type || '' })), + }; + this.waybillPunchAddressMapVisible = false; + this.waybillPunchRecordEditDialog.visible = true; + }, + // 地图选点回填:地址与经纬度必须成套,只回填地址会让坐标停留在旧点上 + handleWaybillPunchAddressMapConfirm(payload) { + const selection = typeof payload === 'string' ? { address: payload } : payload || {}; + if (selection.address) { + this.waybillPunchEditForm.address = selection.address; + } + if ( + selection.longitude !== undefined && + selection.longitude !== null && + selection.longitude !== '' + ) { + this.waybillPunchEditForm.longitude = Number(selection.longitude).toFixed(6); + } + if ( + selection.latitude !== undefined && + selection.latitude !== null && + selection.latitude !== '' + ) { + this.waybillPunchEditForm.latitude = Number(selection.latitude).toFixed(6); + } + }, + beforeWaybillPunchPhotoUpload(file, type = '') { + const validType = ['image/jpeg', 'image/png', 'image/jpg', 'image/bmp'].includes(file.type); + if (!validType) { + this.$message.error('仅支持 JPG、PNG、BMP 图片'); + return false; + } + // 单张 20MB 与司机端一致:凭证要原图,5MB 会把手机拍的正常凭证挡在门外 + if (file.size / 1024 / 1024 >= 20) { + this.$message.error('图片大小不能超过 20MB'); + return false; + } + // 每个照片类型各自最多 9 张。界面已经不给满员的类型「添加」入口,这里再兜一层。 + // 张数按界面上的分组口径统计:没带类型的照片算进兜底类型「凭证」 + const limit = this.waybillPunchEditPhotoLimit; + if (limit > 0 && type) { + const used = (this.waybillPunchEditForm.photos || []).filter( + photo => (String(photo?.type || '').trim() || '凭证') === type + ).length; + if (used >= limit) { + this.$message.error(`${type}最多上传 ${limit} 张`); + return false; + } + } + return true; + }, + handleWaybillPunchPhotoSuccess(res, type = '') { + if (res.code !== 200 || !res.data) { + this.$message.error(res.msg || '上传失败'); + return; + } + const url = res.data.link || res.data.url || res.data.domain || ''; + if (!url) return; + // 照片落进点开的那一组并带上该类型的名字:类型写空,保存时会被按下标回推成别的类型 + const photos = [...this.waybillPunchEditForm.photos, { url, type }]; + // 在途打卡表只有一个照片字段(单张),多传的保存时会被丢掉,这里先只留一张 + this.waybillPunchEditForm.photos = + this.waybillPunchEditRecord.type === 'enroute' ? photos.slice(0, 1) : photos; + }, + handleWaybillPunchPhotoError() { + this.$message.error('上传失败'); + }, + removeWaybillPunchEditPhoto(index) { + const photos = [...this.waybillPunchEditForm.photos]; + photos.splice(index, 1); + this.waybillPunchEditForm.photos = photos; + }, + async submitWaybillPunchRecordEdit() { + await this.$refs.waybillPunchRecordEditFormRef.validate(); + const record = this.waybillPunchEditRecord; + const form = this.waybillPunchEditForm; + const submit = + typeof this.api?.savePunchRecord === 'function' ? this.api.savePunchRecord : savePunchRecord; + this.waybillPunchRecordEditDialog.submitting = true; + try { + await submit({ + waybillId: this.detailRow.id, + type: record.type === 'enroute' ? 'enroute' : 'node', + nodeCode: record.nodeCode || '', + nodeName: record.nodeName || '', + // 弹窗没有打卡状态开关:无论「补卡」还是「编辑打卡」,保存都落一条已打卡流水 + punched: true, + punchTime: form.punchTime, + address: form.address, + longitude: form.longitude, + latitude: form.latitude, + weight: form.weight, + volume: form.volume, + quantity: form.quantity, + remark: form.remark, + photos: form.photos.map(photo => ({ url: photo.url, type: photo.type })), + }); + this.$message.success('保存成功'); + this.waybillPunchRecordEditDialog.visible = false; + this.loadWaybillPunchRecords(); + } finally { + this.waybillPunchRecordEditDialog.submitting = false; + } + }, previewDriverUploadPhoto(photos, index) { const list = (photos || []).map(item => item?.url).filter(Boolean); if (!list.length) return; @@ -5203,12 +6385,14 @@ export default { } }, async renderWaybillLocateMap(locateInfo) { - const longitude = Number(locateInfo?.longitude); - const latitude = Number(locateInfo?.latitude); - if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { + const rawLongitude = Number(locateInfo?.longitude); + const rawLatitude = Number(locateInfo?.latitude); + if (!Number.isFinite(rawLongitude) || !Number.isFinite(rawLatitude)) { this.$message.warning('定位坐标无效,无法在地图上展示'); return; } + // LBS 返回 WGS-84,高德地图使用 GCJ-02,绘制前需转换 + const [longitude, latitude] = wgs84ToGcj02(rawLongitude, rawLatitude); try { await this.loadAmap(); await this.$nextTick(); @@ -5229,7 +6413,7 @@ export default { `
${locateInfo.vehicleNo || '车辆位置'}
`, locateInfo.locateTime ? `
时间:${locateInfo.locateTime}
` : '', locateInfo.address ? `
地址:${locateInfo.address}
` : '', - `
坐标:${longitude}, ${latitude}
`, + `
坐标:${rawLongitude}, ${rawLatitude}
`, ``, ].join(''); this.waybillLocateInfoWindow = new window.AMap.InfoWindow({ @@ -5256,9 +6440,17 @@ export default { } }, async renderWaybillTrackMap(points) { + // LBS 轨迹为 WGS-84,绘制到高德前转为 GCJ-02 const path = (points || []) - .map(item => [Number(item.longitude), Number(item.latitude)]) - .filter(item => Number.isFinite(item[0]) && Number.isFinite(item[1])); + .map(item => { + const longitude = Number(item.longitude); + const latitude = Number(item.latitude); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { + return null; + } + return wgs84ToGcj02(longitude, latitude); + }) + .filter(Boolean); if (!path.length) { this.$message.warning('轨迹坐标无效,无法在地图上展示'); return; @@ -5361,6 +6553,11 @@ export default { if (isAddMode) { delete submitRow.id; delete submitRow.waybillNo; + // 复制新建:业务状态固定为草稿,不沿用原运单状态 + const copyId = this.$route.query.copyId || ''; + if (copyId) { + submitRow.businessStatus = this.config.draftStatus || 'draft'; + } } const skipValidation = saveAsDraft && this.config.skipDraftValidation === true; if ( @@ -5382,14 +6579,16 @@ export default { if (this.config.enableAttachmentTable) { submitRow.attachmentsJson = JSON.stringify(this.attachmentRows); } + const isCopyAdd = Boolean(this.$route.query.copyId) && isAddMode; if (this.config.saveAsDraft && saveAsDraft) { submitRow[this.config.draftStatusProp || 'approvalStatus'] = this.config.draftStatus || 'draft'; } else if ( + !isCopyAdd && this.config.draftStatusProp === 'businessStatus' && submitRow.businessStatus === (this.config.draftStatus || 'draft') ) { - // 正式提交:清空草稿态,由后端按过程配置校正为待执行/进行中 + // 正式提交:清空草稿态,由后端按过程配置校正为待执行/进行中(复制新建除外,保持草稿) submitRow.businessStatus = ''; } delete submitRow.basicInfoTitle; @@ -5480,7 +6679,29 @@ export default { selected?.currency || this.form.settlementCurrency || 'RMB'; - this.taskFreightCurrency = this.normalizeFreightCurrency(value); + const nextCurrency = this.normalizeFreightCurrency(value); + const previousCurrency = this.taskFreightCurrency; + this.taskFreightCurrency = nextCurrency; + if (previousCurrency && previousCurrency !== nextCurrency) { + this.remapWaybillPriceUnits(nextCurrency); + } + }, + remapWaybillPriceUnits(currency) { + if (this.form.priceUnit) { + this.form.priceUnit = remapPriceUnitCurrency(this.form.priceUnit, currency); + } + (this.transportCargoRows || []).forEach(cargo => { + if (cargo.priceUnit) { + cargo.priceUnit = remapPriceUnitCurrency(cargo.priceUnit, currency); + } + }); + (this.taskFreightGroups || []).forEach(group => { + (group.rows || []).forEach(row => { + if (row.priceUnit) { + row.priceUnit = remapPriceUnitCurrency(row.priceUnit, currency); + } + }); + }); }, loadFreightCurrencyOptions() { if (this.freightCurrencyOptions.length || this.freightCurrencyLoading) { @@ -5630,12 +6851,18 @@ export default { } }, rowSave(row, done, loading) { - const submitRow = this.normalizeRow(row); + // 复制页提交走草稿保存,避免 submit 接口把 draft 校正为待执行/进行中 + const isCopySubmit = Boolean(this.$route.query.copyId); + const submitRow = this.normalizeRow(row, isCopySubmit); if (!submitRow) { this.stopSubmitLoading(loading); return; } - this.getSaveRequest()(submitRow).then( + const request = + isCopySubmit && typeof this.api.saveDraft === 'function' + ? this.api.saveDraft + : this.getSaveRequest(); + request(submitRow).then( res => { this.markWaybillListNeedRefresh(); this.onLoad(this.page); @@ -5764,11 +6991,14 @@ export default { if (type === 'add' && copyId) { this.api.getDetail(copyId).then(res => { const sourceData = res.data.data || {}; - // 清除不应该复制的字段(业务状态与原运单保持一致) + this.copySourceBusinessStatus = ''; + // 清除不应该复制的字段;复制新建固定为草稿 delete sourceData.id; delete sourceData.code; delete sourceData.waybillNo; delete sourceData.waybillStatus; + delete sourceData.businessStatus; + delete sourceData.businessStatusName; delete sourceData.driverAcceptStatus; delete sourceData.driverAcceptTime; delete sourceData.driverAcceptDriverId; @@ -5785,14 +7015,15 @@ export default { delete sourceData.updateUserName; this.applyFormDetail(sourceData); - // 标记为手工创建;业务状态沿用原运单 this.form.dataSource = '手工创建'; + this.form.businessStatus = this.config.draftStatus || 'draft'; done(); }); return; } if (type === 'add') { + this.copySourceBusinessStatus = ''; this.form = { ...(this.config.defaultForm || {}), }; @@ -5844,9 +7075,11 @@ export default { applyFormDetail(row) { this.suppressTransportTypeClear = true; this.form = { ...(row || {}) }; - ['quantity', 'mileage', 'unitPrice'].forEach(prop => { - if (Number(this.form[prop]) === -1) this.form[prop] = ''; - }); + ['quantity', 'mileage', 'unitPrice', 'otherFeeTotal', 'freight', 'freightTotal'].forEach( + prop => { + if (this.isSentinelMinusOne(this.form[prop])) this.form[prop] = ''; + } + ); if ([this.form.planId, this.form.planName].some(value => Number(value) === -1)) { this.form.planId = ''; this.form.planName = ''; @@ -6257,17 +7490,17 @@ export default { this.form.quantityUnit || taskInfo.quantityUnit || goodsInfo.quantityUnit || '吨'; const mileage = this.form.mileage || taskInfo.mileage || goodsInfo.mileage || ''; this.form.mileage = this.normalizeMileageValue(mileage); - this.form.unitPrice = this.form.unitPrice || taskInfo.unitPrice || goodsInfo.unitPrice || ''; + this.form.unitPrice = this.normalizeOptionalAmount( + this.form.unitPrice || taskInfo.unitPrice || goodsInfo.unitPrice || '' + ); ['quantity', 'mileage', 'unitPrice'].forEach(prop => { - if (Number(this.form[prop]) === -1) this.form[prop] = ''; + if (this.isSentinelMinusOne(this.form[prop])) this.form[prop] = ''; }); this.form.priceUnit = - this.form.priceUnit || taskInfo.priceUnit || goodsInfo.priceUnit || '元/吨'; - this.form.otherFeeTotal = - this.form.otherFeeTotal || taskInfo.otherFeeTotal || goodsInfo.otherFeeTotal || ''; - if (Number(this.form.otherFeeTotal) === -1) { - this.form.otherFeeTotal = ''; - } + this.form.priceUnit || taskInfo.priceUnit || goodsInfo.priceUnit || this.defaultPriceUnit; + this.form.otherFeeTotal = this.normalizeOptionalAmount( + this.form.otherFeeTotal || taskInfo.otherFeeTotal || goodsInfo.otherFeeTotal || '' + ); this.form.estimatedStartTime = this.form.estimatedStartTime || taskInfo.estimatedStartTime || @@ -6349,7 +7582,7 @@ export default { }, taskFullFreightGroupUnitPrice(group = {}) { const prices = (group.rows || []) - .map(cargo => String(cargo.unitPrice ?? '').trim()) + .map(cargo => this.normalizeOptionalAmount(cargo.unitPrice)) .filter(Boolean); if (!prices.length || prices.some(price => price !== prices[0])) return ''; return prices[0]; @@ -6412,10 +7645,21 @@ export default { }, formatTaskFullFreightNumber(value) { if (value === undefined || value === null || String(value).trim() === '') return ''; - const number = Number(value || 0); + if (this.isSentinelMinusOne(value)) return ''; + const number = Number(value); if (!Number.isFinite(number)) return ''; return Number(number.toFixed(2)).toString(); }, + isSentinelMinusOne(value) { + if (value === undefined || value === null || value === '') return false; + return Number(value) === -1 || String(value).trim() === '-1'; + }, + normalizeOptionalAmount(value) { + if (value === undefined || value === null || value === '' || this.isSentinelMinusOne(value)) { + return ''; + } + return value; + }, handleTaskCarrierTypeChange(value) { const nextValue = value || '承运商'; this.form.carrierName = ''; @@ -6966,9 +8210,11 @@ export default { if (index === 0 && !cargo.unitPrice && this.form.unitPrice) { cargo.unitPrice = this.form.unitPrice; } - cargo.priceUnit = cargo.priceUnit || this.form.priceUnit || '元/吨'; + cargo.priceUnit = cargo.priceUnit || this.form.priceUnit || this.defaultPriceUnit; if (this.isBillingFieldEmpty(cargo.freightAmount)) { - cargo.freightAmount = freightItems[index]?.freightAmount ?? ''; + cargo.freightAmount = this.normalizeOptionalAmount( + freightItems[index]?.freightAmount ?? '' + ); } return cargo; }); @@ -6983,9 +8229,10 @@ export default { return Promise.resolve(this.taskQuantityUnitOptions); } this.taskQuantityUnitLoading = true; - return getDictionary({ code: 'quantity_unit' }) - .then(res => { - this.taskQuantityUnitOptions = this.normalizeDictOptions(res.data.data || []); + return loadMeasurementUnitOptions() + .then(options => { + this.taskQuantityUnitOptions = options; + this.quantityUnitOptions = options.map(item => item.value); return this.taskQuantityUnitOptions; }) .finally(() => { @@ -7159,8 +8406,10 @@ export default { }, syncTaskInfoToRow(row) { row.taskEntryMode = row.taskEntryMode || 'simple'; - ['quantity', 'mileage', 'unitPrice'].forEach(prop => { - if (this.isBillingFieldEmpty(row[prop]) || Number(row[prop]) === -1) row[prop] = ''; + ['quantity', 'mileage', 'unitPrice', 'otherFeeTotal'].forEach(prop => { + if (this.isBillingFieldEmpty(row[prop]) || this.isSentinelMinusOne(row[prop])) { + row[prop] = ''; + } }); const taskInfo = { taskEntryMode: row.taskEntryMode, @@ -7185,7 +8434,7 @@ export default { priceUnit: row.priceUnit, taskRemark: row.taskRemark, }; - if (!this.isBillingFieldEmpty(row.otherFeeTotal) && Number(row.otherFeeTotal) !== -1) { + if (!this.isBillingFieldEmpty(row.otherFeeTotal) && !this.isSentinelMinusOne(row.otherFeeTotal)) { taskInfo.otherFeeTotal = row.otherFeeTotal; } else { delete row.otherFeeTotal; @@ -7319,10 +8568,12 @@ export default { cargoName: row.cargoName || row.goodsName || '', packageType: row.packageType || row.package || '', specification: row.specification || row.spec || '', - freightAmount: row.freightAmount ?? row.amount ?? row.totalAmount ?? '', + freightAmount: this.normalizeOptionalAmount( + row.freightAmount ?? row.amount ?? row.totalAmount ?? '' + ), }; - ['quantity', 'mileage', 'unitPrice'].forEach(prop => { - if (Number(cargo[prop]) === -1) cargo[prop] = ''; + ['quantity', 'mileage', 'unitPrice', 'freightAmount'].forEach(prop => { + if (this.isSentinelMinusOne(cargo[prop])) cargo[prop] = ''; }); return cargo; }, @@ -7341,7 +8592,7 @@ export default { }, addTransportCargoRow(index = -1, row = {}) { const nextRow = this.normalizeTransportCargoRow({ - priceUnit: this.form.priceUnit || '元/吨', + priceUnit: this.form.priceUnit || this.defaultPriceUnit, ...row, }); if ( @@ -7388,12 +8639,9 @@ export default { this.syncTransportCargoJson(); }, resolvePriceUnitByQuantityUnit(quantityUnit) { - return ( - { - 吨: '元/吨', - 方: '元/立方', - 件: '元/件', - }[String(quantityUnit || '').trim()] || '' + return resolvePriceUnitByQuantityUnit( + quantityUnit, + this.taskFreightCurrency || this.form.settlementCurrency || 'RMB' ); }, handleTransportCargoQuantityUnitChange(quantityUnit) { @@ -7565,7 +8813,12 @@ export default { } }, isBillingFieldEmpty(value) { - return value === undefined || value === null || String(value).trim() === ''; + return ( + value === undefined || + value === null || + String(value).trim() === '' || + this.isSentinelMinusOne(value) + ); }, ensureTransportTypeBeforeAddress() { if (!this.shippingInfoFormEnabled || !this.isBillingFieldEmpty(this.form.transportType)) { @@ -10044,6 +11297,81 @@ export default { padding-top: 12px; } + &__exception-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + &__exception-dialog { + padding: 4px 0 0; + + :deep(.section-card + .section-card) { + margin-top: 16px; + } + } + + &__exception-readonly { + color: #606266; + word-break: break-all; + } + + &__exception-attachment-link { + display: block; + margin-bottom: 2px; + } + + &__exception-photos { + display: flex; + flex-wrap: wrap; + gap: 12px; + min-height: 80px; + + :deep(.el-image) { + width: 96px; + height: 96px; + border: 1px solid #eff1f7; + } + } + + &__exception-edit-photos { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + } + + &__exception-edit-photo { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + + :deep(.el-image) { + width: 96px; + height: 96px; + border: 1px solid #eff1f7; + } + } + + &__exception-edit-photo-add { + display: flex; + align-items: center; + justify-content: center; + width: 96px; + height: 96px; + border: 1px dashed #dcdfe6; + border-radius: 4px; + color: #909399; + cursor: pointer; + + &:hover { + border-color: #409eff; + color: #409eff; + } + } + &__waybill-detail-summary { :deep(.el-card__body) { padding: 18px 22px; @@ -10385,6 +11713,29 @@ export default { color: #303133; } + // 异常标签与文本链接紧挨在节点名右边,不推到行尾 + &__punch-record-exception-link { + font-size: 13px; + font-weight: 400; + } + + // 编辑入口推到行尾:它是整条记录的操作,与节点名旁的异常链接区分开 + &__punch-record-edit-link { + margin-left: auto; + font-size: 13px; + font-weight: 400; + } + + &__punch-edit-readonly { + color: #303133; + } + + &__punch-edit-photo-tip { + margin-top: 4px; + font-size: 12px; + color: #909399; + } + &__punch-record-line { font-size: 13px; color: #606266; @@ -10394,7 +11745,6 @@ export default { } } - &__punch-record-photos, &__driver-upload-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -10541,7 +11891,65 @@ export default { } &__route-change-record-content { - white-space: pre-wrap; + display: flex; + flex-direction: column; + gap: 10px; + line-height: 22px; + white-space: normal; + text-align: left; + } + + &__route-change-group { + padding-left: 8px; + border-left: 2px solid #409eff; + } + + &__route-change-group-title { + margin-bottom: 4px; + color: #409eff; + font-weight: 500; + } + + &__route-change-group-text { + color: #606266; + word-break: break-word; + overflow-wrap: anywhere; + } + + &__route-change-field { + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + column-gap: 8px; + row-gap: 2px; + } + + &__route-change-field-label { + color: #909399; + white-space: nowrap; + } + + &__route-change-before, + &__route-change-single { + color: #606266; + word-break: break-word; + overflow-wrap: anywhere; + } + + &__route-change-arrow { + color: #c0c4cc; + text-align: right; + } + + &__route-change-after { + color: #409eff; + word-break: break-word; + overflow-wrap: anywhere; + } + + &__route-change-flag { + margin-left: 6px; + color: #c0c4cc; + font-size: 12px; } &__common-address-toolbar { @@ -10801,7 +12209,7 @@ export default { } :global(.waybill-manage-page__mileage-dialog .waybill-manage-page__mileage-form .el-form-item) { - margin-bottom: 16px; + margin-bottom: 24px; } :global(.waybill-manage-page__mileage-dialog .waybill-manage-page__mileage-form .el-input), diff --git a/src/views/business/contract-manage-change.vue b/src/views/business/contract-manage-change.vue index 6757482..016efb8 100644 --- a/src/views/business/contract-manage-change.vue +++ b/src/views/business/contract-manage-change.vue @@ -45,7 +45,7 @@ - + @@ -63,6 +63,15 @@ disabled /> + + + - + + + + 添加 - + { const number = Number(value); return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : null; }; +const formatContractAmountInput = (value, emptyValue = null) => { + if ( + value === undefined || + value === null || + value === '' || + Number(value) === -1 || + String(value) === '-1' + ) { + return emptyValue; + } + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number.toFixed(2) : emptyValue; +}; const normalizeCustomPeriods = (periods = []) => @@ -424,6 +466,21 @@ export default { ], rules: { contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }], + contractAmount: [ + { required: true, message: '请输入合同金额', trigger: 'blur' }, + { + validator: (rule, value, callback) => { + if (value === undefined || value === null || value === '') { + return callback(new Error('请输入合同金额')); + } + if (Number(value) < 0) { + return callback(new Error('合同金额不能小于0')); + } + callback(); + }, + trigger: 'blur', + }, + ], changeReason: [{ max: 500, message: '变更原因不能超过500个字符', trigger: 'blur' }], }, }; @@ -433,7 +490,7 @@ export default { return this.$route.query.name || '合同变更'; }, changeAttachmentLocationOptions() { - return ['合同文件', '其它附件', '变更材料']; + return ['用印材料', '附件(佐证材料等)', '变更材料']; }, contractAmountDisplay() { const value = this.form.contractAmount; @@ -498,6 +555,9 @@ export default { parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0]; this.form.contractAmount = normalized === '' ? null : normalized; }, + formatContractAmountField() { + this.form.contractAmount = formatContractAmountInput(this.form.contractAmount); + }, loadDictionaries() { Promise.all([ getSystemDictionary({ code: 'currency_type' }), @@ -540,7 +600,7 @@ export default { copyCount: normalizeOptionalPositiveInteger(data.copyCount), invoiceCycle: normalizeOptionalPositiveInteger(data.invoiceCycle), paymentDays: normalizeOptionalPositiveInteger(data.paymentDays), - contractAmount: normalizeOptionalAmount(data.contractAmount), + contractAmount: formatContractAmountInput(data.contractAmount), templateFlag: data.templateFlag ?? 0, electronicSealFlag: data.electronicSealFlag ?? 0, archiveStatus: data.archiveStatus || '未归档', @@ -601,7 +661,7 @@ export default { return []; } }, - // 审核通过后上一次的变更材料归集到「其它附件」,按文件地址/文件名去重,避免重复展示 + // 审核通过后上一次的变更材料归集到「附件(佐证材料等)」,按文件地址/文件名去重,避免重复展示 mergeChangeAttachments(attachments = [], changeAttachments = []) { const key = item => this.attachmentUrl(item) || this.attachmentName(item); const exists = new Set(attachments.map(key)); @@ -655,11 +715,11 @@ export default { previewAttachment(row, rows = this.attachments) { const url = this.attachmentUrl(row); if (!url) { this.$message.warning('附件地址为空,无法预览'); return; } if (this.isAttachmentImage(row)) { this.imagePreviewUrls = (rows || []).filter(item => this.isAttachmentImage(item) && this.attachmentUrl(item)).map(item => this.attachmentUrl(item)); this.imagePreviewIndex = Math.max(this.imagePreviewUrls.indexOf(url), 0); this.imagePreviewVisible = true; return; } this.previewFile = { name: this.attachmentName(row), url, mimeType: row.mimeType || row.contentType || '' }; this.documentPreviewVisible = true; }, handleAttachmentUploadToLocation({ location, rows = [] }) { if (!rows.length) return; - if (location === '合同文件') { + if (isContractSealMaterialLocation(location)) { this.contractFileRows = [...(this.contractFileRows || []), ...rows]; return; } - if (location === '其它附件') { + if (isContractSupportingAttachmentLocation(location)) { this.attachments = [...(this.attachments || []), ...rows]; return; } @@ -692,11 +752,18 @@ export default { .contract-basic-section > .dialog-section-title { margin-bottom: 20px; color: #303133; } .contract-basic-section__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 48px; } .contract-basic-section__change-type { grid-column: 1 / -1; } -.contract-basic-section__remark { width: 100%; margin-bottom: 0 !important; } +.contract-basic-section__remark { + width: 100%; + margin-bottom: 23px !important; + + &:last-child { + margin-bottom: 0 !important; + } +} .contract-basic-section__date-range { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; width: 360px; max-width: 100%; } .contract-basic-section__date-range span { color: #606266; } .contract-basic-section__date-range :deep(.el-date-editor) { width: auto; min-width: 0; } -.contract-basic-section :deep(.el-form-item) { align-items: center; margin-bottom: 16px; } +.contract-basic-section :deep(.el-form-item) { align-items: center; } .contract-basic-section :deep(.el-form-item__label) { flex: 0 0 96px !important; width: 96px !important; height: auto; padding-right: 12px; white-space: normal; word-break: break-all; line-height: 18px; } .contract-basic-section :deep(.el-form-item__content) { min-width: 0; } .contract-basic-section :deep(.el-input), diff --git a/src/views/business/contract-manage.vue b/src/views/business/contract-manage.vue index 4d20756..e6b5df1 100644 --- a/src/views/business/contract-manage.vue +++ b/src/views/business/contract-manage.vue @@ -138,7 +138,7 @@ {{ detailValue('partyA') }} {{ detailValue('partyB') }} {{ detailContractPeriod }} - {{ + {{ detailValue('organizationName') }} {{ @@ -150,6 +150,9 @@ {{ detailValue('signDate') }} + {{ + detailValue('applyDate') + }} {{ detailValue('contractFormat') }} @@ -181,7 +184,10 @@ {{ detailElectronicSealText }} - {{ + {{ + detailValue('mainContent') + }} + {{ detailValue('remark') }} @@ -197,7 +203,7 @@ /> @@ -436,7 +448,7 @@ /> - + + + +
- + @@ -578,7 +601,7 @@ > - + @@ -597,14 +620,24 @@
- + + + + @@ -615,7 +648,7 @@ @@ -1046,12 +1082,15 @@ import { getDictionary as getBizDictionary } from '@/api/system/dictbiz'; import { config, option } from '@/option/business/contract-manage'; import { getToken, isAdministrator } from '@/utils/auth'; import { downloadFileByUrl, downloadXls } from '@/utils/util'; -import { submitMkApprovalFlow } from '@/utils/mk-approval'; +import { getMKApprovalUrl } from '@/api/system/business-process'; +import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval'; import BillingPlanEditor from './components/billing-plan-editor.vue'; import ChangeRecordDetailDialog from '@/components/change-record-detail-dialog/main.vue'; import ContractAttachmentSection, { attachmentName as sharedAttachmentName, attachmentUrl as sharedAttachmentUrl, + isContractSealMaterialLocation, + isContractSupportingAttachmentLocation, normalizeContractFileRows, } from './components/contract-attachment-section.vue'; import HcsContractPanel from './components/hcs-contract-panel.vue'; @@ -1088,6 +1127,19 @@ const normalizeOptionalAmount = (value, emptyValue = null) => { const number = Number(value); return Number.isFinite(number) && number >= 0 ? Number(number.toFixed(2)) : emptyValue; }; +const formatContractAmountInput = (value, emptyValue = null) => { + if ( + value === undefined || + value === null || + value === '' || + Number(value) === -1 || + String(value) === '-1' + ) { + return emptyValue; + } + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number.toFixed(2) : emptyValue; +}; const normalizeProjectValue = value => { if (value === undefined || value === null || value === '' || String(value) === '-1') return ''; return value; @@ -1120,8 +1172,9 @@ const defaultForm = () => ({ partyB: '', handlerUserName: '', signDate: '', + applyDate: '', contractFormat: '', - legalSealFlag: 0, + legalSealFlag: null, copyCount: '', settlementCurrency: '', invoiceCycle: '', @@ -1137,6 +1190,7 @@ const defaultForm = () => ({ effectiveType: '', feeGenerationMode: 'system', archiveStatus: '未归档', + mainContent: '', remark: '', }); // 复制新增:仅保留可编辑字段与明细,剔除主键、编号、审批与流程信息 @@ -1148,6 +1202,7 @@ const createCopiedDetail = (detail = {}) => { return { ...form, contractNo: '', + applyDate: '', archiveStatus: '未归档', feeGenerationMode: detail.feeGenerationMode || '', billingEnabled: detail.billingEnabled, @@ -1243,13 +1298,12 @@ export default { signType: [{ required: true, message: '请选择签约类型', trigger: 'change' }], settlementCurrency: [{ required: true, message: '请选择结算币种', trigger: 'change' }], projectName: [{ required: true, message: '请选择所属项目', trigger: 'change' }], - organizationName: [{ required: true, message: '请选择所属组织', trigger: 'change' }], + organizationName: [{ required: true, message: '请选择合同负责部门', trigger: 'change' }], partyA: [{ required: true, message: '请选择甲方', trigger: 'change' }], partyB: [{ required: true, message: '请选择乙方', trigger: 'change' }], signDate: [{ required: true, message: '请选择签订日期', trigger: 'change' }], settlementMode: [{ required: true, message: '请选择结算方式', trigger: 'change' }], contractFormat: [{ required: true, message: '请选择合同格式', trigger: 'change' }], - legalSealFlag: [{ required: true, message: '请选择是否需要加盖法人章', trigger: 'change' }], invoiceCycle: [ { validator: (rule, value, callback) => { @@ -1264,6 +1318,22 @@ export default { ], startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }], endDate: [{ required: true, message: '请选择结束日期', trigger: 'change' }], + contractAmount: [ + { required: true, message: '请输入合同金额', trigger: 'blur' }, + { + validator: (rule, value, callback) => { + if (value === undefined || value === null || value === '') { + return callback(new Error('请输入合同金额')); + } + if (Number(value) < 0) { + return callback(new Error('合同金额不能小于0')); + } + callback(); + }, + trigger: 'blur', + }, + ], + mainContent: [{ max: 1000, message: '合同主要内容不能超过1000个字符', trigger: 'blur' }], }, submitAction: '', projectOptions: [], @@ -1335,6 +1405,7 @@ export default { flowBox: false, flowUrl: '', processInstanceId: '', + mkEnabled: false, // 页面形态在实例创建时锁定,避免 keep-alive 缓存后随全局 $route 翻转 formPageLocked: false, detailPageLocked: false, @@ -1377,8 +1448,8 @@ export default { return this.$route.query.attachmentUpload === '1'; }, contractAttachmentLocationOptions() { - if (this.isAttachmentUploadMode) return ['合同文件']; - return ['合同文件', '其它附件']; + if (this.isAttachmentUploadMode) return ['用印材料']; + return ['用印材料', '附件(佐证材料等)']; }, contractAmountDisplay() { const value = this.form.contractAmount; @@ -1445,7 +1516,7 @@ export default { expandTrigger: 'click', }; }, - // 所属组织级联:selectedOrganizationId 存部门 id,级联控件用根到节点的 id 路径, + // 合同负责部门级联:selectedOrganizationId 存部门 id,级联控件用根到节点的 id 路径, // 两者在此双向转换;依赖 organizationOptions,树加载完成后回显自动刷新 organizationCascaderValue: { get() { @@ -1580,6 +1651,13 @@ export default { this.loadProjectOptions(); this.loadSettlementDictionaries(); this.loadOrganizationOptions(); + loadMkEnabled() + .then(enabled => { + this.mkEnabled = !!enabled; + }) + .catch(() => { + this.mkEnabled = false; + }); } if (this.isFormPage) this.initFormPage(); if (this.isDetailPage) this.initDetailPage(); @@ -1828,7 +1906,7 @@ export default { copyCount: normalizeOptionalInteger(detail.copyCount, ''), invoiceCycle: normalizeOptionalInteger(detail.invoiceCycle, ''), paymentDays: normalizeOptionalInteger(detail.paymentDays, ''), - contractAmount: normalizeOptionalAmount(detail.contractAmount, null), + contractAmount: formatContractAmountInput(detail.contractAmount, null), archiveStatus: detail.archiveStatus || '未归档', feeGenerationMode: detail.feeGenerationMode || (Number(detail.billingEnabled) === 0 ? 'manual' : 'system'), @@ -1864,6 +1942,7 @@ export default { this.applyDetail(createCopiedDetail(detail)); this.form.handlerUserName = this.userInfo?.realName || this.userInfo?.userName || this.userInfo?.account || ''; + this.refreshPreviewContractNo(); this.$nextTick(() => this.$refs.contractForm?.clearValidate()); }, closeForm() { @@ -1878,9 +1957,10 @@ export default { ['contractName', '合同名称'], ['contractCategory', '合同类型'], ['projectName', '所属项目'], - ['organizationName', '所属组织'], + ['organizationName', '合同负责部门'], ['partyA', '甲方'], ['partyB', '乙方'], + ['contractAmount', '合同金额'], ]; const empty = required.find(([prop]) => { const value = this.form[prop]; @@ -1888,10 +1968,16 @@ export default { }); if (empty) { this.$message.warning( - empty[0] === 'contractName' ? `请输入${empty[1]}` : `请选择${empty[1]}` + ['contractName', 'contractAmount'].includes(empty[0]) + ? `请输入${empty[1]}` + : `请选择${empty[1]}` ); return false; } + if (Number(this.form.contractAmount) < 0) { + this.$message.warning('合同金额不能小于0'); + return false; + } return true; } if (!this.form.contractName?.trim()) { @@ -1914,7 +2000,7 @@ export default { if (action !== 'formal' && action !== 'edit') return true; const required = [ ['projectName', '所属项目'], - ['organizationName', '所属组织'], + ['organizationName', '合同负责部门'], ['partyA', '甲方'], ['partyB', '乙方'], ['startDate', '合同开始日期'], @@ -1922,10 +2008,20 @@ export default { ['signDate', '签订日期'], ['settlementMode', '结算方式'], ['contractFormat', '合同格式'], + ['contractAmount', '合同金额'], ]; - const empty = required.find(([prop]) => !this.form[prop]); + const empty = required.find(([prop]) => { + const value = this.form[prop]; + return value === undefined || value === null || String(value).trim() === ''; + }); if (empty) { - this.$message.warning(`请选择或输入${empty[1]}`); + this.$message.warning( + empty[0] === 'contractAmount' ? `请输入${empty[1]}` : `请选择或输入${empty[1]}` + ); + return false; + } + if (Number(this.form.contractAmount) < 0) { + this.$message.warning('合同金额不能小于0'); return false; } return ( @@ -2115,11 +2211,11 @@ export default { }, handleListAttachmentUploadToLocation({ location, rows = [] }) { if (!rows.length) return; - if (location === '合同文件') { + if (isContractSealMaterialLocation(location)) { this.listUploadContractFileRows = [...(this.listUploadContractFileRows || []), ...rows]; return; } - if (location === '其它附件') { + if (isContractSupportingAttachmentLocation(location)) { this.listUploadAttachmentRows = [...(this.listUploadAttachmentRows || []), ...rows]; } }, @@ -2277,6 +2373,9 @@ export default { column.renderSearch = scope => this.renderOrganizationSearch(scope); } this.syncCurrentOrganizationOption(); + if (this.isFormPage && this.formMode === 'add' && this.form.organizationId) { + this.refreshPreviewContractNo(); + } }) .finally(() => { this.organizationLoading = false; @@ -2339,6 +2438,29 @@ export default { } : { organizationId: '', organizationName: '' } ); + if (this.formMode === 'add') { + this.refreshPreviewContractNo(); + } + }, + refreshPreviewContractNo() { + if (this.formMode !== 'add') return; + const organizationId = this.form.organizationId; + if (!organizationId) { + this.form.contractNo = ''; + return; + } + const requestId = (this.contractNoPreviewRequestId = + (this.contractNoPreviewRequestId || 0) + 1); + api + .nextContractNo(organizationId) + .then(res => { + if (requestId !== this.contractNoPreviewRequestId) return; + this.form.contractNo = res.data?.data || ''; + }) + .catch(() => { + if (requestId !== this.contractNoPreviewRequestId) return; + this.form.contractNo = ''; + }); }, syncCurrentOrganizationOption() { if (!this.form.organizationId || !this.form.organizationName) return; @@ -2352,7 +2474,7 @@ export default { this.organizationFlatOptions.unshift(item); }, renderOrganizationSearch(scope) { - // 搜索区所属组织:单选级联(与新增/编辑表单一致) + // 搜索区合同负责部门:单选级联(与新增/编辑表单一致) // 级联返回 id 路径数组,提交时由 normalizeSearch 取末级 id 转组织名称 return h(ElCascader, { modelValue: Array.isArray(scope.row?.organizationName) ? scope.row.organizationName : [], @@ -2371,7 +2493,7 @@ export default { filterable: true, clearable: true, style: 'width: 100%', - placeholder: '请选择 所属组织', + placeholder: '请选择 合同负责部门', }); }, integerInput(prop, value) { @@ -2384,6 +2506,9 @@ export default { parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0]; this.form.contractAmount = normalized === '' ? null : normalized; }, + formatContractAmountField() { + this.form.contractAmount = formatContractAmountInput(this.form.contractAmount, null); + }, positiveIntegerInput(prop, value) { const normalized = String(value || '').replace(/\D/g, '').replace(/^0+/, ''); this.form[prop] = normalized; @@ -2416,11 +2541,11 @@ export default { }, handleAttachmentUploadToLocation({ location, rows = [] }) { if (!rows.length) return; - if (location === '合同文件') { + if (isContractSealMaterialLocation(location)) { this.contractFileRows = [...(this.contractFileRows || []), ...rows]; return; } - if (location === '其它附件') { + if (isContractSupportingAttachmentLocation(location)) { this.attachmentRows = [...(this.attachmentRows || []), ...rows]; } }, @@ -2781,15 +2906,16 @@ export default { templateFlag: '是否范本', originalContractNo: '原件合同编号', electronicSealFlag: '是否电子章', - remark: '备注', + mainContent: '合同主要内容', + remark: '其他事项说明', feeGenerationMode: '费用生成模式', billingPlanJson: '计费方案', settlementRuleJson: '结算单规则', preSettlementConfigJson: '预结算配置', formalSettlementConfigJson: '正式结算配置', paymentRatioJson: '付款比例设置', - contractFileJson: '合同文件', - attachmentsJson: '其它附件', + contractFileJson: '用印材料', + attachmentsJson: '附件(佐证材料等)', changeAttachmentsJson: '变更材料', }; return labels[field] || field; @@ -2956,13 +3082,8 @@ export default { detailValue(prop) { const value = this.detailRow[prop]; if (prop === 'contractAmount') { - return value === undefined || - value === null || - value === '' || - Number(value) === -1 || - String(value) === '-1' - ? '-' - : value; + const amountText = formatContractAmountInput(value, ''); + return amountText === '' ? '-' : amountText; } if ( prop === 'contractStage' || @@ -2994,6 +3115,10 @@ export default { return; } if (operation.action === 'flow') { + if (this.mkEnabled) { + this.handleMkAudit(row); + return; + } this.openFlow(row); return; } @@ -3047,6 +3172,24 @@ export default { query: { mode: 'add', copyId: row.id, name: '新增合同管理' }, }); }, + handleMkAudit(row) { + if (!row?.id) { + this.$message.warning('合同ID为空,无法打开审核页'); + return; + } + getMKApprovalUrl({ bizId: String(row.id) }) + .then(res => { + const url = res?.data?.data; + if (!url) { + this.$message.error('获取审核跳转地址失败'); + return; + } + window.open(url, '_blank'); + }) + .catch(() => { + this.$message.error('获取审核跳转地址失败'); + }); + }, openFlow(row, loaded = false) { const processInstanceId = row.processInstanceId || row.processInstanceID || row.procInstId; if (!processInstanceId && !loaded && row.id) { @@ -3276,7 +3419,11 @@ export default { &__remark { width: 100%; - margin-bottom: 0 !important; + margin-bottom: 24px !important; + + &:last-child { + margin-bottom: 0 !important; + } } &__date-range { @@ -3350,7 +3497,7 @@ export default { :deep(.el-form-item) { align-items: center; - margin-bottom: 14px; + margin-bottom: 24px; } :deep(.el-form-item__label) { diff --git a/src/views/business/loading-manage.vue b/src/views/business/loading-manage.vue index 363f972..aac814e 100644 --- a/src/views/business/loading-manage.vue +++ b/src/views/business/loading-manage.vue @@ -1,6 +1,6 @@