Merge branch 'master' into feature/bfi-api-rate

# Conflicts:
#	src/components/vehicle-attachment-table/main.vue
#	src/option/payment/paymentApplication.js
#	src/views/payment/payment-application.vue
#	src/views/payment/receipt-claim-record.vue
#	src/views/vehicle/customer-archive.vue
This commit is contained in:
刘泉佳
2026-10-14 14:41:40 +08:00
115 changed files with 10016 additions and 2484 deletions
+18
View File
@@ -0,0 +1,18 @@
# 2026-10-13
## 车辆调度弹窗:使用部门级联选择器宽度对齐
- 现象:`/transportCapacity/vehicle-dispatch` 新增/编辑弹窗中,使用部门 `el-cascader` 明显窄于同行车牌号 `el-input`。
- 根因:`el-cascader` 是 `display: inline-block` 宽度按内容收缩(不像 `el-autocomplete` / `el-input` 有 `--el-input-width: 100%` 兜底);而页面里写的 `.vehicle-dispatch-page :deep(.vehicle-dispatch-form-control){width:100%}` 命中不到 —— avue-crud 弹窗默认 `append-to-body: true`(`append-to-body": validData(crud.tableOption.dialogAppendToBody, true)`),DOM Teleport 到 body,已不在 `.vehicle-dispatch-page` 之下。
- 修法(走项目既有约定):
1. `src/option/transportCapacity/vehicle-dispatch.js` 增加 `dialogCustomClass: 'vehicle-dispatch-dialog'`;
2. `src/styles/element-ui.scss` 增加全局规则 `.vehicle-dispatch-dialog .el-form-item__content > .el-cascader { width:100%; max-width:100% }`。
- 自检:dev(2889) `curl /src/styles/element-ui.scss` 与 `/src/option/transportCapacity/vehicle-dispatch.js` 均 200,编译产物含新规则。
- 遗留观察:同文件里 `.vehicle-dispatch-page :deep(.avue--detail ...)` 那一批查看态禁用样式,同样因 Teleport 属死规则(未改,待确认是否需要迁到 `.vehicle-dispatch-dialog` 作用域)。
## 追加修正(同日 20:xx):第一次改完用户反馈「还是没对齐」
- 原因一:选择器写成了 `.el-form-item__content > .el-cascader`(直接子级)。**avue 表单项在 `el-form-item__content` 与控件之间还有两层包裹 div**(`packages/element-plus/form/index3.js`:content > div > [div|elTooltip] > 控件),必须用后代选择器。已改成 `.vehicle-dispatch-dialog .el-form-item__content .el-cascader`。
- 原因二(更隐蔽):option 用 `labelWidth: 'auto'`,**EP 的 auto 是逐项计算**(实测:3 字 54/64px、2 字 40px、4 字无星号 68px、4 字带星号 78px),label 越宽控件可用宽度越小 → 车牌号 input 486px、使用部门 cascader 472px,仍差 14px。已加 `.vehicle-dispatch-dialog .el-form-item__label { flex: 0 0 calc(4em + 24px) !important; width: calc(4em + 24px) !important }`(本弹窗左右两列最长 label 均为 4 汉字)。
- 验证方式(可复用):临时在项目根建 `cascader-width-check.html`,Vite 直接服务 → `import './src/option/...'` 用真实 option + `import './src/styles/element-ui.scss'`,挂 avue-crud 并 `mounted(){ this.$refs.crud.rowAdd() }` 弹出真实弹窗;用 agent-browser(`/Users/gxwebsoft/.workbuddy/binaries/node/workspace/node_modules/.bin/agent-browser`)`open` + `eval` 读 DOM 链路与 `getBoundingClientRect`。修完已删除该临时文件。
- 结果:全部 label 80px,车牌号 input 与使用部门 cascader 均为 left=732 / width=470 / right=1202,完全一致。
+44
View File
@@ -12,8 +12,46 @@
**坑二:宿主 view 直绑全局 `$route.query.*` 给子组件 prop。** 缓存实例被灌入当前页 id → 拿错 id 查后端(批量弹「运单管理不存在」),live computed 还会误 push 标签。修法:`routePathLocked` 锁路径 + watcher / push 前判 `isOwnedRouteActive` + 宿主绑定加路径守卫。⚠️ `routePathLocked` **必须放 `data()`**(Options API 顺序 data → computed → watch(immediate) → created;放 created 会读到空值、挡掉首次加载)。已修 waybill-manage-page / transport-plan-page + 三个宿主 view。
**坑四:avue-crud 弹窗 `append-to-body` 默认 true(`dialog-form3.js`:`validData(tableOption.dialogAppendToBody, true)`),DOM Teleport 到 body。** 于是页面里以页面容器为祖先的 scoped 选择器(`.xxx-page :deep(.yyy)`)**命中不到弹窗内容**,尤其 `el-cascader`(inline-block、宽度按内容收缩,不像 `el-input`/`el-autocomplete` 有 `--el-input-width:100%` 兜底)会明显比同行 input 窄。修法:option 里配 `dialogCustomClass: 'xxx-dialog'`,规则写到 `src/styles/element-ui.scss` 的 `.xxx-dialog ...`(项目既有约定,10+ 处同款);或把基准类放在 **Teleport 内部元素**上(如 ship.vue 的 `.ship-form` 挂在 el-form 上,自身带 scope 属性,照样生效)。
**坑五:avue 表单项控件不是 `.el-form-item__content` 的直接子级。** `form/index3.js` 渲染为 `content > div > [div|elTooltip] > 控件`,所以 `.el-form-item__content > .el-cascader` 这类**直接子级选择器对 avue 表单全部失效**(手写 `<el-form-item>` 才生效),一律用后代选择器。
**坑六:`labelWidth: 'auto'` 在 Element Plus 里逐项计算,不是全表单共享。** 实测同一弹窗:3 字 54/64px、2 字 40px、4 字 68px、4 字带必填星号 78px;label 越宽 → 控件可用宽度越小,同级控件宽度就不一致(车牌号 input 486 vs 使用部门 cascader 472)。要让同一行控件等宽必须统一 label 宽度:`.xxx-dialog .el-form-item__label { flex: 0 0 calc(Nem + 24px) !important; width: calc(Nem + 24px) !important }`(N=本列最长汉字数,与 ship.vue、`.dialog-form--carded` 同款)。
**验证 avue 弹窗 DOM/宽度的可复用办法:** 项目根建临时 html(Vite 直接服务)→ 引真实 option 与 `src/styles/element-ui.scss`,挂 avue-crud 并 `mounted(){ this.$refs.crud.rowAdd() }` 弹出真弹窗;用 `agent-browser`(`/Users/gxwebsoft/.workbuddy/binaries/node/workspace/node_modules/.bin/agent-browser`)`open` + `eval` 读 DOM 链路与 `getBoundingClientRect`,用完删文件。
**坑三:路由表重复定义 → 内容区整页空白(2026-09-18)。** `/business/contract-manage/detail` 在 `router/views/index.js` 定义两次、子路由同名 `合同详情`。重名 `addRoute` 会 `removeRoute(name)` 移除旧子记录,但**不从父记录 children 里摘除**,第一条 Layout 壳记录留在 matcher 且靠前 → `resolve()` 命中它 → `$route.matched.length === 1` → 二级 router-view 无匹配 → 侧栏/标签正常但内容空白。修法:删重复定义。排查:扫「顶层 path + 首个子路由 name」重复项。
## 列表页右上角按钮组(table-header-tools 标准件)
⚠️ **2026-10-14 修正:最终方向是手写列表页改用 `avue-crud`**。用户要求与其它模块"完全一致",而 avue 的列显隐抽屉(`el-drawer`+表格,隐藏/冻结/过滤/排序,行拖拽)是 avue-crud 内部子组件(`inject: ['crud']`),脱离 avue-crud 拿不出来;第 4 键也是 avue 的"表格↔卡片视图"切换。所以 `table-header-tools` 只是过渡仿制件,**新页面不要再接**,直接写 avue-crud。
手写 `el-table` 列表页若暂时保留,右上角统一用 `src/components/table-header-tools/main.vue`,复刻 avue-crud 默认 4 键:刷新 `el-icon-refresh` / 列设置 `el-icon-operation` / 搜索显隐 `el-icon-search` / 表格尺寸 `el-icon-grid`(default→large→small 循环)。
- 接入范式:`<table-header-tools :columns="columns" v-model:visible-column-keys="visibleColumnKeys" v-model:table-size="tableSize" v-model:search-visible="searchVisible" @refresh="loadTable" />`;配套:搜索区 `v-if="searchVisible"`、el-table `:size="tableSize"`、列用 computed `visibleColumns = columns.filter(c => visibleColumnKeys.includes(c.prop))`。⚠️ 不能 `v-for` + `v-if` 同元素(Vue3 v-if 优先级更高,取不到 column 变量)。
- 表格在子组件时(如 `payment/components/bill-*-table.vue`):子组件加 props `searchVisible` + emits `refresh`/`update:searchVisible`,父页面 `v-model:search-visible` + `@refresh`,搜索子组件加 `v-if="searchVisible"`。
- `icon="el-icon-xxx"` 字符串**依赖 Avue 的全局 el-icon 映射**(`main.js` 只注册 EP 的 PascalCase 图标),临时验证页没 `app.use(Avue)` 会渲染成空圆按钮 —— 别误判成组件 bug。
- 组件已加 `deactivated()` 关列设置弹窗(防 keep-alive 下 Teleport 弹窗残留)。
- 已接入(过渡态,待逐页改 avue):business/{master-order,loading-manage,voucher-manage}、settlement、payment 模块手写页。
## avue-crud 列表页改造要点(payment 模块样板,2026-10-14)
- **列显隐抽屉**:点第 2 键弹出,标题"列显隐"(语言包 `crud.showTitle`),表头 列名/隐藏/冻结/过滤/排序。直接读写 avue crud 的列配置,页面无需实现。
- **第 4 键**:`handleGridShow()` = 表格 ↔ 卡片视图(`tableCard` 按列通用渲染),页面无需写卡片模板。
- `searchOrder` 是**降序**排序(值越大越靠前):`list.sort((a,b) => (b.searchOrder||0) - (a.searchOrder||0))`。首次改造踩过坑。
- `searchIndex: 4` + `searchIcon: true` → 默认只显示前 4 个搜索项 + "展开/收起"(columnLen > searchIndex 时才出现该按钮)。
- **搜索项 label 与表格列 label 不同**(列「收款方」/搜索「客户名称」):单独定义 hide 列 + `searchProp` 指向后端字段(avue 在 search-change 里自动改名)。
- 搜索专用列加 `hide: true` + `showColumn: false`,否则会混进列显隐抽屉。
- ⚠️ **column 数组顺序 = 表格列顺序**:搜索专用列别插在表格列中间(会把表格列顶到前面);deptName 这种"既搜索又展示"的列要放回表格列的正确位置。
- 分页:`page: { currentPage, pageSize, pageSizes, total }`;`on-load` 由 avue 内部(table-page)在 created / 翻页 / 改页容量时触发,`current-change`/`size-change` 里**只改 page 不要再调 onLoad**(否则双请求)。
- `onLoad(page)` 只用 `this.query`(avue 传的 page 对象含 total/pageSizes 等分页字段,别 spread 进查询参数)。
- 搜索参数转换放 `searchChange(params, done)`:日期区间数组拆成 `xxxStartDate/xxxEndDate`;所属组织级联用 `normalizeOrganizationSearch(params, 'deptName')`(见 `mixins/organization-search.js`,option 里该列加 `searchslot: true`)。
- 单元格空值统一 `-`:列级 `formatter: (row, value) => ...`;金额 `Number(v||0).toFixed(2)`;状态/链接用 `slot: true` + `#prop` 插槽。
- 操作列用 `#menu` 插槽(`menuWidth: 320`),`addBtn/viewBtn/delBtn/editBtn: false` 关掉 avue 自带按钮。
## 地址/行政区划展示(全站既有规则)
「省/市/区县」都要空格分段,但**省与自治区不重复展示**:`上海市浦东新区`→`上海市 浦东新区`,`广西壮族自治区南宁市邕宁区`→`南宁市 邕宁区`(同 `formatRoadLocation` / `formatRouteCityDistrict` / 总单列表 `formatRoadAddress` 一致)。
- 统一入口:`src/utils/region-address.js` 的 `formatRegionAddress(value)`(按后缀切分行政区划,`州+市`、`县+区` 连写合并;**只有整串都是行政区划**才重排,站点名「南宁站/广州南站」与含门牌的详细地址原样返回)。v-for 列表里用它做展示时,直接把函数挂进 `methods` 用 ES6 简写(`formatRegionAddress,`)。
- ⚠️ 路线节点的名称字段来自后端 `routeJson`:`routes[i].departureName` = 第 i 段**到货地/途经地**(不是本段发货地),末段 = 最终收货地;`master.departureName/arrivalName` 才是总发货/总收货。总单调度/详情都按「上一个节点的 departureName」拼分段起点。
## 样式硬规则
- 底栏 `flex; justify-content:flex-end`,**不写 gap**(靠 EP 默认 `.el-button+.el-button{margin-left:12px}`);浮动底栏必须 `position:fixed`(祖先 overflow:hidden 使 sticky 失效)+ `:global(.avue--collapse .x){left:60px}`、`:global(.avue-layout--horizontal .x){left:0}`,`:global()` 要包整个选择器。
- 按钮层级:次要=无 type;中间步骤=`primary plain`;主操作=`primary`(禁绿、禁两个蓝实心)。顺序 `[辅助][取消][保存草稿][提交]`。
@@ -25,3 +63,9 @@
- `business-crud-page.vue`:option 经 `cloneOption` 克隆,独立表单页走 `PageAvueForm`。
- 自检:`curl "localhost:2889/src/xxx.vue"` 与 `"...?vue&type=style&index=0&scoped=true&lang.scss"`(200 / 500)。`vite build` 在 `facelogin.vue` 被沙箱中断,不能作唯一校验。dev `VITE_APP_API=/api` 代理 `172.16.203.228:8000`。
- 导出:前端 `exportColumns` 后端未用,以 `XxxExportExcel.java` 为准;BladeX `BeanUtil` 类型不兼容静默跳过(`Date` → `LocalDateTime` 丢值,用 `DateUtil.fromDate`)。
## Element Plus 图标两种用法的机制差异(易误判)
`main.js` 里 `for (...ElementPlusIconsVue) app.component(key, component)` 全局注册了全部图标,因此模板里可以写标签形式 `<Location />`(靠全局注册解析)。
但 `:icon="OfficeBuilding"` 是**表达式**,编译成 `_ctx.OfficeBuilding`,只会从 data / props / computed / setup 返回值里取 —— **组件对象必须放进 `data()`**;放进 `components: {}` 注册里模板取不到,会静默渲染不出图标(不报错,只是空白)。
项目既有惯例:需要 `:icon` 的图标一律 `import` 后在 `data()` 里 `return { Xxx }`(transport-plan-page、waybill-manage-page、business-crud-page 都是这么写的)。
注:标签形式(如 `<Location />`)不依赖 data,放不放 data 都能显示;仅 `:icon` 必须放 data。
+41 -72
View File
@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
'@smallwei/avue': 5ba15ad41b2c301250bf24adf39da2ed526b8375143da2c5a495361b29e862db
importers:
.:
@@ -31,16 +28,16 @@ importers:
version: 1.7.0
'@smallwei/avue':
specifier: ~3.9.2
version: 3.9.2(patch_hash=5ba15ad41b2c301250bf24adf39da2ed526b8375143da2c5a495361b29e862db)(@element-plus/icons-vue@2.3.2(vue@3.5.34))(debug@4.4.3(supports-color@7.2.0))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)
version: 3.9.2(@element-plus/icons-vue@2.3.2(vue@3.5.34))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)
animate.css:
specifier: ^4.1.1
version: 4.1.1
avue-plugin-ueditor:
specifier: ^1.0.4
version: 1.0.4(axios@1.16.0(debug@4.4.3(supports-color@7.2.0)))(vue@3.5.34)
version: 1.0.4(axios@1.16.0)(vue@3.5.34)
axios:
specifier: ^1.13.5
version: 1.16.0(debug@4.4.3(supports-color@7.2.0))
version: 1.16.0
codemirror:
specifier: ^5.65.21
version: 5.65.21
@@ -128,7 +125,7 @@ importers:
version: 5.4.21(sass@1.99.0)(terser@5.46.2)
vite-plugin-compression:
specifier: ^0.5.1
version: 0.5.1(supports-color@7.2.0)(vite@5.4.21(sass@1.99.0)(terser@5.46.2))
version: 0.5.1(vite@5.4.21(sass@1.99.0)(terser@5.46.2))
vite-plugin-vue-setup-extend:
specifier: ^0.4.0
version: 0.4.0(vite@5.4.21(sass@1.99.0)(terser@5.46.2))
@@ -407,35 +404,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@1.0.2':
resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==}
@@ -511,42 +503,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -613,79 +599,66 @@ packages:
resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.3':
resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.3':
resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.3':
resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.3':
resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.3':
resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.3':
resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.3':
resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.3':
resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.3':
resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.3':
resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.3':
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.3':
resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.3':
resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==}
@@ -2654,10 +2627,10 @@ snapshots:
countup.js: 2.10.0
signature_pad: 5.1.3
'@smallwei/avue@3.9.2(patch_hash=5ba15ad41b2c301250bf24adf39da2ed526b8375143da2c5a495361b29e862db)(@element-plus/icons-vue@2.3.2(vue@3.5.34))(debug@4.4.3(supports-color@7.2.0))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)':
'@smallwei/avue@3.9.2(@element-plus/icons-vue@2.3.2(vue@3.5.34))(element-plus@2.14.2(vue@3.5.34))(vue@3.5.34)':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.34)
axios: 0.21.4(debug@4.4.3(supports-color@7.2.0))
axios: 0.21.4
countup.js: 1.9.3
dayjs: 1.11.20
element-plus: 2.14.2(vue@3.5.34)
@@ -2793,25 +2766,25 @@ snapshots:
dependencies:
vue: 3.5.34
'@wangeditor-next/basic-modules@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)':
'@wangeditor-next/basic-modules@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 3.0.0
is-url: 1.2.4
lodash.throttle: 4.1.1
nanoid: 5.1.11
slate: 0.123.0
nanoid: 3.3.12
slate: 0.72.8
snabbdom: 3.6.3
'@wangeditor-next/code-highlight@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)':
'@wangeditor-next/code-highlight@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)':
dependencies:
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 4.0.6
prismjs: 1.30.0
slate: 0.123.0
snabbdom: 3.6.3
'@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)':
'@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)':
dependencies:
'@types/event-emitter': 0.3.5
'@uppy/core': 2.3.4
@@ -2827,9 +2800,9 @@ snapshots:
lodash.foreach: 4.5.0
lodash.throttle: 4.1.1
lodash.toarray: 4.4.0
nanoid: 5.1.11
nanoid: 3.3.12
scroll-into-view-if-needed: 3.1.0
slate: 0.123.0
slate: 0.72.8
slate-history: 0.115.0(slate@0.123.0)
snabbdom: 3.6.3
@@ -2837,13 +2810,13 @@ snapshots:
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor-next/basic-modules': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/code-highlight': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/list-module': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/table-module': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.debounce@4.0.8)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/upload-image-module': 2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/basic-modules@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.foreach@4.5.0)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/video-module': 2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/basic-modules': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor-next/code-highlight': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor-next/list-module': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/table-module': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.debounce@4.0.8)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/upload-image-module': 2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/basic-modules@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.foreach@4.5.0)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/video-module': 2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
dom7: 4.0.6
is-hotkey: 0.2.0
lodash.camelcase: 4.3.0
@@ -2856,16 +2829,16 @@ snapshots:
slate: 0.123.0
snabbdom: 3.6.3
'@wangeditor-next/list-module@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)':
'@wangeditor-next/list-module@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(slate@0.123.0)(snabbdom@3.6.3)':
dependencies:
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 4.0.6
slate: 0.123.0
snabbdom: 3.6.3
'@wangeditor-next/table-module@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.debounce@4.0.8)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)':
'@wangeditor-next/table-module@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.debounce@4.0.8)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)':
dependencies:
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 4.0.6
lodash.debounce: 4.0.8
lodash.throttle: 4.1.1
@@ -2873,22 +2846,22 @@ snapshots:
slate: 0.123.0
snabbdom: 3.6.3
'@wangeditor-next/upload-image-module@2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/basic-modules@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.foreach@4.5.0)(slate@0.123.0)(snabbdom@3.6.3)':
'@wangeditor-next/upload-image-module@2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/basic-modules@2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(lodash.foreach@4.5.0)(slate@0.123.0)(snabbdom@3.6.3)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor-next/basic-modules': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/basic-modules': 2.0.0(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 4.0.6
lodash.foreach: 4.5.0
slate: 0.123.0
snabbdom: 3.6.3
'@wangeditor-next/video-module@2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)':
'@wangeditor-next/video-module@2.0.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor-next/core@1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@4.0.6)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3))(dom7@4.0.6)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@5.1.11)(slate@0.123.0)(snabbdom@3.6.3)
'@wangeditor-next/core': 1.8.0(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.12)(slate@0.72.8)(snabbdom@3.6.3)
dom7: 4.0.6
nanoid: 5.1.11
slate: 0.123.0
@@ -3065,22 +3038,22 @@ snapshots:
asynckit@0.4.0: {}
avue-plugin-ueditor@1.0.4(axios@1.16.0(debug@4.4.3(supports-color@7.2.0)))(vue@3.5.34):
avue-plugin-ueditor@1.0.4(axios@1.16.0)(vue@3.5.34):
dependencies:
'@wangeditor/editor': 5.1.23
'@wangeditor/editor-for-vue': 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.34)
axios: 1.16.0(debug@4.4.3(supports-color@7.2.0))
axios: 1.16.0
vue: 3.5.34
axios@0.21.4(debug@4.4.3(supports-color@7.2.0)):
axios@0.21.4:
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
follow-redirects: 1.16.0
transitivePeerDependencies:
- debug
axios@1.16.0(debug@4.4.3(supports-color@7.2.0)):
axios@1.16.0:
dependencies:
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
follow-redirects: 1.16.0
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@@ -3248,11 +3221,9 @@ snapshots:
dayjs@1.11.20: {}
debug@4.4.3(supports-color@7.2.0):
debug@4.4.3:
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 7.2.0
delayed-stream@1.0.0: {}
@@ -3460,9 +3431,7 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)):
optionalDependencies:
debug: 4.4.3(supports-color@7.2.0)
follow-redirects@1.16.0: {}
form-data@4.0.5:
dependencies:
@@ -4157,10 +4126,10 @@ snapshots:
uuid@8.3.2: {}
vite-plugin-compression@0.5.1(supports-color@7.2.0)(vite@5.4.21(sass@1.99.0)(terser@5.46.2)):
vite-plugin-compression@0.5.1(vite@5.4.21(sass@1.99.0)(terser@5.46.2)):
dependencies:
chalk: 4.1.2
debug: 4.4.3(supports-color@7.2.0)
debug: 4.4.3
fs-extra: 10.1.0
vite: 5.4.21(sass@1.99.0)(terser@5.46.2)
transitivePeerDependencies:
+3 -3
View File
@@ -2,9 +2,9 @@ packages:
- '.'
allowBuilds:
'@parcel/watcher': set this to true or false
es5-ext: set this to true or false
esbuild: set this to true or false
'@parcel/watcher': true
es5-ext: true
esbuild: true
patchedDependencies:
'@smallwei/avue': patches/@smallwei__avue.patch
+8
View File
@@ -90,3 +90,11 @@ export const expireStats = params =>
method: 'get',
params,
});
/** 按合同负责部门预览下一合同编号 */
export const nextContractNo = organizationId =>
request({
url: `${baseUrl}/next-contract-no`,
method: 'get',
params: { organizationId },
});
+11
View File
@@ -52,6 +52,17 @@ export const createFileTask = data =>
request({ url: `${fileTaskBaseUrl}/createFileTask`, method: 'post', data });
export const getPartUploadUrl = (id, partNumber) =>
request({ url: `${fileTaskBaseUrl}/getUploadUrl`, method: 'get', params: { id, partNumber } });
export const uploadPartByProxy = (id, partNumber, partBlob) => {
const formData = new FormData();
formData.append('file', partBlob, `part-${partNumber}.bin`);
return request({
url: `${fileTaskBaseUrl}/uploadPart`,
method: 'post',
params: { id, partNumber },
data: formData,
timeout: 600000,
});
};
export const updateFileTask = data =>
request({ url: `${fileTaskBaseUrl}/updateFileTask`, method: 'post', data });
export const pauseFileTask = ids =>
+8
View File
@@ -34,6 +34,14 @@ export const getPunchRecords = waybillId =>
params: { waybillId },
});
/** 编辑打卡记录(含打卡状态;punched=false 表示取消打卡) */
export const savePunchRecord = data =>
request({
url: `${baseUrl}/punch-record/save`,
method: 'post',
data,
});
/** 运单车辆实时定位 */
export const locateVehicle = id =>
request({
+12 -2
View File
@@ -57,26 +57,36 @@ export const clearNonTopDept = signal => {
});
};
export const syncOaCompany = (current = 1, size = 20, signal) => {
export const syncOaCompany = (current = 1, size = 20, signal, incremental = false, modifiedSince) => {
return request({
url: '/blade-system/dept/sync-oa-company',
method: 'post',
params: {
current,
size,
incremental: !!incremental,
modifiedSince: modifiedSince || undefined,
},
timeout: 60000,
signal,
});
};
export const syncOaDepartment = (current = 1, size = 20, signal) => {
export const syncOaDepartment = (
current = 1,
size = 20,
signal,
incremental = false,
modifiedSince
) => {
return request({
url: '/blade-system/dept/sync-oa-department',
method: 'post',
params: {
current,
size,
incremental: !!incremental,
modifiedSince: modifiedSince || undefined,
},
timeout: 60000,
signal,
+62
View File
@@ -0,0 +1,62 @@
import request from '@/axios';
export const getList = (current, size, params) => {
return request({
url: '/blade-system/mp-user/list',
method: 'get',
params: {
...params,
current,
size,
},
});
};
export const getDetail = id => {
return request({
url: '/blade-system/mp-user/detail',
method: 'get',
params: {
id,
},
});
};
export const submit = row => {
return request({
url: '/blade-system/mp-user/submit',
method: 'post',
data: row,
});
};
export const remove = ids => {
return request({
url: '/blade-system/mp-user/remove',
method: 'post',
params: {
ids,
},
});
};
export const changeStatus = (id, status) => {
return request({
url: '/blade-system/mp-user/change-status',
method: 'post',
params: {
id,
status,
},
});
};
export const resetPassword = userIds => {
return request({
url: '/blade-system/mp-user/reset-password',
method: 'post',
params: {
userIds,
},
});
};
+14 -1
View File
@@ -31,13 +31,15 @@ export const add = row => {
});
};
export const syncIamAccounts = (current = 1, size = 50, signal) => {
export const syncIamAccounts = (current = 1, size = 50, signal, incremental = false, modifiedSince) => {
return request({
url: '/blade-system/user/sync-iam-accounts',
method: 'post',
params: {
current,
size,
incremental: !!incremental,
modifiedSince: modifiedSince || undefined,
},
timeout: 60000,
signal,
@@ -52,6 +54,17 @@ export const update = row => {
});
};
export const changeStatus = (id, status) => {
return request({
url: '/blade-system/user/change-status',
method: 'post',
params: {
id,
status,
},
});
};
export const updatePlatform = (userId, userType, userExt) => {
return request({
url: '/blade-system/user/update-platform',
+2
View File
@@ -9,6 +9,8 @@ export const getDetail = id => request({ url: `${baseUrl}/detail`, method: 'get'
export const follow = data => request({ url: `${baseUrl}/follow`, method: 'post', data });
export const update = data => request({ url: `${baseUrl}/update`, method: 'post', data });
export const complete = data => request({ url: `${baseUrl}/complete`, method: 'post', data });
export const batchComplete = ids =>
@@ -0,0 +1,49 @@
import request from '@/axios';
/**
* 认证记录列表:按审核状态分页,按提交时间倒序
* 审核对象是**一条认证记录**,不是一辆车或一名司机,故必须带 bizType 区分
* status 为审核状态(0审核中 1已通过 2已驳回 3已作废);不传时后端按 0 处理
*/
export const getPendingPage = (bizType, current, size, params) => {
return request({
url: '/blade-transport/certification-audit/pending-page',
method: 'get',
params: {
...params,
bizType,
current,
size,
},
});
};
/**
* 审核详情:同时返回本次待审快照与该对象当前生效快照,供前端做字段级对比
* 从未认证通过时 effectiveSnapshotJson 为空串
*/
export const getAuditDetail = id => {
return request({
url: '/blade-transport/certification-audit/detail',
method: 'get',
params: {
id,
},
});
};
/**
* 审核一条认证记录
* 通过时不传驳回原因;驳回时必须传,且原因不超过 200 字
*/
export const auditCertification = (id, pass, rejectReason) => {
return request({
url: '/blade-transport/certification-audit/audit',
method: 'post',
params: {
id,
pass,
...(rejectReason ? { rejectReason } : {}),
},
});
};
@@ -51,14 +51,6 @@ export const changeStatus = (id, status) => {
});
};
export const auditCertification = (id, certificationStatus, rejectReason) => {
return request({
url: '/blade-transport/transport-vehicle/audit-certification',
method: 'post',
params: { id, certificationStatus, rejectReason },
});
};
export const getExpiryStat = params => {
return request({
url: '/blade-transport/transport-vehicle/expiry-stat',
+8 -1
View File
@@ -189,7 +189,14 @@ axios.interceptors.response.use(
// 兼容 Blob(浏览器 responseType='blob')与 string(非浏览器环境)两种形态。
if (isJsonContentType(res) && !isPlainObject(res.data)) {
try {
const text = res.data instanceof Blob ? await res.data.text() : res.data;
let text = '';
if (typeof res.data?.text === 'function') {
text = await res.data.text();
} else if (typeof res.data === 'string') {
text = res.data;
} else if (typeof res.data?.arrayBuffer === 'function') {
text = new TextDecoder().decode(await res.data.arrayBuffer());
}
const parsed = typeof text === 'string' && text ? JSON.parse(text) : null;
if (parsed && typeof parsed === 'object') {
res.data = parsed;
+67 -5
View File
@@ -16,7 +16,11 @@
<div class="address-map-picker__info">{{ status }}</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selected.longitude || resolving" @click="confirm">
<el-button
type="primary"
:disabled="!hasCoordinateValue(selected.longitude) || !hasCoordinateValue(selected.latitude) || resolving"
@click="confirm"
>
确定
</el-button>
</template>
@@ -271,7 +275,12 @@ export default {
this.runGeocode('address', lnglat)
.then(result => {
const detailAddress = result.regeocode?.formattedAddress || fallbackAddress;
this.selected = { ...this.selected, address: detailAddress };
const region = this.resolveRegionFromGeocode(result);
this.selected = {
...this.selected,
address: detailAddress,
...region,
};
this.keyword = detailAddress || this.keyword;
this.status = detailAddress || '已选点,可确认回填';
})
@@ -285,6 +294,26 @@ export default {
this.resolving = false;
});
},
resolveRegionFromGeocode(result = {}) {
const component = result.regeocode?.addressComponent || {};
const city = Array.isArray(component.city) ? '' : String(component.city || '').trim();
const province = String(component.province || '').trim();
const district = String(component.district || '').trim();
const country = String(component.country || '').trim();
const adcode = String(component.adcode || '').trim();
return {
country,
province,
city: city || province,
district,
adcode,
regionCode: adcode,
provinceName: province,
cityName: city || province,
districtName: district,
countryName: country,
};
},
renderMarker(point) {
this.clearMarker();
this.marker = new window.AMap.Marker({ position: point });
@@ -297,8 +326,31 @@ export default {
this.marker = null;
}
},
syncSelectedFromMarker() {
// 确认时以当前标记点坐标为准,避免换地址后仍回填上一次经纬度
if (!this.marker || typeof this.marker.getPosition !== 'function') {
return;
}
const point = this.marker.getPosition();
if (!point) {
return;
}
const longitude =
typeof point.getLng === 'function' ? point.getLng() : point.lng ?? point.longitude;
const latitude =
typeof point.getLat === 'function' ? point.getLat() : point.lat ?? point.latitude;
if (!Number.isFinite(Number(longitude)) || !Number.isFinite(Number(latitude))) {
return;
}
this.selected = {
...this.selected,
longitude: Number(longitude),
latitude: Number(latitude),
};
},
confirm() {
if (!this.selected.longitude || !this.selected.latitude) {
this.syncSelectedFromMarker();
if (!this.hasCoordinateValue(this.selected.longitude) || !this.hasCoordinateValue(this.selected.latitude)) {
this.$message.warning('请先搜索或点击地图完成选点');
return;
}
@@ -308,8 +360,18 @@ export default {
}
this.$emit('confirm', {
address: this.selected.address,
longitude: this.selected.longitude,
latitude: this.selected.latitude,
longitude: Number(this.selected.longitude),
latitude: Number(this.selected.latitude),
country: this.selected.country || this.selected.countryName || '',
province: this.selected.province || this.selected.provinceName || '',
city: this.selected.city || this.selected.cityName || '',
district: this.selected.district || this.selected.districtName || '',
adcode: this.selected.adcode || this.selected.regionCode || '',
regionCode: this.selected.regionCode || this.selected.adcode || '',
countryName: this.selected.countryName || this.selected.country || '',
provinceName: this.selected.provinceName || this.selected.province || '',
cityName: this.selected.cityName || this.selected.city || '',
districtName: this.selected.districtName || this.selected.district || '',
});
this.visible = false;
},
+1 -1
View File
@@ -576,7 +576,7 @@ export default {
}
.mode-content :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.mode-content :deep(.el-form-item:last-child) {
+117
View File
@@ -0,0 +1,117 @@
<template>
<div class="avue-crud__right table-header-tools">
<el-button v-if="showRefresh" icon="el-icon-refresh" circle @click="$emit('refresh')" />
<el-button
v-if="showColumn"
icon="el-icon-operation"
circle
@click="columnSettingVisible = true"
/>
<el-button
v-if="showSearch"
icon="el-icon-search"
circle
@click="$emit('update:searchVisible', !searchVisible)"
/>
<el-button v-if="showGrid" icon="el-icon-grid" circle @click="toggleTableSize" />
<el-dialog
v-if="showColumn"
v-model="columnSettingVisible"
title="列设置"
width="360px"
append-to-body
destroy-on-close
>
<el-checkbox-group
class="table-header-tools__column-setting"
:model-value="visibleColumnKeys"
@update:model-value="$emit('update:visibleColumnKeys', $event)"
>
<el-checkbox v-for="item in columns" :key="item.prop" :label="item.prop">
{{ item.label }}
</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="columnSettingVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
const TABLE_SIZE_OPTIONS = ['default', 'large', 'small'];
export default {
name: 'TableHeaderTools',
props: {
columns: {
type: Array,
default: () => [],
},
visibleColumnKeys: {
type: Array,
default: () => [],
},
tableSize: {
type: String,
default: 'default',
},
searchVisible: {
type: Boolean,
default: true,
},
showRefresh: {
type: Boolean,
default: true,
},
showColumn: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: true,
},
showGrid: {
type: Boolean,
default: true,
},
},
emits: ['refresh', 'update:visibleColumnKeys', 'update:tableSize', 'update:searchVisible'],
data() {
return {
columnSettingVisible: false,
};
},
deactivated() {
// keep-alive 缓存页面时关闭列设置弹窗,避免 Teleport 到 body 的弹窗切页后残留
this.columnSettingVisible = false;
},
methods: {
toggleTableSize() {
const index = TABLE_SIZE_OPTIONS.indexOf(this.tableSize);
const next = TABLE_SIZE_OPTIONS[(index + 1) % TABLE_SIZE_OPTIONS.length];
this.$emit('update:tableSize', next);
},
},
};
</script>
<style scoped lang="scss">
.table-header-tools.avue-crud__right {
width: auto;
margin: 0 0 0 auto;
flex: none;
white-space: nowrap;
text-align: right;
}
.table-header-tools__column-setting {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 360px;
overflow: auto;
}
</style>
@@ -9,18 +9,21 @@
<el-table :data="rows" border @selection-change="selectedRows = $event">
<el-table-column v-if="!readonly" type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<span
class="vehicle-attachment-table__file-name"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>
{{ attachmentName(row) }}
</span>
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
+4
View File
@@ -305,6 +305,10 @@ export const createOption = () => ({
searchOrder: 3,
searchPlaceholder: '请选择',
dicData: [],
props: {
label: 'label',
value: 'value',
},
filterable: true,
hide: true,
display: false,
+3
View File
@@ -2,6 +2,9 @@ const dimensionOptions = [
{ label: '重量', value: '重量' },
{ label: '体积', value: '体积' },
{ label: '数量', value: '数量' },
{ label: '整车', value: '整车' },
{ label: '里程', value: '里程' },
{ label: '吨·公里', value: '吨·公里' },
];
const statusOptions = [
+1
View File
@@ -117,6 +117,7 @@ export const contractCategoryOptions = [
export const signTypeOptions = [
{ label: '新签', value: '新签' },
{ label: '续签', value: '续签' },
{ label: '补充协议', value: '补充协议' },
];
export const contractStageOptions = [
+39 -10
View File
@@ -6,6 +6,7 @@ import {
contractStageOptions,
createCrudOption,
effectiveTypeOptions,
nonNegativeRule,
selectRule,
signTypeOptions,
textRule,
@@ -61,13 +62,14 @@ export const config = {
['contractNo', '合同编号'],
['contractName', '合同名称'],
['projectName', '所属项目'],
['organizationName', '所属组织'],
['organizationName', '合同负责部门'],
['contractCategory', '合同类别'],
['signType', '签约类型'],
['partyA', '甲方'],
['partyB', '乙方'],
['handlerUserName', '经办人'],
['signDate', '签订日期'],
['applyDate', '申请日期'],
['contractFormat', '合同格式'],
['legalSealFlag', '是否需要加盖法人章'],
['copyCount', '一式(份)'],
@@ -76,7 +78,8 @@ export const config = {
['templateFlag', '是否范本'],
['originalContractNo', '原件合同编号'],
['electronicSealFlag', '是否电子章'],
['remark', '备注', 2],
['mainContent', '合同主要内容', 2],
['remark', '其他事项说明', 2],
],
},
{
@@ -209,7 +212,7 @@ export const config = {
label: '流程',
statusProp: 'approvalStatus',
status: ['approved', 'change_approved'],
stage: ['formal', 'terminated'],
stage: ['temporary', 'formal', 'terminated'],
permission: 'contract_manage_view',
},
{
@@ -294,17 +297,17 @@ export const option = createCrudOption([
display: false,
},
{
label: '所属组织',
label: '合同负责部门',
prop: 'organizationName',
search: true,
searchOrder: 3,
searchslot: true,
searchPlaceholder: '请选择 所属组织',
searchPlaceholder: '请选择 合同负责部门',
formslot: true,
order: 120,
dicData: [],
minWidth: 150,
rules: selectRule('所属组织'),
rules: selectRule('合同负责部门'),
},
{
label: '合同类别',
@@ -435,6 +438,17 @@ export const option = createCrudOption([
minWidth: 130,
rules: [{ required: true, message: '请选择签订日期', trigger: 'change' }],
},
{
label: '申请日期',
prop: 'applyDate',
type: 'date',
format: 'YYYY-MM-DD',
valueFormat: 'YYYY-MM-DD',
sortable: true,
minWidth: 130,
addDisplay: false,
editDisplay: false,
},
{
label: '结算类型',
prop: 'settlementMode',
@@ -468,11 +482,10 @@ export const option = createCrudOption([
{ label: '否', value: 0 },
{ label: '是', value: 1 },
],
value: 0,
clearable: true,
hide: true,
order: 70,
minWidth: 100,
rules: selectRule('是否需要加盖法人章'),
},
{
label: '一式(份)',
@@ -500,6 +513,10 @@ export const option = createCrudOption([
hide: true,
order: 45,
minWidth: 130,
rules: [
{ required: true, message: '请输入合同金额', trigger: 'blur' },
nonNegativeRule('合同金额'),
],
},
{
label: '是否范本',
@@ -617,7 +634,19 @@ export const option = createCrudOption([
minWidth: 110,
},
{
label: '备注',
label: '合同主要内容',
prop: 'mainContent',
type: 'textarea',
span: 24,
minRows: 4,
hide: true,
order: 41,
maxlength: 1000,
showWordLimit: true,
rules: textRule('合同主要内容', 1000),
},
{
label: '其他事项说明',
prop: 'remark',
type: 'textarea',
span: 24,
@@ -626,7 +655,7 @@ export const option = createCrudOption([
order: 40,
maxlength: 2000,
showWordLimit: true,
rules: textRule('备注', 2000),
rules: textRule('其他事项说明', 2000),
},
{
label: '',
+2 -2
View File
@@ -71,7 +71,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,
},
@@ -185,7 +185,7 @@ export const option = createCrudOption([
searchPlaceholder: '请输入',
searchOrder: 8,
minWidth: 150,
rules: textRule('平台公司', 50, true),
rules: textRule('平台公司', 500, true),
},
{
label: '平台公司ID',
-2
View File
@@ -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: '数据来源',
+19
View File
@@ -98,6 +98,24 @@ const getProcessConfigNodes = row => {
const isAcceptProcessNode = node => node.key === 'accept' || node.name === '接单';
/**
* 过程节点列显示文案。
*
* 该列在库里存的是「原始值」,且历史上写入了两种格式:司机打卡推进写的是配置的
* key(unload / dispatch / load …),配载单新建默认值写的是中文名(接单)。
* 故这里按两种格式都认一次,认不出就原样显示,不把值吞掉。
*
* 运单创建后、司机首次打卡前该字段为空,属正常——显示「-」,避免留白看不出是没值还是没渲染。
*/
const formatCurrentProcessNode = row => {
const value = String(getFirstValue(row, ['currentProcessNode', 'currentNode']) || '').trim();
if (!value) return '-';
const node = getProcessConfigNodes(row).find(
item => item.key === value || item.name === value
);
return node?.name || node?.key || value;
};
const isTruthyFlag = value => {
if (value === true || value === 1) return true;
if (value === false || value === 0 || value === null || value === undefined || value === '') {
@@ -608,6 +626,7 @@ export const option = {
{
label: '过程节点',
prop: 'currentProcessNode',
formatter: row => formatCurrentProcessNode(row),
minWidth: 140,
addDisplay: false,
editDisplay: false,
+165 -22
View File
@@ -1,22 +1,165 @@
export const paymentApplicationTableColumns = [
{ prop: 'paymentNo', label: '单据号', minWidth: 140, link: true },
{ prop: 'paymentTypeName', label: '付款类型', minWidth: 110 },
{ prop: 'projectName', label: '所属项目', minWidth: 140 },
{ prop: 'deptName', label: '所属组织', minWidth: 120 },
{ prop: 'contractNo', label: '合同编号', minWidth: 130 },
{ prop: 'contractName', label: '合同名称', minWidth: 140 },
{ prop: 'payerName', label: '付款方', minWidth: 130 },
{ prop: 'payeeName', label: '收款方', minWidth: 130 },
{ prop: 'appliedAmount', label: '申请付款金额', minWidth: 140, money: true },
{ prop: 'paidAmount', label: '已付款金额', minWidth: 130, money: true },
{ prop: 'matchedInvoiceAmount', label: '匹配发票金额', minWidth: 130, money: true },
{ prop: 'applicantName', label: '申请人', minWidth: 100 },
{ prop: 'applyDate', label: '申请日期', minWidth: 120 },
{ prop: 'approvalStatusName', label: '审核状态', minWidth: 110, status: true },
{ prop: 'currentNode', label: '当前节点', minWidth: 120 },
{ prop: 'currentProcessor', label: '当前处理人', minWidth: 120 },
{ prop: 'kingdeeBillNo', label: '金蝶单据号', minWidth: 130 },
{ prop: 'kingdeeStatusName', label: '金蝶单据状态', minWidth: 130 },
{ prop: 'kingdeeLockStatus', label: '金蝶锁状态', minWidth: 150 },
{ 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 },
],
});
+205
View File
@@ -0,0 +1,205 @@
const identityDic = [
{ label: '司机', value: 'driver' },
{ label: '管理员', value: 'admin' },
];
const statusDic = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 },
];
const validatePhone = (rule, value, callback) => {
const phone = String(value || '').trim();
if (!phone) {
callback(new Error('请输入手机号'));
return;
}
if (!/^1\d{10}$/.test(phone)) {
callback(new Error('请输入正确的手机号'));
return;
}
callback();
};
const validateRemark = (rule, value, callback) => {
if (String(value || '').length > 200) {
callback(new Error('备注不能超过200字'));
} else {
callback();
}
};
export default {
height: 'auto',
calcHeight: 22,
dialogWidth: 760,
labelPosition: 'right',
labelWidth: 'auto',
tip: false,
searchBtnText: '查询',
emptyBtnText: '重置',
saveBtnText: '提交',
updateBtnText: '提交',
searchShow: true,
searchIcon: true,
searchLabelWidth: 160,
searchMenuSpan: 24,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
viewBtn: false,
delBtn: false,
editBtn: false,
addBtn: false,
selection: true,
dialogClickModal: false,
menuWidth: 320,
column: [
{
label: '手机号',
prop: 'phone',
minWidth: 130,
search: true,
searchOrder: 4,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 11,
showWordLimit: true,
order: 100,
rules: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ validator: validatePhone, trigger: 'blur' },
],
},
{
label: '账号',
prop: 'account',
minWidth: 130,
search: true,
searchOrder: 3,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 45,
order: 90,
placeholder: '默认与手机号相同',
},
{
label: '姓名',
prop: 'realName',
minWidth: 100,
search: true,
searchOrder: 2,
searchSpan: 6,
searchPlaceholder: '请输入',
maxlength: 10,
showWordLimit: true,
order: 80,
},
{
label: '昵称',
prop: 'name',
minWidth: 100,
maxlength: 20,
showWordLimit: true,
order: 70,
},
{
label: '小程序身份',
prop: 'identities',
type: 'select',
multiple: true,
dataType: 'string',
dicData: identityDic,
minWidth: 140,
search: true,
searchOrder: 1,
searchSpan: 6,
searchPlaceholder: '请选择',
order: 60,
rules: [{ required: true, message: '请选择小程序身份', trigger: 'change' }],
},
{
label: '性别',
prop: 'sex',
type: 'select',
dicUrl: '/blade-system/dict/dictionary?code=sex',
props: {
label: 'dictValue',
value: 'dictKey',
},
dataType: 'number',
minWidth: 80,
hide: true,
order: 50,
},
{
label: 'openid',
prop: 'openid',
minWidth: 180,
overHidden: true,
hide: true,
addDisplay: false,
editDisplay: true,
viewDisplay: true,
order: 40,
placeholder: '可清空以解绑微信',
},
{
label: '状态',
prop: 'status',
type: 'select',
dicData: statusDic,
dataType: 'number',
minWidth: 90,
search: true,
searchOrder: 0,
searchSpan: 6,
addDisplay: false,
editDisplay: false,
value: 1,
order: 30,
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
span: 24,
minRows: 2,
maxlength: 200,
showWordLimit: true,
hide: true,
order: 20,
rules: [{ validator: validateRemark, trigger: 'blur' }],
},
{
label: '创建时间',
prop: 'createTime',
minWidth: 170,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 10,
},
{
label: '更新人',
prop: 'updateUserName',
minWidth: 100,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 5,
},
{
label: '更新时间',
prop: 'updateTime',
minWidth: 170,
addDisplay: false,
editDisplay: false,
viewDisplay: true,
order: 1,
},
],
};
+9 -2
View File
@@ -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 },
+51 -10
View File
@@ -1,7 +1,10 @@
// 一级 value 与二级 value 都用中文原文:库里 exception_type 存的是大类中文名、
// exception_reason 存子类中文名(小程序上报即如此),此处必须与之一致,
// 否则按异常类型做精确匹配的查询会一行都查不到。
export const exceptionCategoryOptions = [
{
label: '交通异常',
value: 'traffic',
value: '交通异常',
children: [
{ label: '道路拥堵', value: '道路拥堵' },
{ label: '交通事故', value: '交通事故' },
@@ -12,7 +15,7 @@ export const exceptionCategoryOptions = [
},
{
label: '车辆异常',
value: 'vehicle',
value: '车辆异常',
children: [
{ label: '车辆故障维修', value: '车辆故障维修' },
{ label: '车辆事故', value: '车辆事故' },
@@ -24,7 +27,7 @@ export const exceptionCategoryOptions = [
},
{
label: '货物异常',
value: 'cargo',
value: '货物异常',
children: [
{ label: '货损', value: '货损' },
{ label: '货差', value: '货差' },
@@ -35,7 +38,7 @@ export const exceptionCategoryOptions = [
},
{
label: '其他异常',
value: 'other',
value: '其他异常',
children: [
{ label: '变更路线地址', value: '变更路线地址' },
{ label: '其他', value: '其他异常-其他' },
@@ -43,6 +46,16 @@ export const exceptionCategoryOptions = [
},
];
/** 一级类型的中文名列表,供编辑表单的级联选择使用 */
export const exceptionTypeOptions = exceptionCategoryOptions.map(item => ({
label: item.label,
value: item.value,
}));
/** 按一级类型取二级选项 */
export const exceptionReasonOptions = typeValue =>
exceptionCategoryOptions.find(item => item.value === typeValue)?.children || [];
export const disposalStatusOptions = [
{ label: '待处理', value: 'pending' },
{ label: '处理中', value: 'processing' },
@@ -65,20 +78,30 @@ export const tableColumns = [
{ label: '项目', prop: 'projectName', minWidth: 160 },
{ label: '车辆', prop: 'vehicleNo', minWidth: 150, link: true },
{ label: '上报人', prop: 'reporterName', minWidth: 120 },
{ label: '上报时间', prop: 'reportTime', minWidth: 170 },
{ label: '异常类型', prop: 'exceptionTypeName', minWidth: 160 },
{ label: '上报说明', prop: 'reportDescription', minWidth: 180 },
{ label: '异常节点', prop: 'nodeName', minWidth: 120 },
{ label: '承运商', prop: 'carrierName', minWidth: 160 },
{ label: '最新跟进说明', prop: 'latestFollowContent', minWidth: 200 },
{ label: '状态', prop: 'disposalStatusName', minWidth: 120 },
];
export const detailFields = [
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo', link: true },
{ label: '运单号', prop: 'waybillNo', link: true },
{ label: '项目', prop: 'projectName', link: true },
{ label: '车牌号', prop: 'vehicleNo', link: true },
{ label: '上报人', prop: 'reporterName' },
// 详情/跟进弹窗的只读信息,按来源分两块:
// 「运单信息」是上报时从运单复制的字段,「异常信息」是司机上报的内容。
// 与编辑弹窗的分区口径一致,改动时两处要一起改。
export const detailWaybillFields = [
{ label: '运单号', prop: 'waybillNo' },
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo' },
{ label: '车牌号', prop: 'vehicleNo' },
{ label: '项目', prop: 'projectName' },
{ label: '承运商', prop: 'carrierName' },
];
export const detailExceptionFields = [
{ label: '上报人', prop: 'reporterName' },
{ label: '上报时间', prop: 'reportTime' },
{ label: '异常节点', prop: 'nodeName' },
{ label: '异常类型', prop: 'exceptionTypeName' },
{ label: '异常原因', prop: 'exceptionReason' },
{ label: '上报说明', prop: 'reportDescription', span: 2 },
@@ -89,3 +112,21 @@ export const followColumns = [
{ label: '跟进人', prop: 'followUserName', minWidth: 130 },
{ label: '跟进时间', prop: 'followTime', minWidth: 170 },
];
// 编辑表单只开放上报四件套。车牌、项目、承运商、配载单号由上报时从运单回填,
// 允许手改会让记录与运单脱钩;处置状态由跟进与完成流转,不在此处。
export const editFormFields = [
{ label: '异常类型', prop: 'exceptionType', type: 'select', required: true },
{ label: '异常原因', prop: 'exceptionReason', type: 'select' },
{ label: '上报说明', prop: 'reportDescription', type: 'textarea', required: true, span: 2 },
];
// 编辑弹窗顶部的运单信息,只读展示,帮助运管员确认改的是哪一单。
// 上报人与上报时间属上报行为,放在「异常信息」里,不在此处重复。
export const editWaybillFields = [
{ label: '运单号', prop: 'waybillNo' },
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo' },
{ label: '车牌号', prop: 'vehicleNo' },
{ label: '项目', prop: 'projectName' },
{ label: '承运商', prop: 'carrierName' },
];
@@ -0,0 +1,51 @@
export const option = {
height: 'auto',
calcHeight: 32,
tip: false,
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
selection: false,
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
columnBtn: false,
menuWidth: 110,
column: [
{
label: '司机姓名',
prop: 'driverName',
search: true,
searchOrder: 3,
minWidth: 120,
},
{
label: '司机手机号',
prop: 'driverMobile',
search: true,
searchOrder: 2,
minWidth: 130,
},
{
label: '审核状态',
prop: 'statusText',
slot: true,
width: 110,
align: 'center',
},
{
label: '提交时间',
prop: 'submitTime',
slot: true,
minWidth: 170,
align: 'center',
},
],
};
@@ -0,0 +1,58 @@
export const option = {
height: 'auto',
calcHeight: 32,
tip: false,
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 4,
searchMenuPosition: 'right',
border: true,
index: true,
indexLabel: '序号',
indexWidth: 70,
selection: false,
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
columnBtn: false,
menuWidth: 110,
column: [
{
label: '车牌号',
prop: 'bizKey',
search: true,
searchOrder: 3,
minWidth: 140,
},
{
label: '司机姓名',
prop: 'driverName',
search: true,
searchOrder: 2,
minWidth: 120,
},
{
label: '司机手机号',
prop: 'driverMobile',
search: true,
searchOrder: 1,
minWidth: 130,
},
{
label: '审核状态',
prop: 'statusText',
slot: true,
width: 110,
align: 'center',
},
{
label: '提交时间',
prop: 'submitTime',
slot: true,
minWidth: 170,
align: 'center',
},
],
};
@@ -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',
@@ -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' }],
+103 -5
View File
@@ -173,7 +173,10 @@
}
.el-dialog .el-col {
// 取消列自身下边距,改由 form-item 统一控制行距;
// flow-root 避免子级 margin 折叠穿透导致换行后行距失效、校验提示被下一行遮挡
margin-bottom: 0;
display: flow-root;
}
.el-dialog .el-form-item,
@@ -181,7 +184,7 @@
.el-dialog .el-form-item--small.el-form-item,
.el-dialog .el-form-item--large.el-form-item,
.el-dialog .el-form-item--medium.el-form-item {
//margin-bottom: 20px;
margin-bottom: 24px;
}
/* 全站弹窗:底部操作栏浮动效果(参照 business/project-apply)
@@ -362,9 +365,14 @@
}
}
// 4) 弹窗内表单项默认上下间距 16px(覆盖默认 18px / 22px,与 customer-archive 对齐)
// 4) 弹窗内表单项上下间距:预留绝对定位校验提示(约 14~18px)的可视空间
.el-dialog .el-form-item {
//margin-bottom: 16px;
//margin-bottom: 24px;
}
// 校验提示浮在控件下方,提高层级避免被下一行输入框背景遮挡
.el-form-item__error {
z-index: 2;
}
// 5) 通用分组白卡组件(<section-card>):标题融入卡头
@@ -446,7 +454,7 @@
.dialog-form--carded {
.el-form-item {
align-items: center;
margin-bottom: 14px;
margin-bottom: 24px;
}
.el-form-item__label {
@@ -644,6 +652,28 @@
background: #fff;
}
// 车辆调度新增/编辑/查看弹窗:使用部门 el-cascader 默认 display: inline-block、宽度按内容收缩,
// 会比同行的 el-input(--el-input-width 默认 100%)窄一大截,需显式拉满列宽。
// 两个必须注意的点(踩过):
// 1) avue-crud 弹窗默认 append-to-body 到 body,页面内 `.vehicle-dispatch-page :deep(...)`
// 这类以页面容器为祖先的选择器匹配不到弹窗内容,必须用弹窗自身 class(dialogCustomClass)在全局兜底;
// 2) avue 表单项在 el-form-item__content 与控件之间还有两层包裹 div
// (form/index3.js 渲染:content > div > [div|elTooltip] > 控件),
// 所以要用后代选择器,不能写 `>` 直接子级。
.vehicle-dispatch-dialog .el-form-item__content .el-cascader {
width: 100%;
max-width: 100%;
}
// 车辆调度弹窗 label 宽度统一:option 用 labelWidth: 'auto' 时 label 宽度随字数变化
// (3 字 64px / 2 字 40px / 4 字带星号 78px),控件可用宽度随之变化,
// 于是同一行两列控件宽度不一致(如车牌号 486px vs 使用部门 472px)。
// 本弹窗左右两列最长 label 均为 4 个汉字(所属组织 / 使用部门 / 申请时间),故统一按 4em + 24px 取宽。
.vehicle-dispatch-dialog .el-form-item__label {
flex: 0 0 calc(4em + 24px) !important;
width: calc(4em + 24px) !important;
}
// 过程配置新增/编辑/查看弹窗(手写 el-dialog,参照 vehicle/credit-score-quantification):
// 灰底弹窗体(全站 .el-dialog__body 已统一)内堆叠 <section-card> 分组白卡,
// 卡间 12px 间距由 section-card 自身 margin-bottom 保证,与项目其他弹窗风格统一
@@ -995,6 +1025,66 @@ td.el-table-fixed-column--right {
text-overflow: clip;
}
// 全站统一:内容型长文本列(文件名 / 单号 / 备注等)单元格内也放了 el-link / span.attachment-file-name,
// 会被上面两条「含 el-link 的单元格」规则一并命中,导致:
// ① 上面第 950 行的 display: flex 让链接成为 flex item,flex item 默认 min-width: auto 无法收缩,
// 超长文本(如未规范命名的附件名)会直接把列撑破;
// ② 豁免规则的 overflow: visible 让溢出内容不再被单元格裁切,直接压到相邻列上。
// 因此对「内容列」做显式兜底:单元格自身恢复块级 + 省略号截断,链接参与收缩并显示省略号。
// 用法:在 <el-table-column> 上配置 class-name="is-ellipsis-link"(动态列可 :class-name 绑定)。
// 操作列不要加此类,否则会失去自动换行能力。
.el-table .el-table__body .cell.is-ellipsis-link {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.el-link {
display: inline-block;
min-width: 0;
max-width: 100%;
overflow: hidden;
vertical-align: middle;
// Element Plus 的 el-link__inner 是 inline-flex,flex 容器上的 text-overflow 不生效,
// 必须降级为块级容器,省略号才会真正渲染出来。
.el-link__inner {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.attachment-file-name {
display: inline-block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
}
// 附件上传表格:文件名统一用 span,视觉与可点击行为对齐原 el-link primary
.attachment-file-name {
color: var(--el-color-primary, #409eff);
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
&.is-disabled {
color: var(--el-text-color-placeholder, #c0c4cc);
cursor: not-allowed;
text-decoration: none;
}
}
// 全站统一:Avue 内置行操作按钮(查看 / 编辑 / 删除 / 复制等)视觉对齐为 el-link 链接样式。
// 选择器仅命中表格行操作列 .avue-crud__menu 内的 is-text 按钮,不误伤顶部工具栏(.avue-crud__header)
// 与弹窗按钮(.avue-crud__dialog__menu)。事件(rowView/rowEdit/rowDel)与权限(permission 指令)
@@ -1073,7 +1163,12 @@ td.el-table-fixed-column--right {
align-items: normal;
}
.el-form-item--default {
margin-bottom: 14px;
margin-bottom: 24px;
}
// 独立表单页(非弹窗)同样避免 el-col 折叠吃掉行距
.el-form .el-col {
display: flow-root;
}
// 配套:星号必须保持「高度 auto + 继承行高」,stretch 才会按首行排布;
@@ -1248,3 +1343,6 @@ td.el-table-fixed-column--right {
cursor: not-allowed;
}
}
.is-ellipsis-link {
overflow: hidden;
}
+434
View File
@@ -0,0 +1,434 @@
/**
* 认证审核通用工具
*
* 认证对象(司机 / 车辆)每次提交产生一条不可变快照记录,「审核」针对的是**一条记录**。
* 记录分两侧:本次**待审快照**与该对象**当前生效快照**(最后一次审核通过的那条)。
* 待审内容在审核通过前不影响生效值,所以审核页必须逐字段对比两侧,而不是只看一条当前值。
*
* 本文件提供三件事:
* 1. 审核状态文案与标签(与后端 statusText 同一套词表);
* 2. 快照字段的展示元数据(中文名、控件类型、分组);
* 3. 两侧快照的字段级 diff 计算,含「从未认证通过、无对比基准」的处理。
*
* 两侧快照字段名完全一致(同一套结构),故可逐键比较。
*/
const EMPTY_TEXT = '—';
const TYPE_IMAGE = 'image';
const TYPE_DATE = 'date';
const TYPE_LONG_TERM = 'longTerm';
/** 审核状态取值 */
export const CERT_RECORD_STATUS = {
PENDING: 0,
APPROVED: 1,
REJECTED: 2,
VOIDED: 3,
};
/** 状态文案统一为:审核中 / 已通过 / 已驳回 / 已作废 */
const CERT_STATUS_TAG = {
0: { label: '审核中', type: 'warning' },
1: { label: '已通过', type: 'success' },
2: { label: '已驳回', type: 'danger' },
3: { label: '已作废', type: 'info' },
};
/** 变更类型 → 差异标签 */
export const CERT_CHANGE_TAG = {
added: { label: '新增', type: 'success' },
modified: { label: '已修改', type: 'warning' },
cleared: { label: '已清空', type: 'danger' },
};
/**
* 「最新提交状态」展示缓存 → 标签
*
* 缓存为空表示该对象从未提交过认证(即没有生效快照)。
* 注意它只是最近一次提交的状态,不等于「是否已认证」——后者由生效快照指针决定。
*/
export const certStatusTag = status => {
if (status === null || status === undefined || status === '') {
return { label: '未认证', type: 'info' };
}
return CERT_STATUS_TAG[status] || { label: EMPTY_TEXT, type: 'info' };
};
const pad = value => String(value).padStart(2, '0');
/** 后端若改用时间戳毫秒序列化日期,也能落成日期文案 */
const EPOCH_PATTERN = /^\d{13}$/;
const isEpochText = text => EPOCH_PATTERN.test(text);
const toDate = text => {
const date = new Date(Number(text));
return Number.isNaN(date.getTime()) ? null : date;
};
/** 提交时间等日期时间展示,兼容 `yyyy-MM-dd HH:mm:ss`、ISO 串与时间戳毫秒 */
export const formatCertDateTime = value => {
if (value === null || value === undefined || value === '') return EMPTY_TEXT;
const text = String(value).trim();
const matched = text.match(
/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T ](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?/
);
if (matched) {
return (
`${matched[1]}-${pad(matched[2])}-${pad(matched[3])} ` +
`${pad(matched[4])}:${pad(matched[5])}:${pad(matched[6] || 0)}`
);
}
const date = isEpochText(text) ? toDate(text) : null;
if (!date) return text;
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
);
};
/**
* 快照字段元数据
*
* 字段名与后端提交 DTO 的 JSON 键一致(司机 PersonCertSubmitRequest / 车辆 VehicleCertSubmitRequest),
* 两侧快照共用同一套结构,故同时用于标签与比较规则。
*
* `longTermKey` 只挂在**被长期有效作废掉的那个字段**上,也就是各组的「有效期止」与强制报废日期:
* 勾了长期有效,它们显示「长期有效」、比较时按同一个值算(避免每次审核都报「已变更」)。
* 「有效期起」不挂:起期是一个独立的日期,长期有效并不会让它失效,
* 挂上去等于把它自己的值藏起来、还让审核员看不到起期的改动。
*/
const CERT_SNAPSHOT_GROUPS = {
driver: [
{
title: '身份信息',
fields: [
{ key: 'realName', label: '真实姓名' },
{ key: 'idCardNo', label: '身份证号' },
{ key: 'idCardFrontUrl', label: '身份证正面照', type: TYPE_IMAGE },
{ key: 'idCardBackUrl', label: '身份证反面照', type: TYPE_IMAGE },
{ key: 'idCardValidStart', label: '身份证有效期起', type: TYPE_DATE },
{
key: 'idCardValidEnd',
label: '身份证有效期止',
type: TYPE_DATE,
longTermKey: 'idCardLongTerm',
},
{ key: 'idCardLongTerm', label: '身份证长期有效', type: TYPE_LONG_TERM },
{ key: 'headPhoto', label: '大头照', type: TYPE_IMAGE },
],
},
{
title: '驾驶证信息',
fields: [
{ key: 'driverFileNo', label: '驾驶证档案编号' },
{ key: 'driverClass', label: '准驾车型' },
{ key: 'driverLicenseFrontUrl', label: '驾驶证主页照片', type: TYPE_IMAGE },
{ key: 'driverLicenseBackUrl', label: '驾驶证副页照片', type: TYPE_IMAGE },
{ key: 'driverValidStart', label: '驾驶证有效期起', type: TYPE_DATE },
{
key: 'driverValidEnd',
label: '驾驶证有效期止',
type: TYPE_DATE,
longTermKey: 'driverLongTerm',
},
{ key: 'driverLongTerm', label: '驾驶证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '从业资格证信息',
fields: [
{ key: 'qualificationType', label: '从业资格认证类型' },
{ key: 'qualificationNo', label: '从业资格证号' },
{
key: 'qualificationFront',
label: '从业资格证封面页',
type: TYPE_IMAGE,
legacyKeys: ['qualificationUrl'],
},
{ key: 'qualificationBack', label: '从业资格证内容页', type: TYPE_IMAGE },
{ key: 'qualificationValidStart', label: '从业资格证有效期起', type: TYPE_DATE },
{
key: 'qualificationValidEnd',
label: '从业资格证有效期止',
type: TYPE_DATE,
longTermKey: 'qualificationLongTerm',
},
{ key: 'qualificationLongTerm', label: '从业资格证长期有效', type: TYPE_LONG_TERM },
],
},
],
/*
* 车辆字段名以 VehicleCertSubmitRequest 为准,与 PersonCertSubmitRequest 不同。
* 键名对不上不报错——每个字段都算「没有值」,两组全显示「—」且 diff 不出变更。
*/
vehicle: [
{
title: '车辆基本信息',
fields: [
{ key: 'plateNo', label: '车牌号' },
{ key: 'vehicleType', label: '车辆类型' },
{ key: 'plateColor', label: '车牌颜色' },
{ key: 'ownerName', label: '所有人' },
{ key: 'vin', label: '车辆识别代号(车架号)' },
{ key: 'outerLength', label: '外廓长度(mm)' },
{ key: 'outerWidth', label: '外廓宽度(mm)' },
{ key: 'outerHeight', label: '外廓高度(mm)' },
{ key: 'approvedLoadKg', label: '核定载质量(KG)' },
{ key: 'tractionMassKg', label: '准牵引总质量(KG)' },
{ key: 'energyType', label: '能源类型' },
{ key: 'businessRelation', label: '业务关系' },
],
},
{
title: '行驶证信息',
fields: [
{ key: 'compulsoryScrapDate', label: '强制报废日期', type: TYPE_DATE, longTermKey: 'compulsoryScrapLongTerm' },
{ key: 'compulsoryScrapLongTerm', label: '强制报废长期有效', type: TYPE_LONG_TERM },
{ key: 'drivingLicenseNo', label: '行驶证档案编号' },
{ key: 'drivingLicenseImage', label: '行驶证主页正面', type: TYPE_IMAGE },
{ key: 'drivingLicenseMainBack', label: '行驶证主页反面', type: TYPE_IMAGE },
{ key: 'drivingLicenseViceFront', label: '行驶证副页正面', type: TYPE_IMAGE },
{ key: 'drivingLicenseViceBack', label: '行驶证副页反面', type: TYPE_IMAGE },
{ key: 'drivingLicenseStartDate', label: '行驶证有效期起', type: TYPE_DATE },
{
key: 'drivingLicenseEndDate',
label: '行驶证有效期止',
type: TYPE_DATE,
longTermKey: 'drivingLicenseLongTerm',
},
{ key: 'drivingLicenseLongTerm', label: '行驶证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '道路运输证信息',
fields: [
{ key: 'roadTransportCertNo', label: '道路运输证号' },
{ key: 'roadTransportCertImage', label: '道路运输证照片', type: TYPE_IMAGE },
{ key: 'roadTransportCertStartDate', label: '道路运输证有效期起', type: TYPE_DATE },
{
key: 'roadTransportCertEndDate',
label: '道路运输证有效期止',
type: TYPE_DATE,
longTermKey: 'roadTransportCertLongTerm',
},
{ key: 'roadTransportCertLongTerm', label: '道路运输证长期有效', type: TYPE_LONG_TERM },
],
},
{
title: '机动车登记本信息',
fields: [
{ key: 'registrationNo', label: '机动车登记编号' },
{ key: 'registrationImage', label: '机动车登记本照片', type: TYPE_IMAGE },
],
},
],
};
/** 提交元信息与主键不参与对比(两侧必相同,展示出来只是噪音) */
const IGNORED_KEYS = [
'id',
'bizType',
'bizId',
'bizKey',
'certificationStatus',
'snapshotJson',
'pendingSnapshotJson',
'effectiveSnapshotJson',
];
/**
* 解析快照 JSON
*
* 后端把 null 序列化为空串,故空串 / 'null' 都按「没有快照」处理,不算解析失败。
*/
const readSnapshot = json => {
if (json && typeof json === 'object') {
return { value: Object.keys(json).length ? json : null, invalid: false };
}
const text = String(json === null || json === undefined ? '' : json).trim();
if (!text || text === 'null') {
return { value: null, invalid: false };
}
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object') {
return { value: parsed, invalid: false };
}
return { value: null, invalid: true };
} catch (error) {
return { value: null, invalid: true };
}
};
/** 解析快照 JSON,无快照或解析失败时返回 null */
export const parseSnapshot = json => readSnapshot(json).value;
const isEmptyValue = value => value === null || value === undefined || value === '';
const isLongTermOn = value => value === 1 || value === '1' || value === true;
/** 日期归一化到 yyyy-MM-dd,便于 `2026-01-01` 与 `2026-01-01T00:00:00` 判等 */
const normalizeDate = value => {
if (isEmptyValue(value)) return '';
const text = String(value).trim();
const matched = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (matched) return `${matched[1]}-${pad(matched[2])}-${pad(matched[3])}`;
const date = isEpochText(text) ? toDate(text) : null;
if (date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
return text;
};
/**
* 取字段值,兼容字段改名前的旧键
*
* 后端把某字段改名后,改名之前提交的记录里存的仍是旧键(例:从业资格证封面页
* 由 `qualificationUrl` 改为 `qualificationFront`)。只读新键会得到空,
* 审核页就把已填的封面页显示成「—」,误导审核员以为对方没传。
* 新旧键都存在时以新键为准。
*/
const readFieldValue = (snapshot, field) => {
if (!snapshot) return undefined;
const value = snapshot[field.key];
if (!isEmptyValue(value)) return value;
const legacyKeys = field.legacyKeys || [];
for (let i = 0; i < legacyKeys.length; i += 1) {
if (!isEmptyValue(snapshot[legacyKeys[i]])) return snapshot[legacyKeys[i]];
}
return value;
};
/** 比较用的归一化取值:展示相同即视为未变更 */
const canonicalOf = (snapshot, field) => {
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_LONG_TERM) {
if (isEmptyValue(value)) return '';
return isLongTermOn(value) ? '1' : '0';
}
if (field.type === TYPE_DATE) {
if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) {
return '长期有效';
}
return normalizeDate(value);
}
return isEmptyValue(value) ? '' : String(value).trim();
};
/** 展示文案:日期型受「长期有效」影响,图片型返回 URL 由组件渲染缩略图 */
const textOf = (snapshot, field) => {
const value = readFieldValue(snapshot, field);
if (field.type === TYPE_IMAGE) {
return isEmptyValue(value) ? '' : String(value);
}
if (field.type === TYPE_LONG_TERM) {
if (isEmptyValue(value)) return EMPTY_TEXT;
return isLongTermOn(value) ? '是' : '否';
}
if (field.type === TYPE_DATE) {
if (field.longTermKey && isLongTermOn(snapshot && snapshot[field.longTermKey])) {
return '长期有效';
}
return normalizeDate(value) || EMPTY_TEXT;
}
return isEmptyValue(value) ? EMPTY_TEXT : String(value);
};
const buildRow = (field, pendingSnapshot, effectiveSnapshot, hasBaseline) => {
const type = field.type || 'text';
const target = { ...field, type };
const pendingCanonical = canonicalOf(pendingSnapshot, target);
const effectiveCanonical = canonicalOf(effectiveSnapshot, target);
let changeType = '';
if (hasBaseline && pendingCanonical !== effectiveCanonical) {
if (!effectiveCanonical) {
changeType = 'added';
} else if (!pendingCanonical) {
changeType = 'cleared';
} else {
changeType = 'modified';
}
}
return {
key: field.key,
label: field.label,
type,
effective: {
raw: readFieldValue(effectiveSnapshot, target),
text: textOf(effectiveSnapshot, target),
},
pending: {
raw: readFieldValue(pendingSnapshot, target),
text: textOf(pendingSnapshot, target),
},
changed: Boolean(changeType),
changeType,
};
};
const unionKeys = (pendingSnapshot, effectiveSnapshot) => {
const keys = [];
[pendingSnapshot, effectiveSnapshot].forEach(snapshot => {
Object.keys(snapshot || {}).forEach(key => {
if (!keys.includes(key)) keys.push(key);
});
});
return keys;
};
/**
* 构造「待审快照 vs 当前生效值」的字段级 diff
*
* @param {string} bizType 认证对象类型:driver / vehicle
* @param {string} pendingJson 本次待审快照 JSON
* @param {string} effectiveJson 该对象当前生效快照 JSON,从未认证通过时为空
* @returns {Object} { hasBaseline, groups, totalCount, changedCount, ... }
* hasBaseline 为 false 表示无对比基准(从未认证通过),此时不产出任何变更标记
*/
export const buildSnapshotDiff = (bizType, pendingJson, effectiveJson) => {
const pending = readSnapshot(pendingJson);
const effective = readSnapshot(effectiveJson);
const hasBaseline = effective.value !== null;
const groupsConfig = CERT_SNAPSHOT_GROUPS[bizType] || [];
const knownKeys = [];
const groups = groupsConfig.map(group => {
const rows = group.fields.map(field => {
// 旧键已由所属字段并入展示,不能再作为独立字段出现,否则同一张封面页会出现两行
knownKeys.push(field.key, ...(field.legacyKeys || []));
return buildRow(field, pending.value, effective.value, hasBaseline);
});
return {
title: group.title,
rows,
changedCount: rows.filter(row => row.changed).length,
};
});
// 元数据未登记的字段照样展示,避免后端新增字段时审核员看不到变化
const extraKeys = unionKeys(pending.value, effective.value).filter(
key => !knownKeys.includes(key) && !IGNORED_KEYS.includes(key)
);
if (extraKeys.length) {
const rows = extraKeys.map(key =>
buildRow({ key, label: key }, pending.value, effective.value, hasBaseline)
);
groups.push({
title: '其他字段',
rows,
changedCount: rows.filter(row => row.changed).length,
});
}
const rows = groups.reduce((all, group) => all.concat(group.rows), []);
return {
hasBaseline,
pendingInvalid: pending.invalid,
effectiveInvalid: effective.invalid,
groups,
totalCount: rows.length,
changedCount: rows.filter(row => row.changed).length,
};
};
+14
View File
@@ -0,0 +1,14 @@
/**
* 合同下拉/选择展示:合同编号/合同名称
*
* @param {Object} contract 合同对象
* @returns {string}
*/
export function formatContractOptionLabel(contract = {}) {
const contractNo = String(contract?.contractNo ?? '').trim();
const contractName = String(contract?.contractName ?? '').trim();
if (contractNo && contractName) {
return `${contractNo}/${contractName}`;
}
return contractNo || contractName || '';
}
+86
View File
@@ -13,6 +13,92 @@ const coordinateRanges = {
},
};
/** WGS-84 → GCJ-02(高德)偏移算法常量 */
const WGS84_TO_GCJ02_PI = Math.PI;
const WGS84_TO_GCJ02_A = 6378245.0;
const WGS84_TO_GCJ02_EE = 0.00669342162296594323;
const isOutOfChina = (longitude, latitude) =>
longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
const transformLatitude = (longitude, latitude) => {
let result =
-100.0 +
2.0 * longitude +
3.0 * latitude +
0.2 * latitude * latitude +
0.1 * longitude * latitude +
0.2 * Math.sqrt(Math.abs(longitude));
result +=
((20.0 * Math.sin(6.0 * longitude * WGS84_TO_GCJ02_PI) +
20.0 * Math.sin(2.0 * longitude * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((20.0 * Math.sin(latitude * WGS84_TO_GCJ02_PI) +
40.0 * Math.sin((latitude / 3.0) * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((160.0 * Math.sin((latitude / 12.0) * WGS84_TO_GCJ02_PI) +
320 * Math.sin((latitude * WGS84_TO_GCJ02_PI) / 30.0)) *
2.0) /
3.0;
return result;
};
const transformLongitude = (longitude, latitude) => {
let result =
300.0 +
longitude +
2.0 * latitude +
0.1 * longitude * longitude +
0.1 * longitude * latitude +
0.1 * Math.sqrt(Math.abs(longitude));
result +=
((20.0 * Math.sin(6.0 * longitude * WGS84_TO_GCJ02_PI) +
20.0 * Math.sin(2.0 * longitude * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((20.0 * Math.sin(longitude * WGS84_TO_GCJ02_PI) +
40.0 * Math.sin((longitude / 3.0) * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
result +=
((150.0 * Math.sin((longitude / 12.0) * WGS84_TO_GCJ02_PI) +
300.0 * Math.sin((longitude / 30.0) * WGS84_TO_GCJ02_PI)) *
2.0) /
3.0;
return result;
};
/**
* WGS-84(GPS / LBS)转 GCJ-02(高德/国测局),供高德地图绘制使用。
* 中国境外坐标不做偏移,原样返回。
* @param {number} longitude
* @param {number} latitude
* @returns {[number, number]} [经度, 纬度]
*/
export const wgs84ToGcj02 = (longitude, latitude) => {
const lng = Number(longitude);
const lat = Number(latitude);
if (!Number.isFinite(lng) || !Number.isFinite(lat) || isOutOfChina(lng, lat)) {
return [lng, lat];
}
let dLat = transformLatitude(lng - 105.0, lat - 35.0);
let dLng = transformLongitude(lng - 105.0, lat - 35.0);
const radLat = (lat / 180.0) * WGS84_TO_GCJ02_PI;
let magic = Math.sin(radLat);
magic = 1 - WGS84_TO_GCJ02_EE * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat =
(dLat * 180.0) /
(((WGS84_TO_GCJ02_A * (1 - WGS84_TO_GCJ02_EE)) / (magic * sqrtMagic)) * WGS84_TO_GCJ02_PI);
dLng = (dLng * 180.0) / ((WGS84_TO_GCJ02_A / sqrtMagic) * Math.cos(radLat) * WGS84_TO_GCJ02_PI);
return [lng + dLng, lat + dLat];
};
export const normalizeCoordinateInput = value => {
const rawValue = String(value ?? '').replace(/[^\d.-]/g, '');
const isNegative = rawValue.startsWith('-');
+109
View File
@@ -0,0 +1,109 @@
const CURRENCY_NAME_MAP = {
CNY: '人民币',
RMB: '人民币',
USD: '美元',
EUR: '欧元',
JPY: '日元',
GBP: '英镑',
};
const CURRENCY_UNIT_MAP = {
CNY: '元',
RMB: '元',
USD: '美元',
EUR: '欧元',
JPY: '日元',
GBP: '英镑',
};
const CURRENCY_NAME_TO_UNIT = {
人民币: '元',
美元: '美元',
欧元: '欧元',
日元: '日元',
英镑: '英镑',
};
/**
* 归一化币种代码(CNY → RMB)
*/
export function normalizeCurrencyCode(value, fallback = 'RMB') {
const raw =
typeof value === 'object'
? value?.dictKey || value?.value || value?.code || value?.dictValue || ''
: value;
const normalized = String(raw || fallback)
.trim()
.toUpperCase();
const code = normalized.includes(' - ') ? normalized.split(' - ')[0].trim() : normalized;
if (!code) return fallback;
return code === 'CNY' ? 'RMB' : code;
}
/**
* 币种中文名称
*/
export function currencyName(value) {
const text = String(value || '');
if (text.includes(' - ')) return text.slice(text.indexOf(' - ') + 3);
return CURRENCY_NAME_MAP[text.toUpperCase()] || text;
}
/**
* 金额单位文案:RMB/CNY → 元,USD → 美元
*/
export function currencyUnit(value) {
const text = String(value || '');
let code = text;
if (text.includes(' - ')) {
code = text.split(' - ')[0].trim();
}
const upperCode = code.toUpperCase();
if (CURRENCY_UNIT_MAP[upperCode]) {
return CURRENCY_UNIT_MAP[upperCode];
}
if (CURRENCY_NAME_TO_UNIT[code]) {
return CURRENCY_NAME_TO_UNIT[code];
}
return text || '元';
}
/**
* 将计价单位前缀替换为当前币种单位,如 元/吨 → 美元/吨
*/
export function remapPriceUnitCurrency(priceUnit, currency) {
const text = String(priceUnit || '').trim();
if (!text || !text.includes('/')) return text;
const suffix = text.slice(text.indexOf('/'));
return `${currencyUnit(currency)}${suffix}`;
}
/**
* 按数量单位生成默认计价单位
*/
export function resolvePriceUnitByQuantityUnit(quantityUnit, currency = 'RMB') {
const unit = currencyUnit(currency);
return (
{
吨: `${unit}/吨`,
方: `${unit}/立方`,
件: `${unit}/件`,
}[String(quantityUnit || '').trim()] || ''
);
}
/**
* 将字典中的计价单位选项映射到当前币种
*/
export function mapFeeUnitOptions(options = [], currency = 'RMB') {
const unit = currencyUnit(currency);
return (options || []).map(item => {
const label = String(item.label || item.value || '');
const value = String(item.value || item.label || '');
return {
...item,
label: label.replace(/^元/, unit).replace(/^人民币/, unit),
value: value.replace(/^元/, unit).replace(/^人民币/, unit),
};
});
}
+99 -22
View File
@@ -6,7 +6,31 @@ import 'nprogress/nprogress.css';
const excelPattern = /\.(xls|xlsx)$/;
/** 是否为浏览器 Blob(含跨 realm 的 Blob-like) */
const isBlobLike = value =>
value != null &&
typeof value === 'object' &&
typeof value.size === 'number' &&
typeof value.type === 'string' &&
(typeof value.text === 'function' || typeof value.arrayBuffer === 'function');
/** axios 拦截器已把 JSON Blob 解析成的业务对象 */
const isBusinessJson = value =>
value != null &&
typeof value === 'object' &&
!isBlobLike(value) &&
!(value instanceof ArrayBuffer) &&
('code' in value || 'success' in value || 'msg' in value);
const isExcelResponse = res => {
// 已是业务 JSON(导入成功/失败提示)时,绝不能当失败明细 Excel 处理
if (isBusinessJson(res?.data)) {
return false;
}
// 只有文件流才可能是失败明细;普通对象没有 .text/.arrayBuffer
if (!isBlobLike(res?.data)) {
return false;
}
const headers = res.headers || {};
const contentType = String(headers['content-type'] || res.data?.type || '').toLowerCase();
const contentDisposition = String(headers['content-disposition'] || '').toLowerCase();
@@ -23,8 +47,34 @@ const isExcelResponse = res => {
);
};
const parseBlobJson = async blob => {
const text = await blob.text();
/**
* 解析导入接口响应体为 JSON 对象。
* <p>
* axios 拦截器在 Content-Type 为 JSON 时,会把 `responseType: 'blob'` 的 Blob
* 提前解析成普通对象;此处需同时兼容 Blob / 字符串 / 已解析对象,避免再调
* `blob.text()` 时出现「blob.text is not a function」。
*/
const parseBlobJson = async data => {
if (data == null) {
return {};
}
if (isBusinessJson(data)) {
return data;
}
if (typeof data === 'object' && !isBlobLike(data) && !(data instanceof ArrayBuffer)) {
return data;
}
if (typeof data === 'string') {
return data ? JSON.parse(data) : {};
}
let text = '';
if (typeof data.text === 'function') {
text = await data.text();
} else if (typeof data.arrayBuffer === 'function') {
text = new TextDecoder().decode(await data.arrayBuffer());
} else if (data instanceof ArrayBuffer) {
text = new TextDecoder().decode(data);
}
return text ? JSON.parse(text) : {};
};
@@ -39,18 +89,21 @@ const parseBlobJson = async blob => {
* 这里补上这一步:从错误响应的 Blob 里取出后端 `msg`,取不到再退回通用文案。
*/
const resolveErrorMessage = async error => {
const blob = error?.response?.data ?? error?.data;
if (blob instanceof Blob) {
const payload = error?.response?.data ?? error?.data;
if (isBusinessJson(payload) && payload.msg) {
return payload.msg;
}
if (isBlobLike(payload) || typeof payload === 'string') {
try {
const result = await parseBlobJson(blob);
const result = await parseBlobJson(payload);
if (result?.msg) return result.msg;
} catch (ignored) {
// 响应体不是 JSON(如网关返回的 HTML 错误页),退回通用文案
}
}
const fallback = error?.response?.data?.msg || error?.message;
const fallback = payload?.msg || error?.message;
// axios 的英文通用文案对用户没有意义,统一换成中文提示
if (!fallback || /Request failed with status code/i.test(fallback)) {
if (!fallback || /Request failed with status code|\.text is not a function/i.test(fallback)) {
return '导入失败,请检查导入文件后重试';
}
return fallback;
@@ -68,8 +121,11 @@ const resolveErrorMessage = async error => {
*/
const decorateFailExcel = async (blob, businessName, failDetailDecorator) => {
if (typeof failDetailDecorator !== 'function') return blob;
if (!isBlobLike(blob) && !(blob instanceof ArrayBuffer)) {
return blob;
}
const ExcelJS = await import('exceljs');
const buf = await blob.arrayBuffer();
const buf = typeof blob.arrayBuffer === 'function' ? await blob.arrayBuffer() : blob;
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buf);
await failDetailDecorator(workbook, { businessName });
@@ -77,6 +133,31 @@ const decorateFailExcel = async (blob, businessName, failDetailDecorator) => {
return new Blob([out], { type: 'application/vnd.ms-excel' });
};
/** 通知上传组件成功:只传业务响应体,避免其误调 axios 响应上的 .text() */
const notifyUploadSuccess = (option, res) => {
if (typeof option?.onSuccess !== 'function') {
return;
}
try {
const body = isBusinessJson(res?.data) ? res.data : { code: 200, success: true };
option.onSuccess(body);
} catch (callbackError) {
console.warn('upload onSuccess callback failed', callbackError);
}
};
/** 通知上传组件失败 */
const notifyUploadError = (option, error) => {
if (typeof option?.onError !== 'function') {
return;
}
try {
option.onError(error instanceof Error ? error : new Error(String(error?.message || error || '导入失败')));
} catch (callbackError) {
console.warn('upload onError callback failed', callbackError);
}
};
/**
* 打开导入弹窗
* @param {Object} vm 组件实例
@@ -108,9 +189,7 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
const fileName = file && file.name ? file.name.toLowerCase() : '';
if (!excelPattern.test(fileName)) {
vm.$message.error('请上传 .xls,.xlsx 标准格式文件');
if (typeof option.onError === 'function') {
option.onError(new Error('请上传 .xls,.xlsx 标准格式文件'));
}
notifyUploadError(option, new Error('请上传 .xls,.xlsx 标准格式文件'));
return;
}
@@ -130,12 +209,11 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
);
vm.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const result = await parseBlobJson(res.data);
if (result.code !== 200) {
// 成功体常被 axios 拦截器提前解析成对象;优先走业务 JSON,避免再调 Blob.text
const result = isBusinessJson(res?.data) ? res.data : await parseBlobJson(res?.data);
if (Number(result.code) !== 200) {
vm.$message.error(result.msg || '导入失败');
if (typeof option.onError === 'function') {
option.onError(new Error(result.msg || '导入失败'));
}
notifyUploadError(option, new Error(result.msg || '导入失败'));
return;
}
vm.$message.success('导入完成');
@@ -149,18 +227,17 @@ export const handleImportExcel = async (vm, option, column, businessName, onSucc
} else if (typeof vm.initTree === 'function') {
vm.initTree();
}
if (typeof option.onSuccess === 'function') {
option.onSuccess(res);
}
// 上传组件 onSuccess 约定接收「响应体」,不是完整 axios 响应。
// 若传入 axios 响应对象,部分环境下会按 Fetch Response 调用 .text(),
// 从而在导入已成功后仍弹出「e.text is not a function」。
notifyUploadSuccess(option, res);
} catch (error) {
// 拦截器已提示过的错误(如后端 400 返回的业务提示)不再重复弹一次,
// 只保留一次清晰的中文提示。
if (!error?._notified) {
vm.$message.error(await resolveErrorMessage(error));
}
if (typeof option.onError === 'function') {
option.onError(error);
}
notifyUploadError(option, error);
} finally {
loading.close();
NProgress.done();
+27
View File
@@ -0,0 +1,27 @@
import { getList as getMeasurementUnitList } from '@/api/base/measurement-unit';
/**
* 加载启用状态的计量单位选项,展示/取值均为「计量单位」字段(unitName)。
* @returns {Promise<Array<{ label: string, value: string, dimension: string, id: string|number }>>}
*/
export const loadMeasurementUnitOptions = () => {
return getMeasurementUnitList(1, 9999, { status: 1 }).then(res => {
const data = res?.data?.data || res?.data || {};
const records = Array.isArray(data) ? data : data.records || [];
const names = new Set();
return records
.filter(item => item.status === undefined || item.status === null || Number(item.status) === 1)
.map(item => {
const name = String(item.unitName || '').trim();
if (!name || names.has(name)) return null;
names.add(name);
return {
id: item.id,
label: name,
value: name,
dimension: String(item.dimension || '').trim(),
};
})
.filter(Boolean);
});
};
+89
View File
@@ -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;
+29
View File
@@ -0,0 +1,29 @@
/**
* 路线变更记录内容格式化
*
* 存储格式(单行):
* YD-20260904-0022:发货地 A → B;到货地 C → D;YD-20260904-0025:发货地 E → F;到货地 G → H
*
* 展示格式(变更前 / 变更后分行):
* YD-20260904-0022:发货地 A →
* B
* 到货地 C →
* D
*
* 说明:仅用于展示层,不改变已存储的 content 文本(历史数据同样适用)。
*/
export const formatRouteChangeContent = (content, emptyText = '-') => {
if (content === undefined || content === null) return emptyText;
const text = String(content).trim();
if (!text) return emptyText;
return text
.replace(/\r\n?/g, '\n')
// 多条记录之间换行
.replace(/[ \t]*[;;][ \t]*/g, '\n')
// 变更前地址 / 变更后地址分行(箭头保留在上一行行尾)
.replace(/[ \t]*[-—]+>[ \t]*|[ \t]*→[ \t]*/g, ' →\n')
.replace(/\n{2,}/g, '\n')
.replace(/[ \t]+\n/g, '\n')
.trim();
};
+312 -35
View File
@@ -144,7 +144,7 @@
<script>
import { getList, getDetail, submit, remove, changeStatus } from '@/api/base/airport-master';
import { getLazyTree } from '@/api/base/region';
import { getDetail as getRegionDetail, getLazyTree } from '@/api/base/region';
import { exportBlob } from '@/api/common';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
@@ -322,6 +322,20 @@ export default {
prop: 'shortName',
minWidth: 130,
},
{
label: '详细地址',
prop: 'detailAddress',
formslot: true,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请选择详细地址', trigger: 'change' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '所属省份',
prop: 'provinceCode',
@@ -388,20 +402,6 @@ export default {
addDisplay: false,
editDisplay: false,
},
{
label: '详细地址',
prop: 'detailAddress',
formslot: true,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请选择详细地址', trigger: 'change' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '经度',
prop: 'longitude',
@@ -578,33 +578,51 @@ export default {
return targetColumns.find(item => item.prop === targetProp) || null;
},
loadProvinceOptions() {
getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
return getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
this.provinceOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('provinceCode').dicData = this.provinceOptions;
const provinceColumn = this.findColumn('provinceCode');
if (provinceColumn) {
provinceColumn.dicData = this.provinceOptions;
}
return this.provinceOptions;
});
},
loadCityOptions(provinceCode) {
if (!provinceCode) {
this.cityOptions = [];
this.findColumn('cityCode').dicData = [];
return Promise.resolve();
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = [];
}
return Promise.resolve([]);
}
return getLazyTree(provinceCode).then(res => {
this.cityOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('cityCode').dicData = this.cityOptions;
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = this.cityOptions;
}
return this.cityOptions;
});
},
loadDistrictOptions(cityCode) {
if (!cityCode) {
this.districtOptions = [];
this.findColumn('districtCode').dicData = [];
return Promise.resolve();
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = [];
}
return Promise.resolve([]);
}
this.districtLoading = true;
return getLazyTree(cityCode)
.then(res => {
this.districtOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('districtCode').dicData = this.districtOptions;
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = this.districtOptions;
}
return this.districtOptions;
})
.finally(() => {
this.districtLoading = false;
@@ -631,6 +649,22 @@ export default {
: String(row.districtCode),
};
},
ensureCityOption(row) {
if (!row.cityCode || !row.cityName) return;
const exists = this.cityOptions.some(item => item.id === row.cityCode);
if (exists) return;
this.cityOptions = [
...this.cityOptions,
{
id: row.cityCode,
title: row.cityName,
},
];
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = this.cityOptions;
}
},
ensureDistrictOption(row) {
if (!row.districtCode || !row.districtName) return;
const exists = this.districtOptions.some(item => item.id === row.districtCode);
@@ -642,7 +676,10 @@ export default {
title: row.districtName,
},
];
this.findColumn('districtCode').dicData = this.districtOptions;
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = this.districtOptions;
}
},
handleIataChange(value) {
this.form.iataCode = String(value || '')
@@ -698,25 +735,265 @@ export default {
openAddressMapPicker() {
this.addressMapPickerVisible = true;
},
formatMapCoordinate(value) {
if (value === undefined || value === null || value === '') {
return '';
}
const number = Number(value);
if (!Number.isFinite(number)) {
return '';
}
return normalizeCoordinateInput(number.toFixed(6));
},
patchDialogForm(patch = {}) {
Object.assign(this.form, patch);
const tableForm = this.$refs.crud?.tableForm;
if (tableForm && tableForm !== this.form) {
Object.assign(tableForm, patch);
}
},
handleAddressMapConfirm(payload) {
const selection = typeof payload === 'string' ? { address: payload } : payload || {};
const longitude = this.formatMapCoordinate(selection.longitude);
const latitude = this.formatMapCoordinate(selection.latitude);
const patch = {};
if (selection.address) {
this.form.detailAddress = selection.address;
patch.detailAddress = selection.address;
}
if (longitude !== '' && latitude !== '') {
patch.longitude = longitude;
patch.latitude = latitude;
}
if (Object.keys(patch).length) {
this.patchDialogForm(patch);
}
this.applyMapRegionSelection(selection);
},
normalizeAmapCityName(city, province) {
if (Array.isArray(city)) {
return String(province || '').trim();
}
const cityName = String(city || '').trim();
return cityName || String(province || '').trim();
},
buildRegionLookupCodes(adcode) {
const code = String(adcode || '').trim();
if (!code) {
return [];
}
const codes = [code];
if (/^\d{6}$/.test(code)) {
if (code.endsWith('00')) {
const cityCode = code.slice(0, 4);
if (cityCode && !codes.includes(cityCode)) {
codes.push(cityCode);
}
}
if (code.endsWith('0000')) {
const provinceCode = code.slice(0, 2);
if (provinceCode && !codes.includes(provinceCode)) {
codes.push(provinceCode);
}
}
}
return codes;
},
fetchRegionDetailByCodes(codes = []) {
const tryNext = index => {
if (index >= codes.length) {
return Promise.resolve(null);
}
return getRegionDetail(codes[index])
.then(res => {
const detail = res.data && res.data.data;
if (detail && detail.code) {
return detail;
}
return tryNext(index + 1);
})
.catch(() => tryNext(index + 1));
};
return tryNext(0);
},
resolveProvinceCode(provinceName) {
const name = String(provinceName || '').trim();
if (!name) {
return '';
}
const province = this.provinceOptions.find(
item => item.title === name || item.id === name || String(item.title).includes(name)
);
return province ? province.id : '';
},
resolveCityCode(cityName, regionCode) {
const name = String(cityName || '').trim();
const code = String(regionCode || '').trim();
const city = this.cityOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return city ? city.id : '';
},
resolveDistrictCode(districtName, regionCode) {
const name = String(districtName || '').trim();
const code = String(regionCode || '').trim();
const district = this.districtOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return district ? district.id : code || '';
},
applyRegionDetail(detail = {}) {
const provinceName = detail.provinceName || '';
const cityName = detail.cityName || '';
const districtName = detail.districtName || detail.name || '';
return this.loadProvinceOptions()
.then(() => {
const provinceCode =
String(detail.provinceCode || '').trim() || this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode =
String(detail.cityCode || '').trim() ||
this.resolveCityCode(cityName, detail.districtCode || detail.code);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(
districtName,
detail.districtCode || detail.code
);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionByNames(selection = {}) {
const provinceName = String(selection.province || selection.provinceName || '').trim();
const cityName = this.normalizeAmapCityName(
selection.city || selection.cityName,
provinceName
);
const districtName = String(selection.district || selection.districtName || '').trim();
const adcode = String(selection.adcode || selection.regionCode || '').trim();
// 本页无国家字段:国内 adcode 走中国省份树;无匹配则不强制回填
if (
selection.longitude !== undefined &&
selection.longitude !== null &&
selection.longitude !== ''
!provinceName &&
!cityName &&
!districtName &&
!(/^\d{6}$/.test(adcode) || String(selection.country || selection.countryName || '').includes('中国'))
) {
this.form.longitude = Number(selection.longitude).toFixed(6);
return Promise.resolve();
}
if (
selection.latitude !== undefined &&
selection.latitude !== null &&
selection.latitude !== ''
) {
this.form.latitude = Number(selection.latitude).toFixed(6);
return this.loadProvinceOptions()
.then(() => {
const provinceCode = this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode = this.resolveCityCode(cityName, adcode);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(districtName, adcode);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionSelection(selection = {}) {
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const hasNameHint = Boolean(
selection.country ||
selection.countryName ||
selection.province ||
selection.provinceName ||
selection.city ||
selection.cityName ||
selection.district ||
selection.districtName
);
if (!adcode && !hasNameHint) {
return Promise.resolve();
}
this.regionInitializing = true;
const lookupCodes = this.buildRegionLookupCodes(adcode);
return this.fetchRegionDetailByCodes(lookupCodes)
.then(detail => {
if (detail) {
return this.applyRegionDetail(detail);
}
return this.applyMapRegionByNames(selection);
})
.catch(() => this.applyMapRegionByNames(selection))
.finally(() => {
this.$nextTick(() => {
window.setTimeout(() => {
this.regionInitializing = false;
}, 0);
});
});
},
normalizeRow(row) {
row.code = row.iataCode ? `JC-${row.iataCode}` : row.code;
+30 -7
View File
@@ -344,15 +344,21 @@ export default {
getDeptTree(this.userInfo.tenantId).then(res => {
this.deptOptions = this.flattenDept(res.data.data || []);
const deptColumn = this.findColumn(this.option.column, 'searchDeptId');
deptColumn.dicData = this.deptOptions;
if (deptColumn) {
deptColumn.dicData = this.deptOptions;
}
});
},
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
// 雪花 ID 必须保持字符串,避免 JS Number 精度丢失导致筛错组织。
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return;
result.push({
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: item.id,
value: id,
rawLabel: item.title || item.deptName || item.name,
});
if (item.children && item.children.length) {
@@ -361,6 +367,19 @@ export default {
});
return result;
},
normalizeListQuery(params = {}) {
// 搜索条件优先于缓存的 query,避免旧组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
} else {
delete query.deptId;
}
query.allDept = this.isAdmin ? 1 : 0;
return query;
},
getRecords(res) {
const data = res && res.data && res.data.data;
if (Array.isArray(data)) return data;
@@ -967,13 +986,15 @@ export default {
},
searchReset() {
this.query = {};
this.allDept = this.isAdmin ? 1 : 0;
this.page.currentPage = 1;
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.allDept = this.query.allDept;
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -994,7 +1015,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, { ...params, ...this.query, allDept: this.allDept })
const query = this.normalizeListQuery(params);
this.query = query;
this.allDept = query.allDept;
getList(page.currentPage, page.pageSize, query)
.then(res => {
const data = res.data.data;
this.page.total = data.total;
@@ -1027,8 +1051,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: this.allDept,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
+326 -83
View File
@@ -264,7 +264,7 @@ import {
changeStatus,
getPortSelect,
} from '@/api/base/port-terminal';
import { getLazyTree } from '@/api/base/region';
import { getDetail as getRegionDetail, getLazyTree } from '@/api/base/region';
import { exportBlob } from '@/api/common';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
@@ -584,7 +584,7 @@ export default {
span: 12,
minWidth: 220,
overHidden: false,
order: 85,
order: 90,
placeholder: '点击选择地图地址',
maxlength: 255,
showWordLimit: true,
@@ -841,10 +841,60 @@ export default {
},
handleCountryNameChange(value) {
const country = this.countryOptions.find(item => item.title === value);
this.setProvinceOptions([]);
this.setCityOptions([]);
this.setDistrictOptions([]);
this.loadProvinceOptions(country ? country.id : '');
this.clearSearchCityAndDistrict();
// 搜索栏为国家 → 城市 → 区县(无省份),选中国家后需直接加载该国下全部城市
this.loadCityOptionsByCountry(country ? country.id : '');
},
clearSearchCityAndDistrict() {
const searchForm = this.$refs.crud && this.$refs.crud.searchForm;
if (searchForm) {
searchForm.city = undefined;
searchForm.districtName = undefined;
}
if (this.query) {
this.query.city = undefined;
this.query.districtName = undefined;
}
},
loadCityOptionsByCountry(countryCode) {
if (!countryCode) {
this.setCityOptions([]);
return Promise.resolve([]);
}
this.cityLoading = true;
return this.loadProvinceOptions(countryCode)
.then(provinces =>
Promise.all((provinces || []).map(province => this.fetchCityOptions(province.id)))
)
.then(cityGroups => {
const cityMap = new Map();
cityGroups.flat().forEach(city => {
if (city && city.id && !cityMap.has(city.id)) {
cityMap.set(city.id, city);
}
});
const cities = Array.from(cityMap.values());
this.setCityOptions(cities);
return cities;
})
.finally(() => {
this.cityLoading = false;
});
},
fetchCityOptions(provinceCode) {
if (!provinceCode) {
return Promise.resolve([]);
}
if (this.cityOptionCache[provinceCode]) {
return Promise.resolve(this.cityOptionCache[provinceCode]);
}
return getLazyTree(provinceCode).then(res => {
const options = (res.data.data || []).map(this.normalizeRegionOption);
this.cityOptionCache[provinceCode] = options;
return options;
});
},
syncCountryName(countryCode) {
const country = this.countryOptions.find(item => item.id === countryCode);
@@ -925,10 +975,8 @@ export default {
return Promise.resolve(this.cityOptionCache[provinceCode]);
}
this.cityLoading = true;
return getLazyTree(provinceCode)
.then(res => {
const options = (res.data.data || []).map(this.normalizeRegionOption);
this.cityOptionCache[provinceCode] = options;
return this.fetchCityOptions(provinceCode)
.then(options => {
this.setCityOptions(options);
return options;
})
@@ -1047,6 +1095,13 @@ export default {
handleCityNameChange(value) {
const city = this.cityOptions.find(item => item.title === value);
this.setDistrictOptions([]);
const searchForm = this.$refs.crud && this.$refs.crud.searchForm;
if (searchForm) {
searchForm.districtName = undefined;
}
if (this.query) {
this.query.districtName = undefined;
}
this.loadDistrictOptions(city ? city.id : '');
},
handleDistrictChange(value) {
@@ -1060,17 +1115,224 @@ export default {
openAddressMapPicker() {
this.addressMapPickerVisible = true;
},
formatMapCoordinate(value) {
if (value === undefined || value === null || value === '') {
return '';
}
const number = Number(value);
if (!Number.isFinite(number)) {
return '';
}
return normalizeCoordinateInput(number.toFixed(6));
},
patchDialogForm(patch = {}) {
// 同步页面 form 与 Avue 弹窗内部 tableForm,避免只改了一边导致经纬度不刷新
Object.assign(this.form, patch);
const tableForm = this.$refs.crud?.tableForm;
if (tableForm && tableForm !== this.form) {
Object.assign(tableForm, patch);
}
},
handleAddressMapConfirm(payload) {
const selection = typeof payload === 'string' ? { address: payload } : payload || {};
const longitude = this.formatMapCoordinate(selection.longitude);
const latitude = this.formatMapCoordinate(selection.latitude);
const patch = {};
if (selection.address) {
this.form.detailAddress = selection.address;
patch.detailAddress = selection.address;
}
if (selection.longitude !== undefined && selection.longitude !== null && selection.longitude !== '') {
this.form.longitude = Number(selection.longitude).toFixed(6);
// 换地址时必须同步覆盖经纬度,不能保留上一次选点
if (longitude !== '' && latitude !== '') {
patch.longitude = longitude;
patch.latitude = latitude;
}
if (selection.latitude !== undefined && selection.latitude !== null && selection.latitude !== '') {
this.form.latitude = Number(selection.latitude).toFixed(6);
if (Object.keys(patch).length) {
this.patchDialogForm(patch);
}
this.applyMapRegionSelection(selection);
},
normalizeAmapCityName(city, province) {
if (Array.isArray(city)) {
return String(province || '').trim();
}
const cityName = String(city || '').trim();
return cityName || String(province || '').trim();
},
buildRegionLookupCodes(adcode) {
const code = String(adcode || '').trim();
if (!code) {
return [];
}
const codes = [code];
if (/^\d{6}$/.test(code)) {
if (code.endsWith('00')) {
const cityCode = code.slice(0, 4);
if (cityCode && !codes.includes(cityCode)) {
codes.push(cityCode);
}
}
if (code.endsWith('0000')) {
const provinceCode = code.slice(0, 2);
if (provinceCode && !codes.includes(provinceCode)) {
codes.push(provinceCode);
}
}
}
return codes;
},
fetchRegionDetailByCodes(codes = []) {
const tryNext = index => {
if (index >= codes.length) {
return Promise.resolve(null);
}
return getRegionDetail(codes[index])
.then(res => {
const detail = res.data && res.data.data;
if (detail && detail.code) {
return detail;
}
return tryNext(index + 1);
})
.catch(() => tryNext(index + 1));
};
return tryNext(0);
},
resolveCountryFromRegionDetail(detail = {}) {
const ancestors = String(detail.ancestors || '')
.split(',')
.map(item => String(item || '').trim())
.filter(Boolean);
for (let index = 0; index < ancestors.length; index += 1) {
const ancestorCode = ancestors[index];
if (this.countryOptions.some(item => item.id === ancestorCode)) {
return ancestorCode;
}
}
if (
detail.parentCode &&
Number(detail.regionLevel) === 1 &&
this.countryOptions.some(item => item.id === String(detail.parentCode))
) {
return String(detail.parentCode);
}
if (/^\d{6}$/.test(String(detail.code || ''))) {
return DEFAULT_COUNTRY_CODE;
}
return '';
},
applyRegionDetail(detail = {}) {
const countryHint = this.resolveCountryFromRegionDetail(detail);
return this.resolveCountryCode(countryHint || DEFAULT_COUNTRY_CODE)
.then(countryCode => {
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(() => {
this.form.provinceCode =
String(detail.provinceCode || '') || this.resolveProvinceCode(detail.provinceName);
const province = this.provinceOptions.find(item => item.id === this.form.provinceCode);
this.form.provinceName = province ? province.title : detail.provinceName || '';
return this.loadCityOptions(this.form.provinceCode);
})
.then(() => {
this.form.cityCode =
String(detail.cityCode || '') ||
this.resolveCityCode(detail.cityName, detail.districtCode || detail.code);
const city = this.cityOptions.find(item => item.id === this.form.cityCode);
this.form.city = city ? city.title : detail.cityName || '';
return this.loadDistrictOptions(this.form.cityCode);
})
.then(() => {
this.form.districtCode = this.resolveDistrictCode(
detail.districtName || detail.name,
detail.districtCode || detail.code
);
this.handleDistrictChange(this.form.districtCode);
});
},
applyMapRegionByNames(selection = {}) {
const provinceName = String(selection.province || selection.provinceName || '').trim();
const cityName = this.normalizeAmapCityName(
selection.city || selection.cityName,
provinceName
);
const districtName = String(selection.district || selection.districtName || '').trim();
const countryName = String(selection.country || selection.countryName || '').trim();
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const countryPromise = countryName
? this.resolveCountryCode(countryName)
: Promise.resolve(/^\d{6}$/.test(adcode) ? DEFAULT_COUNTRY_CODE : '');
return countryPromise
.then(countryCode => {
if (!countryCode) {
return null;
}
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(provinces => {
if (!provinces) {
return null;
}
this.form.provinceCode = this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === this.form.provinceCode);
this.form.provinceName = province ? province.title : provinceName;
if (!this.form.provinceCode) {
this.clearCityValue();
return null;
}
return this.loadCityOptions(this.form.provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
this.form.cityCode = this.resolveCityCode(cityName, adcode);
const city = this.cityOptions.find(item => item.id === this.form.cityCode);
this.form.city = city ? city.title : cityName;
if (!this.form.cityCode) {
this.clearDistrictValue();
return null;
}
return this.loadDistrictOptions(this.form.cityCode);
})
.then(districts => {
if (!districts) {
return;
}
this.form.districtCode = this.resolveDistrictCode(districtName, adcode);
this.handleDistrictChange(this.form.districtCode);
});
},
applyMapRegionSelection(selection = {}) {
if (this.isParentRegionLocked) {
return Promise.resolve();
}
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const hasNameHint = Boolean(
selection.country ||
selection.countryName ||
selection.province ||
selection.provinceName ||
selection.city ||
selection.cityName ||
selection.district ||
selection.districtName
);
if (!adcode && !hasNameHint) {
return Promise.resolve();
}
const lookupCodes = this.buildRegionLookupCodes(adcode);
return this.fetchRegionDetailByCodes(lookupCodes)
.then(detail => {
if (detail) {
return this.applyRegionDetail(detail);
}
return this.applyMapRegionByNames(selection);
})
.catch(() => this.applyMapRegionByNames(selection));
},
resolveCountryCode(country) {
if (!country) {
@@ -1251,13 +1513,50 @@ export default {
this.$message({ type: 'success', message: '操作成功!' });
});
},
createEmptyPortTerminalForm() {
return {
category: '港口',
dataSource: '手动录入',
status: 1,
parentId: undefined,
parentCode: '',
parentName: '',
name: '',
code: '',
terminalCode: '',
country: '',
countryCode: '',
provinceCode: undefined,
provinceName: '',
cityCode: undefined,
city: '',
districtCode: undefined,
districtName: '',
regionCode: '',
detailAddress: '',
longitude: '',
latitude: '',
remark: '',
};
},
resetAddForm() {
// 新增必须整表重置,避免沿用上一次编辑/查看残留
this.form = this.createEmptyPortTerminalForm();
this.setProvinceOptions([]);
this.setCityOptions([]);
this.setDistrictOptions([]);
},
beforeOpen(done, type) {
if (type === 'add') {
this.form.category = this.form.category || '港口';
this.form.dataSource = this.form.dataSource || '手动录入';
this.form.status = this.form.status || 1;
this.resetAddForm();
this.handleCategoryChange('港口', false);
done();
this.$nextTick(() => {
this.$refs.crud?.clearValidate?.();
setTimeout(() => this.disableDialogAutocomplete(), 50);
});
return;
}
this.handleCategoryChange(this.form.category || '港口');
if (['edit', 'view'].includes(type)) {
getDetail(this.form.id).then(res => {
this.form = res.data.data;
@@ -1292,35 +1591,7 @@ export default {
setTimeout(() => this.disableDialogAutocomplete(), 50);
});
});
return;
}
this.resolveCountryCode(this.form.country)
.then(countryCode => {
this.form.countryCode = countryCode;
this.syncCountryName(countryCode);
return this.loadProvinceOptions(countryCode);
})
.then(() => {
this.form.provinceCode =
String(this.form.provinceCode || '') ||
this.resolveProvinceCode(this.form.provinceName);
return this.loadCityOptions(this.form.provinceCode);
})
.then(() => {
this.form.cityCode = this.resolveCityCode(this.form.city, this.form.regionCode);
return this.loadDistrictOptions(this.form.cityCode);
})
.then(() => {
this.form.districtCode = this.resolveDistrictCode(
this.form.districtName,
this.form.regionCode
);
if (!this.form.regionCode) {
this.handleDistrictChange(this.form.districtCode);
}
done();
setTimeout(() => this.disableDialogAutocomplete(), 50);
});
},
disableDialogAutocomplete() {
this.$nextTick(() => {
@@ -1443,42 +1714,14 @@ export default {
</style>
<style lang="scss">
/* 港口码头编辑/新增弹窗:对齐 railway-station 统一风格(灰底 + 白卡表单 + 浮动底栏,padding 24px 与全局弹窗标准一致) */
//.port-terminal-edit-dialog {
// /* 灰底内容区 */
// .el-dialog__body {
// background-color: #f2f2f2;
// padding: 8px;
// }
// /* 表单白卡(与全局 .el-dialog .avue-form 一致,padding 24px 对齐 customer-archive / railway-station) */
// .avue-form {
// background: #fff;
// border-radius: 6px;
// box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
// padding: 24px !important;
// margin-bottom: 0;
// }
// /* 底部按钮区白卡(Avue 弹窗 footer 在 body 内,接续白卡) */
// .avue-dialog__footer {
// background: #fff;
// border-radius: 0 0 6px 6px;
// box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
// padding: 16px 24px !important;
// display: flex;
// justify-content: flex-end;
// }
// /* label 宽度统一 108px(对齐 railway-station,标签对齐更整齐) */
// .el-form-item__label {
// width: 108px !important;
// }
// /* 所有 input 文本框、select 下拉控件统一宽度 250px */
// .el-input,
// .el-select {
// width: 250px !important;
// }
// /* 备注宽度 85% */
// .remark-input {
// width: 85% !important;
// }
//}
/* 港口码头编辑/新增弹窗:与全局表单项行距保持一致 */
.port-terminal-edit-dialog {
.el-form-item,
.el-form-item--default.el-form-item,
.el-form-item--small.el-form-item,
.el-form-item--large.el-form-item,
.el-form-item--medium.el-form-item {
margin-bottom: 20px;
}
}
</style>
+307 -45
View File
@@ -144,7 +144,7 @@
<script>
import { getList, getDetail, submit, remove, changeStatus } from '@/api/base/railway-station';
import { getLazyTree } from '@/api/base/region';
import { getDetail as getRegionDetail, getLazyTree } from '@/api/base/region';
import { exportBlob } from '@/api/common';
import { mapGetters } from 'vuex';
import { downloadXls } from '@/utils/util';
@@ -171,7 +171,12 @@ export default {
}
};
const validateTelegraphCode = (rule, value, callback) => {
if (!/^[A-Z]{3}$/.test(String(value || '').toUpperCase())) {
const telegraphCode = String(value || '').trim();
if (!telegraphCode) {
callback();
return;
}
if (!/^[A-Z]{3}$/.test(telegraphCode.toUpperCase())) {
callback(new Error('电报略码格式不正确或已存在'));
} else {
callback();
@@ -273,10 +278,7 @@ export default {
prop: 'telegraphCode',
minWidth: 100,
maxlength: 3,
rules: [
{ required: true, message: '电报略码格式不正确或已存在', trigger: 'blur' },
{ validator: validateTelegraphCode, trigger: 'blur' },
],
rules: [{ validator: validateTelegraphCode, trigger: 'blur' }],
change: ({ value }) => this.handleTelegraphChange(value),
blur: ({ value }) => this.handleTelegraphChange(value),
},
@@ -293,6 +295,20 @@ export default {
// prop: 'railwayLine',
// minWidth: 130,
// },
{
label: '详细地址',
prop: 'detailAddress',
formslot: true,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请选择详细地址', trigger: 'change' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '所属省份',
prop: 'provinceCode',
@@ -360,20 +376,6 @@ export default {
addDisplay: false,
editDisplay: false,
},
{
label: '详细地址',
prop: 'detailAddress',
formslot: true,
span: 24,
minWidth: 220,
overHidden: false,
maxlength: 255,
showWordLimit: true,
rules: [
{ required: true, message: '请选择详细地址', trigger: 'change' },
{ max: 255, message: '详细地址不能超过255字', trigger: 'blur' },
],
},
{
label: '经度',
prop: 'longitude',
@@ -549,33 +551,51 @@ export default {
return targetColumns.find(item => item.prop === targetProp) || null;
},
loadProvinceOptions() {
getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
return getLazyTree(DEFAULT_COUNTRY_CODE).then(res => {
this.provinceOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('provinceCode').dicData = this.provinceOptions;
const provinceColumn = this.findColumn('provinceCode');
if (provinceColumn) {
provinceColumn.dicData = this.provinceOptions;
}
return this.provinceOptions;
});
},
loadCityOptions(provinceCode) {
if (!provinceCode) {
this.cityOptions = [];
this.findColumn('cityCode').dicData = [];
return Promise.resolve();
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = [];
}
return Promise.resolve([]);
}
return getLazyTree(provinceCode).then(res => {
this.cityOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('cityCode').dicData = this.cityOptions;
const cityColumn = this.findColumn('cityCode');
if (cityColumn) {
cityColumn.dicData = this.cityOptions;
}
return this.cityOptions;
});
},
loadDistrictOptions(cityCode) {
if (!cityCode) {
this.districtOptions = [];
this.findColumn('districtCode').dicData = [];
return Promise.resolve();
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = [];
}
return Promise.resolve([]);
}
this.districtLoading = true;
return getLazyTree(cityCode)
.then(res => {
this.districtOptions = (res.data.data || []).map(this.normalizeRegionOption);
this.findColumn('districtCode').dicData = this.districtOptions;
const districtColumn = this.findColumn('districtCode');
if (districtColumn) {
districtColumn.dicData = this.districtOptions;
}
return this.districtOptions;
})
.finally(() => {
this.districtLoading = false;
@@ -681,28 +701,269 @@ export default {
openAddressMapPicker() {
this.addressMapPickerVisible = true;
},
formatMapCoordinate(value) {
if (value === undefined || value === null || value === '') {
return '';
}
const number = Number(value);
if (!Number.isFinite(number)) {
return '';
}
return normalizeCoordinateInput(number.toFixed(6));
},
patchDialogForm(patch = {}) {
Object.assign(this.form, patch);
const tableForm = this.$refs.crud?.tableForm;
if (tableForm && tableForm !== this.form) {
Object.assign(tableForm, patch);
}
},
handleAddressMapConfirm(payload) {
const selection = typeof payload === 'string' ? { address: payload } : payload || {};
const longitude = this.formatMapCoordinate(selection.longitude);
const latitude = this.formatMapCoordinate(selection.latitude);
const patch = {};
if (selection.address) {
this.form.detailAddress = selection.address;
patch.detailAddress = selection.address;
}
if (longitude !== '' && latitude !== '') {
patch.longitude = longitude;
patch.latitude = latitude;
}
if (Object.keys(patch).length) {
this.patchDialogForm(patch);
}
this.applyMapRegionSelection(selection);
},
normalizeAmapCityName(city, province) {
if (Array.isArray(city)) {
return String(province || '').trim();
}
const cityName = String(city || '').trim();
return cityName || String(province || '').trim();
},
buildRegionLookupCodes(adcode) {
const code = String(adcode || '').trim();
if (!code) {
return [];
}
const codes = [code];
if (/^\d{6}$/.test(code)) {
if (code.endsWith('00')) {
const cityCode = code.slice(0, 4);
if (cityCode && !codes.includes(cityCode)) {
codes.push(cityCode);
}
}
if (code.endsWith('0000')) {
const provinceCode = code.slice(0, 2);
if (provinceCode && !codes.includes(provinceCode)) {
codes.push(provinceCode);
}
}
}
return codes;
},
fetchRegionDetailByCodes(codes = []) {
const tryNext = index => {
if (index >= codes.length) {
return Promise.resolve(null);
}
return getRegionDetail(codes[index])
.then(res => {
const detail = res.data && res.data.data;
if (detail && detail.code) {
return detail;
}
return tryNext(index + 1);
})
.catch(() => tryNext(index + 1));
};
return tryNext(0);
},
resolveProvinceCode(provinceName) {
const name = String(provinceName || '').trim();
if (!name) {
return '';
}
const province = this.provinceOptions.find(
item => item.title === name || item.id === name || String(item.title).includes(name)
);
return province ? province.id : '';
},
resolveCityCode(cityName, regionCode) {
const name = String(cityName || '').trim();
const code = String(regionCode || '').trim();
const city = this.cityOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return city ? city.id : '';
},
resolveDistrictCode(districtName, regionCode) {
const name = String(districtName || '').trim();
const code = String(regionCode || '').trim();
const district = this.districtOptions.find(
item => item.title === name || item.id === code || item.id === name
);
return district ? district.id : code || '';
},
applyRegionDetail(detail = {}) {
const provinceName = detail.provinceName || '';
const cityName = detail.cityName || '';
const districtName = detail.districtName || detail.name || '';
return this.loadProvinceOptions()
.then(() => {
const provinceCode =
String(detail.provinceCode || '').trim() || this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode =
String(detail.cityCode || '').trim() ||
this.resolveCityCode(cityName, detail.districtCode || detail.code);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(
districtName,
detail.districtCode || detail.code
);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionByNames(selection = {}) {
const provinceName = String(selection.province || selection.provinceName || '').trim();
const cityName = this.normalizeAmapCityName(
selection.city || selection.cityName,
provinceName
);
const districtName = String(selection.district || selection.districtName || '').trim();
const adcode = String(selection.adcode || selection.regionCode || '').trim();
// 本页无国家字段:国内 adcode 走中国省份树;无匹配则不强制回填
if (
selection.longitude !== undefined &&
selection.longitude !== null &&
selection.longitude !== ''
!provinceName &&
!cityName &&
!districtName &&
!(/^\d{6}$/.test(adcode) || String(selection.country || selection.countryName || '').includes('中国'))
) {
this.form.longitude = Number(selection.longitude).toFixed(6);
return Promise.resolve();
}
if (
selection.latitude !== undefined &&
selection.latitude !== null &&
selection.latitude !== ''
) {
this.form.latitude = Number(selection.latitude).toFixed(6);
return this.loadProvinceOptions()
.then(() => {
const provinceCode = this.resolveProvinceCode(provinceName);
const province = this.provinceOptions.find(item => item.id === provinceCode);
this.patchDialogForm({
provinceCode: provinceCode || undefined,
provinceName: province ? province.title : provinceName,
});
if (!provinceCode) {
return null;
}
return this.loadCityOptions(provinceCode);
})
.then(cities => {
if (!cities) {
return null;
}
const cityCode = this.resolveCityCode(cityName, adcode);
const city = this.cityOptions.find(item => item.id === cityCode);
this.patchDialogForm({
cityCode: cityCode || undefined,
cityName: city ? city.title : cityName,
});
this.ensureCityOption({
cityCode,
cityName: city ? city.title : cityName,
});
if (!cityCode) {
return null;
}
return this.loadDistrictOptions(cityCode);
})
.then(districts => {
if (!districts) {
return;
}
const districtCode = this.resolveDistrictCode(districtName, adcode);
this.patchDialogForm({
districtCode: districtCode || undefined,
});
this.handleDistrictChange(districtCode);
this.ensureDistrictOption({
districtCode,
districtName: this.form.districtName || districtName,
});
});
},
applyMapRegionSelection(selection = {}) {
const adcode = String(selection.adcode || selection.regionCode || '').trim();
const hasNameHint = Boolean(
selection.country ||
selection.countryName ||
selection.province ||
selection.provinceName ||
selection.city ||
selection.cityName ||
selection.district ||
selection.districtName
);
if (!adcode && !hasNameHint) {
return Promise.resolve();
}
this.regionInitializing = true;
const lookupCodes = this.buildRegionLookupCodes(adcode);
return this.fetchRegionDetailByCodes(lookupCodes)
.then(detail => {
if (detail) {
return this.applyRegionDetail(detail);
}
return this.applyMapRegionByNames(selection);
})
.catch(() => this.applyMapRegionByNames(selection))
.finally(() => {
this.$nextTick(() => {
window.setTimeout(() => {
this.regionInitializing = false;
}, 0);
});
});
},
normalizeRow(row) {
row.code = row.tmisCode ? `TL${row.tmisCode}` : row.code;
row.telegraphCode = String(row.telegraphCode || '').trim() || undefined;
row.regionCode = String(row.regionCode || '').trim();
row.regionName = String(row.regionName || '').trim();
row.districtCode = String(row.districtCode || '').trim();
@@ -735,14 +996,15 @@ export default {
return records.some(item => item[prop] === row[prop] && String(item.id || '') !== rowId);
});
};
return Promise.all([
hasDuplicate({ tmisCode: row.tmisCode }, 'tmisCode'),
hasDuplicate({ telegraphCode: row.telegraphCode }, 'telegraphCode'),
]).then(([tmisExists, telegraphExists]) => {
if (tmisExists) {
const uniqueChecks = [hasDuplicate({ tmisCode: row.tmisCode }, 'tmisCode')];
if (row.telegraphCode) {
uniqueChecks.push(hasDuplicate({ telegraphCode: row.telegraphCode }, 'telegraphCode'));
}
return Promise.all(uniqueChecks).then(results => {
if (results[0]) {
return Promise.reject(new Error('该TMIS编码已存在'));
}
if (telegraphExists) {
if (results[1]) {
return Promise.reject(new Error('电报略码格式不正确或已存在'));
}
return Promise.resolve();
+1 -1
View File
@@ -787,6 +787,6 @@ export default {
margin-bottom: 12px !important;
}
.el-form-item{
margin-bottom: 9px !important;
margin-bottom: 22px !important;
}
</style>
+2 -1
View File
@@ -352,7 +352,8 @@ export default {
return result;
},
normalizeListQuery(params = {}) {
const query = { ...params, ...this.query };
// 搜索条件优先于缓存的 query,避免默认组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
+41 -17
View File
@@ -415,19 +415,27 @@ export default {
});
},
buildDeptTree(tree) {
return tree.map(item => ({
label: item.title || item.deptName || item.name,
value: item.id,
children:
item.children && item.children.length ? this.buildDeptTree(item.children) : undefined,
}));
return tree.map(item => {
// 雪花 ID 必须保持字符串,避免 JS Number 精度丢失导致筛错组织。
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
return {
label: item.title || item.deptName || item.name,
value: id,
children:
item.children && item.children.length ? this.buildDeptTree(item.children) : undefined,
};
}).filter(item => item.value);
},
flattenDept(tree, level = 0) {
const result = [];
tree.forEach(item => {
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return;
result.push({
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: item.id,
value: id,
});
if (item.children && item.children.length) {
result.push(...this.flattenDept(item.children, level + 1));
@@ -436,7 +444,26 @@ export default {
return result;
},
defaultDeptId() {
return (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
const raw = (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
return (
String(raw)
.split(',')
.map(item => item.trim())
.filter(Boolean)[0] || ''
);
},
normalizeListQuery(params = {}) {
// 搜索条件优先于缓存的 query,避免默认组织 ID 覆盖用户新选择的组织。
const query = { ...this.query, ...params };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
} else {
delete query.deptId;
}
query.allDept = this.isAdmin ? 1 : 0;
return query;
},
applyDefaultDeptSearch() {
const deptId = this.defaultDeptId();
@@ -999,9 +1026,9 @@ export default {
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -1024,12 +1051,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeListQuery(params);
this.query = query;
api
.getList(page.currentPage, page.pageSize, {
...params,
...this.query,
allDept: this.isAdmin ? 1 : 0,
})
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data;
this.page.total = result.total;
@@ -1042,8 +1067,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: this.isAdmin ? 1 : 0,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
@@ -171,7 +171,7 @@
:label="item.label"
:value="item.value" /></el-select></template
></el-table-column>
<el-table-column label="单价(元)" width="180"
<el-table-column :label="unitPriceColumnLabel" width="180"
><template #default="{ row }"
><div v-if="readonly && usesRangeUnitPrice(row)" class="limit-list">
<span v-for="(item, rangeIndex) in getRanges(row)" :key="rangeIndex">{{
@@ -379,6 +379,7 @@ import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { InfoFilled } from '@element-plus/icons-vue';
import { getDictionary } from '@/api/system/dictbiz';
import SectionCard from '@/components/section-card/main.vue';
import { currencyUnit } from '@/utils/currency';
const clone = value => JSON.parse(JSON.stringify(value));
/** 计费要素与计量单位维度的对应关系(这些要素可选计费单位) */
@@ -386,12 +387,15 @@ const BILLING_ELEMENT_DIMENSION_MAP = {
按重量: '重量',
按体积: '体积',
按数量: '数量',
按里程: '里程',
'按吨·公里': '吨·公里',
'固定金额(整单一口价)': '整车',
};
const BILLING_UNIT_SELECTABLE_ELEMENTS = Object.keys(BILLING_ELEMENT_DIMENSION_MAP);
/** 固定计费单位(不可编辑) */
const FIXED_BILLING_UNIT_MAP = {
/** 对应维度下无计量单位时的默认计费单位 */
const DEFAULT_BILLING_UNIT_MAP = {
按里程: '公里',
'按吨·公里': '吨公里',
'按吨·公里': '吨·公里',
'固定金额(整单一口价)': '单',
};
const defaultRule = () => ({
@@ -428,6 +432,7 @@ export default {
value: { type: Object, default: () => ({}) },
index: { type: Number, default: -1 },
readonly: Boolean,
settlementCurrency: { type: String, default: 'RMB' },
},
emits: ['update:modelValue', 'save'],
data() {
@@ -497,6 +502,9 @@ export default {
emitPath: true,
};
},
unitPriceColumnLabel() {
return `单价(${currencyUnit(this.settlementCurrency)})`;
},
},
mounted() {
this.loadDictionaries();
@@ -652,58 +660,61 @@ export default {
}),
]).finally(() => {
this.unitLoading = false;
(this.draft.rules || []).forEach(row => this.syncBillingUnit(row));
});
},
measurementDimension(billingElement) {
return BILLING_ELEMENT_DIMENSION_MAP[billingElement] || '';
},
fixedBillingUnit(billingElement) {
return FIXED_BILLING_UNIT_MAP[String(billingElement || '').trim()] || '';
defaultBillingUnit(billingElement) {
return DEFAULT_BILLING_UNIT_MAP[String(billingElement || '').trim()] || '';
},
canSelectBillingUnit(row) {
return BILLING_UNIT_SELECTABLE_ELEMENTS.includes(String(row?.billingElement || '').trim());
},
billingUnitPlaceholder(row) {
if (!row?.billingElement) return '请先选择计费要素';
if (this.fixedBillingUnit(row.billingElement)) return this.fixedBillingUnit(row.billingElement);
if (!this.canSelectBillingUnit(row)) return '当前计费要素无需选择';
return '请选择';
},
unitOptionsFor(row) {
const fixedUnit = this.fixedBillingUnit(row?.billingElement);
if (fixedUnit) {
return [{ id: fixedUnit, label: fixedUnit, value: fixedUnit }];
}
if (!this.canSelectBillingUnit(row)) {
return [];
}
const dimension = this.measurementDimension(row?.billingElement);
if (dimension) {
return this.measurementUnits
.filter(item => String(item.dimension || '').trim() === dimension)
.map(item => ({
id: item.id,
label: item.unitName,
value: item.unitName,
}))
.filter(item => item.value);
const defaultUnit = this.defaultBillingUnit(row?.billingElement);
const options = dimension
? this.measurementUnits
.filter(item => String(item.dimension || '').trim() === dimension)
.map(item => ({
id: item.id,
label: item.unitName,
value: item.unitName,
}))
.filter(item => item.value)
: [];
// 对应维度未配置计量单位时,下拉仅展示默认值
if (!options.length && defaultUnit) {
return [{ id: `default-${defaultUnit}`, label: defaultUnit, value: defaultUnit }];
}
return [];
return options;
},
syncBillingUnit(row) {
const fixedUnit = this.fixedBillingUnit(row?.billingElement);
if (fixedUnit) {
row.billingUnit = fixedUnit;
return;
}
if (!this.canSelectBillingUnit(row)) {
row.billingUnit = '';
return;
}
const options = this.unitOptionsFor(row);
if (!options.some(item => String(item.value) === String(row.billingUnit || ''))) {
row.billingUnit = '';
const defaultUnit = this.defaultBillingUnit(row?.billingElement);
const current = String(row.billingUnit || '').trim();
if (options.some(item => String(item.value) === current)) {
return;
}
if (defaultUnit && options.some(item => String(item.value) === defaultUnit)) {
row.billingUnit = defaultUnit;
return;
}
row.billingUnit = options[0]?.value || defaultUnit || '';
},
feeTypeKey(row) {
const option = this.feeCategories.find(
@@ -215,7 +215,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -2065,11 +2065,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -2338,14 +2349,16 @@
<div class="business-crud-page__transport-plan-detail-field is-attachments">
<span>附件</span>
<div class="business-crud-page__transport-plan-detail-attachments">
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ attachmentName(file) }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -2531,14 +2544,16 @@
<div class="business-crud-page__waybill-attachments">
<span class="business-crud-page__waybill-label">附件</span>
<div>
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ file.originalName || file.name || '-' }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.originalName || file.name || '-' }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -2820,11 +2835,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260">
<el-table-column
label="文件名"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column
@@ -2968,9 +2993,10 @@
<el-table-column prop="changeUser" label="变更人" min-width="120" />
<el-table-column prop="content" label="变更内容" min-width="360">
<template #default="{ row }">
<div class="business-crud-page__route-change-record-content">
{{ row.content || '-' }}
</div>
<div
class="business-crud-page__route-change-record-content"
v-text="formatRouteChangeContent(row.content)"
/>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
@@ -3056,7 +3082,7 @@
<el-option
v-for="item in transportPlanImportContractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -4225,11 +4251,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row, dispatchItemAttachmentRows)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.enter="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.space.prevent="previewAttachment(row, dispatchItemAttachmentRows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -5242,8 +5279,11 @@ 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 { formatContractOptionLabel } from '@/utils/contract';
import { openImportDialog } from '@/utils/import-excel';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { formatRouteChangeContent } from '@/utils/route-change';
import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
@@ -5822,7 +5862,7 @@ export default {
billCycleTypeOptions: ['固定截单日', '自然月', '自定义多周期'],
reconciliationModeOptions: ['明细逐行核对', '按账单汇总核对', '跳过自动核对'],
packageOptions,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
quantityUnitOptions: [],
attachmentFileTypes: [
'pdf',
'bmp',
@@ -5865,7 +5905,6 @@ export default {
this.ensureBillingUnitOptions();
}
if (this.config.enableTaskInfoForm) {
this.loadTaskQuantityUnitOptions();
this.loadTaskFeeUnitOptions();
this.ensureBillingCargoTypeOptions();
}
@@ -5886,6 +5925,7 @@ export default {
} else {
this.consumeTemplateCreatePayload();
}
this.loadTaskQuantityUnitOptions();
},
computed: {
...mapGetters(['permission', 'userInfo']),
@@ -6589,6 +6629,8 @@ export default {
},
},
methods: {
formatContractOptionLabel,
formatRouteChangeContent,
initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
@@ -7789,8 +7831,6 @@ export default {
['contractName', '客户合同'],
['planName', '计划名称'],
['transportType', '运输方式'],
['planStartDate', '计划开始日期'],
['planEndDate', '计划结束日期'],
['departureAddress', '发货地址'],
['arrivalAddress', '收货地址'],
];
@@ -9270,9 +9310,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(() => {
@@ -14111,7 +14152,7 @@ export default {
&__dispatch-shipping-form {
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
}
@@ -14634,7 +14675,7 @@ export default {
width: 100%;
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
:deep(.el-select),
@@ -15860,6 +15901,9 @@ export default {
&__route-change-record-content {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 22px;
}
@media (max-width: 1200px) {
@@ -16251,7 +16295,7 @@ export default {
}
:global(.business-crud-page__mileage-dialog .business-crud-page__mileage-form .el-form-item) {
margin-bottom: 16px;
margin-bottom: 24px;
}
:global(.business-crud-page__mileage-dialog .business-crud-page__mileage-form .el-input),
@@ -16,7 +16,7 @@
class="contract-manage-form__attachment-table"
@selection-change="selected = $event"
>
<el-table-column v-if="!readonly" type="selection" width="55" align="center" />
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column v-if="attachmentType" label="附件类型" min-width="160" align="center">
<template #default="{ row, $index }">
@@ -45,11 +45,17 @@
align="left"
header-align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="preview?.(row, rows)">{{
attachmentName(row)
}}</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="preview?.(row, rows)"
@keydown.enter="preview?.(row, rows)"
@keydown.space.prevent="preview?.(row, rows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column v-if="description" label="附件描述" min-width="220">
@@ -173,13 +179,41 @@ import { downloadFileByUrl } from '@/utils/util';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
const CONTRACT_ATTACHMENT_TYPE_OTHER = '其他文件';
/** 用印材料历史取值兼容别名 */
export const CONTRACT_SEAL_MATERIAL_LOCATION = '用印材料';
export const CONTRACT_SEAL_MATERIAL_TYPE_ALIASES = {
合同文件: CONTRACT_SEAL_MATERIAL_LOCATION,
};
/** 佐证材料附件位置(历史:其它附件) */
export const CONTRACT_SUPPORTING_ATTACHMENT_LOCATION = '附件(佐证材料等)';
export const CONTRACT_ATTACHMENT_TYPE_DICT = 'contract_attachment_type';
export const CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT = 'contract_supporting_attachment_type';
export const isContractSupportingAttachmentLocation = location => {
const text = String(location || '');
return (
text === CONTRACT_SUPPORTING_ATTACHMENT_LOCATION ||
text === '其它附件' ||
text === '其他附件'
);
};
export const resolveAttachmentTypeDictCode = (location, fallback = CONTRACT_ATTACHMENT_TYPE_DICT) =>
isContractSupportingAttachmentLocation(location)
? CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
: fallback || CONTRACT_ATTACHMENT_TYPE_DICT;
export const defaultContractAttachmentTypeOptions = [
{ label: '合同文件', value: '合同文件' },
{ label: '用印材料', value: '用印材料' },
{ label: '双章归档文件', value: '双章归档文件' },
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典 contract_attachment_type 动态加载 */
export const defaultContractSupportingAttachmentTypeOptions = [
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典动态加载 */
export const contractAttachmentTypeOptions = defaultContractAttachmentTypeOptions;
export const isContractSealMaterialLocation = location => {
const text = String(location || '');
return text === CONTRACT_SEAL_MATERIAL_LOCATION || text === '合同文件';
};
export const attachmentFileTypes = [
'pdf',
'bmp',
@@ -234,8 +268,11 @@ const isOtherAttachmentTypeOption = item => {
const attachmentTypeKeywords = fileType => {
const text = String(fileType || '');
if (text.includes('双章')) return ['双章归档文件', '双章归档', '双章'];
if (text.includes('合同') && !text.includes('其他') && !text.includes('其它')) {
return ['合同文件', '合同'];
if (
text.includes('用印') ||
(text.includes('合同') && !text.includes('其他') && !text.includes('其它'))
) {
return ['用印材料', '合同文件', '用印', '合同'];
}
if (isOtherAttachmentTypeOption({ label: text, value: text })) return [];
return text ? [text] : [];
@@ -254,8 +291,13 @@ export const resolveContractAttachmentType = (
const list = options.length ? options : defaultContractAttachmentTypeOptions;
const otherType = resolveOtherAttachmentType(list);
const values = list.map(item => item.value);
if (values.includes(row.fileType)) return row.fileType;
const matchedByLabel = list.find(item => String(item.label) === String(row.fileType));
const rawFileType = String(row.fileType || '');
const aliasedFileType = CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawFileType] || rawFileType;
if (values.includes(aliasedFileType)) return aliasedFileType;
if (values.includes(rawFileType)) return rawFileType;
const matchedByLabel = list.find(
item => String(item.label) === aliasedFileType || String(item.label) === rawFileType
);
if (matchedByLabel) return matchedByLabel.value;
const fileName = normalizeAttachmentTypeText(attachmentName(row));
if (!fileName) return otherType;
@@ -283,17 +325,32 @@ export const normalizeContractFileRows = (rows, options = defaultContractAttachm
description: row.description || '',
}));
const mapDictOptions = response => {
const mapDictOptions = (response, dictCode = CONTRACT_ATTACHMENT_TYPE_DICT) => {
const data = response?.data?.data || response?.data || [];
const records = Array.isArray(data) ? data : data.records || [];
const applySealAlias = dictCode === CONTRACT_ATTACHMENT_TYPE_DICT;
return records
.map(item => ({
label: item.dictValue,
value: item.dictKey,
}))
.map(item => {
const rawLabel = String(item.dictValue || '');
const rawValue = String(item.dictKey || '');
const label = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawLabel] ||
(rawLabel === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawLabel)
: rawLabel;
const value = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawValue] ||
(rawValue === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawValue)
: rawValue;
return { label, value };
})
.filter(item => item.label && item.value);
};
const defaultOptionsByDictCode = dictCode =>
dictCode === CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
? [...defaultContractSupportingAttachmentTypeOptions]
: [...defaultContractAttachmentTypeOptions];
export default {
name: 'ContractAttachmentSection',
components: { UploadFilled },
@@ -307,8 +364,13 @@ export default {
markApprovedOnUpload: Boolean,
useUploadDialog: Boolean,
dialogOnly: Boolean,
attachmentLocation: { type: String, default: '合同文件' },
attachmentLocation: { type: String, default: CONTRACT_SEAL_MATERIAL_LOCATION },
attachmentLocationOptions: { type: Array, default: null },
/** 附件类型业务字典 code,默认用印材料字典;佐证材料传 contract_supporting_attachment_type */
attachmentTypeDictCode: {
type: String,
default: CONTRACT_ATTACHMENT_TYPE_DICT,
},
preview: { type: Function, default: null },
},
emits: ['update:rows', 'upload-to-location', 'upload-confirmed'],
@@ -317,8 +379,9 @@ export default {
selected: [],
attachmentFileTypes,
contractAttachmentTypeOptions: [...defaultContractAttachmentTypeOptions],
activeAttachmentTypeDictCode: CONTRACT_ATTACHMENT_TYPE_DICT,
uploadDialogVisible: false,
uploadDialogLocation: '合同文件',
uploadDialogLocation: CONTRACT_SEAL_MATERIAL_LOCATION,
uploadDialogType: resolveOtherAttachmentType(defaultContractAttachmentTypeOptions),
uploadDialogFiles: [],
uploadDialogFileList: [],
@@ -337,48 +400,91 @@ export default {
defaultAttachmentType() {
return resolveOtherAttachmentType(this.contractAttachmentTypeOptions);
},
resolvedAttachmentTypeDictCode() {
return this.attachmentTypeDictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
},
resolvedLocationOptions() {
const options = (this.attachmentLocationOptions || []).filter(Boolean);
if (options.length) return options;
return ['合同文件', '其它附件'];
if (options.length) {
return options.map(item => {
if (item === '合同文件') return CONTRACT_SEAL_MATERIAL_LOCATION;
if (item === '其它附件' || item === '其他附件') {
return CONTRACT_SUPPORTING_ATTACHMENT_LOCATION;
}
return item;
});
}
return [CONTRACT_SEAL_MATERIAL_LOCATION, CONTRACT_SUPPORTING_ATTACHMENT_LOCATION];
},
},
watch: {
attachmentTypeDictCode: {
immediate: false,
handler() {
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
},
},
created() {
this.loadAttachmentTypeOptions();
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
methods: {
attachmentName,
loadAttachmentTypeOptions() {
return getBizDictionary({ code: 'contract_attachment_type' })
currentUploadUserName() {
const raw = this.$store.getters.userInfo;
const userInfo = raw && !Array.isArray(raw) ? raw : {};
// 与顶部栏一致优先 real_name;用印材料 / 佐证材料共用
return (
userInfo.real_name ||
userInfo.realName ||
userInfo.nick_name ||
userInfo.nickName ||
userInfo.user_name ||
userInfo.userName ||
userInfo.account ||
''
);
},
loadAttachmentTypeOptions(dictCode = this.resolvedAttachmentTypeDictCode, alignRows = false) {
const code = dictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
this.activeAttachmentTypeDictCode = code;
const fallbackOptions = defaultOptionsByDictCode(code);
return getBizDictionary({ code })
.then(res => {
const options = mapDictOptions(res);
// 切换字典期间若已发起更新请求,忽略过期响应
if (this.activeAttachmentTypeDictCode !== code) return;
const options = mapDictOptions(res, code);
if (options.length) {
// 字典缺少「其他文件」时补上,保证未匹配文件名可默认选中
this.contractAttachmentTypeOptions = options.some(isOtherAttachmentTypeOption)
? options
: [...options, { label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER }];
if (
!this.contractAttachmentTypeOptions.some(
item => String(item.value) === String(this.uploadDialogType)
)
) {
this.uploadDialogType = this.defaultAttachmentType;
}
// 字典加载后,把已有行的中文类型名对齐到 dictKey,保证下拉可选中
if (this.attachmentType && (this.rows || []).length) {
const aligned = (this.rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, this.contractAttachmentTypeOptions),
}));
const changed = aligned.some(
(row, index) => row.fileType !== this.rows[index]?.fileType
);
if (changed) this.update(aligned);
}
} else {
this.contractAttachmentTypeOptions = fallbackOptions;
}
if (
!this.contractAttachmentTypeOptions.some(
item => String(item.value) === String(this.uploadDialogType)
)
) {
this.uploadDialogType = this.defaultAttachmentType;
}
// 字典加载后,把已有行的中文类型名对齐到 dictKey,保证下拉可选中
if (alignRows && this.attachmentType && (this.rows || []).length) {
const aligned = (this.rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, this.contractAttachmentTypeOptions),
}));
const changed = aligned.some(
(row, index) => row.fileType !== this.rows[index]?.fileType
);
if (changed) this.update(aligned);
}
})
.catch(() => {
// 字典加载失败时保留默认选项
if (this.activeAttachmentTypeDictCode !== code) return;
this.contractAttachmentTypeOptions = fallbackOptions;
this.uploadDialogType = this.defaultAttachmentType;
});
},
isRowLocked(row) {
@@ -388,13 +494,14 @@ export default {
this.$emit('update:rows', Array.isArray(rows) ? rows.map(item => ({ ...item })) : []);
},
enrichUploadedRow(row, existing) {
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadUserName = this.currentUploadUserName();
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const next = {
...existing,
...row,
description: row.description || existing?.description || '',
uploadUserName: existing?.uploadUserName || row.uploadUserName || uploadUserName,
// 新上传行始终写入当前用户;已有行保留原上传人
uploadUserName: existing?.uploadUserName || uploadUserName || row.uploadUserName || '',
uploadTime: existing?.uploadTime || row.uploadTime || uploadTime,
approved: existing?.approved || row.approved || false,
};
@@ -434,20 +541,38 @@ export default {
resolveTypeByLocation(location) {
const options = this.contractAttachmentTypeOptions || [];
const locationText = String(location || '');
if (locationText.includes('其它') || locationText.includes('其他')) {
if (
isContractSupportingAttachmentLocation(locationText) ||
locationText.includes('佐证') ||
locationText.includes('其它') ||
locationText.includes('其他')
) {
return this.defaultAttachmentType;
}
const matched = options.find(
item =>
String(item.value) === locationText ||
String(item.label) === locationText ||
String(item.value).includes('用印') ||
String(item.label).includes('用印') ||
String(item.value).includes('合同') ||
String(item.label).includes('合同')
);
return matched?.value || options[0]?.value || this.defaultAttachmentType;
},
syncUploadDialogTypeByLocation() {
this.uploadDialogType = this.resolveTypeByLocation(this.uploadDialogLocation);
const dictCode = resolveAttachmentTypeDictCode(
this.uploadDialogLocation,
this.resolvedAttachmentTypeDictCode
);
const applyType = () => {
this.uploadDialogType = this.resolveTypeByLocation(this.uploadDialogLocation);
};
if (dictCode !== this.activeAttachmentTypeDictCode) {
this.loadAttachmentTypeOptions(dictCode, false).finally(applyType);
return;
}
applyType();
},
openUploadDialog() {
const options = this.resolvedLocationOptions;
@@ -544,13 +669,13 @@ export default {
this.$message.warning('请选择附件类型');
return;
}
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadUserName = this.currentUploadUserName();
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const appended = this.uploadDialogFiles.map(row => ({
...row,
fileType: this.uploadDialogType,
description: row.description || '',
uploadUserName: row.uploadUserName || uploadUserName,
uploadUserName: uploadUserName || row.uploadUserName || '',
uploadTime: row.uploadTime || uploadTime,
...(this.markApprovedOnUpload ? { approved: true } : {}),
}));
@@ -607,7 +732,15 @@ export default {
downloadFileByUrl(url, attachmentName(row));
},
batchDownload() {
(this.selected.length ? this.selected : this.rows || []).forEach(this.download);
const sourceRows = this.selected.length ? this.selected : this.rows || [];
const downloadableRows = sourceRows.filter(row => attachmentUrl(row));
if (!downloadableRows.length) {
this.$message.warning(this.selected.length ? '所选附件暂无可下载地址' : '暂无可下载附件');
return;
}
downloadableRows.forEach((row, index) => {
window.setTimeout(() => this.download(row), index * 400);
});
},
formatSize(size) {
const value = Number(size);
@@ -44,19 +44,22 @@
<dt>附件</dt>
<dd>
<template v-if="attachments.length">
<el-link
<span
v-for="file in attachments"
:key="file.url || file.link || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>{{ attachmentName(file) }}</el-link
>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
</template>
<span v-else>-</span>
</dd>
</div>
</dl>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div></template></div>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ formatRegionAddress(route.departureAddress) || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ formatRegionAddress(route.arrivalAddress) || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</small></div></template></div>
</section>
<el-dialog v-model="documentPreviewVisible" :title="previewFile.name || '附件预览'" append-to-body destroy-on-close width="90%" top="4vh">
@@ -107,6 +110,7 @@ import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archiv
import { CaretBottom, CaretTop } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { formatRegionAddress } from '@/utils/region-address';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
@@ -161,6 +165,7 @@ export default {
} finally { this.loading = false; }
},
methods: {
formatRegionAddress,
quantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
segmentNumber(route, index) { return String(route.segmentNo || '').replace(/^段/, '') || index + 1; },
isSegmentExpanded(segmentNo) { return Boolean(this.expandedSegments[segmentNo]); },
@@ -330,7 +335,7 @@ export default {
.transport-flow-tag {
margin-left: 8px;
}
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #303133; font-size: 14px; word-break: break-all; } :deep(.el-link) { font-size: 14px; } }
.detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #303133; font-size: 14px; word-break: break-all; } .attachment-file-name { font-size: 14px; max-width: 100%; } }
.route-map { display: flex; align-items: flex-start; padding: 16px 24px 20px; border-top: 1px solid #eff1f7; overflow-x: auto; }
.route-map__node { display: grid; flex: 0 0 150px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; white-space: nowrap; } }
.route-map__progress { color: #303133 !important; }
@@ -21,10 +21,10 @@
<div class="route-overview__node">
<span :class="['route-badge', index === 0 ? 'start' : 'middle']">{{ index === 0 ? '起' : '经' }}</span>
<el-tooltip :content="route.departureName || '-'" placement="top" :show-after="120">
<strong>{{ route.departureName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'departure') }}</strong>
</el-tooltip>
<el-tooltip :content="route.departureAddress || '-'" placement="top" :show-after="120">
<small>{{ route.departureAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.departureAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.departureContact, route.departurePhone)" placement="top" :show-after="120">
<small>{{ contactText(route.departureContact, route.departurePhone) }}</small>
@@ -34,10 +34,10 @@
<div v-if="index === routes.length - 1" class="route-overview__node">
<span class="route-badge end">终</span>
<el-tooltip :content="route.arrivalName || '-'" placement="top" :show-after="120">
<strong>{{ route.arrivalName || '-' }}</strong>
<strong>{{ dispatchLocationName(route, 'arrival') }}</strong>
</el-tooltip>
<el-tooltip :content="route.arrivalAddress || '-'" placement="top" :show-after="120">
<small>{{ route.arrivalAddress || '-' }}</small>
<small>{{ formatRegionAddress(route.arrivalAddress) || '-' }}</small>
</el-tooltip>
<el-tooltip :content="contactText(route.arrivalContact, route.arrivalPhone)" placement="top" :show-after="120">
<small>{{ contactText(route.arrivalContact, route.arrivalPhone) }}</small>
@@ -266,7 +266,7 @@
<el-col :span="6">
<el-form-item label="单价">
<el-input :model-value="freightGroupUnitPrice(group)" inputmode="decimal" placeholder="请输入" @input="value => handleFreightGroupUnitPriceInput(group, value)" @clear="() => handleFreightGroupUnitPriceInput(group, '')">
<template #append><el-select :model-value="freightGroupPriceUnit(group)" class="freight-unit-select" @change="value => handleFreightGroupPriceUnitChange(group, value)"><el-option label="元/吨" value="元/吨" /><el-option label="元/件" value="元/件" /><el-option label="元/方" value="元/方" /></el-select></template>
<template #append><el-select :model-value="freightGroupPriceUnit(group)" class="freight-unit-select" @change="value => handleFreightGroupPriceUnitChange(group, value)"><el-option v-for="item in priceUnitOptions(route)" :key="item.value" :label="item.label" :value="item.value" /></el-select></template>
</el-input>
</el-form-item>
</el-col>
@@ -317,6 +317,14 @@
<script>
import * as api from '@/api/business/master-order';
import { fetchDriversByCarrierOrganizations } from '@/utils/carrier-org-resource';
import { formatContractOptionLabel } from '@/utils/contract';
import {
currencyUnit,
remapPriceUnitCurrency,
resolvePriceUnitByQuantityUnit,
} from '@/utils/currency';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { formatRegionAddress } from '@/utils/region-address';
import { isMobile } from '@/utils/validate';
const unwrapRecords = res => {
@@ -347,7 +355,8 @@ export default {
carrierLoading: false,
driverLoading: false,
escortLoading: false,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
quantityUnitOptions: [],
quantityUnitLoading: false,
};
},
computed: {
@@ -389,10 +398,24 @@ export default {
},
},
async mounted() {
await this.loadQuantityUnitOptions();
await Promise.all([this.load(), this.loadCarrierOptions()]);
this.applySelfOperatedCarrierDefaults();
},
methods: {
loadQuantityUnitOptions() {
if (this.quantityUnitLoading) return this.quantityUnitLoadPromise;
this.quantityUnitLoading = true;
this.quantityUnitLoadPromise = loadMeasurementUnitOptions()
.then(options => {
this.quantityUnitOptions = options.map(item => item.value);
return this.quantityUnitOptions;
})
.finally(() => {
this.quantityUnitLoading = false;
});
return this.quantityUnitLoadPromise;
},
async load() {
if (!this.id) return;
this.loading = true;
@@ -412,8 +435,10 @@ export default {
},
async loadContractCurrency() {
const directCurrency = this.master?.settlementCurrency || this.master?.settlementCurrencyCode || this.master?.currency;
const previousCurrency = this.master?.settlementCurrency;
if (!this.master?.projectId || !this.master?.contractId) {
this.master.settlementCurrency = this.normalizeCurrencyValue(directCurrency || 'RMB');
this.syncRouteCurrency(previousCurrency, this.master.settlementCurrency);
return;
}
try {
@@ -427,6 +452,20 @@ export default {
this.master.customerContractPartyB = '';
this.master.settlementCurrency = 'RMB';
}
this.syncRouteCurrency(previousCurrency, this.master.settlementCurrency);
},
syncRouteCurrency(_previousCurrency, nextCurrency) {
const currency = this.normalizeCurrencyValue(nextCurrency || 'RMB');
(this.routes || []).forEach(route => {
route.currency = currency;
(route.freightItems || []).forEach(item => {
if (item.priceUnit) {
item.priceUnit = remapPriceUnitCurrency(item.priceUnit, currency);
} else if (item.quantityUnit) {
item.priceUnit = resolvePriceUnitByQuantityUnit(item.quantityUnit, currency);
}
});
});
},
dispatchRouteNodes() {
const routes = Array.isArray(this.master?.routes) ? this.master.routes : [];
@@ -601,8 +640,23 @@ export default {
const prices = (group.rows || []).map(item => String(item.unitPrice ?? '').trim()).filter(Boolean);
return !prices.length || prices.some(price => price !== prices[0]) ? '' : prices[0];
},
freightGroupPriceUnit(group = {}) { return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.quantityUnit}` : ''); },
freightGroupPriceUnit(group = {}) {
if (group.rows?.[0]?.priceUnit) return group.rows[0].priceUnit;
if (!group.quantityUnit) return '';
return (
resolvePriceUnitByQuantityUnit(group.quantityUnit, this.master?.settlementCurrency || 'RMB') ||
`${currencyUnit(this.master?.settlementCurrency || 'RMB')}/${group.quantityUnit}`
);
},
freightGroupAmount(group = {}) { return (group.rows || []).reduce((sum, item) => sum + this.freightAmount(item), 0); },
priceUnitOptions(route = {}) {
const unit = currencyUnit(route.currency || this.master?.settlementCurrency || 'RMB');
return [
{ label: `${unit}/吨`, value: `${unit}/吨` },
{ label: `${unit}/件`, value: `${unit}/件` },
{ label: `${unit}/方`, value: `${unit}/方` },
];
},
handleFreightGroupUnitPriceInput(group, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
const normalized = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
@@ -612,6 +666,10 @@ export default {
handleFreightGroupQuantityUnitChange(route, group, value) {
(group.rows || []).forEach(item => {
item.quantityUnit = value || '';
item.priceUnit = resolvePriceUnitByQuantityUnit(
value,
route.currency || this.master?.settlementCurrency || 'RMB'
) || item.priceUnit;
const goods = (route.goods || []).find(row => String(row.sourceIndex) === String(item.sourceIndex));
if (goods) goods.quantityUnit = value || '';
});
@@ -634,7 +692,7 @@ export default {
})),
});
},
currencyLabel(value) { return ({ CNY: '元', RMB: '元', USD: '美元', EUR: '欧元' })[value] || value || '元'; },
currencyLabel(value) { return currencyUnit(value); },
quantityNumber(value) { return Number(value || 0); },
goodsKey(goods = {}, includeUnit = true) {
return [
@@ -794,7 +852,12 @@ export default {
const mileage = Number(value);
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
},
addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; },
formatRegionAddress,
addressText(name, address) {
// 名称与详细地址都要按「市 区县」展示;两者格式化后相同时只展示一次
const values = [this.formatRegionAddress(name), this.formatRegionAddress(address)].filter(Boolean);
return [...new Set(values)].join(' / ') || '-';
},
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
dateStartLabel(route) { return route.documentType === '计划单' ? '计划开始日期' : '预计发货日期'; },
dateEndLabel(route) { return route.documentType === '计划单' ? '计划结束日期' : '预计完成日期'; },
@@ -824,6 +887,7 @@ export default {
removeGoods(route, index) { route.goods.splice(index, 1); this.syncFreightItems(route); },
syncFreightItems(route) {
const oldItems = route.freightItems || [];
const currency = route.currency || this.master?.settlementCurrency || 'RMB';
route.freightItems = (route.goods || [])
.filter(item => item.sourceIndex !== undefined && item.sourceIndex !== '')
.map(item => {
@@ -835,7 +899,10 @@ export default {
quantity: item.dispatchQuantity || '',
quantityUnit: item.quantityUnit || '',
unitPrice: old.unitPrice || '',
priceUnit: old.priceUnit || `元/${item.quantityUnit || '吨'}`,
priceUnit:
old.priceUnit ||
resolvePriceUnitByQuantityUnit(item.quantityUnit || '吨', currency) ||
`${currencyUnit(currency)}/${item.quantityUnit || '吨'}`,
};
});
},
@@ -888,9 +955,11 @@ export default {
},
carrierOptionLabel(item) {
const duplicateCount = this.carrierOptions.filter(option => option.carrierName === item.carrierName).length;
return duplicateCount > 1
? `${item.carrierName}(${item.contractName || item.contractId})`
: item.carrierName;
if (duplicateCount <= 1) {
return item.carrierName;
}
const contractLabel = formatContractOptionLabel(item);
return contractLabel ? `${item.carrierName}(${contractLabel})` : item.carrierName;
},
driverOptionLabel(item) { return item.driverName || item.name || ''; },
driverOptionKey(item) { return item.id || item.driverId || this.driverOptionLabel(item) || item.mobile; },
@@ -1197,7 +1266,7 @@ export default {
.route-overview__line { flex: 0 1 56px; min-width: 24px; height: 32px; margin-top: 0; border-bottom: 6px solid #e4e7ed; }
.segment-header { min-height: 46px; padding: 0 20px; border-bottom: 1px solid #eff1f7; font-weight: 600; em { color: #409eff; font-style: normal; } }
.segment-content { padding: 16px 24px; }
.dispatch-form { :deep(.el-form-item) { margin-bottom: 14px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } :deep(.el-input-group__append .el-select) { width: auto; } }
.dispatch-form { :deep(.el-form-item) { margin-bottom: 24px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } :deep(.el-input-group__append .el-select) { width: auto; } }
/* 短标签按自身宽度,避免被「预计发货日期」等长标签把左侧撑开 */
.dispatch-form__compact-label {
:deep(.el-form-item__label) {
@@ -27,7 +27,7 @@
><el-option
v-for="contract in contracts"
:key="contract.id"
:label="contract.contractName"
:label="formatContractOptionLabel(contract)"
:value="contract.id" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="总单号" required
@@ -261,11 +261,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="240" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="240"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -589,6 +600,7 @@ import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
import { exportBlob, importBlob } from '@/api/common';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { OfficeBuilding, Search } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
@@ -597,10 +609,10 @@ import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { mapGetters } from 'vuex';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
const quantityUnitOptions = ['吨', '件', '方'];
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
@@ -703,10 +715,11 @@ export default {
'zip',
],
transports: ['公路运输', '铁路运输', '水路运输', '航空运输'],
quantityUnitOptions,
quantityUnitOptions: [],
quantityUnitLoading: false,
form: {
routes: [{ segmentNo: '段1', transportType: '' }],
goods: [{ quantityUnit: quantityUnitOptions[0] }],
goods: [{ quantityUnit: '吨' }],
attachmentsJson: '[]',
transportOrganizationType: '多式联运',
},
@@ -773,6 +786,7 @@ export default {
async mounted() {
this.loading = true;
try {
await this.loadQuantityUnitOptions();
await Promise.all([this.loadProjects(), this.loadCargoTypeOptions(), this.loadRegionOptions()]);
if (this.id) {
await this.loadDetail(this.id);
@@ -782,6 +796,23 @@ export default {
}
},
methods: {
formatContractOptionLabel,
defaultQuantityUnit() {
return this.quantityUnitOptions[0] || '吨';
},
loadQuantityUnitOptions() {
if (this.quantityUnitLoading) return this.quantityUnitLoadPromise;
this.quantityUnitLoading = true;
this.quantityUnitLoadPromise = loadMeasurementUnitOptions()
.then(options => {
this.quantityUnitOptions = options.map(item => item.value);
return this.quantityUnitOptions;
})
.finally(() => {
this.quantityUnitLoading = false;
});
return this.quantityUnitLoadPromise;
},
responseData(response) {
return response?.data?.data || response?.data || response || {};
},
@@ -958,7 +989,7 @@ export default {
finalTransportType: hasFinalSegment
? finalSegment.transportType
: detail.finalTransportType || '',
goods: detail.goods || [{ quantityUnit: quantityUnitOptions[0] }],
goods: detail.goods || [{ quantityUnit: this.defaultQuantityUnit() }],
attachmentsJson: detail.attachmentsJson || '[]',
transportOrganizationType: '多式联运',
};
@@ -1659,7 +1690,7 @@ export default {
'',
cargoTypePath,
quantity: row.quantity || '',
quantityUnit: row.quantityUnit || quantityUnitOptions[0],
quantityUnit: row.quantityUnit || this.defaultQuantityUnit(),
packageType: row.packageType || row.package || '',
brand: row.brand || '',
specification: row.specification || row.spec || '',
@@ -1785,7 +1816,7 @@ export default {
},
addGoods(index) {
this.form.goods.splice(index + 1, 0, {
quantityUnit: quantityUnitOptions[0],
quantityUnit: this.defaultQuantityUnit(),
materialCode: '',
deviceCode: '',
remark: '',
@@ -2047,7 +2078,7 @@ export default {
font-size: 16px;
}
.el-form-item {
margin-bottom: 14px;
margin-bottom: 24px;
}
}
.master-editor__quantity-field {
@@ -0,0 +1,305 @@
<template>
<div v-if="groups.length" class="punch-record-photos">
<section v-for="group in groups" :key="group.type" class="punch-record-photos__group">
<div class="punch-record-photos__head">
<span class="punch-record-photos__title">{{ group.type }}</span>
<span v-if="editable" class="punch-record-photos__count">{{ countText(group) }}</span>
</div>
<div
class="punch-record-photos__grid"
:class="{ 'punch-record-photos__grid--edit': editable }"
>
<div
v-for="(item, groupIndex) in group.items"
:key="`${item.photo.url}-${item.index}`"
class="punch-record-photos__item"
@click="handlePreview(item.index)"
>
<el-image
:src="item.photo.url"
:preview-src-list="nativePreview ? previewUrls : []"
:initial-index="nativePreview ? item.index : 0"
:preview-teleported="nativePreview"
:lazy="lazy"
fit="cover"
/>
<!-- 主图 = 该类型的第一张,按上传顺序取;没有独立字段,也不可切换 -->
<div v-if="groupIndex === 0" class="punch-record-photos__badge">主图</div>
<el-link
v-if="editable"
class="punch-record-photos__remove"
type="danger"
:underline="false"
@click.stop="$emit('remove', item.index)"
>删除</el-link
>
</div>
<el-upload
v-if="editable && !group.full"
class="punch-record-photos__upload"
:action="uploadAction"
name="file"
:headers="uploadHeaders"
:show-file-list="false"
:before-upload="file => handleBeforeUpload(file, group.type)"
:on-success="res => $emit('upload-success', res, group.type)"
:on-error="error => $emit('upload-error', error, group.type)"
>
<div class="punch-record-photos__add">+ 添加</div>
</el-upload>
<div v-else-if="editable" class="punch-record-photos__add punch-record-photos__add--full">
已满 {{ maxPerType }} 张
</div>
</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue';
// 打卡照片是每个打卡节点必有的一个照片类型,不由过程配置的凭证类型决定;
// 它与各凭证类型各自最多 9 张,同一类型下第一张即界面上的「主图」。
const PUNCH_PHOTO_TYPE = '打卡照片';
// 照片没带类型时的兜底名称,与后端落库时的兜底口径一致
const PHOTO_TYPE_FALLBACK = '凭证';
const MAX_PHOTOS_PER_TYPE = 9;
const props = defineProps({
// 扁平照片数组:管理端回显用 voucherType,司机端回显与旧数据用 type
photos: {
type: Array,
default: () => [],
},
// 恒定展示的照片类型:「打卡照片」+ 该节点过程配置勾选的凭证类型。
// 留空则只按照片自身带的类型分组(只读回显用)。
groupTypes: {
type: Array,
default: () => [],
},
// 编辑态:每个类型自带「添加」格与删除链接,并显示已选/上限
editable: {
type: Boolean,
default: false,
},
// 每个类型的张数上限;0 表示不限制(在途打卡只有一个照片字段,张数由「只保留第一张」约束)
maxPerType: {
type: Number,
default: MAX_PHOTOS_PER_TYPE,
},
// 编辑态用 el-image 自带预览;只读态不发预览事件,把点击交给调用方开自己的看图器
nativePreview: {
type: Boolean,
default: false,
},
lazy: {
type: Boolean,
default: false,
},
uploadAction: {
type: String,
default: '',
},
uploadHeaders: {
type: Object,
default: () => ({}),
},
// 返回 false 即中止本次上传;类型、体积、张数校验都在调用方
beforeUpload: {
type: Function,
default: null,
},
});
const emit = defineEmits(['preview', 'remove', 'upload-success', 'upload-error']);
const photoType = photo =>
String(photo?.voucherType || photo?.type || '').trim() || PHOTO_TYPE_FALLBACK;
const groups = computed(() => {
const photos = Array.isArray(props.photos) ? props.photos : [];
const names = [];
const buckets = new Map();
const ensure = name => {
if (!buckets.has(name)) {
buckets.set(name, []);
names.push(name);
}
return buckets.get(name);
};
// 恒定展示的类型先占位:一张照片都没有的类型也要出现,否则编辑态下没有添加入口
props.groupTypes.forEach(type => {
const name = String(type || '').trim();
if (name) ensure(name);
});
// 按上传顺序入组,组间顺序即类型首次出现的顺序,不重排照片
photos.forEach((photo, index) => {
if (!photo?.url) return;
ensure(photoType(photo)).push({ photo, index });
});
const list = names.map(type => {
const items = buckets.get(type);
return {
type,
items,
full: props.editable && props.maxPerType > 0 && items.length >= props.maxPerType,
};
});
// 编辑态至少要有一个分组,否则记录里还没有照片时就没有添加入口
if (props.editable && !list.length) {
return [{ type: props.groupTypes[0] || PUNCH_PHOTO_TYPE, items: [], full: false }];
}
return list;
});
// 预览列表按原始下标对齐,删除/分组都不改变点击某张时打开的是哪一张
const previewUrls = computed(() =>
(Array.isArray(props.photos) ? props.photos : []).map(photo => photo?.url || '')
);
const handlePreview = index => {
if (props.nativePreview) return;
emit('preview', index);
};
const handleBeforeUpload = (file, type) => {
if (typeof props.beforeUpload !== 'function') return true;
return props.beforeUpload(file, type);
};
const countText = group =>
props.maxPerType > 0 ? `${group.items.length}/${props.maxPerType}` : String(group.items.length);
</script>
<style lang="scss" scoped>
.punch-record-photos {
// 弹窗里本组件挂在 el-form-item__content 下,那是个 flex 容器:
// 不给宽度就会收缩成内容宽,里面的网格跟着塌成一小块
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 4px;
&__group {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
&__head {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 20px;
}
&__title {
font-weight: 600;
color: #303133;
word-break: break-all;
}
&__count {
font-size: 12px;
color: #909399;
}
// 只读回显:撑满容器宽度的五列网格
&__grid {
width: 100%;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
// 编辑态沿用弹窗原本的固定 96px 方图 + 自动换行:尺寸不随容器宽窄变化
&__grid--edit {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 10px;
}
&__item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
.el-image {
display: block;
width: 100%;
aspect-ratio: 1;
border: 1px solid #eff1f7;
border-radius: 4px;
overflow: hidden;
background: #f5f7fa;
}
}
&__grid--edit &__item {
width: 96px;
.el-image {
width: 96px;
height: 96px;
aspect-ratio: auto;
}
}
&__badge {
position: absolute;
top: 0;
left: 0;
padding: 0 6px;
border-radius: 4px 0 4px 0;
background: rgba(64, 158, 255, 0.9);
font-size: 12px;
line-height: 18px;
color: #fff;
}
&__remove {
align-self: center;
font-size: 12px;
}
&__upload {
width: 100%;
}
&__grid--edit &__upload {
width: 96px;
}
&__add {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
aspect-ratio: 1;
border: 1px dashed #dcdfe6;
border-radius: 4px;
font-size: 13px;
color: #909399;
cursor: pointer;
}
&__grid--edit &__add {
width: 96px;
height: 96px;
aspect-ratio: auto;
}
&__add--full {
color: #c0c4cc;
cursor: not-allowed;
}
}
</style>
@@ -113,7 +113,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -153,11 +153,24 @@
@click="handleTransportSecondaryAddressInputClick('departure')"
>
<template #suffix>
<el-icon @click.stop="openTransportMapDialog('departure')">
<el-icon
class="shipping-template-page__map-suffix"
@click.stop="openTransportMapDialog('departure')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="shipping-template-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
/>
</el-tooltip>
<span>联系人</span>
<el-input
v-model="form.departureContact"
@@ -166,16 +179,6 @@
/>
<span>联系方式</span>
<el-input v-model="form.departurePhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
@@ -197,25 +200,28 @@
@click="handleTransportSecondaryAddressInputClick('arrival')"
>
<template #suffix>
<el-icon @click.stop="openTransportMapDialog('arrival')">
<el-icon
class="shipping-template-page__map-suffix"
@click.stop="openTransportMapDialog('arrival')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="shipping-template-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
/>
</el-tooltip>
<span>联系人</span>
<el-input v-model="form.arrivalContact" placeholder="请输入" :disabled="dialogReadonly" />
<span>联系方式</span>
<el-input v-model="form.arrivalPhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
@@ -454,11 +460,20 @@
>
<el-table-column type="selection" width="55" />
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220"
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
><template #default="{ row }"
><el-link type="primary" @click="previewAttachment(row)">{{
attachmentName(row)
}}</el-link></template
><span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span></template
></el-table-column
>
<el-table-column label="附件描述" min-width="220"
@@ -721,11 +736,21 @@
</div>
<el-table :data="detailAttachmentRows" border empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220">
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">{{
attachmentName(row)
}}</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -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) {
@@ -149,7 +149,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -492,11 +492,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -673,14 +684,16 @@
<div class="transport-plan-page__transport-plan-detail-field is-attachments">
<span>附件</span>
<div class="transport-plan-page__transport-plan-detail-attachments">
<el-link
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>
{{ attachmentName(file) }}
</el-link>
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ attachmentName(file) }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
@@ -855,11 +868,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260">
<el-table-column
label="文件名"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column
@@ -980,7 +1003,7 @@
<el-option
v-for="item in transportPlanImportContractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -1744,11 +1767,11 @@
><el-select
:model-value="dispatchFreightGroupPriceUnit(group)"
class="transport-plan-page__dispatch-unit-select"
placeholder="元/吨"
:placeholder="defaultPriceUnit"
@change="value => handleDispatchFreightGroupPriceUnitChange(group, value)"
@visible-change="visible => visible && loadTaskFeeUnitOptions()"
><el-option
v-for="item in taskFeeUnitOptions"
v-for="item in taskFeeUnitSelectOptions"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select
@@ -1969,17 +1992,17 @@
<el-select
v-model="dispatchItemForm.priceUnit"
class="transport-plan-page__dispatch-unit-select"
placeholder="元/吨"
:placeholder="defaultPriceUnit"
:loading="taskFeeUnitLoading"
@visible-change="visible => visible && loadTaskFeeUnitOptions()"
>
<el-option
v-if="!taskFeeUnitOptions.some(item => item.value === '元/吨')"
label="元/吨"
value="元/吨"
v-if="!taskFeeUnitSelectOptions.some(item => item.value === defaultPriceUnit)"
:label="defaultPriceUnit"
:value="defaultPriceUnit"
/>
<el-option
v-for="item in taskFeeUnitOptions"
v-for="item in taskFeeUnitSelectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
@@ -2201,11 +2224,22 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="200" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="200"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row, dispatchItemAttachmentRows)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.enter="previewAttachment(row, dispatchItemAttachmentRows)"
@keydown.space.prevent="previewAttachment(row, dispatchItemAttachmentRows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
@@ -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) {
@@ -1,7 +1,7 @@
<template>
<div class="voucher-folder-page voucher-folder-page--embedded">
<section class="voucher-folder-page__search">
<el-form :model="query" label-position="right" label-width="160px">
<el-form :model="query" label-position="right">
<div class="voucher-folder-page__search-grid">
<el-form-item label="车牌号">
<el-input v-model="query.plateNo" clearable placeholder="请输入" />
@@ -196,7 +196,7 @@ watch(
}
&__search-grid {
display: grid;
grid-template-columns: 360px 360px 1fr;
grid-template-columns: 220px 220px 1fr;
gap: 8px 24px;
}
&__search-actions {
@@ -270,15 +270,22 @@
prop="mileage"
label="里程(km)"
width="100"
/><el-table-column prop="unitPrice" label="单价" width="90" /><el-table-column
prop="freight"
label="运费"
width="100"
/><el-table-column prop="otherFeeTotal" label="其他费用合计" width="120" /><el-table-column
prop="freightTotal"
label="运费合计"
width="100"
/><el-table-column
>
<template #default="{ row }">{{ formatOptionalNumber(row.mileage) }}</template>
</el-table-column>
<el-table-column prop="unitPrice" label="单价" width="90">
<template #default="{ row }">{{ formatOptionalNumber(row.unitPrice) }}</template>
</el-table-column>
<el-table-column prop="freight" label="运费" width="100">
<template #default="{ row }">{{ formatOptionalNumber(row.freight) }}</template>
</el-table-column>
<el-table-column prop="otherFeeTotal" label="其他费用合计" width="120">
<template #default="{ row }">{{ formatOptionalNumber(row.otherFeeTotal) }}</template>
</el-table-column>
<el-table-column prop="freightTotal" label="运费合计" width="100">
<template #default="{ row }">{{ formatOptionalNumber(row.freightTotal) }}</template>
</el-table-column>
<el-table-column
prop="actualStartDate"
label="实际发货时间"
width="160"
@@ -374,7 +381,7 @@
<el-option
v-for="item in contracts"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -610,6 +617,7 @@ import { getList as getProjectList } from '@/api/business/project-apply';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { getDictionary } from '@/api/system/dictbiz';
import * as api from '@/api/business/waybill-manage';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadXls } from '@/utils/util';
import SectionCard from '@/components/section-card/main.vue';
@@ -879,11 +887,14 @@ const syncCarrierOptions = () => {
carrierOptions.value = [];
return;
}
const carrierId =
const carrierId = resolveCarrierArchiveId(
customerContract?.partyBId ||
customerContract?.partyBUserId ||
customerContract?.customerId ||
'';
customerContract?.partyBUserId ||
customerContract?.contractPartyBId ||
customerContract?.customerId ||
'',
carrierName
);
carrierOptions.value = [{ id: carrierId, carrierName, label: carrierName, value: carrierName }];
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
@@ -1079,19 +1090,40 @@ const transportTypeLabel = value => {
);
return matched?.dictValue || matched?.label || named || text || '-';
};
const buildBatchQueryParams = () => ({
batchNo: query.batchNo || undefined,
carrierId: query.carrierId || undefined,
createUser: query.createUser || undefined,
createTimeStart: query.createTimeRange?.[0] || undefined,
createTimeEnd: query.createTimeRange?.[1] || undefined,
current: page.current,
size: page.size,
});
const loadListOptions = async () => {
const res = await api.getImportOptions?.();
const data = res?.data?.data || {};
queryCarriers.value = data.carriers || [];
creators.value = data.creators || [];
};
const loadBatches = async () => {
const res = await api.getImportBatches({ ...query, current: page.current, size: page.size });
const res = await api.getImportBatches(buildBatchQueryParams());
batches.value = res.data?.data?.records || [];
page.total = res.data?.data?.total || 0;
};
onMounted(() => {
if (props.standalone) loadBatches();
if (props.standalone) {
loadListOptions();
loadBatches();
}
if (props.createPage) openCreatePage();
});
watch(
() => props.modelValue,
value => {
if (value) loadBatches();
if (value) {
loadListOptions();
loadBatches();
}
}
);
const resetQuery = () => {
@@ -1106,7 +1138,7 @@ const loadDetails = async () => {
current: detailPage.current,
size: detailPage.size,
});
details.value = res.data?.data?.records || [];
details.value = (res.data?.data?.records || []).map(normalizeDetailRow);
detailPage.total = res.data?.data?.total || 0;
};
const openCreate = () => prepareCreateForm(true);
@@ -1155,7 +1187,7 @@ const loadBatchRows = async batchId => {
if (!batchId) return;
const res = await api.getImportDetails({ batchId, current: 1, size: 9999 });
rows.value = extractRecords(res).map(({ id, ...item }, index) => ({
...item,
...normalizeDetailRow(item),
_key: `${id || 'row'}-${index}`,
batchNo: item.batchNo || form.batchNo,
}));
@@ -1288,15 +1320,60 @@ const contractChange = id => {
const carrierTypeChange = () => {
syncCarrierOptions();
};
const resolveCarrierArchiveId = (carrierId, carrierName) => {
if (carrierId) return carrierId;
const name = String(carrierName || '').trim();
if (!name) return '';
const matched = queryCarriers.value.find(item => String(item.name || '').trim() === name);
return matched?.id || '';
};
const carrierChange = value => {
const item = carrierOptions.value.find(option => String(option.value) === String(value || ''));
clearCarrier();
if (!item) return;
form.carrierId = item.id || '';
form.carrierIds = item.id ? [item.id] : [];
const carrierId = resolveCarrierArchiveId(item.id, item.carrierName);
form.carrierId = carrierId;
form.carrierIds = carrierId ? [carrierId] : [];
form.carrierName = item.carrierName || '';
if (!isSelfOperated.value) form.carrierContractId = item.carrierContractId || '';
};
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
const OPTIONAL_NUMBER_EMPTY_SENTINELS = [-1];
const OPTIONAL_NUMBER_PROPS = ['mileage', 'unitPrice', 'freight', 'otherFeeTotal', 'freightTotal'];
const normalizeOptionalImportNumber = (value, emptySentinels = OPTIONAL_NUMBER_EMPTY_SENTINELS) => {
if (!String(value ?? '').trim()) return null;
return emptySentinels.some(sentinel => Number(value) === sentinel) ? null : value;
};
const formatOptionalNumber = value => {
const normalized = normalizeOptionalImportNumber(value);
return normalized === null || normalized === undefined ? '' : normalized;
};
const normalizeDetailRow = row => {
const next = { ...(row || {}) };
OPTIONAL_NUMBER_PROPS.forEach(prop => {
next[prop] = formatOptionalNumber(next[prop]);
});
return next;
};
const normalizeImportRow = row => ({
...row,
mileage: normalizeOptionalImportNumber(
firstNotEmpty(row.mileage, row['里程'], row['里程(km)'], row['里程(公里)'])
),
otherFeeTotal: normalizeOptionalImportNumber(
firstNotEmpty(row.otherFeeTotal, row['其他费用合计'])
),
unitPrice: normalizeOptionalImportNumber(firstNotEmpty(row.unitPrice, row['单价'])),
freight: normalizeOptionalImportNumber(firstNotEmpty(row.freight, row['运费'])),
freightTotal: normalizeOptionalImportNumber(firstNotEmpty(row.freightTotal, row['运费合计'])),
transportType: firstNotEmpty(
row.transportType,
row['运输类型'],
row['*运输类型'],
row['运输方式'],
row['*运输方式']
),
});
const fileChange = async (file, list) => {
if (!projectInfoReady.value) {
ElMessage.warning('请先选择项目、客户合同和承运信息,再导入运单明细');
@@ -1370,6 +1447,9 @@ const fileChange = async (file, list) => {
item[key] ??
'';
});
['mileage', 'unitPrice', 'freight', 'otherFeeTotal', 'freightTotal'].forEach(prop => {
row[prop] = normalizeOptionalImportNumber(row[prop]);
});
row.actualStartDate = normalizeImportDate(row.actualStartDate);
row.actualEndDate = normalizeImportDate(row.actualEndDate);
row.planStartDate = normalizeImportDate(row.planStartDate);
@@ -1618,6 +1698,7 @@ const downloadTemplate = async () => {
ElMessage.error('模板下载失败');
}
};
const isValidMobile = value => /^\d{11}$/.test(String(value || '').trim());
const validateImportRows = () => {
if (!rows.value.length) {
ElMessage.warning('请上传至少一条运单明细');
@@ -1629,6 +1710,11 @@ const validateImportRows = () => {
ElMessage.warning(`第${index + 1}行${missingField.label}不能为空`);
return false;
}
const driverPhone = String(row.driverPhone ?? '').trim();
if (driverPhone && !isValidMobile(driverPhone)) {
ElMessage.warning(`第${index + 1}行司机/船长手机号格式不正确,应为11位数字`);
return false;
}
}
return true;
};
@@ -1656,31 +1742,6 @@ const removeSelectedRows = () => {
rowSelection.value = [];
refreshDuplicateFlags();
};
const firstNotEmpty = (...values) => values.find(value => String(value ?? '').trim()) ?? '';
const normalizeOptionalImportNumber = (value, emptySentinels = []) => {
if (!String(value ?? '').trim()) return null;
return emptySentinels.some(sentinel => Number(value) === sentinel) ? null : value;
};
const normalizeImportRow = row => ({
...row,
mileage: normalizeOptionalImportNumber(
firstNotEmpty(row.mileage, row['里程'], row['里程(km)'], row['里程(公里)']),
[-1]
),
otherFeeTotal: normalizeOptionalImportNumber(
firstNotEmpty(row.otherFeeTotal, row['其他费用合计'])
),
unitPrice: normalizeOptionalImportNumber(firstNotEmpty(row.unitPrice, row['单价'])),
freight: normalizeOptionalImportNumber(firstNotEmpty(row.freight, row['运费'])),
freightTotal: normalizeOptionalImportNumber(firstNotEmpty(row.freightTotal, row['运费合计'])),
transportType: firstNotEmpty(
row.transportType,
row['运输类型'],
row['*运输类型'],
row['运输方式'],
row['*运输方式']
),
});
const normalizeOptionalPlanId = value => {
if (value === undefined || value === null || value === '') return null;
return Number(value) === -1 || Number(value) <= 0 ? null : value;
@@ -1688,11 +1749,23 @@ const normalizeOptionalPlanId = value => {
const buildImportPayload = (status = form.status, rowList = rows.value, extra = {}) => {
const { file, ...batch } = form;
const planId = normalizeOptionalPlanId(batch.planId);
const carrierId = resolveCarrierArchiveId(
batch.carrierId || batch.carrierIds?.[0] || '',
batch.carrierName
);
const carrierIds =
Array.isArray(batch.carrierIds) && batch.carrierIds.length
? batch.carrierIds
: carrierId
? [carrierId]
: [];
return {
...batch,
planId,
// 未选计划时不带占位名称,避免落库/回显 -1
planName: planId ? batch.planName || undefined : null,
carrierId: carrierId || undefined,
carrierIds,
status,
rows: rowList.map(normalizeImportRow).map(stripInternalRowFields),
...extra,
File diff suppressed because it is too large Load Diff
+83 -16
View File
@@ -45,7 +45,7 @@
<el-form-item label="项目">
<el-input v-model="form.projectName" disabled />
</el-form-item>
<el-form-item label="所属组织">
<el-form-item label="合同负责部门">
<el-input v-model="form.organizationName" disabled />
</el-form-item>
<el-form-item label="甲方">
@@ -63,6 +63,15 @@
disabled
/>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="form.applyDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
disabled
/>
</el-form-item>
<el-form-item label="合同期限">
<div class="contract-basic-section__date-range">
<el-date-picker
@@ -91,12 +100,13 @@
<template #suffix>天</template>
</el-input>
</el-form-item>
<el-form-item label="合同金额">
<el-form-item label="合同金额" prop="contractAmount">
<el-input
:model-value="contractAmountDisplay"
placeholder="请输入"
clearable
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
@@ -154,7 +164,7 @@
</el-input>
</el-form-item>
<el-form-item label="是否需要加盖法人章">
<el-select v-model="form.legalSealFlag" placeholder="请选择">
<el-select v-model="form.legalSealFlag" placeholder="请选择" clearable>
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
@@ -173,20 +183,30 @@
<el-input v-model="form.handlerUserName" disabled />
</el-form-item>
</div>
<el-form-item label="备注" class="contract-basic-section__remark">
<el-form-item label="合同主要内容" class="contract-basic-section__remark">
<el-input
v-model="form.mainContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入合同主要内容"
/>
</el-form-item>
<el-form-item label="其他事项说明" class="contract-basic-section__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入备注"
placeholder="请输入其他事项说明"
/>
</el-form-item>
</section>
<contract-attachment-section
title="合同文件"
title="用印材料"
description
attachment-type
:attachment-location-options="changeAttachmentLocationOptions"
@@ -251,13 +271,20 @@
<el-button type="primary" plain @click="addPaymentRatioRow">添加</el-button>
</section>
<billing-plan-editor v-model="planDialogVisible" :value="planEditor" :index="planEditorIndex" @save="savePlan" />
<billing-plan-editor
v-model="planDialogVisible"
:value="planEditor"
:index="planEditorIndex"
:settlement-currency="form.settlementCurrency"
@save="savePlan"
/>
<contract-attachment-section
title="其它附件"
title="附件(佐证材料等)"
description
attachment-type
attachment-location="其它附件"
attachment-location="附件(佐证材料等)"
attachment-type-dict-code="contract_supporting_attachment_type"
:attachment-location-options="changeAttachmentLocationOptions"
:rows="attachments"
:preview="previewAttachment"
@@ -308,6 +335,8 @@ import * as api from '@/api/business/contract-manage';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import ContractAttachmentSection, {
isContractSealMaterialLocation,
isContractSupportingAttachmentLocation,
normalizeContractFileRows,
} from './components/contract-attachment-section.vue';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
@@ -358,6 +387,19 @@ const normalizeOptionalAmount = value => {
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),
+196 -49
View File
@@ -128,7 +128,7 @@
<el-descriptions-item label="甲方">{{ detailValue('partyA') }}</el-descriptions-item>
<el-descriptions-item label="乙方">{{ detailValue('partyB') }}</el-descriptions-item>
<el-descriptions-item label="合同期限">{{ detailContractPeriod }}</el-descriptions-item>
<el-descriptions-item label="所属组织">{{
<el-descriptions-item label="合同负责部门">{{
detailValue('organizationName')
}}</el-descriptions-item>
<el-descriptions-item label="经办人">{{
@@ -140,6 +140,9 @@
<el-descriptions-item label="签订日期">{{
detailValue('signDate')
}}</el-descriptions-item>
<el-descriptions-item label="申请日期">{{
detailValue('applyDate')
}}</el-descriptions-item>
<el-descriptions-item label="合同格式">{{
detailValue('contractFormat')
}}</el-descriptions-item>
@@ -171,14 +174,17 @@
<el-descriptions-item label="是否电子章">
{{ detailElectronicSealText }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{
<el-descriptions-item label="合同主要内容" :span="3">{{
detailValue('mainContent')
}}</el-descriptions-item>
<el-descriptions-item label="其他事项说明" :span="3">{{
detailValue('remark')
}}</el-descriptions-item>
</el-descriptions>
</section>
<contract-attachment-section
title="合同文件"
title="用印材料"
description
attachment-type
readonly
@@ -294,7 +300,7 @@
</section>
<contract-attachment-section
title="其它附件"
title="附件(佐证材料等)"
description
readonly
:rows="detailAttachmentRows"
@@ -377,8 +383,14 @@
<el-form-item label="合同编号">
<el-input
v-model="form.contractNo"
:disabled="formMode === 'edit'"
placeholder="不填写,则系统自动生成"
disabled
:placeholder="
formMode === 'add'
? form.organizationId
? '系统按部门规则自动生成'
: '请先选择合同负责部门'
: ''
"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
@@ -417,7 +429,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="所属组织" prop="organizationName">
<el-form-item label="合同负责部门" prop="organizationName">
<el-cascader
v-model="organizationCascaderValue"
:options="organizationOptions"
@@ -471,6 +483,16 @@
placeholder="请选择签订日期"
/>
</el-form-item>
<el-form-item label="申请日期">
<el-date-picker
v-model="form.applyDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="提交正式合同时自动填充"
disabled
/>
</el-form-item>
<el-form-item label="合同期限" prop="startDate">
<div class="contract-manage-form__date-range">
<el-date-picker
@@ -497,12 +519,13 @@
@input="value => integerInput('paymentDays', value)"
><template #suffix>天</template></el-input>
</el-form-item>
<el-form-item label="合同金额">
<el-form-item label="合同金额" prop="contractAmount">
<el-input
:model-value="contractAmountDisplay"
placeholder="请输入"
clearable
@input="handleContractAmountInput"
@blur="formatContractAmountField"
@clear="form.contractAmount = null"
/>
</el-form-item>
@@ -557,7 +580,7 @@
>
</el-form-item>
<el-form-item label="是否需要加盖法人章" prop="legalSealFlag">
<el-select v-model="form.legalSealFlag" placeholder="请选择">
<el-select v-model="form.legalSealFlag" placeholder="请选择" clearable>
<el-option label="否" :value="0" />
<el-option label="是" :value="1" />
</el-select>
@@ -576,20 +599,30 @@
<el-input v-model="form.handlerUserName" disabled />
</el-form-item>
</div>
<el-form-item label="备注" prop="remark" class="contract-manage-form__remark">
<el-form-item label="合同主要内容" prop="mainContent" class="contract-manage-form__remark">
<el-input
v-model="form.mainContent"
type="textarea"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入合同主要内容"
/>
</el-form-item>
<el-form-item label="其他事项说明" prop="remark" class="contract-manage-form__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
:rows="4"
maxlength="1000"
show-word-limit
placeholder="请输入备注"
placeholder="请输入其他事项说明"
/>
</el-form-item>
</section>
<contract-attachment-section
title="合同文件"
title="用印材料"
description
attachment-type
:attachment-location-options="contractAttachmentLocationOptions"
@@ -809,10 +842,11 @@
</section>
<contract-attachment-section
title="其它附件"
title="附件(佐证材料等)"
description
attachment-type
attachment-location="其它附件"
attachment-location="附件(佐证材料等)"
attachment-type-dict-code="contract_supporting_attachment_type"
:attachment-location-options="contractAttachmentLocationOptions"
:readonly="isAttachmentUploadMode"
:rows="attachmentRows"
@@ -920,18 +954,19 @@
v-model="billingPlanBox"
:value="billingPlanForm"
:index="billingPlanIndex"
:settlement-currency="form.settlementCurrency"
@save="saveBillingPlan"
/>
<contract-attachment-section
ref="listAttachmentUploader"
dialog-only
title="合同文件"
title="用印材料"
description
attachment-type
use-upload-dialog
mark-approved-on-upload
:attachment-location-options="['合同文件', '其它附件']"
:attachment-location-options="['用印材料', '附件(佐证材料等)']"
:rows="listUploadContractFileRows"
@update:rows="listUploadContractFileRows = $event"
@upload-to-location="handleListAttachmentUploadToLocation"
@@ -988,6 +1023,7 @@
v-model="detailBillingPlanBox"
:value="detailBillingPlanForm"
:index="detailBillingPlanIndex"
:settlement-currency="detailRow.settlementCurrency || form.settlementCurrency"
readonly
/>
@@ -1019,12 +1055,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 PdfPreview from '@/components/pdf-preview/main.vue';
@@ -1060,6 +1099,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;
@@ -1092,8 +1144,9 @@ const defaultForm = () => ({
partyB: '',
handlerUserName: '',
signDate: '',
applyDate: '',
contractFormat: '',
legalSealFlag: 0,
legalSealFlag: null,
copyCount: '',
settlementCurrency: '',
invoiceCycle: '',
@@ -1109,6 +1162,7 @@ const defaultForm = () => ({
effectiveType: '',
feeGenerationMode: 'system',
archiveStatus: '未归档',
mainContent: '',
remark: '',
});
// 复制新增:仅保留可编辑字段与明细,剔除主键、编号、审批与流程信息
@@ -1120,6 +1174,7 @@ const createCopiedDetail = (detail = {}) => {
return {
...form,
contractNo: '',
applyDate: '',
archiveStatus: '未归档',
feeGenerationMode: detail.feeGenerationMode || '',
billingEnabled: detail.billingEnabled,
@@ -1214,13 +1269,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) => {
@@ -1235,6 +1289,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: [],
@@ -1301,6 +1371,7 @@ export default {
flowBox: false,
flowUrl: '',
processInstanceId: '',
mkEnabled: false,
// 页面形态在实例创建时锁定,避免 keep-alive 缓存后随全局 $route 翻转
formPageLocked: false,
detailPageLocked: false,
@@ -1343,8 +1414,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;
@@ -1411,7 +1482,7 @@ export default {
expandTrigger: 'click',
};
},
// 所属组织级联:selectedOrganizationId 存部门 id,级联控件用根到节点的 id 路径,
// 合同负责部门级联:selectedOrganizationId 存部门 id,级联控件用根到节点的 id 路径,
// 两者在此双向转换;依赖 organizationOptions,树加载完成后回显自动刷新
organizationCascaderValue: {
get() {
@@ -1520,6 +1591,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();
@@ -1730,7 +1808,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'),
@@ -1766,6 +1844,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() {
@@ -1780,9 +1859,10 @@ export default {
['contractName', '合同名称'],
['contractCategory', '合同类型'],
['projectName', '所属项目'],
['organizationName', '所属组织'],
['organizationName', '合同负责部门'],
['partyA', '甲方'],
['partyB', '乙方'],
['contractAmount', '合同金额'],
];
const empty = required.find(([prop]) => {
const value = this.form[prop];
@@ -1790,10 +1870,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()) {
@@ -1816,7 +1902,7 @@ export default {
if (action !== 'formal' && action !== 'edit') return true;
const required = [
['projectName', '所属项目'],
['organizationName', '所属组织'],
['organizationName', '合同负责部门'],
['partyA', '甲方'],
['partyB', '乙方'],
['startDate', '合同开始日期'],
@@ -1824,10 +1910,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 (
@@ -2017,11 +2113,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];
}
},
@@ -2141,6 +2237,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;
@@ -2203,6 +2302,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;
@@ -2216,7 +2338,7 @@ export default {
this.organizationFlatOptions.unshift(item);
},
renderOrganizationSearch(scope) {
// 搜索区所属组织:单选级联(与新增/编辑表单一致)
// 搜索区合同负责部门:单选级联(与新增/编辑表单一致)
// 级联返回 id 路径数组,提交时由 normalizeSearch 取末级 id 转组织名称
return h(ElCascader, {
modelValue: Array.isArray(scope.row?.organizationName) ? scope.row.organizationName : [],
@@ -2235,7 +2357,7 @@ export default {
filterable: true,
clearable: true,
style: 'width: 100%',
placeholder: '请选择 所属组织',
placeholder: '请选择 合同负责部门',
});
},
integerInput(prop, value) {
@@ -2248,6 +2370,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;
@@ -2280,11 +2405,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];
}
},
@@ -2645,15 +2770,16 @@ export default {
templateFlag: '是否范本',
originalContractNo: '原件合同编号',
electronicSealFlag: '是否电子章',
remark: '备注',
mainContent: '合同主要内容',
remark: '其他事项说明',
feeGenerationMode: '费用生成模式',
billingPlanJson: '计费方案',
settlementRuleJson: '结算单规则',
preSettlementConfigJson: '预结算配置',
formalSettlementConfigJson: '正式结算配置',
paymentRatioJson: '付款比例设置',
contractFileJson: '合同文件',
attachmentsJson: '其它附件',
contractFileJson: '用印材料',
attachmentsJson: '附件(佐证材料等)',
changeAttachmentsJson: '变更材料',
};
return labels[field] || field;
@@ -2820,13 +2946,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' ||
@@ -2858,6 +2979,10 @@ export default {
return;
}
if (operation.action === 'flow') {
if (this.mkEnabled) {
this.handleMkAudit(row);
return;
}
this.openFlow(row);
return;
}
@@ -2911,6 +3036,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) {
@@ -3117,7 +3260,11 @@ export default {
&__remark {
width: 100%;
margin-bottom: 0 !important;
margin-bottom: 24px !important;
&:last-child {
margin-bottom: 0 !important;
}
}
&__date-range {
@@ -3191,7 +3338,7 @@ export default {
:deep(.el-form-item) {
align-items: center;
margin-bottom: 14px;
margin-bottom: 24px;
}
:deep(.el-form-item__label) {
+170 -92
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="loading-manage-page">
<div v-if="!isStandalonePage" class="loading-manage-page__search">
<div v-if="!isStandalonePage && searchVisible" class="loading-manage-page__search">
<el-form :model="searchForm" label-position="right" label-width="88px">
<div class="loading-manage-page__search-grid">
<el-form-item label="配载单号">
@@ -228,17 +228,13 @@
<el-button type="primary" plain @click="handleExport">批量导出</el-button>
<el-button type="primary" plain @click="handleBatchComplete">批量完成</el-button>
</div>
<div class="loading-manage-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
<el-tooltip content="表格密度" placement="top">
<el-button :icon="Rank" text @click="toggleTableSize" />
</el-tooltip>
<el-tooltip content="列设置" placement="top">
<el-button :icon="Setting" text @click="handleColumnSetting" />
</el-tooltip>
</div>
<table-header-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
@@ -254,7 +250,12 @@
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="58" fixed="left" align="center" />
<el-table-column label="配载单号" prop="loadingNo" min-width="170">
<el-table-column
v-if="isColumnVisible('loadingNo')"
label="配载单号"
prop="loadingNo"
min-width="170"
>
<template #default="{ row }">
<el-link
type="primary"
@@ -264,7 +265,12 @@
>
</template>
</el-table-column>
<el-table-column label="运单号" prop="loadingSubNos" min-width="220">
<el-table-column
v-if="isColumnVisible('loadingSubNos')"
label="运单号"
prop="loadingSubNos"
min-width="220"
>
<template #default="{ row }">
<span class="loading-manage-page__sub-nos">
<template v-if="splitText(row.loadingSubNos).length">
@@ -283,11 +289,40 @@
</span>
</template>
</el-table-column>
<el-table-column label="车牌号" prop="vehicleNo" min-width="170" show-overflow-tooltip />
<el-table-column label="司机" prop="driverName" min-width="120" show-overflow-tooltip />
<el-table-column label="联系电话" prop="driverPhone" min-width="140" show-overflow-tooltip />
<el-table-column label="承运类型" prop="carrierType" min-width="120" show-overflow-tooltip />
<el-table-column label="发货地" prop="departureAddress" min-width="180">
<el-table-column
v-if="isColumnVisible('vehicleNo')"
label="车牌号"
prop="vehicleNo"
min-width="170"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverName')"
label="司机"
prop="driverName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverPhone')"
label="联系电话"
prop="driverPhone"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('carrierType')"
label="承运类型"
prop="carrierType"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('departureAddress')"
label="发货地"
prop="departureAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'departure')"
@@ -304,7 +339,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="途经地" prop="transitAddress" min-width="190">
<el-table-column
v-if="isColumnVisible('transitAddress')"
label="途经地"
prop="transitAddress"
min-width="190"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'transit')"
@@ -321,7 +361,12 @@
</div>
</template>
</el-table-column>
<el-table-column label="到货地" prop="arrivalAddress" min-width="180">
<el-table-column
v-if="isColumnVisible('arrivalAddress')"
label="到货地"
prop="arrivalAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'arrival')"
@@ -338,20 +383,40 @@
</div>
</template>
</el-table-column>
<el-table-column label="承运商" prop="carrierName" min-width="160" show-overflow-tooltip />
<el-table-column label="运输方式" prop="transportType" min-width="150" show-overflow-tooltip>
<el-table-column
v-if="isColumnVisible('carrierName')"
label="承运商"
prop="carrierName"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('transportType')"
label="运输方式"
prop="transportType"
min-width="150"
show-overflow-tooltip
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column label="数据来源" prop="dataSource" min-width="130" show-overflow-tooltip />
<el-table-column
v-if="isColumnVisible('dataSource')"
label="数据来源"
prop="dataSource"
min-width="130"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createUserName')"
label="创建人"
prop="createUserName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createTime')"
label="创建时间"
prop="createTime"
min-width="170"
@@ -359,6 +424,7 @@
sortable
/>
<el-table-column
v-if="isColumnVisible('updateTime')"
label="更新时间"
prop="updateTime"
min-width="170"
@@ -366,12 +432,20 @@
sortable
/>
<el-table-column
v-if="isColumnVisible('currentProcessNode')"
label="过程节点"
prop="currentProcessNode"
min-width="130"
show-overflow-tooltip
/>
<el-table-column label="状态" prop="businessStatus" width="110" fixed="right" align="center">
<el-table-column
v-if="isColumnVisible('businessStatus')"
label="状态"
prop="businessStatus"
width="110"
fixed="right"
align="center"
>
<template #default="{ row }">
<span :class="['loading-manage-page__status', `is-${row.businessStatus || 'unknown'}`]">
{{ statusText(row) }}
@@ -484,12 +558,16 @@
<div class="loading-detail__field loading-detail__attachment-field">
<span class="loading-detail__label">附件</span>
<span class="loading-detail__value"
><el-link
><span
v-for="file in detailAttachments"
:key="file.url || file.name"
type="primary"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
>{{ file.name }}</el-link
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.name }}</span
><span v-if="!detailAttachments.length">-</span></span
>
</div>
@@ -555,15 +633,12 @@
prop="packageType"
label="包装"
min-width="100" /><el-table-column
prop="weight"
label="重量(吨)"
min-width="110" /><el-table-column
prop="volume"
label="体积(方)"
min-width="110" /><el-table-column
prop="quantity"
label="数量"
min-width="90" /><el-table-column
prop="quantityUnit"
label="数量单位"
min-width="100" /><el-table-column
prop="materialCode"
label="物料编码"
min-width="120" /><el-table-column
@@ -665,13 +740,6 @@
record.statusName || (record.punched ? '已打卡' : '未打卡')
}}</el-tag
>
<el-tag
v-if="record.type === 'enroute'"
size="small"
type="info"
effect="plain"
>在途</el-tag
>
<el-tag
v-if="record.exceptionFlag"
size="small"
@@ -701,22 +769,12 @@
>
备注:{{ record.remark }}
</div>
<div
<punch-record-photos
v-if="record.photos && record.photos.length"
class="loading-detail__punch-record-photos"
>
<div
v-for="(photo, pi) in record.photos"
:key="`${record.nodeCode || index}-${pi}`"
class="loading-detail__driver-upload-item"
@click="previewDriverUploadPhoto(record.photos, pi)"
>
<el-image :src="photo.url" fit="cover" lazy />
<div class="loading-detail__driver-upload-label">
{{ photo.label || `${record.nodeName}-凭证` }}
</div>
</div>
</div>
:photos="record.photos"
lazy
@preview="previewDriverUploadPhoto(record.photos, $event)"
/>
</template>
<div v-else class="loading-detail__punch-record-line">暂未打卡</div>
</div>
@@ -1066,6 +1124,7 @@
label="运单号"
min-width="150"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link v-if="row.waybillNo" type="primary" @click="openWaybillDetail(row)">
@@ -1357,7 +1416,7 @@
<el-table-column prop="cargoType" label="货物类型" min-width="140" />
<el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="quantity" label="数量" min-width="100" />
<el-table-column prop="quantityUnit" label="单位" min-width="100" />
<el-table-column prop="quantityUnit" label="数量单位" min-width="100" />
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
<el-table-column prop="brand" label="品牌" min-width="120" />
@@ -1400,7 +1459,11 @@
<el-option
v-for="item in taskCarrierOptions"
:key="item.id || item.carrierName"
:label="item.carrierName || item.partyB"
:label="
dialogForm.carrierType === '承运商'
? formatTaskCarrierContractLabel(item)
: item.carrierName || item.partyB
"
:value="
dialogForm.carrierType === '承运商'
? item.id
@@ -1622,9 +1685,7 @@
label="变更人"
min-width="120" /><el-table-column prop="content" label="变更内容" min-width="360"
><template #default="{ row }"
><div class="loading-route-change-dialog__record-content">
{{ row.content || '-' }}
</div></template
><div class="loading-route-change-dialog__record-content">{{ formatRouteChangeContent(row.content) }}</div></template
></el-table-column
><el-table-column
prop="remark"
@@ -1716,12 +1777,14 @@ import { getParentOptions as getCargoTypeOptions } from '@/api/base/cargo-type';
import { getList as getCustomerList } from '@/api/vehicle/customer-archive';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { fetchDriversByCarrierOrganizations } from '@/utils/carrier-org-resource';
import { formatContractOptionLabel } from '@/utils/contract';
import {
getList as getProcessConfigList,
getDetail as getProcessConfigDetail,
getVoucherImages as getProcessConfigVoucherImages,
} from '@/api/business/process-config';
import VoucherExecutionDetail from './components/voucher-execution-detail.vue';
import PunchRecordPhotos from './components/punch-record-photos.vue';
import {
carrierTypeOptions,
loadingManageConfig,
@@ -1730,10 +1793,12 @@ import {
} from '@/option/business/loading-manage';
import { option as waybillOption } from '@/option/business/waybill-manage';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Location, Rank, Refresh, Search, Setting } from '@element-plus/icons-vue';
import { Location, Rank, Search } from '@element-plus/icons-vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import dayjs from 'dayjs';
import { isMobile } from '@/utils/validate';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { formatRouteChangeContent } from '@/utils/route-change';
const defaultTransportType = '';
const defaultCandidateTransportType = '';
@@ -1927,21 +1992,44 @@ const uniqueText = (rows, prop) =>
),
].join('、');
const LOADING_TABLE_COLUMNS = [
{ prop: 'loadingNo', label: '配载单号' },
{ prop: 'loadingSubNos', label: '运单号' },
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'driverName', label: '司机' },
{ prop: 'driverPhone', label: '联系电话' },
{ prop: 'carrierType', label: '承运类型' },
{ prop: 'departureAddress', label: '发货地' },
{ prop: 'transitAddress', label: '途经地' },
{ prop: 'arrivalAddress', label: '到货地' },
{ prop: 'carrierName', label: '承运商' },
{ prop: 'transportType', label: '运输方式' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'currentProcessNode', label: '过程节点' },
{ prop: 'businessStatus', label: '状态' },
];
export default {
components: {
Rank,
Search,
TableHeaderTools,
VoucherExecutionDetail,
PunchRecordPhotos,
},
data() {
return {
Rank,
Location,
Refresh,
Search,
Setting,
loading: false,
tableSize: 'default',
searchVisible: true,
columnSettingOptions: LOADING_TABLE_COLUMNS,
visibleColumnKeys: LOADING_TABLE_COLUMNS.map(column => column.prop),
tableData: [],
page: {
currentPage: 1,
@@ -2189,6 +2277,15 @@ export default {
},
},
methods: {
formatRouteChangeContent,
formatTaskCarrierContractLabel(item = {}) {
const carrierName = item.carrierName || item.partyB || '';
const contractLabel = formatContractOptionLabel(item);
if (carrierName && contractLabel) {
return `${carrierName}(${contractLabel})`;
}
return carrierName || contractLabel;
},
syncStandaloneTagTitle() {
if (!this.isStandalonePage) return;
const title = this.isStandaloneDetailPage ? this.detailPageTitle : this.formPageDefaultTitle;
@@ -2248,13 +2345,8 @@ export default {
selectionChange(rows) {
this.selectionRows = rows || [];
},
toggleTableSize() {
const order = ['default', 'small', 'large'];
const index = order.indexOf(this.tableSize);
this.tableSize = order[(index + 1) % order.length];
},
handleColumnSetting() {
ElMessage.info('列设置按原型保留入口,当前默认展示全部列表字段');
isColumnVisible(prop) {
return this.visibleColumnKeys.includes(prop);
},
statusText(row) {
return row.businessStatusName || loadingStatusMap[row.businessStatus] || '未知';
@@ -2757,7 +2849,6 @@ export default {
if (status === 'draft') {
return [
{ type: 'edit', label: '编辑' },
{ type: 'copy', label: '复制' },
{ type: 'delete', label: '删除' },
];
}
@@ -2769,36 +2860,24 @@ export default {
if (row.driverRejected) {
actions.push({ type: 'reassign', label: '重新派单' });
}
actions.push(
{ type: 'edit', label: '编辑' },
{ type: 'copy', label: '复制' },
{ type: 'cancel', label: '取消' }
);
actions.push({ type: 'edit', label: '编辑' }, { type: 'cancel', label: '取消' });
return actions;
}
if (status === 'running') {
return [
{ type: 'view', label: '详情' },
{ type: 'complete', label: '完成' },
{ type: 'copy', label: '复制' },
];
}
if (status === 'completed' || status === 'cancelled') {
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
];
return [{ type: 'view', label: '详情' }];
}
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
];
return [{ type: 'view', label: '详情' }];
},
handleRowAction(type, row) {
const actionMap = {
view: () => this.gotoLoadingDetail(row),
edit: () => this.openLoadingDialog('edit', row),
copy: () => this.gotoLoadingCopy(row),
reassign: () => this.gotoLoadingReassign(row),
delete: () => this.deleteLoading(row),
start: () => this.startLoading(row),
@@ -4473,8 +4552,7 @@ export default {
margin: 12px 0;
}
.loading-manage-page__toolbar-left,
.loading-manage-page__toolbar-right {
.loading-manage-page__toolbar-left {
display: flex;
align-items: center;
gap: 12px;
@@ -4733,7 +4811,7 @@ export default {
}
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
:deep(.el-form-item__label) {
@@ -4928,7 +5006,6 @@ export default {
margin-left: 12px;
}
}
.loading-detail__punch-record-photos,
.loading-detail__driver-upload-grid,
.loading-detail__voucher-image-grid {
display: grid;
@@ -5052,8 +5129,9 @@ export default {
gap: 4px;
}
.loading-route-change-dialog__record-content {
white-space: normal;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 22px;
}
}
+24 -4
View File
@@ -1,7 +1,7 @@
<template>
<basic-container class="master-order-page">
<template v-if="mode === 'list'">
<div class="master-order-page__search">
<div v-show="searchVisible" class="master-order-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="master-order-page__search-grid">
<el-form-item v-for="field in primaryFields" :key="field.prop" :label="field.label">
@@ -55,8 +55,16 @@
</div>
<section class="master-list-panel">
<div class="toolbar">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button
><el-button type="primary" plain @click="download">批量导出</el-button>
<div class="toolbar-left">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button>
<el-button type="primary" plain @click="download">批量导出</el-button>
</div>
<table-header-tools
:show-column="false"
:show-grid="false"
v-model:search-visible="searchVisible"
@refresh="load"
/>
</div>
<div v-loading="loading" class="master-cards">
<article v-for="row in records" :key="row.id" class="master-card">
@@ -147,12 +155,13 @@
<script>
import * as api from '@/api/business/master-order';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import MasterOrderEditor from './components/master-order-editor.vue';
import MasterOrderDispatch from './components/master-order-dispatch.vue';
import MasterOrderDetail from './components/master-order-detail.vue';
export default {
components: { MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
components: { TableHeaderTools, MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
data() {
return {
// 实例创建时锁定所属路由。keep-alive 缓存后 $route 会变成其它页面,
@@ -160,6 +169,7 @@ export default {
//(mode=add 且无 id)会落入详情分支并请求缺少 id 的详情接口。
routePathLocked: this.$route.path,
searchExpanded: false,
searchVisible: true,
loading: false,
records: [],
query: { businessStatus: '' },
@@ -633,8 +643,18 @@ export default {
}
.master-list-panel {
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
}
.master-card {
display: flex;
+57 -21
View File
@@ -429,7 +429,7 @@ const NODE_TEMPLATES = [
type: 'confirm',
supportVoucher: false,
},
{ key: 'arrive_scene', name: '到场', type: 'punch', enabled: false },
{ key: 'arrive_scene', name: '到场', type: 'punch', enabled: false, supportCargo: false },
{
key: 'load',
name: '装货',
@@ -485,6 +485,7 @@ const NODE_TEMPLATES = [
key: 'return',
name: '回单',
type: 'confirm',
supportCargo: false,
supportVoucher: true,
voucherOptions: ['磅单', '签收单'],
},
@@ -565,7 +566,13 @@ export default {
});
},
defaultDeptId() {
return (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
const raw = (this.userInfo && (this.userInfo.deptId || this.userInfo.dept_id)) || '';
return (
String(raw)
.split(',')
.map(item => item.trim())
.filter(Boolean)[0] || ''
);
},
applyDefaultDeptSearch() {
const deptId = this.defaultDeptId();
@@ -580,15 +587,32 @@ export default {
});
},
flattenDept(tree, level = 0) {
return tree.flatMap(item => [
{
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: item.id,
},
...(item.children && item.children.length
? this.flattenDept(item.children, level + 1)
: []),
]);
return tree.flatMap(item => {
const id =
item.id === undefined || item.id === null || item.id === '' ? '' : String(item.id);
if (!id) return [];
return [
{
label: `${' '.repeat(level)}${item.title || item.deptName || item.name}`,
value: id,
},
...(item.children && item.children.length
? this.flattenDept(item.children, level + 1)
: []),
];
});
},
normalizeListQuery(params = {}) {
const query = { ...params, ...this.query };
const rawDeptId = query.deptId ?? query.searchDeptId;
delete query.searchDeptId;
if (rawDeptId !== undefined && rawDeptId !== null && String(rawDeptId).trim() !== '') {
query.deptId = String(rawDeptId).split(',')[0].trim();
} else {
delete query.deptId;
}
query.allDept = this.isAdmin ? 1 : 0;
return query;
},
displayStatus(status) {
const statusMap = {
@@ -631,7 +655,9 @@ export default {
enabled: false,
punch: true,
location: true,
uploadCargo: true,
// 不支持货量的节点(到场/回单/接单等)不得默认勾上,否则会写出
// 「上传货量为是、货量类型为空」的配置,而这类节点在界面上没有货量开关可关
uploadCargo: !!template?.supportCargo,
cargoTypes: [],
uploadVoucher: undefined,
voucherTypes: [],
@@ -646,6 +672,10 @@ export default {
confirmInternal: false,
...template,
};
if (!node.supportCargo) {
node.uploadCargo = false;
node.cargoTypes = [];
}
if ((node.key === 'accept' || node.name === '接单') && node.confirmMode === 'yes') {
node.confirmDriver = true;
} else if ((node.key === 'accept' || node.name === '接单') && node.confirmMode !== 'yes') {
@@ -918,6 +948,15 @@ export default {
return false;
}
}
// 勾了上传货量却没选类型,在小程序侧等同于不采集货量;此处直接拦住,
// 避免保存出「显示采集、实际不采集」的配置
const cargoWithoutTypes = this.nodeRows.find(
item => item.enabled && item.supportCargo && item.uploadCargo && !(item.cargoTypes || []).length
);
if (cargoWithoutTypes) {
this.$message.warning(`「${cargoWithoutTypes.name}」已选择上传货量,请至少勾选一个货量类型`);
return false;
}
return true;
},
needsDuplicateVoucherConfirm() {
@@ -1074,9 +1113,9 @@ export default {
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = params;
this.query = this.normalizeListQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page, params);
this.onLoad(this.page, this.query);
done();
},
selectionChange(list) {
@@ -1099,12 +1138,10 @@ export default {
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeListQuery(params);
this.query = query;
api
.getList(page.currentPage, page.pageSize, {
...params,
...this.query,
allDept: this.isAdmin ? 1 : 0,
})
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data || {};
this.page.total = result.total || 0;
@@ -1117,8 +1154,7 @@ export default {
},
buildExportParams() {
return {
...this.query,
allDept: this.isAdmin ? 1 : 0,
...this.normalizeListQuery(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
+121 -34
View File
@@ -231,6 +231,9 @@
<el-select
v-model="form.undertakeDeptId"
placeholder="请选择"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
:disabled="isBasicInfoReadonly"
@@ -573,7 +576,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -583,7 +586,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -593,7 +596,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -603,7 +606,7 @@
type="textarea"
:rows="2"
placeholder="请输入"
maxlength="200"
maxlength="1000"
show-word-limit
/>
</el-form-item>
@@ -645,11 +648,22 @@
</el-select>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="220" align="center" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="220"
align="center"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="240" align="center">
@@ -846,15 +860,15 @@
v-model="userQuery.realName"
placeholder="用户姓名"
clearable
@keyup.enter="loadUsers"
@keyup.enter="searchUsers"
/>
<el-input
v-model="userQuery.phone"
placeholder="手机号"
clearable
@keyup.enter="loadUsers"
@keyup.enter="searchUsers"
/>
<el-button type="primary" @click="loadUsers">查询</el-button>
<el-button type="primary" @click="searchUsers">查询</el-button>
<el-button @click="resetUserQuery">重置</el-button>
</div>
<el-table :data="userData" border v-loading="userLoading" @row-dblclick="selectUser">
@@ -945,7 +959,7 @@ const emptyForm = () => ({
projectShortName: '',
businessDeptId: '',
businessDeptName: '',
undertakeDeptId: '',
undertakeDeptId: [],
undertakeDeptName: '',
projectSource: '',
sourceRemark: '',
@@ -1135,7 +1149,15 @@ export default {
{ max: 20, message: '项目简称不能超过20个字符', trigger: 'blur' },
],
businessDeptId: [{ required: true, message: '请选择业务部门', trigger: 'change' }],
undertakeDeptId: [{ required: true, message: '请选择平台公司', trigger: 'change' }],
undertakeDeptId: [
{
type: 'array',
required: true,
min: 1,
message: '请选择平台公司',
trigger: 'change',
},
],
businessMode: [{ required: true, message: '请选择业务模式', trigger: 'change' }],
customerNames: [{ required: true, message: '请选择客户名称', trigger: 'change' }],
carrierNames: [{ required: true, message: '请选择下游承运商', trigger: 'change' }],
@@ -1188,6 +1210,18 @@ export default {
changeReason: [
{ max: 2000, message: '变更原因不能超过2000个字符', trigger: 'blur' },
],
projectIntro: [
{ max: 1000, message: '项目简介不能超过1000个字符', trigger: 'blur' },
],
profitRemark: [
{ max: 1000, message: '盈利模式及利润测算情况说明不能超过1000个字符', trigger: 'blur' },
],
riskPoint: [
{ max: 1000, message: '项目风险点不能超过1000个字符', trigger: 'blur' },
],
emergencyPlan: [
{ max: 1000, message: '风险防范措施及应急预案不能超过1000个字符', trigger: 'blur' },
],
},
deptOptions: [],
businessDeptTreeOptions: [],
@@ -1485,13 +1519,13 @@ export default {
this.businessDeptTreeOptions = [{ label, value: detail.businessDeptId }];
this.deptOptions = [{ label, rawLabel: label, value: detail.businessDeptId }];
}
if (detail.undertakeDeptId) {
this.platformCompanyOptions = [
{
label: detail.undertakeDeptName || String(detail.undertakeDeptId),
value: detail.undertakeDeptId,
},
];
const undertakeDeptIds = this.parseUndertakeDeptIds(detail.undertakeDeptId);
const undertakeDeptNames = this.parseUndertakeDeptNames(detail.undertakeDeptName);
if (undertakeDeptIds.length) {
this.platformCompanyOptions = undertakeDeptIds.map((id, index) => ({
label: undertakeDeptNames[index] || String(id),
value: id,
}));
}
},
resolvePublicDictOptions(options, currentValue) {
@@ -1837,6 +1871,8 @@ export default {
this.form = {
...form,
...situation,
undertakeDeptId: this.parseUndertakeDeptIds(form.undertakeDeptId),
undertakeDeptName: form.undertakeDeptName || '',
customerNames: this.getCustomerRowNames(customerRows, 'customer') || form.customerNames,
carrierNames: this.getCustomerRowNames(carrierRows, 'carrier') || form.carrierNames,
};
@@ -2003,8 +2039,13 @@ export default {
});
},
normalizeSubmitForm(options = {}) {
const undertakeDeptIds = this.parseUndertakeDeptIds(this.form.undertakeDeptId);
const undertakeDeptName =
this.resolveUndertakeDeptNames(undertakeDeptIds) || this.form.undertakeDeptName || '';
const submitRow = {
...this.form,
undertakeDeptId: undertakeDeptIds.join(','),
undertakeDeptName,
fundLimit: this.formatAmount(this.form.fundLimit),
receivableLimit: this.formatAmount(this.form.receivableLimit),
...Object.fromEntries(
@@ -2396,11 +2437,40 @@ export default {
this.$refs.projectForm?.validateField('businessDeptId');
},
handleUndertakeDeptChange(value) {
const id = Array.isArray(value) ? value.at(-1) : value;
const dept = this.platformCompanyOptions.find(item => String(item.value) === String(id));
this.form.undertakeDeptName = dept ? dept.label : '';
const ids = this.parseUndertakeDeptIds(value);
this.form.undertakeDeptId = ids;
this.form.undertakeDeptName = this.resolveUndertakeDeptNames(ids);
this.$refs.projectForm?.validateField('undertakeDeptId');
},
parseUndertakeDeptIds(value) {
if (Array.isArray(value)) {
return value
.map(item => String(item ?? '').trim())
.filter(item => item && item !== '-1');
}
return String(value ?? '')
.split(/[,,]/)
.map(item => item.trim())
.filter(item => item && item !== '-1');
},
parseUndertakeDeptNames(value) {
return String(value ?? '')
.split(/[,,、]/)
.map(item => item.trim())
.filter(Boolean);
},
resolveUndertakeDeptNames(ids = []) {
return ids
.map(id => {
const dept = this.platformCompanyOptions.find(item => String(item.value) === String(id));
return dept?.label || '';
})
.filter(Boolean)
.join('、');
},
firstUndertakeDeptId(value) {
return this.parseUndertakeDeptIds(value)[0] || '';
},
// 由部门 id 反查「根 → 该节点」的 id 路径,供级联回显(找不到返回 null)
findDeptPath(id, tree = [], parents = []) {
const target = Array.isArray(id) ? id.at(-1) : id;
@@ -2439,15 +2509,23 @@ export default {
});
},
ensureCurrentPlatformOption() {
const id = this.form.undertakeDeptId;
if (!id) return;
const exists = this.platformCompanyOptions.some(item => String(item.value) === String(id));
if (!exists) {
this.platformCompanyOptions.unshift({
label: this.form.undertakeDeptName || String(id),
value: id,
});
}
const ids = this.parseUndertakeDeptIds(this.form.undertakeDeptId);
if (!ids.length) return;
const names = this.parseUndertakeDeptNames(this.form.undertakeDeptName);
ids.forEach((id, index) => {
const exists = this.platformCompanyOptions.some(item => String(item.value) === String(id));
if (!exists) {
this.platformCompanyOptions.unshift({
label: names[index] || String(id),
value: id,
});
}
});
// 选项 value 与已选 ID 类型对齐,避免回显失败
this.form.undertakeDeptId = ids.map(id => {
const matched = this.platformCompanyOptions.find(item => String(item.value) === String(id));
return matched ? matched.value : id;
});
},
excludeExternalOrganization(list = []) {
return list.reduce((result, item) => {
@@ -2756,11 +2834,20 @@ export default {
if (this.dialogReadonly) return;
this.userPickType = type;
this.userBox = true;
this.userPage.currentPage = 1;
this.loadUsers();
},
searchUsers() {
this.userPage.currentPage = 1;
this.loadUsers();
},
loadUsers() {
this.userLoading = true;
getUserList(this.userPage.currentPage, this.userPage.pageSize, this.userQuery)
const params = {
realName: String(this.userQuery.realName || '').trim(),
phone: String(this.userQuery.phone || '').trim(),
};
getUserList(this.userPage.currentPage, this.userPage.pageSize, params)
.then(res => {
const result = res.data.data || {};
this.userData = result.records || [];
@@ -2810,7 +2897,7 @@ export default {
}
:deep(.el-form-item) {
margin-bottom: 14px;
//margin-bottom: 24px;
}
:deep(.el-form-item__label) {
+50 -10
View File
@@ -99,11 +99,21 @@
<el-table :data="attachmentRows" border @selection-change="selectedAttachments = $event">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名称" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -184,6 +194,7 @@
title="查看临时额度申请"
append-to-body
destroy-on-close
align-center
:width="formDialogWidth"
class="temporary-credit-limit-dialog temporary-credit-limit-detail-dialog"
>
@@ -216,11 +227,21 @@
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名称" min-width="240" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="240"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" show-overflow-tooltip>
@@ -615,6 +636,12 @@ export default {
}
const value = row[prop];
if (value === undefined || value === null || value === '') return '-';
if (
(prop === 'usedFundLimit' || prop === 'remainingFundLimit') &&
Number(value) === -1
) {
return '-';
}
return value;
},
applyDetail(detail) {
@@ -1188,10 +1215,10 @@ export default {
}
// 新增/编辑/查看:宽度与高度规则统一(Avue setPx 对 '1100px' 会拼成 '1100pxpx',故 option 用数字 1100)
// margin:auto 配合下方 unscoped 的 overlay flex,实现垂直水平居中
.temporary-credit-limit-dialog.el-dialog {
width: 1100px !important;
margin-top: 20px !important;
margin-bottom: 20px !important;
margin: auto !important;
height: auto !important;
max-height: calc(100vh - 40px) !important;
display: flex;
@@ -1329,3 +1356,16 @@ export default {
}
</style>
<!-- append-to-body 弹窗挂到 body,overlay 无 scoped 属性,需全局样式才能居中 -->
<style lang="scss">
.el-overlay-dialog:has(> .temporary-credit-limit-dialog) {
display: flex;
align-items: center;
justify-content: center;
}
.temporary-credit-limit-dialog.el-dialog {
margin: auto !important;
}
</style>
+51 -22
View File
@@ -585,6 +585,10 @@ export default {
this.$message.warning(`第${index + 1}条调度明细缺少车牌号`);
return;
}
if (!this.resolveDispatchQuantityUnit(row)) {
this.$message.warning(`第${index + 1}条调度明细缺少数量单位`);
return;
}
}
this.$confirm(
@@ -600,32 +604,57 @@ export default {
this.submitDispatch();
});
},
resolveDispatchQuantityUnit(row = {}) {
const topUnit = String(row.quantityUnit || '').trim();
if (topUnit) return topUnit;
const goodsList = this.parseGoodsList(row.goodsJson);
for (const goods of goodsList) {
const unit = String(
goods?.quantityUnit || goods?.goodsQuantityUnit || goods?.unit || ''
).trim();
if (unit) return unit;
}
return TRANSPORT_PLAN_QUANTITY_UNIT;
},
enrichDispatchGoodsJson(row = {}, quantityUnit) {
const goodsList = this.parseGoodsList(row.goodsJson);
if (goodsList.length) {
const nextGoods = goodsList.map(goods => ({
...goods,
quantityUnit: String(goods.quantityUnit || quantityUnit || '').trim() || quantityUnit,
}));
return JSON.stringify(nextGoods);
}
return JSON.stringify([
{
cargoName: row.cargoName,
cargoType: row.cargoType,
quantity: row.quantity,
quantityUnit,
specification: row.specification,
model: row.model,
packageType: row.packageType,
brand: row.brand,
},
]);
},
submitDispatch() {
this.saving = true;
const payload = {
id: this.planId,
mode: 'submit',
waybills: this.dispatchList.map(row => ({
...row,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: row.estimatedStartTime || this.planData.planStartDate || '',
estimatedEndTime: row.estimatedEndTime || this.planData.planEndDate || '',
goodsJson:
row.goodsJson ||
JSON.stringify([
{
cargoName: row.cargoName,
cargoType: row.cargoType,
quantity: row.quantity,
quantityUnit: row.quantityUnit,
specification: row.specification,
model: row.model,
packageType: row.packageType,
brand: row.brand,
},
]),
})),
waybills: this.dispatchList.map(row => {
const quantityUnit = this.resolveDispatchQuantityUnit(row);
return {
...row,
quantityUnit,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: row.estimatedStartTime || this.planData.planStartDate || '',
estimatedEndTime: row.estimatedEndTime || this.planData.planEndDate || '',
goodsJson: this.enrichDispatchGoodsJson(row, quantityUnit),
};
}),
};
dispatch(payload)
@@ -639,7 +668,7 @@ export default {
})
.catch(err => {
console.error('调度失败:', err);
this.$message.error('调度失败');
this.$message.error(err?.response?.data?.msg || err?.message || '调度失败');
})
.finally(() => {
this.saving = false;
+4 -2
View File
@@ -51,7 +51,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -165,6 +165,7 @@ import { getList as getContractList } from '@/api/business/contract-manage';
import { getList as getProjectList } from '@/api/business/project-apply';
import { importTransportPlan, validateTransportPlan } from '@/api/business/transport-plan';
import { config } from '@/option/business/transport-plan';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadXls } from '@/utils/util';
import dayjs from 'dayjs';
@@ -271,6 +272,7 @@ export default {
this.loadProjectOptions();
},
methods: {
formatContractOptionLabel,
loadProjectOptions() {
if (this.projectLoading) return;
this.projectLoading = true;
@@ -651,7 +653,7 @@ export default {
}
:deep(.el-form-item) {
margin-bottom: 16px;
margin-bottom: 24px;
}
}
+18 -4
View File
@@ -96,11 +96,25 @@ const uploadReplaceFile = async file => {
if (!task?.id || !task.objectKey) throw new Error('创建分片上传任务失败');
replaceProgress.value = 0;
for (let partNumber = 1; partNumber <= task.chunkTotal; partNumber += 1) {
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
const part = file.slice((partNumber - 1) * chunkSize, Math.min(partNumber * chunkSize, file.size));
const response = await fetch(urlRes.data?.data, { method: 'PUT', body: part });
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
const url = urlRes.data?.data;
let etag = '';
const needProxy =
typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
String(url || '')
.trim()
.toLowerCase()
.startsWith('http:');
if (!url || needProxy) {
const proxyRes = await api.uploadPartByProxy(task.id, partNumber, part);
etag = String(proxyRes.data?.data || '').replaceAll('"', '');
} else {
const response = await fetch(url, { method: 'PUT', body: part });
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
etag = String(response.headers.get('etag') || '').replaceAll('"', '');
}
if (!etag) throw new Error('未获取到分片 ETag');
await api.updateFileTask({ id: task.id, partNumber, etag });
replaceProgress.value = Math.round((partNumber / task.chunkTotal) * 100);
+139 -22
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="voucher-manage-page">
<section class="voucher-manage-page__search">
<section v-show="searchVisible" class="voucher-manage-page__search">
<el-form :model="query" label-position="right" label-width="88px">
<div class="voucher-manage-page__search-grid">
<el-form-item label="凭证批次号"
@@ -55,10 +55,25 @@
</section>
<section>
<div class="voucher-manage-page__toolbar">
<el-button type="primary" @click="openUpload">批量导入凭证</el-button
><el-button plain @click="openProgress">上传进度</el-button>
<div class="voucher-manage-page__toolbar-left">
<el-button type="primary" @click="openUpload">批量导入凭证</el-button>
<el-button plain @click="openProgress">上传进度</el-button>
</div>
<table-header-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="load"
/>
</div>
<el-table v-loading="loading" :data="rows" border class="voucher-manage-page__table">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
class="voucher-manage-page__table"
>
<el-table-column type="selection" width="52" fixed="left" align="center" /><el-table-column
type="index"
label="序号"
@@ -66,27 +81,47 @@
fixed="left"
align="center"
/>
<el-table-column prop="voucherBatchNo" label="凭证批次号" min-width="150" fixed="left"
<el-table-column
v-if="isColumnVisible('voucherBatchNo')"
prop="voucherBatchNo"
label="凭证批次号"
min-width="150"
fixed="left"
><template #default="{ row }"
><el-link class="voucher-manage-page__link" type="primary" @click="view(row)">{{
row.voucherBatchNo
}}</el-link></template
></el-table-column
>
<el-table-column prop="waybillBatchNo" label="运单批次号" min-width="170" /><el-table-column
<el-table-column
v-if="isColumnVisible('waybillBatchNo')"
prop="waybillBatchNo"
label="运单批次号"
min-width="170"
/><el-table-column
v-if="isColumnVisible('fileName')"
prop="fileName"
label="文件名"
min-width="200"
show-overflow-tooltip
/>
<el-table-column v-if="!isCarrier" prop="uploadSource" label="上传来源" width="100" /><el-table-column
v-if="!isCarrier"
<el-table-column
v-if="!isCarrier && isColumnVisible('uploadSource')"
prop="uploadSource"
label="上传来源"
width="100"
/><el-table-column
v-if="!isCarrier && isColumnVisible('carrierName')"
prop="carrierName"
label="承运商名称"
min-width="160"
><template #default="{ row }">{{ row.carrierName || '-' }}</template></el-table-column
>
<el-table-column prop="processStatus" label="处理状态" width="120"
<el-table-column
v-if="isColumnVisible('processStatus')"
prop="processStatus"
label="处理状态"
width="120"
><template #default="{ row }"
><span
:class="{ 'voucher-manage-page__process-failed': row.processStatus === '处理失败' }"
@@ -94,17 +129,35 @@
></template
></el-table-column
><el-table-column
v-if="isColumnVisible('voucherCount')"
prop="voucherCount"
label="凭证数量"
width="100"
/><el-table-column
v-if="isColumnVisible('relatedWaybillCount')"
prop="relatedWaybillCount"
label="凭证已关联数量"
width="125"
/> <el-table-column prop="unRelatedWaybillCount" label="凭证未关联数量" width="125" />
<el-table-column prop="createUserName" label="创建人" min-width="120" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
/><el-table-column
v-if="isColumnVisible('unRelatedWaybillCount')"
prop="unRelatedWaybillCount"
label="凭证未关联数量"
width="125"
/>
<el-table-column
v-if="isColumnVisible('createUserName')"
prop="createUserName"
label="创建人"
min-width="120"
/>
<el-table-column
v-if="isColumnVisible('createTime')"
prop="createTime"
label="创建时间"
min-width="170"
/>
<el-table-column
v-if="isColumnVisible('auditStatus')"
prop="auditStatus"
label="审核状态"
width="120"
@@ -512,6 +565,7 @@ import { WarnTriangleFilled } from '@element-plus/icons-vue';
import { getList as getProjectList } from '@/api/business/project-apply';
import * as api from '@/api/business/voucher-manage';
import SectionCard from '@/components/section-card/main.vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
const router = useRouter();
const store = useStore();
@@ -539,6 +593,47 @@ const checkIfCarrier = () => {
isCarrier.value = topDeptName === '外部组织';
};
const VOUCHER_BASE_COLUMNS = [
{ prop: 'voucherBatchNo', label: '凭证批次号' },
{ prop: 'waybillBatchNo', label: '运单批次号' },
{ prop: 'fileName', label: '文件名' },
{ prop: 'uploadSource', label: '上传来源', carrierHidden: true },
{ prop: 'carrierName', label: '承运商名称', carrierHidden: true },
{ prop: 'processStatus', label: '处理状态' },
{ prop: 'voucherCount', label: '凭证数量' },
{ prop: 'relatedWaybillCount', label: '凭证已关联数量' },
{ prop: 'unRelatedWaybillCount', label: '凭证未关联数量' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'auditStatus', label: '审核状态' },
];
const searchVisible = ref(true);
const tableSize = ref('default');
const visibleColumnKeys = ref([]);
const columnSettingOptions = computed(() =>
VOUCHER_BASE_COLUMNS.filter(column => !isCarrier.value || !column.carrierHidden).map(column => ({
prop: column.prop,
label: column.label,
}))
);
const isColumnVisible = prop => visibleColumnKeys.value.includes(prop);
watch(
columnSettingOptions,
options => {
const props = options.map(column => column.prop);
if (!visibleColumnKeys.value.length) {
visibleColumnKeys.value = props;
return;
}
const visible = new Set(visibleColumnKeys.value);
visibleColumnKeys.value = props.filter(prop => visible.has(prop));
if (!visibleColumnKeys.value.length) visibleColumnKeys.value = props;
},
{ immediate: true }
);
onMounted(() => {
checkIfCarrier();
});
@@ -742,6 +837,30 @@ const calculateMd5 = async file => {
const buffer = await file.arrayBuffer();
return md5.base64(buffer);
};
const needsUploadProxy = url =>
typeof window !== 'undefined' &&
window.location.protocol === 'https:' &&
String(url || '')
.trim()
.toLowerCase()
.startsWith('http:');
const uploadPartWithFallback = async (taskId, partNumber, part, signal) => {
const urlRes = await api.getPartUploadUrl(taskId, partNumber);
if (signal?.aborted) throw new DOMException('上传已暂停', 'AbortError');
const url = urlRes.data?.data;
// HTTPS 页面直连 http://IP MinIO 会被 Mixed Content 拦截,回退服务端代传
if (!url || needsUploadProxy(url)) {
const proxyRes = await api.uploadPartByProxy(taskId, partNumber, part);
return String(proxyRes.data?.data || '').replaceAll('"', '');
}
const response = await fetch(url, {
method: 'PUT',
body: part,
signal,
});
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
return String(response.headers.get('etag') || '').replaceAll('"', '');
};
const uploadParts = async (file, task, sessionId) => {
uploading.value = true;
activeUploadTaskId.value = task.id;
@@ -756,21 +875,13 @@ const uploadParts = async (file, task, sessionId) => {
) {
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const url = urlRes.data?.data;
const part = file.slice(
(partNumber - 1) * chunkSize,
Math.min(partNumber * chunkSize, file.size)
);
const response = await fetch(url, {
method: 'PUT',
body: part,
signal: abortController.signal,
});
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
const etag = await uploadPartWithFallback(task.id, partNumber, part, abortController.signal);
if (!etag) throw new Error('MinIO 未返回分片 ETag,请确认跨域配置已暴露 ETag 响应头');
await api.updateFileTask({ id: task.id, partNumber, etag });
ensureUploadSession(sessionId, task.id);
@@ -1329,9 +1440,15 @@ load();
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 12px 0;
}
&__toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
&__table {
width: 100%;
}
@@ -1370,7 +1487,7 @@ load();
display: none;
}
&__upload-form :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
&__upload-tip {
margin-left: 16px;
+1 -1
View File
@@ -529,7 +529,7 @@ export default {
<style scoped lang="scss">
.bill-ledger-form-page__form :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.bill-ledger-form-page__basic-grid {
display: grid;
+4
View File
@@ -1,6 +1,7 @@
<template>
<basic-container class="bill-ledger-page">
<bill-ledger-search
v-if="searchVisible"
:query="query"
:loading="loading"
@search="handleSearch"
@@ -20,10 +21,12 @@
:rows="rows"
:loading="loading"
:page="page"
v-model:search-visible="searchVisible"
@add="openCreate"
@view="openView"
@edit="openEdit"
@delete="handleDelete"
@refresh="loadData"
@page-change="handlePageChange"
@size-change="handleSizeChange"
/>
@@ -124,6 +127,7 @@ export default {
return {
query: emptyQuery(),
counts: { all: 0, within30: 0, within90: 0, over90: 0 },
searchVisible: true,
rows: [],
loading: false,
page: { current: 1, size: 10, total: 0 },
+1 -1
View File
@@ -367,7 +367,7 @@ export default {
<style scoped lang="scss">
.bill-payment-form-page__form :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.bill-payment-form-page__form :deep(.el-input),
.bill-payment-form-page__form :deep(.el-input-number),
+4
View File
@@ -1,6 +1,7 @@
<template>
<basic-container class="bill-payment-page">
<bill-payment-search
v-if="searchVisible"
:query="query"
:loading="loading"
@search="handleSearch"
@@ -11,6 +12,7 @@
:loading="loading"
:page="page"
:mk-enabled="mkEnabled"
v-model:search-visible="searchVisible"
@add="openCreate"
@view="openView"
@edit="openEdit"
@@ -20,6 +22,7 @@
@approve="handleApprove"
@return="handleReturn"
@void="handleVoid"
@refresh="loadTable"
@page-change="handlePageChange"
@size-change="handleSizeChange"
/>
@@ -47,6 +50,7 @@ export default {
data() {
return {
query: emptyQuery(),
searchVisible: true,
rows: [],
loading: false,
page: { current: 1, size: 10, total: 0 },
@@ -4,11 +4,19 @@
<el-button v-if="hasPermission('bill_ledger_add')" type="primary" @click="$emit('add')">
新增
</el-button>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table v-loading="loading" :data="rows" border>
<el-table v-loading="loading" :data="rows" :size="tableSize" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -60,20 +68,30 @@
<script>
import { mapGetters } from 'vuex';
import { billLedgerTableColumns } from '@/option/payment/billLedger';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
export default {
name: 'BillLedgerTable',
components: { TableHeaderTools },
props: {
rows: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
page: { type: Object, required: true },
searchVisible: { type: Boolean, default: true },
},
emits: ['add', 'view', 'edit', 'delete', 'page-change', 'size-change'],
emits: ['add', 'view', 'edit', 'delete', 'page-change', 'size-change', 'refresh', 'update:searchVisible'],
data() {
return { columns: billLedgerTableColumns };
return {
columns: billLedgerTableColumns,
visibleColumnKeys: billLedgerTableColumns.map(column => column.prop),
tableSize: 'default',
};
},
computed: {
...mapGetters(['permission']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
methods: {
hasPermission(code) {
@@ -96,7 +114,7 @@ export default {
.bill-ledger-table__toolbar {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: space-between;
padding: 12px 0;
}
.bill-ledger-table__links {
@@ -4,11 +4,19 @@
<el-button v-if="hasPermission('bill_payment_add')" type="primary" @click="$emit('add')">
新增
</el-button>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table v-loading="loading" :data="rows" border>
<el-table v-loading="loading" :data="rows" :size="tableSize" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -61,7 +69,7 @@
<el-link
v-if="
mkEnabled &&
row.approvalStatus === 'reviewing' &&
['reviewing', 'approved'].includes(row.approvalStatus) &&
hasPermission('bill_payment_approve')
"
type="primary"
@@ -115,15 +123,18 @@
<script>
import { mapGetters } from 'vuex';
import { billPaymentTableColumns } from '@/option/payment/billPayment';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import { isAdministrator } from '@/utils/auth';
export default {
name: 'BillPaymentTable',
components: { TableHeaderTools },
props: {
rows: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
page: { type: Object, required: true },
mkEnabled: { type: Boolean, default: false },
searchVisible: { type: Boolean, default: true },
},
emits: [
'add',
@@ -137,12 +148,21 @@ export default {
'void',
'page-change',
'size-change',
'refresh',
'update:searchVisible',
],
data() {
return { columns: billPaymentTableColumns };
return {
columns: billPaymentTableColumns,
visibleColumnKeys: billPaymentTableColumns.map(column => column.prop),
tableSize: 'default',
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -173,7 +193,8 @@ export default {
}
.bill-payment-table__toolbar {
display: flex;
justify-content: flex-start;
align-items: center;
justify-content: space-between;
padding: 12px 0;
}
.bill-payment-table__links {
+26 -12
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="invoice-page">
<section class="invoice-page__search">
<section v-if="searchVisible" class="invoice-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="invoice-page__search-grid">
<el-form-item label="单据号">
@@ -79,18 +79,26 @@
>导出</el-button
>
</div>
<div class="invoice-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button text :icon="Refresh" @click="loadTable" />
</el-tooltip>
</div>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table v-loading="loading" :data="rows" border @selection-change="selection = $event">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selection = $event"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -161,7 +169,7 @@
v-if="
mkEnabled &&
hasPermission('invoice_application_approve') &&
row.approvalStatus === 'reviewing'
['reviewing', 'approved'].includes(row.approvalStatus)
"
type="primary"
@click="handleMkAudit(row)"
@@ -218,12 +226,13 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as XLSX from 'xlsx';
import * as api from '@/api/payment/invoiceApplication';
import * as formalSettlementApi from '@/api/settlement/formalSettlement';
import { invoiceApplicationTableColumns } from '@/option/payment/invoiceApplication';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import organizationSearch from '@/mixins/organization-search';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
@@ -239,17 +248,20 @@ const emptyQuery = () => ({
export default {
name: 'InvoiceApplication',
components: { TableHeaderTools },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
query: emptyQuery(),
searchExpanded: false,
searchVisible: true,
tableSize: 'default',
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
columns: invoiceApplicationTableColumns,
visibleColumnKeys: invoiceApplicationTableColumns.map(column => column.prop),
rows: [],
selection: [],
loading: false,
@@ -260,6 +272,9 @@ export default {
},
computed: {
...mapGetters(['permission']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
mounted() {
this.loadOrganizationOptions();
@@ -531,7 +546,6 @@ export default {
padding: 12px 0;
}
.invoice-page__toolbar-left,
.invoice-page__toolbar-right,
.invoice-page__links {
display: flex;
align-items: center;
+1 -1
View File
@@ -610,7 +610,7 @@ export default {
padding-bottom: 72px;
}
.receipt-form-page__form :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.receipt-form-page__form :deep(.el-select),
.receipt-form-page__form :deep(.el-input-number) {
+26 -12
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="receipt-page">
<section class="receipt-page__search">
<section v-if="searchVisible" class="receipt-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="receipt-page__search-grid">
<el-form-item label="发票号码">
@@ -85,18 +85,26 @@
>导出</el-button
>
</div>
<div class="receipt-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button text :icon="Refresh" @click="loadTable" />
</el-tooltip>
</div>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table v-loading="loading" :data="rows" border @selection-change="selection = $event">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selection = $event"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -150,7 +158,7 @@
v-if="
mkEnabled &&
hasPermission('invoice_receipt_approve') &&
row.approvalStatus === 'reviewing'
['reviewing', 'approved'].includes(row.approvalStatus)
"
type="primary"
@click="handleMkAudit(row)"
@@ -203,11 +211,12 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as XLSX from 'xlsx';
import * as api from '@/api/payment/invoiceReceipt';
import { invoiceReceiptTableColumns } from '@/option/payment/invoiceReceipt';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import organizationSearch from '@/mixins/organization-search';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
@@ -224,17 +233,20 @@ const emptyQuery = () => ({
export default {
name: 'InvoiceReceipt',
components: { TableHeaderTools },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
query: emptyQuery(),
searchExpanded: false,
searchVisible: true,
tableSize: 'default',
approvalStatusOptions: api.approvalStatusOptions,
kingdeePushStatusOptions: api.kingdeePushStatusOptions,
columns: invoiceReceiptTableColumns,
visibleColumnKeys: invoiceReceiptTableColumns.map(column => column.prop),
rows: [],
selection: [],
loading: false,
@@ -245,6 +257,9 @@ export default {
},
computed: {
...mapGetters(['permission']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
mounted() {
this.loadOrganizationOptions();
@@ -461,7 +476,6 @@ export default {
padding: 12px 0;
}
.receipt-page__toolbar-left,
.receipt-page__toolbar-right,
.receipt-page__links {
display: flex;
align-items: center;
+76 -69
View File
@@ -36,23 +36,25 @@
<el-col v-if="isSettlementRelatedType" :span="6"
><el-form-item label="结算单号" prop="referenceId">
<div class="payment-form-page__settlement-nos">
<template v-if="referenceSettlementLinks.length">
<el-link
v-for="item in referenceSettlementLinks"
:key="`${item.id || item.settlementNo}`"
type="primary"
@click="openSettlementDetail(item)"
>
{{ item.settlementNo }}
</el-link>
</template>
<el-input
v-else
v-model="referenceLabel"
readonly
<el-select
ref="settlementNoSelectRef"
:model-value="referenceSettlementNos"
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="1"
:disabled="readonly"
placeholder="请选择预结算/正式结算单"
/>
class="payment-form-page__settlement-select"
@visible-change="handleSettlementSelectVisible"
>
<el-option
v-for="item in referenceSettlementLinks"
:key="`${item.id || item.settlementNo}`"
:label="item.settlementNo"
:value="item.settlementNo"
/>
</el-select>
<el-button
v-if="!readonly"
class="payment-form-page__settlement-select-btn"
@@ -100,7 +102,7 @@
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="item.contractName || item.contractNo"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -251,7 +253,7 @@
</el-table-column>
</el-table>
</section-card>
<section-card v-if="isSettlementRelatedType" title="发票信息">
<section-card v-if="showInvoiceSection" title="发票信息">
<el-table :data="form.invoices" border>
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="settlementNo" label="结算单号" min-width="120">
@@ -355,19 +357,22 @@
value="special_approval" /><el-option
label="其他"
value="other" /></el-select></template></el-table-column
><el-table-column label="文件名" min-width="220"
><el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
><template #default="{ row }"
><div
class="payment-form-page__attachment-file-name-cell"
:title="attachmentFileName(row) || '-'"
>
<span
class="payment-form-page__attachment-file-name"
:class="{ 'is-disabled': !attachmentFileUrl(row) }"
@click="attachmentFileUrl(row) && previewAttachment(row)"
>{{ attachmentFileName(row) || '-' }}</span
>
</div></template
><span
class="attachment-file-name"
:class="{ 'is-disabled': !attachmentFileUrl(row) }"
role="button"
tabindex="0"
@click="attachmentFileUrl(row) && previewAttachment(row)"
@keydown.enter="attachmentFileUrl(row) && previewAttachment(row)"
@keydown.space.prevent="attachmentFileUrl(row) && previewAttachment(row)"
>{{ attachmentFileName(row) || '-' }}</span
></template
></el-table-column
><el-table-column label="附件描述" min-width="220"
><template #default="{ row }"
@@ -671,6 +676,7 @@ import {
getList as getCustomerArchiveList,
} from '@/api/vehicle/customer-archive';
import { readSettlementTransfer, removeSettlementTransfer } from '@/utils/settlement-transfer';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl } from '@/utils/util';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import {
@@ -887,6 +893,10 @@ export default {
isSettlementRelatedType() {
return ['progress_advance', 'settlement_payment'].includes(this.form.paymentType);
},
// 项目预付、进度预付不展示发票信息,仅尾款付款展示
showInvoiceSection() {
return this.form.paymentType === 'settlement_payment';
},
canSelectPreSettlement() {
// 进度预付仅预结算;尾款付款可正式/预结算
return ['progress_advance', 'settlement_payment'].includes(this.form.paymentType);
@@ -939,6 +949,9 @@ export default {
referenceType: this.form.preSettlementId ? 'pre' : 'formal',
}));
},
referenceSettlementNos() {
return this.referenceSettlementLinks.map(item => item.settlementNo).filter(Boolean);
},
settlementRows() {
if (this.referenceRows.length) {
return this.referenceRows.map(item => ({
@@ -1038,6 +1051,7 @@ export default {
this.initialize();
},
methods: {
formatContractOptionLabel,
hasPermission(code) {
return this.permission?.[code] !== false;
},
@@ -1122,7 +1136,7 @@ export default {
callback();
},
validateInvoices() {
if (this.form.paymentType === 'project_advance') return;
if (!this.showInvoiceSection) return;
for (const invoice of this.form.invoices || []) {
if (String(invoice.settlementNo || '').length > 100) {
throw new Error('结算单号不能超过100个字符');
@@ -1904,6 +1918,7 @@ export default {
this.contractPaymentRatioExceeded = exceeded;
},
handleTypeChange() {
// 切换类型时清空发票;预付类型本身也不再展示/提交发票
this.form.invoices = [];
this.paymentRecords = [];
Object.assign(this.form, {
@@ -2091,8 +2106,20 @@ export default {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
this.form.projectName = project?.projectName || project?.projectShortName || '';
Object.assign(this.form, {
deptId: project?.undertakeDeptId || project?.businessDeptId || null,
deptName: project?.undertakeDeptName || project?.businessDeptName || '',
deptId:
String(project?.undertakeDeptId || '')
.split(/[,,]/)
.map(item => item.trim())
.find(Boolean) ||
project?.businessDeptId ||
null,
deptName:
String(project?.undertakeDeptName || '')
.split(/[,,、]/)
.map(item => item.trim())
.find(Boolean) ||
project?.businessDeptName ||
'',
contractId: null,
contractNo: '',
contractName: '',
@@ -2260,6 +2287,13 @@ export default {
}
return Promise.all(loaders);
},
handleSettlementSelectVisible(visible) {
// 结算单号仅做展示,通过「选择」按钮维护,避免下拉改值
if (!visible) return;
this.$nextTick(() => {
this.$refs.settlementNoSelectRef?.blur?.();
});
},
handleReferenceSearch() {
this.formalPage.current = 1;
this.prePage.current = 1;
@@ -2623,7 +2657,7 @@ export default {
attachmentsJson: JSON.stringify(attachments || []),
remark,
invoices:
paymentType === 'project_advance'
paymentType === 'project_advance' || paymentType === 'progress_advance'
? []
: invoices.map(invoice => ({
settlementNo: invoice.settlementNo || '',
@@ -2704,13 +2738,18 @@ export default {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
width: 100%;
min-height: 32px;
}
.payment-form-page__settlement-nos :deep(.el-input) {
.payment-form-page__settlement-select {
flex: 1;
min-width: 120px;
min-width: 0;
}
.payment-form-page__settlement-select :deep(.el-select__wrapper) {
width: 100%;
}
.payment-form-page__settlement-select :deep(.el-tag__close) {
display: none;
}
.payment-form-page__settlement-select-btn {
flex-shrink: 0;
@@ -2787,38 +2826,6 @@ export default {
justify-content: center;
gap: 8px;
}
.payment-form-page__attachment-file-name-cell {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.payment-form-page__attachment-file-name {
display: block;
width: 100%;
overflow: hidden;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #409eff;
}
&.is-disabled {
color: var(--el-text-color-regular);
cursor: default;
&:hover {
color: var(--el-text-color-regular);
}
}
}
.payment-form-page__attachment-upload .vehicle-attachment-upload {
width: auto;
}
@@ -2826,7 +2833,7 @@ export default {
display: inline-flex;
}
.payment-form-page :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.payment-form-page :deep(.el-input-number .el-input__inner) {
text-align: left;
+201 -358
View File
@@ -1,297 +1,179 @@
<template>
<basic-container class="payment-page">
<section class="payment-page__search">
<el-form :model="query" label-position="right" label-width="88px">
<div class="payment-page__search-grid">
<el-form-item label="单据号"
><el-input v-model="query.paymentNo" clearable
/></el-form-item>
<el-form-item label="日期"
><el-date-picker
v-model="query.applyDateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="~"
start-placeholder="年/月/日"
end-placeholder="年/月/日"
/></el-form-item>
<el-form-item label="客户名称"
><el-input v-model="query.payeeName" clearable
/></el-form-item>
<el-form-item label="项目名称"
><el-input v-model="query.projectName" clearable
/></el-form-item>
<template v-if="searchExpanded">
<el-form-item label="所属组织"
><el-cascader
v-model="query.deptPath"
:options="organizationTreeOptions"
:props="organizationCascaderProps"
filterable
clearable
style="width: 100%"
placeholder="请选择 所属组织"
@change="val => { query.deptName = findOrgById(val)?.rawLabel || '' }"
/></el-form-item>
<el-form-item label="关联结算单"
><el-input v-model="query.settlementNo" clearable
/></el-form-item>
<el-form-item label="付款类型"
><el-select v-model="query.paymentType" clearable
><el-option
v-for="item in paymentTypeOptions"
:key="item.value"
v-bind="item" /></el-select
></el-form-item>
<el-form-item label="审核状态"
><el-select v-model="query.approvalStatus" clearable
><el-option
v-for="item in approvalStatusOptions"
:key="item.value"
v-bind="item" /></el-select
></el-form-item>
<el-form-item label="金蝶单据状态"
><el-select v-model="query.kingdeeStatus" clearable
><el-option
v-for="item in kingdeeStatusOptions"
:key="item.value"
v-bind="item" /></el-select
></el-form-item>
</template>
</div>
<div class="payment-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
<el-button text @click="searchExpanded = !searchExpanded"
><el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon
></el-button>
</div>
</el-form>
</section>
<section class="payment-page__table-panel">
<div class="payment-page__toolbar">
<div class="payment-page__toolbar-left">
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
@click="openCreate"
>新增付款申请</el-button
>
<el-button
v-if="hasPermission('payment_application_sync')"
type="primary"
plain
:loading="syncLoading"
@click="handleSync"
>批量同步</el-button
>
<el-button
v-if="hasPermission('payment_application_sync')"
type="primary"
plain
:loading="resultSyncing"
@click="handleSyncResult"
>同步付款结果</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openSettlementPayment"
>申请付尾款</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openProjectAdvance"
>预付申请</el-button
>
<el-button
v-if="hasPermission('payment_application_export')"
type="primary"
plain
@click="handleExport"
>导出</el-button
>
</div>
<el-button text :icon="Refresh" @click="loadTable" />
</div>
<el-table v-loading="loading" :data="rows" border @selection-change="selection = $event">
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
:key="column.prop"
v-bind="column"
align="center"
show-overflow-tooltip
<avue-crud
ref="crud"
:option="option"
:table-loading="loading"
:data="rows"
v-model:page="page"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
@click="openCreate"
>新增付款申请</el-button
>
<template #default="{ row }">
<el-link v-if="column.link" type="primary" @click="openView(row)">{{
row[column.prop]
}}</el-link>
<el-tag
v-else-if="column.status"
:type="statusType(row.approvalStatus)"
class="status-text"
>{{ row[column.prop] || '-' }}</el-tag
>
<span v-else-if="column.money">{{ formatMoney(row[column.prop]) }}</span>
<template v-else-if="column.prop === 'kingdeeLockStatus'">
<el-tooltip v-if="lockTag(row).show" :content="lockTag(row).tip" placement="top">
<el-tag :type="lockTag(row).type" class="status-text">{{ lockTag(row).label }}</el-tag>
</el-tooltip>
<span v-else>-</span>
</template>
<span v-else>{{ displayValue(row[column.prop]) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right" align="center">
<template #default="{ row }"
><div class="payment-page__links">
<el-link
v-if="hasPermission('payment_application_view')"
type="primary"
@click="openView(row)"
>查看</el-link
>
<el-link
v-if="
hasPermission('payment_application_edit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="openEdit(row)"
>编辑</el-link
>
<el-link
v-if="
hasPermission('payment_application_submit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="handleSubmit(row)"
>提交</el-link
>
<el-link
v-if="
mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="primary"
@click="handleMkAudit(row)"
>流程</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="primary"
@click="handleApprove(row)"
>通过</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="danger"
@click="handleReturn(row)"
>驳回</el-link
>
<el-link
v-if="
hasPermission('payment_application_void') &&
row.approvalStatus === 'approved' &&
row.kingdeeLockStatus !== 'locked'
"
type="danger"
@click="handleVoid(row)"
>作废</el-link
>
<el-link
v-if="hasPermission('payment_application_delete') && row.approvalStatus === 'draft'"
type="danger"
@click="handleDelete(row)"
>删除</el-link
>
</div></template
>
</el-table-column>
</el-table>
<div class="payment-page__pagination">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadTable"
@size-change="handleSizeChange"
/>
</div>
</section>
<el-button
v-if="hasPermission('payment_application_sync')"
type="primary"
plain
@click="handleSync"
>批量同步</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openSettlementPayment"
>申请付尾款</el-button
>
<el-button
v-if="hasPermission('payment_application_add')"
type="primary"
plain
@click="openProjectAdvance"
>预付申请</el-button
>
<el-button
v-if="hasPermission('payment_application_export')"
type="primary"
plain
@click="handleExport"
>导出</el-button
>
</template>
<template #paymentNo="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.paymentNo }}</el-link>
</template>
<template #approvalStatusName="{ row }">
<el-tag :type="statusType(row.approvalStatus)" class="status-text">{{
row.approvalStatusName || '-'
}}</el-tag>
</template>
<template #menu="{ row }">
<el-link
v-if="hasPermission('payment_application_view')"
type="primary"
@click="openView(row)"
>查看</el-link
>
<el-link
v-if="
hasPermission('payment_application_edit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="openEdit(row)"
>编辑</el-link
>
<el-link
v-if="
hasPermission('payment_application_submit') &&
['draft', 'returned'].includes(row.approvalStatus)
"
type="primary"
@click="handleSubmit(row)"
>提交</el-link
>
<el-link
v-if="
mkEnabled &&
hasPermission('payment_application_approve') &&
['reviewing', 'approved'].includes(row.approvalStatus)
"
type="primary"
@click="handleMkAudit(row)"
>流程</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="primary"
@click="handleApprove(row)"
>通过</el-link
>
<el-link
v-if="
!mkEnabled &&
hasPermission('payment_application_approve') &&
row.approvalStatus === 'reviewing'
"
type="danger"
@click="handleReturn(row)"
>驳回</el-link
>
<el-link
v-if="hasPermission('payment_application_void') && row.approvalStatus === 'approved'"
type="danger"
@click="handleVoid(row)"
>作废</el-link
>
<el-link
v-if="hasPermission('payment_application_delete') && row.approvalStatus === 'draft'"
type="danger"
@click="handleDelete(row)"
>删除</el-link
>
</template>
</avue-crud>
</basic-container>
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as api from '@/api/payment/paymentApplication';
import { paymentApplicationTableColumns } from '@/option/payment/paymentApplication';
import { paymentApplicationOption } from '@/option/payment/paymentApplication';
import organizationSearch from '@/mixins/organization-search';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
const emptyQuery = () => ({
paymentNo: '',
applyDateRange: [],
payeeName: '',
projectName: '',
deptName: '',
deptPath: [],
settlementNo: '',
paymentType: '',
approvalStatus: '',
kingdeeStatus: '',
});
export default {
name: 'PaymentApplication',
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
query: emptyQuery(),
searchExpanded: false,
paymentTypeOptions: api.paymentTypeOptions,
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
columns: paymentApplicationTableColumns,
rows: [],
selection: [],
loading: false,
syncLoading: false,
resultSyncing: false,
page: { current: 1, size: 10, total: 0 },
query: {},
option: {},
page: {
currentPage: 1,
pageSize: 10,
pageSizes: [10, 20, 50, 100],
total: 0,
},
mkEnabled: false,
};
},
computed: {
...mapGetters(['permission']),
visibleSearchFields() {
return this.searchExpanded ? this.columns : this.columns.slice(0, 4);
},
},
created() {
this.option = paymentApplicationOption({
paymentTypeOptions: api.paymentTypeOptions,
approvalStatusOptions: api.approvalStatusOptions,
kingdeeStatusOptions: api.kingdeeStatusOptions,
});
this.loadOrganizationOptions();
},
mounted() {
this.loadOrganizationOptions();
this.loadTable();
loadMkEnabled()
.then(enabled => {
this.mkEnabled = !!enabled;
@@ -308,39 +190,52 @@ export default {
const body = response?.data || response || {};
return body?.data || body;
},
buildParams() {
const params = { ...this.query };
onLoad(page) {
this.loading = true;
api
.getList(page.currentPage, page.pageSize, { ...this.query })
.then(res => {
const data = this.unwrapData(res);
this.rows = data.records || [];
this.page.total = Number(data.total || 0);
})
.finally(() => {
this.loading = false;
this.selection = [];
this.$refs.crud?.toggleSelection?.();
});
},
searchChange(params, done) {
// 所属组织级联返回 id 路径数组,转成部门名称给后端
this.normalizeOrganizationSearch(params, 'deptName');
// 日期区间拆成后端需要的起止字段
const range = params.applyDateRange || [];
delete params.applyDateRange;
if (range.length === 2) {
params.applyStartDate = range[0];
params.applyEndDate = range[1];
}
return params;
this.query = {
...params,
applyStartDate: range[0] || '',
applyEndDate: range[1] || '',
};
this.page.currentPage = 1;
this.onLoad(this.page);
done();
},
async loadTable() {
this.loading = true;
try {
const data = this.unwrapData(
await api.getList(this.page.current, this.page.size, this.buildParams())
);
this.rows = data.records || [];
this.page.total = data.total || 0;
} finally {
this.loading = false;
}
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page);
},
handleSearch() {
this.page.current = 1;
this.loadTable();
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
resetSearch() {
this.query = emptyQuery();
this.handleSearch();
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
handleSizeChange() {
this.page.current = 1;
this.loadTable();
refreshChange() {
this.onLoad(this.page);
},
selectionChange(list) {
this.selection = list;
},
openCreate() {
this.$router.push({ path: '/payment/payment-application/form', query: { mode: 'add' } });
@@ -380,7 +275,7 @@ export default {
await this.$confirm('确认删除该付款申请?', '提示', { type: 'warning' });
await api.remove(row.id);
this.$message.success('删除成功');
this.loadTable();
this.refreshChange();
},
async handleSubmit(row) {
const previousApprovalStatus = row.approvalStatus || '';
@@ -392,7 +287,7 @@ export default {
approvalStatus: previousApprovalStatus,
});
this.$message.success('提交成功');
this.loadTable();
this.refreshChange();
},
async handleApprove(row) {
const res = await api.approve({ id: row.id });
@@ -402,7 +297,7 @@ export default {
} else {
this.$message.success(res.data.msg || '审批通过');
}
this.loadTable();
this.refreshChange();
},
handleMkAudit(row) {
if (!row?.id) {
@@ -426,18 +321,18 @@ export default {
const { value } = await this.$prompt('请输入驳回原因', '审批驳回');
await api.returnBill({ id: row.id, reason: value });
this.$message.success('已驳回');
this.loadTable();
this.refreshChange();
},
async handleVoid(row) {
const { value } = await this.$prompt('请输入作废原因', '作废付款申请');
await api.voidBill({ id: row.id, reason: value });
this.$message.success('作废成功');
this.loadTable();
this.refreshChange();
},
async handleSyncRow(row) {
const data = this.unwrapData(await api.syncKingdee(row.id));
this.$message.success(`已生成金蝶付款单:${data}`);
await this.loadTable();
await this.refreshChange();
this.warnAttachmentNotUploaded([row.id]);
},
async handleSync() {
@@ -460,7 +355,7 @@ export default {
} else {
this.$message.success(`已同步${data.length}条付款申请`);
}
await this.loadTable();
await this.refreshChange();
this.warnAttachmentNotUploaded(rows.map(row => row.id));
} finally {
this.syncLoading = false;
@@ -490,9 +385,15 @@ export default {
this.resultSyncing = false;
}
},
handleExport() {
const headers = this.columns.map(item => item.label);
const rows = this.rows.map(row => this.columns.map(item => row[item.prop] || ''));
const columns = this.option.column.filter(column => !column.hide && column.prop);
const headers = columns.map(item => item.label);
const rows = this.rows.map(row =>
columns.map(item =>
row[item.prop] === null || row[item.prop] === undefined ? '' : row[item.prop]
)
);
const csv = [headers, ...rows]
.map(item => item.map(value => `"${String(value).replaceAll('"', '""')}"`).join(','))
.join('\n');
@@ -542,54 +443,6 @@ export default {
</script>
<style scoped lang="scss">
.payment-page__search {
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.payment-page__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
.payment-page__search :deep(.el-form-item) {
margin-bottom: 8px;
}
.payment-page__search :deep(.el-input),
.payment-page__search :deep(.el-select),
.payment-page__search :deep(.el-date-editor) {
width: 100%;
}
.payment-page__search-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
}
.payment-page__table-panel {
margin-top: 8px;
}
.payment-page__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.payment-page__toolbar-left {
display: flex;
gap: 8px;
}
.payment-page__links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.payment-page__pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.payment-page :deep(.el-table) {
--el-table-border-color: #eff1f7;
}
@@ -599,14 +452,4 @@ export default {
:deep(.payment-page.basic-container .basic-container__card > .el-card__body) {
padding: 0;
}
@media (max-width: 1200px) {
.payment-page__search-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.payment-page__search-grid {
grid-template-columns: 1fr;
}
}
</style>
+21 -5
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="claim-record-page">
<section-card class="claim-record-page__search">
<section-card v-if="searchVisible" class="claim-record-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="claim-record-page__search-grid">
<el-form-item label="认领通知单">
@@ -96,9 +96,19 @@
</el-button>
</div>
<el-table v-loading="loading" :data="rows" border>
<div class="claim-record-page__toolbar">
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table v-loading="loading" :data="rows" :size="tableSize" border>
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -157,6 +167,7 @@ import { mapGetters } from 'vuex';
import * as api from '@/api/payment/receiptClaimRecord';
import { receiptClaimRecordTableColumns } from '@/option/payment/receiptClaimRecord';
import SectionCard from '@/components/section-card/main.vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import { isAdministrator } from '@/utils/auth';
const emptyQuery = () => ({
@@ -176,17 +187,20 @@ const emptyQuery = () => ({
export default {
name: 'ReceiptClaimRecord',
components: { SectionCard },
components: { SectionCard, TableHeaderTools },
data() {
return {
ArrowDown,
ArrowUp,
query: emptyQuery(),
searchExpanded: false,
searchVisible: true,
tableSize: 'default',
claimStatusOptions: api.claimStatusOptions,
kingdeeBillStatusOptions: api.kingdeeBillStatusOptions,
pushStatusOptions: api.pushStatusOptions,
columns: receiptClaimRecordTableColumns,
visibleColumnKeys: receiptClaimRecordTableColumns.map(column => column.prop),
rows: [],
loading: false,
refreshing: false,
@@ -195,6 +209,9 @@ export default {
},
computed: {
...mapGetters(['permission', 'userInfo']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -314,9 +331,8 @@ export default {
}
.claim-record-page__toolbar {
display: flex;
justify-content: flex-start;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 12px 0;
}
.claim-record-page__pagination {
+41 -19
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="receipt-flow-page">
<section class="receipt-flow-page__search">
<section v-if="searchVisible" class="receipt-flow-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="receipt-flow-page__search-grid">
<el-form-item label="认领通知单">
@@ -97,33 +97,43 @@
<section class="receipt-flow-page__table-panel">
<div class="receipt-flow-page__toolbar">
<el-button
v-if="hasPermission('receipt_flow_sync')"
type="primary"
:loading="syncing"
@click="handleSync"
>
手动同步流水
</el-button>
<el-button
v-if="hasPermission('receipt_flow_claim')"
type="primary"
:disabled="!selectedRows.length"
@click="openBatchClaim"
>
批量认领
</el-button>
<div class="receipt-flow-page__toolbar-left">
<el-button
v-if="hasPermission('receipt_flow_sync')"
type="primary"
:loading="syncing"
@click="handleSync"
>
手动同步流水
</el-button>
<el-button
v-if="hasPermission('receipt_flow_claim')"
type="primary"
:disabled="!selectedRows.length"
@click="openBatchClaim"
>
批量认领
</el-button>
</div>
<table-header-tools
:columns="columns"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selectedRows = $event"
>
<el-table-column type="selection" width="52" align="center" />
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -271,6 +281,7 @@ import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as api from '@/api/payment/receiptFlow';
import { receiptFlowTableColumns } from '@/option/payment/receiptFlow';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import { isAdministrator } from '@/utils/auth';
const emptyQuery = () => ({
@@ -291,6 +302,7 @@ const emptyQuery = () => ({
export default {
name: 'ReceiptFlow',
components: { TableHeaderTools },
data() {
return {
ArrowDown,
@@ -300,6 +312,9 @@ export default {
pushStatusOptions: api.pushStatusOptions,
kingdeeBillStatusOptions: api.kingdeeBillStatusOptions,
columns: receiptFlowTableColumns,
visibleColumnKeys: receiptFlowTableColumns.map(column => column.prop),
searchVisible: true,
tableSize: 'default',
rows: [],
selectedRows: [],
loading: false,
@@ -312,6 +327,9 @@ export default {
},
computed: {
...mapGetters(['permission', 'userInfo']),
visibleColumns() {
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -507,11 +525,15 @@ export default {
}
.receipt-flow-page__toolbar {
display: flex;
justify-content: flex-start;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 12px 0;
}
.receipt-flow-page__toolbar-left {
display: flex;
gap: 8px;
}
.receipt-flow-page__pagination {
display: flex;
justify-content: flex-end;
@@ -53,7 +53,7 @@
<el-option
v-for="item in contracts"
:key="item.id"
:label="`${item.contractNo} / ${item.contractName}`"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -176,6 +176,17 @@
<section-card title="结算明细">
<template #extra>
<div class="formal-editor__actions">
<el-button
v-if="hasPermission('formal_settlement_export')"
type="primary"
plain
@click="exportDetails"
>
导出
</el-button>
<el-button v-if="editable" type="primary" plain @click="openDetailDialog">
选择结算明细
</el-button>
<el-button type="primary" text @click="detailCollapsed = !detailCollapsed">
{{ detailCollapsed ? '展开明细' : '收起明细' }}
</el-button>
@@ -183,33 +194,46 @@
</template>
<el-form
:model="detailQuery"
inline
label-position="right"
label-width="88px"
label-width="auto"
class="formal-editor__detail-filter"
@submit.prevent
>
<el-form-item label="单据号">
<el-input v-model="detailQuery.documentNo" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.documentNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="运单号">
<el-input v-model="detailQuery.waybillNo" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.waybillNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="批次号">
<el-input v-model="detailQuery.batchNo" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.batchNo"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item label="货物名称">
<el-input v-model="detailQuery.cargoName" clearable @keyup.enter="handleDetailQuery" />
<el-input
v-model="detailQuery.cargoName"
clearable
placeholder="请输入"
@keyup.enter="handleDetailQuery"
/>
</el-form-item>
<el-form-item class="formal-editor__detail-filter-actions">
<el-button type="primary" @click="handleDetailQuery">查询</el-button>
<el-button @click="resetDetailQuery">重置</el-button>
<el-button type="primary" :disabled="!details.length" @click="exportDetails">
导出
</el-button>
<el-button v-if="editable" type="primary" @click="openDetailDialog">
选择结算明细
</el-button>
</el-form-item>
</el-form>
<el-table v-show="!detailCollapsed" :data="pagedDetails" border>
@@ -299,15 +323,23 @@
<span v-else>{{ attachmentTypeName(row.type) }}</span>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="220" align="left" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="220"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link
:disabled="!getAttachmentUrl(row)"
type="primary"
@click="previewAttachment(row)"
>
{{ row.originalName || row.name || '-' }}
</el-link>
<span
class="attachment-file-name"
:class="{ 'is-disabled': !getAttachmentUrl(row) }"
role="button"
tabindex="0"
@click="getAttachmentUrl(row) && previewAttachment(row)"
@keydown.enter="getAttachmentUrl(row) && previewAttachment(row)"
@keydown.space.prevent="getAttachmentUrl(row) && previewAttachment(row)"
>{{ row.originalName || row.name || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" min-width="120" align="center">
@@ -610,9 +642,10 @@
</section-card>
</div>
<template v-if="!pageMode" #footer>
<el-button v-if="editable" @click="handleSyncPlaceholder">同步</el-button>
<el-button @click="visible = false">取消</el-button>
<el-button
v-if="editable && !recordId"
v-if="editable"
type="primary"
plain
:loading="draftSaving"
@@ -625,9 +658,10 @@
>
</template>
<div v-if="pageMode && !publicMode" class="formal-editor__page-actions">
<el-button v-if="editable" @click="handleSyncPlaceholder">同步</el-button>
<el-button @click="visible = false">取消</el-button>
<el-button
v-if="editable && !recordId"
v-if="editable"
type="primary"
plain
:loading="draftSaving"
@@ -966,6 +1000,7 @@ import { getDictionary } from '@/api/system/dictbiz';
import { h } from 'vue';
import { InfoFilled } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl } from '@/utils/util';
import {
downloadKingdeeInvoiceFile,
@@ -974,6 +1009,7 @@ import {
} from '@/utils/kingdee-attachment';
import KingdeeInvoicePoolDialog from '@/components/kingdee-invoice-pool-dialog/main.vue';
import * as XLSX from 'xlsx';
import { isAdministrator } from '@/utils/auth';
export default {
name: 'FormalSettlementEditor',
@@ -1110,7 +1146,7 @@ export default {
};
},
computed: {
...mapGetters(['userInfo']),
...mapGetters(['permission', 'userInfo']),
visible: {
get() {
return this.modelValue;
@@ -1138,6 +1174,9 @@ export default {
editable() {
return !this.readonly;
},
isAdmin() {
return isAdministrator(this.userInfo);
},
title() {
return this.readonly ? '查看正式结算单' : this.recordId ? '编辑正式结算单' : '新增正式结算单';
},
@@ -1203,6 +1242,10 @@ export default {
},
},
methods: {
formatContractOptionLabel,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
async loadFormalDetail(id) {
const response = this.publicMode
? await getMkPublicDetail('formal-settlement', id)
@@ -2506,7 +2549,11 @@ export default {
remark: this.form.remark,
};
},
handleSyncPlaceholder() {
this.$message.info('开发中');
},
async handleDraftSave() {
// 保存草稿:不做表单校验,直接落草稿并返回列表刷新
this.draftSaving = true;
try {
await save({
@@ -2514,8 +2561,8 @@ export default {
approvalStatus: 'draft',
});
this.$message.success('保存成功');
this.visible = false;
this.$emit('success');
this.visible = false;
} finally {
this.draftSaving = false;
}
@@ -2819,20 +2866,24 @@ export default {
}
.formal-editor__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.formal-editor__detail-filter {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
align-items: center;
gap: 8px 16px;
margin-bottom: 8px;
margin-bottom: 12px;
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.formal-editor__detail-filter-actions {
grid-column: 1 / -1;
width: auto;
margin-left: 0;
white-space: nowrap;
justify-self: end;
}
.formal-editor__page-actions {
@@ -2862,7 +2913,7 @@ export default {
gap: 8px;
}
.formal-editor :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.formal-editor :deep(.el-form-item__content) {
line-height: normal;
@@ -2963,9 +3014,29 @@ export default {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
/* 结算明细筛选区:覆盖全局 el-form-item 24px 行距,保证筛选控件与「查询/重置」同行居中对齐 */
.formal-editor__detail-filter :deep(.el-form-item) {
display: flex;
min-width: 0;
margin-right: 0;
margin-bottom: 0;
}
.formal-editor__detail-filter :deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
.formal-editor__detail-filter :deep(.el-input) {
width: 100%;
}
.formal-editor__detail-filter-actions :deep(.el-form-item__content) {
flex: none;
justify-content: flex-end;
flex-wrap: nowrap;
}
@media (max-width: 1200px) {
.formal-editor__detail-filter {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
}
}
</style>
@@ -40,16 +40,20 @@
>导出</el-button
>
</div>
<div class="fs-table-panel__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="$emit('refresh')" />
</el-tooltip>
</div>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table
ref="tableRef"
v-loading="loading"
:data="rows"
:size="tableSize"
border
highlight-current-row
@current-change="handleCurrentChange"
@@ -58,7 +62,7 @@
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -126,15 +130,16 @@
</template>
<script>
import { Refresh } from '@element-plus/icons-vue';
import {
invoiceStatusName,
kingdeeSyncStatusOptions,
paymentStatusOptions,
} from '@/option/settlement/formalSettlementSearch';
import SettlementTableTools from './settlement-table-tools.vue';
export default {
name: 'FormalSettlementTablePanel',
components: { SettlementTableTools },
props: {
rows: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
@@ -143,6 +148,7 @@ export default {
page: { type: Object, required: true },
hasPermission: { type: Function, required: true },
mkEnabled: { type: Boolean, default: false },
searchVisible: { type: Boolean, default: true },
},
emits: [
'create',
@@ -154,14 +160,47 @@ export default {
'page-change',
'action',
'update:selection',
'update:searchVisible',
],
data() {
return {
Refresh,
selection: [],
tableSize: 'default',
visibleColumnKeys: [],
knownColumnProps: [],
};
},
computed: {
columnSettingOptions() {
return this.columns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleColumns() {
if (!this.visibleColumnKeys.length) return this.columns;
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
watch: {
columns: {
immediate: true,
handler(columns) {
const props = columns.map(column => column.prop);
if (!this.visibleColumnKeys.length) {
this.visibleColumnKeys = props;
this.knownColumnProps = props;
return;
}
const visible = new Set(this.visibleColumnKeys);
const known = new Set(this.knownColumnProps || []);
props.forEach(prop => {
if (!known.has(prop)) visible.add(prop);
});
this.knownColumnProps = props;
this.visibleColumnKeys = props.filter(prop => visible.has(prop));
},
},
rows() {
this.selection = [];
this.$refs.tableRef?.clearSelection();
@@ -202,7 +241,11 @@ export default {
actions.push({ type: 'delete', label: '删除', danger: true });
if (this.hasPermission('formal_settlement_submit') && this.isEditable(row))
actions.push({ type: 'submit', label: '提交' });
if (this.mkEnabled && this.hasPermission('formal_settlement_approve') && row.approvalStatus === 'reviewing') {
if (
this.mkEnabled &&
this.hasPermission('formal_settlement_approve') &&
['reviewing', 'approved'].includes(row.approvalStatus)
) {
actions.push({ type: 'mkAudit', label: '流程' });
}
if (!this.mkEnabled && this.hasPermission('formal_settlement_approve') && row.approvalStatus === 'reviewing') {
@@ -271,11 +314,6 @@ export default {
align-items: center;
padding: 12px 0;
}
.fs-table-panel__toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.fs-table-panel__links {
display: flex;
flex-wrap: wrap;
@@ -20,7 +20,7 @@
<el-form-item :label="field.label" :prop="field.prop">
<el-input
v-if="field.type === 'contract'"
v-model="form.contractName"
:model-value="formatContractOptionLabel(form)"
readonly
:disabled="!editable || details.length > 0"
placeholder="请选择合同"
@@ -354,11 +354,21 @@
<span v-else>{{ attachmentTypeName(row.type) }}</span>
</template>
</el-table-column>
<el-table-column label="文件名称" min-width="260" show-overflow-tooltip>
<el-table-column
label="文件名称"
min-width="260"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
@@ -963,6 +973,7 @@ import {
settlementDetailColumns,
settlementSummaryColumns,
} from '@/option/settlement/preSettlementTable';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadFileByUrl } from '@/utils/util';
import { submitMkApprovalFlow } from '@/utils/mk-approval';
import * as XLSX from 'xlsx';
@@ -1266,6 +1277,7 @@ export default {
},
},
methods: {
formatContractOptionLabel,
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
@@ -3003,7 +3015,7 @@ export default {
}
:deep(.pre-settlement-editor .el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
:deep(.pre-settlement-editor .el-table),
@@ -193,11 +193,21 @@
<el-table :data="attachments" border @selection-change="selectedAttachments = $event">
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column label="文件名" min-width="220" show-overflow-tooltip>
<el-table-column
label="文件名"
min-width="220"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="240">
@@ -638,7 +648,7 @@ export default {
gap: 8px;
}
.settlement-adjustment-editor :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.settlement-adjustment-editor :deep(.el-table) {
--el-table-border-color: #eff1f7;
@@ -0,0 +1,13 @@
<template>
<table-header-tools v-bind="$attrs" />
</template>
<script>
import TableHeaderTools from '@/components/table-header-tools/main.vue';
export default {
name: 'SettlementTableTools',
components: { TableHeaderTools },
inheritAttrs: false,
};
</script>
@@ -2072,7 +2072,7 @@ export default {
display: none;
}
.reconciliation-editor :deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
}
.reconciliation-editor :deep(.el-table) {
--el-table-border-color: #eff1f7;
@@ -10,22 +10,26 @@
>
<el-button type="primary" plain @click="$emit('export')">导出</el-button>
</div>
<div class="tr-table-panel__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="$emit('refresh')" />
</el-tooltip>
</div>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
:search-visible="searchVisible"
@update:search-visible="$emit('update:searchVisible', $event)"
@refresh="$emit('refresh')"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in columns"
v-for="column in visibleColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -73,24 +77,68 @@
</template>
<script>
import { Refresh } from '@element-plus/icons-vue';
import SettlementTableTools from './settlement-table-tools.vue';
export default {
name: 'TransportReconciliationTablePanel',
components: { SettlementTableTools },
props: {
rows: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
page: { type: Object, required: true },
hasPermission: { type: Function, required: true },
searchVisible: { type: Boolean, default: true },
},
emits: ['create', 'export', 'refresh', 'page-change', 'update:selection', 'action'],
emits: [
'create',
'export',
'refresh',
'page-change',
'update:selection',
'action',
'update:searchVisible',
],
data() {
return {
Refresh,
selection: [],
tableSize: 'default',
visibleColumnKeys: [],
knownColumnProps: [],
};
},
computed: {
columnSettingOptions() {
return this.columns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleColumns() {
if (!this.visibleColumnKeys.length) return this.columns;
return this.columns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
watch: {
columns: {
immediate: true,
handler(columns) {
const props = columns.map(column => column.prop);
if (!this.visibleColumnKeys.length) {
this.visibleColumnKeys = props;
this.knownColumnProps = props;
return;
}
const visible = new Set(this.visibleColumnKeys);
const known = new Set(this.knownColumnProps || []);
props.forEach(prop => {
if (!known.has(prop)) visible.add(prop);
});
this.knownColumnProps = props;
this.visibleColumnKeys = props.filter(prop => visible.has(prop));
},
},
},
methods: {
handleSelectionChange(value) {
this.selection = value;
@@ -127,11 +175,6 @@ export default {
align-items: center;
padding: 12px 0;
}
.tr-table-panel__toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.tr-table-panel__links {
display: flex;
flex-wrap: wrap;
@@ -8,6 +8,7 @@
:record-id="recordId"
:readonly="readonly"
:initial-data="editorInitialData"
@success="markListNeedRefresh"
/>
</basic-container>
</template>
@@ -16,6 +17,8 @@
import FormalSettlementEditor from './components/formal-settlement-editor.vue';
import { readSettlementTransfer, removeSettlementTransfer } from '@/utils/settlement-transfer';
const LIST_REFRESH_KEY = 'formal-settlement-list-refresh';
export default {
name: 'FormalSettlementForm',
components: { FormalSettlementEditor },
@@ -54,6 +57,9 @@ export default {
this.syncTagTitle();
},
methods: {
markListNeedRefresh() {
sessionStorage.setItem(LIST_REFRESH_KEY, '1');
},
goBack() {
removeSettlementTransfer(this.$route.query.transferToken);
this.$router.$avueRouter.closeTag();
+10 -1
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="formal-page">
<section class="formal-page__search">
<section v-show="searchVisible" class="formal-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="formal-page__search-grid">
<el-form-item v-for="field in visibleSearchFields" :key="field.prop" :label="field.label">
@@ -66,6 +66,7 @@
:has-permission="hasPermissionFn"
:sync-loading="syncLoading"
:mk-enabled="mkEnabled"
v-model:search-visible="searchVisible"
@create="openCreate"
@payment="openPaymentDialog"
@sync="handleSync"
@@ -86,6 +87,7 @@
:has-permission="hasPermissionFn"
:sync-loading="syncLoading"
:mk-enabled="mkEnabled"
v-model:search-visible="searchVisible"
@create="openCreate"
@payment="openPaymentDialog"
@sync="handleSync"
@@ -214,6 +216,7 @@ export default {
ArrowUp,
query: emptyQuery(),
searchExpanded: false,
searchVisible: true,
activeSettlementType: 'payable',
searchFields: formalSettlementSearchFields,
columns: formalSettlementTableColumns,
@@ -262,6 +265,12 @@ export default {
this.mkEnabled = false;
});
},
activated() {
if (sessionStorage.getItem('formal-settlement-list-refresh') === '1') {
sessionStorage.removeItem('formal-settlement-list-refresh');
this.loadTable();
}
},
methods: {
hasPermission(code) {
return this.permission?.[code] !== false;
+26 -12
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="pre-settlement-page">
<section class="pre-settlement-page__search">
<section v-show="searchVisible" class="pre-settlement-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="pre-settlement-page__search-grid">
<template v-for="field in visibleSearchFields" :key="field.prop">
@@ -94,16 +94,19 @@
导出
</el-button>
</div>
<div class="pre-settlement-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
</div>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
class="pre-settlement-page__table"
@selection-change="handleSelectionChange"
@@ -111,7 +114,7 @@
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in tableColumns"
v-for="column in visibleTableColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
@@ -185,7 +188,7 @@
v-if="
mkEnabled &&
hasPermission('pre_settlement_approve') &&
row.approvalStatus === 'reviewing'
['reviewing', 'approved'].includes(row.approvalStatus)
"
type="primary"
@click="handleMkAudit(row)"
@@ -285,7 +288,7 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import {
approve,
@@ -306,6 +309,7 @@ import { downloadFile } from '@/utils/util';
import { getMKApprovalUrl } from '@/api/system/business-process';
import { loadMkEnabled, submitMkApprovalFlow } from '@/utils/mk-approval';
import PreSettlementEditor from './components/pre-settlement-editor.vue';
import SettlementTableTools from './components/settlement-table-tools.vue';
import { isAdministrator } from '@/utils/auth';
const emptyQuery = () => ({
@@ -323,18 +327,20 @@ const emptyQuery = () => ({
export default {
name: 'PreSettlement',
components: { PreSettlementEditor },
components: { PreSettlementEditor, SettlementTableTools },
mixins: [organizationSearch],
data() {
return {
ArrowDown,
ArrowUp,
Refresh,
loading: false,
query: emptyQuery(),
searchFields: preSettlementSearchFields,
searchExpanded: false,
searchVisible: true,
tableSize: 'default',
tableColumns: preSettlementTableColumns,
visibleColumnKeys: preSettlementTableColumns.map(column => column.prop),
rows: [],
selection: [],
page: {
@@ -365,6 +371,15 @@ export default {
visibleSearchFields() {
return this.searchExpanded ? this.searchFields : this.searchFields.slice(0, 4);
},
columnSettingOptions() {
return this.tableColumns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleTableColumns() {
return this.tableColumns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
},
watch: {
'$route.query.detailNo'(detailNo) {
@@ -776,7 +791,6 @@ export default {
}
&__toolbar-left,
&__toolbar-right,
&__links {
display: flex;
flex-wrap: wrap;
@@ -1,6 +1,6 @@
<template>
<basic-container class="settlement-detail-page">
<section class="settlement-detail-page__search">
<section v-show="searchVisible" class="settlement-detail-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="settlement-detail-page__search-grid">
<template v-for="field in visibleSearchFields" :key="field.prop">
@@ -44,19 +44,19 @@
</el-button>
<el-button type="primary" plain @click="handleExport">导出</el-button>
</div>
<div class="settlement-detail-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
<el-tooltip content="列设置" placement="top">
<el-button :icon="Setting" text @click="noop" />
</el-tooltip>
</div>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
class="settlement-detail-page__table"
@selection-change="selectionChange"
@@ -64,7 +64,7 @@
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="64" fixed="left" align="center" />
<el-table-column
v-for="column in displayTableColumns"
v-for="column in visibleTableColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
@@ -164,8 +164,8 @@
show-overflow-tooltip
/>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default>
<el-link type="primary" @click="openChangeRecordsDialog">查看详情</el-link>
<template #default="{ row }">
<el-link type="primary" @click="openChangeRecordsDialog(row)">查看详情</el-link>
</template>
</el-table-column>
</el-table>
@@ -938,7 +938,7 @@
</template>
<script>
import { ArrowDown, ArrowUp, Close, Refresh, Setting } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp, Close } from '@element-plus/icons-vue';
import {
changeRecordColumns,
feeDetailBaseColumns,
@@ -950,6 +950,7 @@ import {
tableColumns,
transferSearchFields,
} from '@/option/settlement/receivable-payable-detail';
import SettlementTableTools from './components/settlement-table-tools.vue';
import * as api from '@/api/settlement/receivable-payable-detail';
import { getList as getContractList } from '@/api/business/contract-manage';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
@@ -957,6 +958,7 @@ import { getContractOptions as getSettlementContractOptions } from '@/api/settle
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { exportBlob } from '@/api/common';
import { getDictionary } from '@/api/system/dictbiz';
import { formatContractOptionLabel } from '@/utils/contract';
import { downloadXls } from '@/utils/util';
import { createSettlementTransfer } from '@/utils/settlement-transfer';
import {
@@ -988,6 +990,7 @@ const ADJUST_BILLING_TYPE_MAP = {
};
export default {
components: { SettlementTableTools },
props: {
settlementType: {
type: String,
@@ -999,8 +1002,6 @@ export default {
ArrowDown,
ArrowUp,
Close,
Refresh,
Setting,
searchFields,
tableColumns,
tableFeeItemNames: [],
@@ -1009,6 +1010,10 @@ export default {
generateWaybillColumns,
loading: false,
searchExpanded: false,
searchVisible: true,
tableSize: 'default',
visibleColumnKeys: [],
knownColumnProps: [],
query: {},
rows: [],
selection: [],
@@ -1044,6 +1049,7 @@ export default {
priceUnitLoading: false,
priceUnitRequest: null,
changeRows: [],
changeBatchRecords: [],
changePage: { current: 1, size: 10, total: 0 },
latestChangeRows: [],
latestChangeLoading: false,
@@ -1103,14 +1109,14 @@ export default {
const contract = this.contractOptions.find(
item => String(item.id) === String(this.generateQuery.contractId)
);
return contract?.contractName || '';
return formatContractOptionLabel(contract);
},
updateContractName() {
const contractId = this.updateFeeForm.contractId;
const contract = [...this.updateContractOptions, ...this.contractOptions].find(
item => String(item.id) === String(contractId)
);
return contract?.contractName || '';
return formatContractOptionLabel(contract);
},
generateContractCategory() {
return this.settlementType === 'payable' ? '承运商合同' : '客户合同';
@@ -1168,6 +1174,18 @@ export default {
columns.splice(totalIndex, 0, ...dynamicColumns);
return columns;
},
columnSettingOptions() {
return this.displayTableColumns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleTableColumns() {
if (!this.visibleColumnKeys.length) return this.displayTableColumns;
return this.displayTableColumns.filter(column =>
this.visibleColumnKeys.includes(column.prop)
);
},
previewColumns() {
return [...generatePreviewColumns, ...this.dynamicFeeColumns, ...feeDetailTailColumns];
},
@@ -1190,6 +1208,27 @@ export default {
'$route.fullPath'() {
this.openRouteDetail();
},
displayTableColumns: {
immediate: true,
handler(columns) {
const props = columns.map(column => column.prop);
if (!this.visibleColumnKeys.length) {
this.visibleColumnKeys = props;
this.knownColumnProps = [...props];
return;
}
const known = new Set(this.knownColumnProps);
const visible = new Set(this.visibleColumnKeys);
props.forEach(prop => {
if (!known.has(prop)) {
known.add(prop);
visible.add(prop);
}
});
this.knownColumnProps = [...known];
this.visibleColumnKeys = props.filter(prop => visible.has(prop));
},
},
},
async mounted() {
this.loadTransportTypeOptions();
@@ -1992,34 +2031,64 @@ export default {
this.latestChangeLoading = true;
this.latestChangeRows = [];
try {
const res = await api.getChangeRecords(1, 1, {
const res = await api.getChangeRecords(1, 1000, {
detailId: this.detailDialog.row.id,
});
const data = this.unwrapPage(res);
this.latestChangeRows = (data.records || []).slice(0, 1);
this.latestChangeRows = this.buildChangeRecordBatches(data.records || []);
} finally {
this.latestChangeLoading = false;
}
},
openChangeRecordsDialog() {
buildChangeRecordBatches(records = []) {
const batchMap = new Map();
records.forEach(record => {
const batchKey = record.adjustTime || '';
if (!batchMap.has(batchKey)) {
batchMap.set(batchKey, []);
}
batchMap.get(batchKey).push(record);
});
return Array.from(batchMap.entries()).map(([batchKey, batchRecords]) => {
const first = batchRecords[0] || {};
const lineNos = [
...new Set(batchRecords.map(item => item.lineNo).filter(value => value !== undefined && value !== null && value !== '')),
];
const cargoNames = [
...new Set(
batchRecords.map(item => item.cargoName).filter(value => value !== undefined && value !== null && value !== '')
),
];
const changeContents = batchRecords
.map(item => item.changeContent)
.filter(value => value !== undefined && value !== null && value !== '');
return {
...first,
lineNo: lineNos.join('、'),
cargoName: cargoNames.join('、'),
changeContent: changeContents.join(';'),
adjustUserName: first.adjustUserName,
adjustReason: first.adjustReason,
adjustTime: first.adjustTime || batchKey,
_batchKey: batchKey,
_batchRecords: batchRecords,
};
});
},
openChangeRecordsDialog(row) {
if (!this.detailDialog.row) return;
this.changeBatchRecords = Array.isArray(row?._batchRecords) ? row._batchRecords : [];
this.changeRecordsDialog.visible = true;
this.changePage.current = 1;
this.loadChangeRecords();
},
async loadChangeRecords() {
if (!this.detailDialog.row) return;
this.changeRecordsDialog.loading = true;
try {
const res = await api.getChangeRecords(this.changePage.current, this.changePage.size, {
detailId: this.detailDialog.row.id,
});
const data = this.unwrapPage(res);
this.changeRows = data.records || [];
this.changePage.total = data.total || 0;
} finally {
this.changeRecordsDialog.loading = false;
}
loadChangeRecords() {
const records = this.changeBatchRecords || [];
const size = Number(this.changePage.size) || 10;
const current = Number(this.changePage.current) || 1;
const fromIndex = Math.max(0, (current - 1) * size);
this.changeRows = records.slice(fromIndex, fromIndex + size);
this.changePage.total = records.length;
},
handleChangeSizeChange() {
this.changePage.current = 1;
@@ -2624,6 +2693,10 @@ export default {
return this.formatCell(row?.[prop]);
},
formatFeeDetailCell(row, prop) {
if (prop === 'transportQuantityText' || prop === 'transportQuantity') {
if (this.isBlankTransportQuantity(row)) return '-';
return this.formatDetailCell(row, prop);
}
if (prop !== 'originalAmountText') return this.formatDetailCell(row, prop);
const adjustAmount = Number(row?.adjustAmount || 0);
@@ -2642,6 +2715,17 @@ export default {
formatCell(value) {
return value === null || value === undefined || value === '' ? '-' : value;
},
isBlankTransportQuantity(row = {}) {
const quantity = row.transportQuantity;
if (quantity !== null && quantity !== undefined && quantity !== '') {
const amount = Number(quantity);
return !Number.isFinite(amount) || amount === 0 || amount === -1;
}
const text = String(row.transportQuantityText || '').trim();
if (!text || text === '-') return true;
const amount = Number(text.split(/\s+/)[0]);
return Number.isFinite(amount) && (amount === 0 || amount === -1);
},
parseJson(value) {
if (!value) return [];
try {
@@ -2663,7 +2747,6 @@ export default {
if (Array.isArray(data?.data?.records)) return data.data.records;
return [];
},
noop() {},
},
};
</script>
@@ -2717,8 +2800,7 @@ export default {
background: transparent;
}
.settlement-detail-page__toolbar-left,
.settlement-detail-page__toolbar-right {
.settlement-detail-page__toolbar-left {
display: flex;
align-items: center;
gap: 8px;
@@ -2758,7 +2840,7 @@ export default {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
:deep(.el-form-item) {
margin-bottom: 14px;
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
+32 -16
View File
@@ -1,6 +1,6 @@
<template>
<basic-container class="settlement-adjustment-page">
<section class="settlement-adjustment-page__search">
<section v-show="searchVisible" class="settlement-adjustment-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="settlement-adjustment-page__search-grid">
<el-form-item v-for="field in visibleSearchFields" :key="field.prop" :label="field.label">
@@ -55,13 +55,22 @@
type="primary"
@click="openCreate"
>新增</el-button
><div class="settlement-adjustment-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
</div>
>
<settlement-table-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table v-loading="loading" :data="rows" border @selection-change="selection = $event">
<el-table
v-loading="loading"
:data="rows"
:size="tableSize"
border
@selection-change="selection = $event"
>
<el-table-column type="selection" width="52" fixed="left" align="center" /><el-table-column
type="index"
label="序号"
@@ -70,7 +79,7 @@
align="center"
/>
<el-table-column
v-for="column in tableColumns"
v-for="column in visibleTableColumns"
:key="column.prop"
v-bind="column"
align="center"
@@ -165,13 +174,14 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import * as api from '@/api/settlement/settlementAdjustment';
import { settlementAdjustmentSearchFields } from '@/option/settlement/settlementAdjustmentSearch';
import organizationSearch from '@/mixins/organization-search';
import { settlementAdjustmentTableColumns } from '@/option/settlement/settlementAdjustmentTable';
import SettlementAdjustmentEditor from './components/settlement-adjustment-editor.vue';
import SettlementTableTools from './components/settlement-table-tools.vue';
import { isAdministrator } from '@/utils/auth';
const emptyQuery = () => ({
@@ -186,17 +196,19 @@ const emptyQuery = () => ({
});
export default {
name: 'SettlementAdjustment',
components: { SettlementAdjustmentEditor },
components: { SettlementAdjustmentEditor, SettlementTableTools },
mixins: [organizationSearch],
data: () => ({
ArrowDown,
ArrowUp,
Refresh,
loading: false,
query: emptyQuery(),
searchFields: settlementAdjustmentSearchFields,
searchExpanded: false,
searchVisible: true,
tableSize: 'default',
tableColumns: settlementAdjustmentTableColumns,
visibleColumnKeys: settlementAdjustmentTableColumns.map(column => column.prop),
rows: [],
selection: [],
page: { current: 1, size: 10, total: 0 },
@@ -207,6 +219,15 @@ export default {
visibleSearchFields() {
return this.searchExpanded ? this.searchFields : this.searchFields.slice(0, 4);
},
columnSettingOptions() {
return this.tableColumns.map(column => ({
prop: column.prop,
label: column.label,
}));
},
visibleTableColumns() {
return this.tableColumns.filter(column => this.visibleColumnKeys.includes(column.prop));
},
isAdmin() {
return isAdministrator(this.userInfo);
},
@@ -359,11 +380,6 @@ export default {
align-items: center;
min-height: 56px;
}
&__toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
&__links {
display: flex;
flex-wrap: wrap;
@@ -1,6 +1,6 @@
<template>
<basic-container class="reconciliation-page">
<section class="reconciliation-page__search">
<section v-show="searchVisible" class="reconciliation-page__search">
<el-form :model="query" label-position="right" label-width="88px" @submit.prevent>
<div class="reconciliation-page__search-grid">
<el-form-item v-for="field in visibleSearchFields" :key="field.prop" :label="field.label">
@@ -54,6 +54,7 @@
:loading="loading"
:page="page"
:has-permission="hasPermission"
v-model:search-visible="searchVisible"
@create="openCreate"
@export="handleExport"
@refresh="loadTable"
@@ -69,6 +70,7 @@
:loading="loading"
:page="page"
:has-permission="hasPermission"
v-model:search-visible="searchVisible"
@create="openCreate"
@export="handleExport"
@refresh="loadTable"
@@ -91,7 +93,7 @@
</template>
<script>
import { ArrowDown, ArrowUp, Refresh } from '@element-plus/icons-vue';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { mapGetters } from 'vuex';
import { downloadFile } from '@/utils/util';
import * as api from '@/api/settlement/transportReconciliation';
@@ -121,9 +123,9 @@ export default {
return {
ArrowDown,
ArrowUp,
Refresh,
query: emptyQuery(),
searchExpanded: false,
searchVisible: true,
searchFields: transportReconciliationSearchFields,
columns: transportReconciliationTableColumns,
settlementType: 'payable',
+310 -61
View File
@@ -35,8 +35,16 @@
plain
:loading="oaSyncLoading"
v-if="isAdministrator"
@click="handleOaOrgSync"
>自动同步组织
@click="handleOaOrgFullSync"
>全量同步
</el-button>
<el-button
type="primary"
plain
:loading="oaSyncLoading"
v-if="isAdministrator"
@click="handleOaOrgIncrementalSync"
>增量同步
</el-button>
</template>
<template #menu="scope">
@@ -189,6 +197,22 @@ import func from '@/utils/func';
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const ORGANIZATION_TYPE_DICT_CODE = 'organization_type';
/**
* 组织类型名称 → 字典数字键(兼容 dictKey 配成中文的情况)
* 与业务字典 organization_type 及历史别名对齐;优先仍以数字 dictKey 为准
*/
const ORGANIZATION_TYPE_CODE_BY_LABEL = {
集团: 1,
一级集团: 1,
公司: 2,
事业部: 2,
二级事业部: 2,
部门: 3,
分公司: 4,
三级分公司: 4,
物流园区: 5,
承运商: 6,
};
export default {
data() {
@@ -220,6 +244,7 @@ export default {
oaSyncCancelled: false,
oaSyncStatus: 'running',
oaSyncStage: 'company',
oaSyncMode: 'full',
oaSyncClearNonTop: false,
oaSyncController: null,
oaSyncProgress: {
@@ -264,6 +289,7 @@ export default {
minWidth: 160,
order: 80,
search: true,
searchOrder: 2,
rules: [
{
required: true,
@@ -307,9 +333,17 @@ export default {
callback(new Error('上级组织未配置组织编码,请先完善上级组织编码'));
return;
}
const pattern = new RegExp(`^${escapeRegExp(this.parentDeptCode)}-\\d+$`);
// 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字(中间不加-)
let expectedPrefix = this.parentDeptCode;
const category = Number(this.form?.deptCategory);
if (category === 2) {
expectedPrefix = 'GWWL';
} else if (category === 3 && this.form?.belongCompanyCode) {
expectedPrefix = String(this.form.belongCompanyCode).trim();
}
const pattern = new RegExp(`^${escapeRegExp(expectedPrefix)}\\d{3}$`);
if (!pattern.test(deptCode)) {
callback(new Error(`组织编码格式应为:${this.parentDeptCode}-分段数字`));
callback(new Error(`组织编码格式应为:${expectedPrefix}三位数字(不足补零,中间不加-)`));
return;
}
callback();
@@ -343,11 +377,40 @@ export default {
},
],
},
{
label: '合同编码前缀',
prop: 'contractCodePrefix',
minWidth: 160,
order: 62,
maxlength: 64,
showWordLimit: true,
placeholder: '如 GWZH成都',
tip: '合同编号规则:前缀(年份)三位流水号,流水按本部门独立编号',
rules: [
{
validator: (rule, value, callback) => {
const prefix = String(value || '').trim();
if (!prefix) {
callback();
return;
}
if (prefix.length > 64) {
callback(new Error('合同编码前缀不能超过64个字符'));
return;
}
callback();
},
trigger: 'blur',
},
],
},
{
label: '组织简称',
prop: 'shortName',
minWidth: 140,
order: 60,
search: true,
searchOrder: 1,
},
{
label: '助记码',
@@ -421,15 +484,33 @@ export default {
{
label: '排序',
prop: 'sort',
type: 'number',
type: 'input',
align: 'right',
width: 80,
order: 30,
change: ({ value }) => {
if (value === undefined || value === null || value === '') {
return;
}
const digits = String(value).replace(/\D/g, '');
if (String(value) !== digits) {
this.form.sort = digits;
}
},
rules: [
{
required: true,
message: '请输入排序',
trigger: 'blur',
validator: (rule, value, callback) => {
if (value === '' || value === undefined || value === null) {
callback();
return;
}
if (!/^\d+$/.test(String(value).trim())) {
callback(new Error('排序只能输入数字'));
return;
}
callback();
},
trigger: ['blur', 'change'],
},
],
},
@@ -468,12 +549,10 @@ export default {
value: 'tenantId',
},
hide: true,
search: website.tenantMode,
},
{
label: '机构全称',
prop: 'fullName',
search: true,
hide: true,
addDisplay: false,
editDisplay: false,
@@ -527,16 +606,17 @@ export default {
return ids.join(',');
},
oaSyncDialogTitle() {
const modeLabel = this.oaSyncMode === 'incremental' ? '增量同步' : '全量同步';
if (this.oaSyncStatus === 'done') {
return '同步完成';
return `${modeLabel}完成`;
}
if (this.oaSyncStatus === 'cancelled') {
return '已取消同步';
return `已取消${modeLabel}`;
}
if (this.oaSyncStatus === 'error') {
return '同步失败';
return `${modeLabel}失败`;
}
return '自动同步组织';
return modeLabel;
},
oaSyncStageLabel() {
if (this.oaSyncStage === 'clear') {
@@ -576,10 +656,10 @@ export default {
},
},
methods: {
handleOaOrgSync() {
handleOaOrgFullSync() {
this.$confirm(
'是否清除非顶级组织?选择“是”将先删除顶级以外的组织后再同步;选择“否”则直接同步。',
'提示',
'全量同步将拉取 OA 全部公司/部门;已存在且 OA 更新时间一致的记录会跳过。是否清除后同步?选择“是”将保留顶级「外部组织」与集团锚点(id=135/GWWL),删除其余组织后再同步;选择“否”则直接全量同步。',
'全量同步',
{
confirmButtonText: '是',
cancelButtonText: '否',
@@ -589,20 +669,37 @@ export default {
}
)
.then(() => {
this.startOaOrgSync(true);
this.startOaOrgSync({ clearNonTop: true, incremental: false });
})
.catch(action => {
if (action === 'cancel') {
this.startOaOrgSync(false);
this.startOaOrgSync({ clearNonTop: false, incremental: false });
}
});
},
startOaOrgSync(clearNonTop) {
handleOaOrgIncrementalSync() {
this.$confirm(
'增量同步仅拉取本地最大 OA 更新时间之后有变更的组织;不存在的新增,已存在且更新时间不一致的修改,一致的跳过。是否继续?',
'增量同步',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
closeOnClickModal: false,
type: 'info',
}
)
.then(() => {
this.startOaOrgSync({ clearNonTop: false, incremental: true });
})
.catch(() => {});
},
startOaOrgSync({ clearNonTop = false, incremental = false } = {}) {
this.oaSyncLoading = true;
this.oaSyncVisible = true;
this.oaSyncRunning = true;
this.oaSyncCancelled = false;
this.oaSyncStatus = 'running';
this.oaSyncMode = incremental ? 'incremental' : 'full';
this.oaSyncClearNonTop = !!clearNonTop;
this.oaSyncStage = clearNonTop ? 'clear' : 'company';
this.oaSyncController = new AbortController();
@@ -625,10 +722,21 @@ export default {
},
async runOaOrgSyncStage(syncApi) {
const size = 20;
const incremental = this.oaSyncMode === 'incremental';
let current = 1;
let modifiedSince = '';
while (!this.oaSyncCancelled) {
const res = await syncApi(current, size, this.oaSyncController?.signal);
const res = await syncApi(
current,
size,
this.oaSyncController?.signal,
incremental,
modifiedSince
);
const page = res?.data?.data || {};
if (!modifiedSince && page.modifiedSince) {
modifiedSince = page.modifiedSince;
}
this.oaSyncProgress.current = page.current || current;
this.oaSyncProgress.size = page.size || size;
this.oaSyncProgress.total = page.total || 0;
@@ -755,7 +863,7 @@ export default {
.filter(Boolean);
this.resetDeptCategoryOptions();
if (list.length && !this.organizationTypeOptions.length) {
this.$message.warning('组织类型字典 dictKey 须为数字 1~6,当前无法解析,请检查业务字典配置');
this.$message.warning('组织类型字典无法解析,请检查业务字典 organization_type 的字典键配置');
}
// 字典异步返回后触发表格组织类型标签重绘
if (Array.isArray(this.data) && this.data.length) {
@@ -765,35 +873,46 @@ export default {
},
/**
* 业务字典项转下拉选项。后端 deptCategory 为 Integer,value 必须始终为数字。
* dictKey 为纯数字时直接使用;若配成中文则回退用 sort(1~6)作为层级码。
* 优先用数字 dictKey;其次按名称映射;再回退 sort,避免字典项被静默丢弃。
*/
normalizeOrganizationTypeOption(item) {
const label = item?.dictValue ?? item?.label;
if (label === undefined || label === null || String(label).trim() === '') {
return null;
}
const labelText = String(label).trim();
const rawKey = item?.dictKey ?? item?.value;
const keyText = rawKey === undefined || rawKey === null ? '' : String(rawKey).trim();
const keyNumber = Number(keyText);
const isPureNumber = keyText !== '' && !Number.isNaN(keyNumber) && String(keyNumber) === keyText;
if (isPureNumber) {
return {
label: String(label),
label: labelText,
value: keyNumber,
};
}
// 兼容 dictKey 误配成中文:用排序号作为 1~6 层级码,保证下拉有数据且可提交 Integer
const sortNumber = Number(item?.sort);
if (!Number.isNaN(sortNumber) && sortNumber >= 1 && sortNumber <= 6) {
// dictKey / 名称 配成中文时,按标准名称映射到数字键
const codeByLabel =
ORGANIZATION_TYPE_CODE_BY_LABEL[labelText] ?? ORGANIZATION_TYPE_CODE_BY_LABEL[keyText];
if (codeByLabel) {
return {
label: String(label),
label: labelText,
value: codeByLabel,
};
}
// 未命中名称映射时,用排序号兜底,保证与业务字典项一致展示
const sortNumber = Number(item?.sort);
if (!Number.isNaN(sortNumber) && sortNumber >= 1) {
return {
label: labelText,
value: sortNumber,
};
}
return null;
},
/**
* 提交前将组织类型规范为 Integer;空状态字段去掉,避免 "" 反序列化失败。
* 提交前将组织类型规范为 Integer;空状态/排序字段去掉,避免 "" 反序列化失败。
* 排序为空时由后端默认填充 1。
*/
prepareDeptSubmitRow(row) {
const category = this.resolveDeptCategoryValue(row.deptCategory);
@@ -806,6 +925,11 @@ export default {
} else {
row.status = Number(row.status);
}
if (row.sort === '' || row.sort === null || row.sort === undefined) {
delete row.sort;
} else {
row.sort = Number(String(row.sort).trim());
}
return true;
},
resolveDeptCategoryValue(value) {
@@ -813,18 +937,25 @@ export default {
this.$message.warning('请选择组织类型');
return null;
}
if (typeof value === 'number' && !Number.isNaN(value)) {
return value;
}
const asNumber = Number(value);
if (!Number.isNaN(asNumber) && String(asNumber) === String(value).trim()) {
return asNumber;
}
// 容错:表单里残留了中文标签时,按字典项 label 反查数字编码
// 容错:表单里残留了中文标签时,按字典项 label / 标准名称反查数字编码
const matched = this.organizationTypeOptions.find(
item => String(item.label) === String(value) || this.isSameDeptCategory(item.value, value)
);
if (matched && typeof matched.value === 'number' && !Number.isNaN(matched.value)) {
return matched.value;
}
this.$message.warning('组织类型无效,请重新选择(字典键须为数字 1~6)');
const codeByLabel = ORGANIZATION_TYPE_CODE_BY_LABEL[String(value).trim()];
if (codeByLabel) {
return codeByLabel;
}
this.$message.warning('组织类型无效,请重新选择');
return null;
},
resetDeptCategoryOptions() {
@@ -860,35 +991,9 @@ export default {
const text = matched?.label || value;
return String(text).includes('承运商');
},
filterOrganizationTypeOptions(values) {
const valueSet = new Set(values.map(item => String(item)));
const filtered = this.organizationTypeOptions.filter(item => valueSet.has(String(item.value)));
// 字典 key 非数字时无法按历史层级码过滤,回退展示全部,避免下拉无选项
return filtered.length ? filtered : [...this.organizationTypeOptions];
},
updateDeptCategoryOptions() {
const categoryColumn = this.findColumn(this.option.column, 'deptCategory');
const parent = this.parentDept;
if (!parent || !categoryColumn) {
return;
}
let dicData = [];
if (this.isRootDept(parent)) {
dicData =
parent.deptName === '外部组织'
? this.filterOrganizationTypeOptions([6])
: this.filterOrganizationTypeOptions([1]);
} else if (Number(parent.deptCategory) === 1) {
dicData = this.filterOrganizationTypeOptions([2, 3]);
} else if ([2, 3].includes(Number(parent.deptCategory))) {
dicData = this.filterOrganizationTypeOptions([4, 5, 6]);
} else {
dicData = [...this.organizationTypeOptions];
}
categoryColumn.dicData = dicData;
if (!dicData.some(item => this.isSameDeptCategory(item.value, this.form.deptCategory))) {
this.form.deptCategory = dicData[0]?.value;
}
// 组织层级仅由 parentId 决定,组织类型不再按上级过滤,始终展示全部字典项
this.resetDeptCategoryOptions();
if (this.isCarrierDeptCategory(this.form.deptCategory)) {
this.openCarrierDialog();
}
@@ -994,10 +1099,141 @@ export default {
column.addDisabled = true;
this.$refs.crud.rowAdd();
},
getDeptTableRef() {
return this.$refs.crud?.$refs?.table;
},
getDeptTableScrollWrap() {
const table = this.getDeptTableRef();
const root = table?.$el || this.$refs.crud?.$el;
if (!root || typeof root.querySelector !== 'function') {
return null;
}
return (
root.querySelector('.el-table__body-wrapper .el-scrollbar__wrap') ||
root.querySelector('.el-scrollbar__wrap') ||
root.querySelector('.el-table__body-wrapper')
);
},
getDeptTableScrollTop() {
const wrap = this.getDeptTableScrollWrap();
return wrap ? wrap.scrollTop : 0;
},
restoreDeptTableScroll(scrollTop) {
const wrap = this.getDeptTableScrollWrap();
if (!wrap) {
return;
}
wrap.scrollTop = scrollTop;
},
findDeptRowById(list, id) {
if (!Array.isArray(list) || id === undefined || id === null || id === '') {
return null;
}
for (let index = 0; index < list.length; index += 1) {
const item = list[index];
if (String(item.id) === String(id)) {
return item;
}
const child = this.findDeptRowById(item.children, id);
if (child) {
return child;
}
}
return null;
},
syncLazyTreeAfterAdd(row, parentId) {
const table = this.getDeptTableRef();
if (!table || !parentId || String(parentId) === '0') {
return;
}
const parentRow = this.findDeptRowById(this.data, parentId);
if (parentRow) {
parentRow.hasChildren = true;
if (!Array.isArray(parentRow.children)) {
parentRow.children = [];
}
if (!parentRow.children.some(item => String(item.id) === String(row.id))) {
parentRow.children.push(row);
}
}
const lazyMapRef = table.store?.states?.lazyTreeNodeMap;
const treeDataRef = table.store?.states?.treeData;
const lazyMap = lazyMapRef?.value ?? lazyMapRef;
const treeData = treeDataRef?.value ?? treeDataRef;
const mapKey =
(lazyMap && Object.prototype.hasOwnProperty.call(lazyMap, parentId) && parentId) ||
(lazyMap && Object.prototype.hasOwnProperty.call(lazyMap, String(parentId)) && String(parentId)) ||
(parentRow && parentRow.id);
if (mapKey !== undefined && mapKey !== null && lazyMap) {
const currentChildren = Array.isArray(lazyMap[mapKey])
? lazyMap[mapKey]
: parentRow?.children || [];
const nextChildren = currentChildren.some(item => String(item.id) === String(row.id))
? [...currentChildren]
: [...currentChildren, row];
if (
Object.prototype.hasOwnProperty.call(lazyMap, mapKey) &&
typeof table.updateKeyChildren === 'function'
) {
table.updateKeyChildren(mapKey, nextChildren);
} else if (lazyMapRef && Object.prototype.hasOwnProperty.call(lazyMapRef, 'value')) {
// 首次挂子节点:直接写入懒加载缓存,避免再次 treeLoad 导致滚动回顶
lazyMapRef.value = {
...lazyMapRef.value,
[mapKey]: nextChildren,
};
} else {
lazyMap[mapKey] = nextChildren;
}
}
if (treeData && parentRow) {
const treeKey = Object.prototype.hasOwnProperty.call(treeData, parentRow.id)
? parentRow.id
: Object.prototype.hasOwnProperty.call(treeData, String(parentRow.id))
? String(parentRow.id)
: parentRow.id;
const nextNode = {
...(treeData[treeKey] || {}),
lazy: true,
loaded: true,
loading: false,
expanded: true,
hasChildren: true,
};
if (treeDataRef && Object.prototype.hasOwnProperty.call(treeDataRef, 'value')) {
treeDataRef.value = {
...treeDataRef.value,
[treeKey]: nextNode,
};
} else {
treeData[treeKey] = nextNode;
}
}
if (parentRow && typeof table.toggleRowExpansion === 'function') {
table.toggleRowExpansion(parentRow, true);
}
},
scrollDeptRowIntoView(rowId) {
if (rowId === undefined || rowId === null || rowId === '') {
return;
}
const table = this.getDeptTableRef();
const root = table?.$el || this.$refs.crud?.$el;
if (!root || typeof root.querySelector !== 'function') {
return;
}
const rowEl =
root.querySelector(`tr[data-row-key="${rowId}"]`) ||
root.querySelector(`tr[data-row-key='${rowId}']`);
if (rowEl && typeof rowEl.scrollIntoView === 'function') {
rowEl.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
},
rowSave(row, done, loading) {
row.tenantId = row.tenantId || this.userInfo.tenantId || website.tenantId;
row.deptCode = String(row.deptCode || '').trim();
row.kingdeeDeptCode = String(row.kingdeeDeptCode || '').trim();
row.contractCodePrefix = String(row.contractCodePrefix || '').trim();
row.leaderId = func.join(row.leaderId);
this.normalizeTopParent(row);
if (!this.prepareDeptSubmitRow(row)) {
@@ -1009,6 +1245,7 @@ export default {
loading();
return;
}
const scrollTop = this.getDeptTableScrollTop();
add(row).then(
res => {
// 获取新增数据的相关字段
@@ -1016,12 +1253,22 @@ export default {
row.id = data.id;
row.deptCategoryName = data.deptCategoryName;
row.tenantId = data.tenantId;
if (row.hasChildren === undefined || row.hasChildren === null) {
row.hasChildren = false;
}
this.$message({
type: 'success',
message: '操作成功!',
});
// 数据回调进行刷新
// 数据回调进行刷新(局部插入,避免整树重载跳回顶部)
done(row);
this.$nextTick(() => {
this.syncLazyTreeAfterAdd(row, this.normalizeParentId(row.parentId));
this.restoreDeptTableScroll(scrollTop);
this.$nextTick(() => {
this.scrollDeptRowIntoView(row.id);
});
});
},
error => {
window.console.log(error);
@@ -1033,6 +1280,7 @@ export default {
row.tenantId = row.tenantId || this.userInfo.tenantId || website.tenantId;
row.deptCode = String(row.deptCode || '').trim();
row.kingdeeDeptCode = String(row.kingdeeDeptCode || '').trim();
row.contractCodePrefix = String(row.contractCodePrefix || '').trim();
row.leaderId = func.join(row.leaderId);
this.normalizeTopParent(row);
if (!this.prepareDeptSubmitRow(row)) {
@@ -1166,7 +1414,8 @@ export default {
done();
},
beforeClose(done) {
this.parentId = '';
// 保持为根级查询条件,避免关闭弹窗后若触发重载时 parentId 为空导致整树重置
this.parentId = 0;
this.parentDeptCode = '';
this.parentDept = null;
const column = this.findColumn(this.option.column, 'parentId');
+355
View File
@@ -0,0 +1,355 @@
<template>
<basic-container class="mp-user-page">
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
ref="crud"
:permission="permissionList"
:before-open="beforeOpen"
@row-save="rowSave"
@row-update="rowUpdate"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
v-if="permission.mp_user_add"
@click="$refs.crud.rowAdd()"
>
新增
</el-button>
<el-button
type="primary"
plain
v-if="permission.mp_user_reset"
@click="handleReset"
>
密码重置
</el-button>
<el-button
type="danger"
plain
v-if="permission.mp_user_delete"
@click="handleDelete"
>
批量删除
</el-button>
</template>
<template #identities="{ row }">
{{ row.identitiesName || formatIdentities(row.identities) }}
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'primary' : 'info'" class="status-text">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
<template #updateUserName="{ row }">
{{ formatUpdateUserName(row) }}
</template>
<template #menu="{ row, index }">
<el-link
type="primary"
v-if="permission.mp_user_view"
@click="$refs.crud.rowView(row, index)"
>
查看
</el-link>
<el-link
type="primary"
v-if="permission.mp_user_edit"
@click="$refs.crud.rowEdit(row, index)"
>
编辑
</el-link>
<el-link
type="primary"
v-if="permission.mp_user_status"
@click="handleStatus(row)"
>
{{ row.status === 1 ? '停用' : '启用' }}
</el-link>
<el-link
type="primary"
v-if="permission.mp_user_reset"
@click="handleResetForRow(row)"
>
重置密码
</el-link>
<el-link
type="danger"
v-if="permission.mp_user_delete"
@click="rowDel(row)"
>
删除
</el-link>
</template>
<template #menu-form="{ size }">
<el-button
v-if="permission.mp_user_reset && form.id && dialogType === 'edit'"
:size="size"
@click="handleResetForRow(form)"
>
重置密码
</el-button>
</template>
</avue-crud>
</basic-container>
</template>
<script>
import { h } from 'vue';
import { getList, getDetail, submit, remove, changeStatus, resetPassword } from '@/api/system/mpUser';
import option from '@/option/system/mpUser';
import { mapGetters } from 'vuex';
import { formatUpdateUserName } from '@/utils/audit';
const IDENTITY_LABEL = {
driver: '司机',
admin: '管理员',
};
export default {
data() {
return {
form: {},
dialogType: '',
query: {},
loading: true,
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
selectionList: [],
option,
data: [],
};
},
computed: {
...mapGetters(['permission']),
permissionList() {
return {
addBtn: this.validData(this.permission.mp_user_add, false),
viewBtn: this.validData(this.permission.mp_user_view, false),
delBtn: this.validData(this.permission.mp_user_delete, false),
editBtn: this.validData(this.permission.mp_user_edit, false),
};
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
},
methods: {
formatUpdateUserName,
formatIdentities(value) {
if (!value) return '';
return String(value)
.split(',')
.map(item => IDENTITY_LABEL[item] || item)
.filter(Boolean)
.join(',');
},
normalizeSubmitRow(row = {}) {
const payload = { ...row };
if (Array.isArray(payload.identities)) {
payload.identities = payload.identities.filter(Boolean).join(',');
}
if (payload.phone) {
payload.phone = String(payload.phone).trim();
}
if (!payload.account && payload.phone) {
payload.account = payload.phone;
}
delete payload.password;
delete payload.identitiesName;
delete payload.sexName;
delete payload.createUserName;
delete payload.updateUserName;
return payload;
},
beforeOpen(done, type) {
this.dialogType = type;
if (['edit', 'view'].includes(type) && this.form.id) {
getDetail(this.form.id).then(res => {
const detail = res.data.data || {};
if (detail.identities && typeof detail.identities === 'string') {
detail.identities = detail.identities.split(',').filter(Boolean);
}
this.form = detail;
done();
});
return;
}
if (type === 'add') {
this.form = {
status: 1,
identities: ['driver'],
};
}
done();
},
rowSave(row, done, loading) {
submit(this.normalizeSubmitRow(row)).then(
() => {
this.onLoad(this.page);
this.$message.success('操作成功');
done();
},
() => loading()
);
},
rowUpdate(row, index, done, loading) {
submit(this.normalizeSubmitRow(row)).then(
() => {
this.onLoad(this.page);
this.$message.success('操作成功');
done();
},
() => loading()
);
},
rowDel(row) {
this.$confirm('确定删除该小程序用户?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => remove(row.id))
.then(() => {
this.onLoad(this.page);
this.$message.success('操作成功');
});
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定删除选中的小程序用户?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => remove(this.ids))
.then(() => {
this.onLoad(this.page);
this.$message.success('操作成功');
this.$refs.crud.toggleSelection();
});
},
handleStatus(row) {
const status = row.status === 1 ? 0 : 1;
const text = status === 1 ? '启用' : '停用';
this.$confirm(`是否${text}该小程序用户?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => changeStatus(row.id, status))
.then(() => {
this.$message.success('操作成功');
this.onLoad(this.page);
});
},
handleReset() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定将选中账号密码重置为随机密码?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => resetPassword(this.ids))
.then(res => {
this.showResetPassword(res.data.data);
this.$refs.crud.toggleSelection();
});
},
handleResetForRow(row) {
if (!row || !row.id) {
this.$message.warning('请先保存用户后再重置密码');
return;
}
const account = row.account || row.phone || row.id;
this.$confirm(`确定将账号【${account}】的密码重置为随机密码?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => resetPassword(row.id))
.then(res => {
this.showResetPassword(res.data.data);
});
},
showResetPassword(passwordMap) {
const passwordList = Object.keys(passwordMap || {}).map(account =>
h('div', null, `${account}:${passwordMap[account]}`)
);
this.$alert(h('div', null, passwordList), '密码重置成功,请立即保存', {
confirmButtonText: '我已保存',
type: 'success',
});
},
searchReset() {
this.query = {};
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
selectionChange(list) {
this.selectionList = list;
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page, params = {}) {
this.loading = true;
const query = {
...this.query,
...params,
};
if (Array.isArray(query.identities)) {
query.identities = query.identities.filter(Boolean).join(',');
}
getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data || {};
this.page.total = result.total || 0;
this.data = result.records || [];
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.mp-user-page {
:deep(.avue-crud__menu) {
min-height: auto;
}
}
</style>

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