6 Commits

Author SHA1 Message Date
b2894lxlx 7078de6385 调试mk 2026-09-20 18:12:51 +08:00
b2894lxlx 4af9afda61 调试mk 2026-09-20 13:17:05 +08:00
b2894lxlx 50ab33f369 Merge remote-tracking branch 'websoft/master'
# Conflicts:
#	src/views/business/project-apply.vue
2026-09-18 18:26:13 +08:00
b2894lxlx bcbc45bb03 调整 用户 2026-09-18 18:25:52 +08:00
gxwebsoft ff9fb9168b fix(router): 修复运单管理详情路由及合同详情路由重复定义问题
- 为 waybill-manage-page、transport-plan-page 等组件增加 routePathLocked 变量锁定路由路径
- 添加 isOwnedRouteActive 计算属性,防止缓存实例错误读取其它路由的 detailId 导致误请求
- watcher 和 openDetail 方法中添加路由守卫,避免多余的请求和误开详情标签
- 修改宿主视图绑定 detailId,避免直接绑定全局 $route.query 导致缓存污染
- 删除 router/views/index.js 中合同管理详情路由重复定义,防止内容区空白问题
- 优化 waybill-import-dialog 组件样式和结构,增加 section-card 包裹表单区域
- 调整相关组件 data、computed、watch 使用,确保第一次立即 watcher 正确读取路由数据
- 验证所有改动后相关页面 HTTP 200,标签页行为正常,解决「运单管理不存在」和合同详情空白问题
2026-09-18 12:22:25 +08:00
gxwebsoft 986feff7b7 fix(waybill-manage): 修复运单详情弹窗残留问题
- 页面形态状态(列表/独立表单页/独立详情页)在实例创建时锁定,避免随路由变化切换导致弹窗残留
- 模板中形态相关所有判定改为使用锁定状态变量,保证渲染一致性
- 修改 detailContainer、crudContainer、pageFormOption 等方法使用锁定状态,避免缓存实例 option 变形
- 新增 closeInnerDialogs 方法,在组件 deactivated 和 beforeUnmount 生命周期关闭所有 append-to-body 弹窗
- 解决标签页 keep-alive 机制下弹窗 Teleport 持续遗留导致界面异常的问题
- 对运单管理页面多处弹窗和视图控制做统一修复,提升页面稳定性和体验
2026-09-18 11:29:52 +08:00
25 changed files with 1755 additions and 200 deletions
+82
View File
@@ -38,3 +38,85 @@
**校验**dev server curl 该 .vue 与 scoped style 均 200。仍未 commit。 **校验**dev server curl 该 .vue 与 scoped style 均 200。仍未 commit。
**未提交**:上述改动 + 上一轮 loading-manage 独立页改造均未 commit。 **未提交**:上述改动 + 上一轮 loading-manage 独立页改造均未 commit。
---
## 排查(未改码):配载单详情页反复弹「运单管理不存在」
用户截图:打开 `/business/loading-manage/detail?id=2097607162718441474&name=配载单详情`,连续弹出 6-7 条红色「运单管理不存在」,标签栏堆了一串「运单管理详情」。
**文案来源**:前端 src 里没有这句话,是后端 `BusinessException` 的 msg,被 `src/axios.js` 的响应拦截器(`status !== 200``ElMessage.error(message)`)统一弹出。
**主因(通道 A**`views/business/waybill-manage-detail.vue` 只有 23 行,写了 `:detail-id="$route.query.id"` —— 直接绑全局 `$route`**没有路径守卫**。`waybill-manage-page.vue``detailId` watcher 是 `immediate` 且对任何非空 id 都 `openDetail({id})`。于是:
1. 之前打开过的每个运单详情标签(`/business/waybill-manage/detail?id=A``?id=B`…)各是一个被 keep-alive 缓存的独立实例;
2. 跳到配载单详情后,这些实例被 deactivate 但仍随 `$route` 重渲染 → `$route.query.id` 变成配载单 id
3. prop 变化 → `openDetail({id: 配载单id})``getDetail(配载单id)` 去查运单 → 后端「运单管理不存在」→ 拦截器弹一次。**几个旧标签就几条提示**;
4. 且被污染实例的 `isStandaloneWaybillDetailPage` 仍是 live computed(false) → `openDetail``$router.push` 分支 → 又生成新的「运单管理详情」标签(标签栏那串的来源)。
同理风险:`waybill-manage.vue:6``transport-plan.vue:6``:detail-id="$route.query.detailId"``business-crud-page.vue:7356` 会 push `/business/loading-manage?detailId=id`,正好会污染运单列表实例)。
**次因(通道 B**`loading-manage.vue``restoreWaybillRows` 会用 `waybillIdsJson` 里的每个运单 id 并发 `getWaybillDetail(id)`,若历史运单已被删/失效,也会报同一句文案且并发多条。单条 `.catch(() => null)` 挡不住拦截器已弹出的提示。
**区分方法**Network 里看报错请求的 `id` —— 等于 `2097607162718441474`(配载单 id)→ 通道 A;是别的数字(运单 id)→ 通道 B。或先关掉所有「运单管理详情」标签再复现,通道 A 会消失。
**待确认的修法**(用户要求先报不改):
1. `waybill-manage-page.vue``created()``routePathLocked = this.$route.path``detailId` watcher 首行加 `if (this.$route.path !== this.routePathLocked) return;`
2. `openDetail()` 的 push 分支加同样守卫(防其它调用路径)。
3. 宿主 view 绑定加路径守卫(`waybill-manage-detail.vue` / `waybill-manage.vue` / `transport-plan.vue`)双保险。
4. 可选:`getWaybillDetail` 支持静默模式,供 `restoreWaybillRows` 用,避免历史脏数据刷屏。
## 修复(已执行):「运单管理不存在」误报 + 误开标签
用户确认后按上方案落地,5 个文件:
1. `src/views/business/components/waybill-manage-page.vue`
- `data()` 新增 `routePathLocked: this.$route.path` —— **必须放 data,不能放 created**Vue Options API 顺序是 data → computed → watch(immediate) → created(已在 runtime-core `applyOptions` 源码中核实)。放 created 会让首个 immediate watcher 读到空值。
- 新增 computed `isOwnedRouteActive() { return this.$route.path === this.routePathLocked; }`
- `detailId` watcher 首行加 `if (!this.isOwnedRouteActive) return;`
- `openDetail``$router.push` 分支同样加守卫。
2. `src/views/business/components/transport-plan-page.vue`:完全同构的写法(`:detail-id` host 一致),加同样的 `routePathLocked` + `isOwnedRouteActive` + watcher 守卫。
3. `src/views/business/waybill-manage-detail.vue``:detail-id="$route.query.id"``:detail-id="detailId"`computed 做 `this.$route.path === '/business/waybill-manage/detail'` 守卫。
4. `src/views/business/waybill-manage.vue`:同上,守卫 `/business/waybill-manage`(保留旧链接 `?detailId=` 兼容)。
5. `src/views/business/transport-plan.vue`:同上,守卫 `/business/transport-plan`
**验证**
- dev server curl 5 个 .vue + 2 个 scoped style 全部 HTTP 200。
-`@vue/server-renderer` 写了临时 mjs(已删)模拟带 `$route` 的 app,确认 **data() 里访问 `this.$route` 可行**immediate watcher 触发时 `routePathLocked` 已是正确路径、首次加载不被拦截。
- `$route` 是 vue-router 挂在 `app.config.globalProperties` 上的 gettervue-router.mjs:1498),组件实例化时已就绪。
**未做(第 4 条可选)**:给 `getWaybillDetail` 加静默模式供 `restoreWaybillRows` 使用(历史失效运单仍会刷多条红字),待用户定夺。
**另注**`business-crud-page.vue:6651` 有同构的 `detailId` immediate watcher,但全项目只有上述 3 个宿主 view 传 `:detail-id`,它暂无调用方,未动。
**仍未 commit。**
## 修复(已执行):合同详情页整页空白 —— 路由表重复定义
**现象**:主订单详情点合同编号 → `/business/contract-manage/detail?id=...&name=合同详情` 内容区空白(侧栏/标签正常)。
**定位过程(关键:真实浏览器复现,不猜)**
1. 静态排查全部排除:`contract-manage.vue` 及其 26 个依赖 HTTP 200、模块 `import()` 成功、无循环依赖、详情分支引用的 24 个成员全部有定义。
2. dev 环境登录态无法直接拿(验证码 + `admin/admin``error_code 2004 用户密码强度过低`),改为**注入式复现**
- `agent-browser open <目标 URL>`(落 `/login`);
- eval 取 `document.getElementById('app').__vue_app__.config.globalProperties``$router` / `$store`
- 写 cookie `saber3-access-token`(注意 token 走 **js-cookie**,不是 localStorage+ `$store.state.user.token`
- 覆写 `XMLHttpRequest.prototype.open/send` 让所有 `/api/**` 返回 `{code:200,data:{...}}`defineProperty 伪造 readyState/status/responseText 后手动触发 `onloadend`)。
-`$router.push(...)` 做 SPA 跳转。
3. 复现出空白,并拿到决定性证据:
- `/business/contract-manage/detail``$route.matched.length === 1`(只有 Layoutname undefined),`#avue-view.children.length === 0`innerHTML 为 `<!---->`
- 对照 `/business/contract-manage/change`(结构相同但未重复定义)→ `matched.length === 2`(Layout + 合同变更),内容完整渲染。
- `$router.getRoutes()` 里 detail 有 **3 条**记录:2 个 Layout 父记录 + 1 个子记录(`合同详情`)。
**根因**`src/router/views/index.js``/business/contract-manage/detail` **被复制粘贴定义了两次**,两次的子路由都叫 `合同详情`。vue-router 的 `addRoute` 遇到重名会 `removeRoute(name)` 把先注册的子记录删掉,但**不会把它从父记录的 `children` 数组里摘除**;于是第一条 Layout 壳记录留在 matcher 列表里且排在前面,`resolve('/business/contract-manage/detail')` 命中这个"无有效子路由"的壳 → matched 只有 1 层 → `page/index/index.vue``#avue-view` 的二级 `<router-view>` 无匹配 → 内容区空白。
`getRoutes()` 计数是判据:form / change 都是 2 条 = 父 + 子(健康),detail 是 3 条 = 2 父 + 1 子(坏)。)
**修复**:删除重复块(原 314-325 行),只保留 159-169 行那一份。
**验证**
- 重复扫描脚本(正则匹配「顶层 path + `component: Layout` + 首个子路由 name」):views/index.js 27 条 Layout 子路由中仅 `合同详情` 重复;page/index.js 无重复 → 已清零。
- 修复后浏览器复验:detail → `matched.length === 2`Layout + 合同详情)、`#avue-view.children.length === 1`,正文完整渲染「基本信息 / 签约类型 / 合同编号 HT-2026-001 / … / 变更记录」。
- 全量回归:48 条 business/vehicle/payment/settlement 路由 `resolve()``matched.length` 全部 ≥ 2`bad: []`
- `curl` 校验:`views/index.js``contract-manage.vue` 均 200。
- 残留报错均为 mock 数据形状不符所致(所有接口都返回同一个合同对象,导致 `tree.reduce` / `feeCategories.find` 之类失败),非真实缺陷。
**未 commit**(连同 1、2 轮改动一起待确认)。
+20 -33
View File
@@ -1,40 +1,27 @@
# 项目长期记忆(tms-erp-web-ws / Saber3 # 项目长期记忆(tms-erp-web-ws / Saber3
配套后端:`/Users/gxwebsoft/JAVA/tms-api`不是 tms-erp-api-ws,后者功能滞后)。 后端:`/Users/gxwebsoft/JAVA/tms-api``tms-erp-api-ws` 滞后,别看错仓库)。
## 列表 + 独立表单页 + 独立详情页(同一组件分流 ## 列表 / 表单 / 详情三态同组件分流
- 路由:`/xxx`(菜单)`/xxx/form``/xxx/detail` 指向同一 .vueform/detail `component: Layout` + 空 children`meta:{keepAlive:false}`detail 带 `activeMenu:'/xxx'` `/xxx``/xxx/form``/xxx/detail` 指向同一 `.vue`form/detail `src/router/views/index.js` `component: Layout` + 空 `children.path` + `meta:{keepAlive:false}`detail `activeMenu`)。组件内按 `$route.path` 分流,必须 `watch:{$route}` 重新 init;标签标题取 `query.name`;独立页容器固定 `<div v-if>`,不用 `el-dialog`
- 组件内按 `$route.path` 分流,列表部分统一 `v-if="!isStandalonePage"` 隐藏。
- 路由记录不同但组件相同 → 必须 `watch:{$route}` 里重新 init`mounted` 只覆盖直接打开/刷新。
- 跳转 `push({path:'/xxx/form',query:{mode,id,name:'新增xxx'}})`,标签标题取 `query.name`
- 底栏:只读详情页「关闭」;表单页「关闭/暂存/确认」。
## ⚠️ 独立页容器禁止用 `<component :is>` 在 div 与 el-dialog 间切换 ## ⚠️ keep-alive 三坑
- **现象**:改成独立整页后,点左侧菜单又弹出旧弹窗,且永久盖在页面上 背景(`router/tab.js` + `page/index/index.vue`):按 `fullPath` 建 wrapper`<keep-alive :include="tagsKeep">` 缓存,**每标签一实例、永不销毁**,deactivate 后**仍随全局 `$route` 重渲染**
- **根因**`src/router/tab.js` 按 fullPath 建 wrapper 组件,`layout.vue``<keep-alive :include="tagsKeep">` 缓存,每个标签一个实例。实例被 deactivate 后**仍会随 `$route` 重新渲染**;此时 `:is` 由 div 变回 `el-dialog``v-model` 仍为 true`append-to-body` 的弹窗经 Teleport 渲染进 body——而 `KeepAlive.deactivate → Teleport.move` 只做 reorder**不会把 teleport 出去的 DOM 搬回 storage container**,于是弹窗永久残留。
- **正确写法**:独立页固定 `<div v-if="isXxxPage">` 普通容器,绝不按路由回退成 el-dialog;列表页分支另写或只保留弹窗一种形态 **坑一:独立页容器禁用 `<component :is>` 在 div / el-dialog 间切换。** `append-to-body` 走 Teleport`TeleportImpl.move` 忽略 `moveType`,不会把 DOM 搬回缓存容器 → 切菜单冒出旧弹窗。修法:固定 `<div v-if>` + `deactivated(){closeInnerDialogs()}` + `beforeUnmount()`(写在子组件也生效)。已修 loading-manage / project-apply / waybill-manage-page;未修 `settlement/components/{pre,formal}-settlement-editor.vue``transport-reconciliation-editor.vue``waybill-import-dialog.vue`
- **兜底**:页面内其它 `append-to-body` 的二级弹窗,加 `deactivated(){closeInnerDialogs()}` + `beforeUnmount(){closeInnerDialogs()}` 把 v-model 置 false。
- 已按此修法落地:loading-manage、project-apply2026-09-18)、waybill-manage-page(同日,锁 detailPage/formPage/formMode 三个标志) **坑二:宿主 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
- 未修同类风险:`settlement/components/{pre,formal}-settlement-editor.vue``transport-reconciliation-editor.vue``pageMode ? 'div':'el-dialog'`)、`waybill-import-dialog.vue``standalone`/`createPage`)。
**坑三:路由表重复定义 → 内容区整页空白(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」重复项。
## 样式硬规则 ## 样式硬规则
- 底栏`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()` 要包整个选择器。 - 底栏 `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`(禁绿、禁两个蓝实心)。顺序 `[辅助][取消][保存草稿][提交]` - 按钮层级:次要= type;中间步骤=`primary plain`;主操作=`primary`(禁绿、禁两个蓝实心)。顺序 `[辅助][取消][保存草稿][提交]`
- 独立页标题 `.archive-page-form__title`18px/600 + 4px 主色竖条);分组用全局 `<section-card>`弹窗灰底 `#f5f6fa` - 独立页标题 `.archive-page-form__title`;分组用全局 `<section-card>`上传证件区 `width:100%; max-width:240px` + `.el-upload{height:151px}`
- 上传证件区:`width:100%; max-width:240px` + `.el-upload{height:151px}`,禁写死 px - ⚠️ `<style scoped lang="scss">` 顶层禁 `//` 注释(Vite5 + sass `Unexpected '/'` → 500),顶层用 `/* */`。其余见 `src/styles/element-ui.scss``AGENTS.md` 4.4
- ⚠️ `<style scoped lang="scss">` **顶层禁 `//` 注释**Vite5+sass 报 `Unexpected '/'` → 样式模块 500),顶层用 `/* */`
- 其余全站细则见 `src/styles/element-ui.scss` 与 AGENTS.md 4.4。
## 表单文案 ## 表单 / 自检 / 导出
- placeholder 只写 `请输入`/`请选择`;例外保留`请输入或选择车辆`、日期区间 `起/止`、示例值`如:京A12345`。校验 message 须带字段名。 - placeholder 只写 `请输入`/`请选择`;例外:`请输入或选择车辆`、日期区间 `起`/`止`、示例值。校验 message 须带字段名。
- `business-crud-page.vue`option 经 `cloneOption` 克隆,独立表单页走 `PageAvueForm`
## 复用与自检 - 自检:`curl "localhost:2889/src/xxx.vue"``"...?vue&type=style&index=0&scoped=true&lang.scss"`200 / 500)。`vite build``facelogin.vue` 被沙箱中断,不能作唯一校验。dev `VITE_APP_API=/api` 代理 `172.16.203.228:8000`
- `business/components/business-crud-page.vue`option 经 `cloneOption` 克隆;独立表单页走 `PageAvueForm`;详情弹窗 `detailButton + detailSections` - 导出:前端 `exportColumns` 后端未用,以 `XxxExportExcel.java` 为准;BladeX `BeanUtil` 类型不兼容静默跳过(`Date``LocalDateTime` 丢值,用 `DateUtil.fromDate`
- 自检:dev(2889) 时 `curl "localhost:2889/src/xxx.vue"``curl "...?vue&type=style&index=0&scoped=true&lang.scss"`,200 通过、500 返回带堆栈错误页。
- ⚠️ `vite build` 会在 `src/page/login/facelogin.vue` 被沙箱敏感内容保护中断(环境问题),不能作为唯一校验手段。
- dev`VITE_APP_API=/api`,代理 `172.16.203.228:8000`
## 导出(后端 FastExcel
- 前端 `exportColumns` 后端未使用,导出列以 `XxxExportExcel.java` 为准。
- BladeX `BeanUtil` 类型不兼容时静默跳过(`Date createTime``LocalDateTime` 丢值),须 Service 内 `DateUtil.fromDate(...)` 赋值。
- 排查导出文件用 Python `zipfile``xl/worksheets/sheet1.xml`
+19
View File
@@ -33,6 +33,25 @@ export const getPunchRecords = waybillId =>
method: 'get', method: 'get',
params: { waybillId }, params: { waybillId },
}); });
/** 运单车辆实时定位 */
export const locateVehicle = id =>
request({
url: `${baseUrl}/locate`,
method: 'post',
params: { id },
timeout: 60000,
});
/** 运单车辆历史轨迹 */
export const trackVehicle = (id, startDate, endDate) =>
request({
url: `${baseUrl}/track`,
method: 'post',
params: { id, startDate, endDate },
timeout: 60000,
});
export const roadLoading = ids => export const roadLoading = ids =>
request({ request({
url: `${baseUrl}/road-loading`, url: `${baseUrl}/road-loading`,
+18
View File
@@ -0,0 +1,18 @@
import request from '@/axios';
/**
* 调用 MK processSubmit 提交审核流
* @param {Object} data
* @param {string} data.templateCode 模板编码
* @param {string} data.submitIdentity 提交人(手机号)
* @param {string} data.loginName 登录账号(手机号)
* @param {string} data.formInstanceId 业务表单实例 id
* @param {string} [data.subject] 流程标题
*/
export const processSubmit = data => {
return request({
url: '/blade-system/businessProcess/processSubmit',
method: 'post',
data,
});
};
+26
View File
@@ -48,6 +48,32 @@ export const syncIamOrganizations = () => {
}); });
}; };
export const syncOaCompany = (current = 1, size = 20, signal) => {
return request({
url: '/blade-system/dept/sync-oa-company',
method: 'post',
params: {
current,
size,
},
timeout: 60000,
signal,
});
};
export const syncOaDepartment = (current = 1, size = 20, signal) => {
return request({
url: '/blade-system/dept/sync-oa-department',
method: 'post',
params: {
current,
size,
},
timeout: 60000,
signal,
});
};
export const update = row => { export const update = row => {
return request({ return request({
url: '/blade-system/dept/submit', url: '/blade-system/dept/submit',
+7 -1
View File
@@ -31,10 +31,16 @@ export const add = row => {
}); });
}; };
export const syncIamAccounts = () => { export const syncIamAccounts = (current = 1, size = 50, signal) => {
return request({ return request({
url: '/blade-system/user/sync-iam-accounts', url: '/blade-system/user/sync-iam-accounts',
method: 'post', method: 'post',
params: {
current,
size,
},
timeout: 60000,
signal,
}); });
}; };
+39
View File
@@ -22,6 +22,19 @@ export const getDetail = id => {
}); });
}; };
export const getPublicDetail = id => {
return request({
url: '/blade-transport/customer-archive/public/detail',
method: 'get',
meta: {
isToken: false,
},
params: {
id,
},
});
};
export const getChangeRecordList = (customerId, current, size) => { export const getChangeRecordList = (customerId, current, size) => {
return request({ return request({
url: '/blade-transport/customer-archive/change-record/list', url: '/blade-transport/customer-archive/change-record/list',
@@ -34,6 +47,32 @@ export const getChangeRecordList = (customerId, current, size) => {
}); });
}; };
export const getPublicChangeRecordList = (customerId, current, size) => {
return request({
url: '/blade-transport/customer-archive/public/change-record/list',
method: 'get',
meta: {
isToken: false,
},
params: {
customerId,
current,
size,
},
});
};
export const postPublicProcessMessage = data => {
return request({
url: '/blade-transport/customer-archive/public/process-message',
method: 'post',
meta: {
isToken: false,
},
data,
});
};
export const submit = row => { export const submit = row => {
return request({ return request({
url: '/blade-transport/customer-archive/submit', url: '/blade-transport/customer-archive/submit',
+14
View File
@@ -38,6 +38,20 @@ export const createOption = () => ({
menuWidth: 240, menuWidth: 240,
menuFixed: 'right', menuFixed: 'right',
column: [ column: [
{
label: '计量单位编码',
prop: 'unitCode',
minWidth: 150,
search: true,
searchOrder: 4,
searchSpan: 6,
maxlength: 50,
showWordLimit: true,
rules: [
{ required: true, message: '请输入计量单位编码', trigger: 'blur' },
{ max: 50, message: '计量单位编码不能超过50个字', trigger: 'blur' },
],
},
{ {
label: '计量单位', label: '计量单位',
prop: 'unitName', prop: 'unitName',
+2
View File
@@ -3,6 +3,8 @@ import { getToken } from '@/utils/auth';
import store from '@/store'; import store from '@/store';
import { generateIframePath, processUrlForQuery, isURL } from './router'; import { generateIframePath, processUrlForQuery, isURL } from './router';
import { wrapViewLoader } from '@/utils/chunk-reload'; import { wrapViewLoader } from '@/utils/chunk-reload';
// 保持懒加载,避免 eager 与 store 循环依赖(Cannot access 'store' before initialization
const modules = import.meta.glob('../**/**/*.vue'); const modules = import.meta.glob('../**/**/*.vue');
// 将多级路由扁平化为二级路由,支持 keep-alive 跨层级缓存 // 将多级路由扁平化为二级路由,支持 keep-alive 跨层级缓存
+10
View File
@@ -74,6 +74,16 @@ export default [
isAuth: false, isAuth: false,
}, },
}, },
{
path: '/vehicle/customer-archive/public-view',
name: '查看客商信息',
component: () => import('@/views/vehicle/customer-archive-public-view.vue'),
meta: {
keepAlive: false,
isTab: false,
isAuth: false,
},
},
{ {
path: '/', path: '/',
name: '主页', name: '主页',
+47 -51
View File
@@ -1,5 +1,8 @@
import Layout from '@/page/index/index.vue'; import Layout from '@/page/index/index.vue';
import Store from '@/store/'; import Store from '@/store/';
import { wrapViewLoader } from '@/utils/chunk-reload';
const loadView = loader => wrapViewLoader(loader);
export default [ export default [
{ {
@@ -10,7 +13,7 @@ export default [
path: '', path: '',
name: '汇票付款表单', name: '汇票付款表单',
meta: { keepAlive: false, activeMenu: '/payment/bill-payment' }, meta: { keepAlive: false, activeMenu: '/payment/bill-payment' },
component: () => import('@/views/payment/bill-payment-form.vue'), component: loadView(() => import('@/views/payment/bill-payment-form.vue')),
}, },
], ],
}, },
@@ -22,7 +25,7 @@ export default [
path: '', path: '',
name: '汇票台账表单', name: '汇票台账表单',
meta: { keepAlive: false, activeMenu: '/payment/bill-ledger' }, meta: { keepAlive: false, activeMenu: '/payment/bill-ledger' },
component: () => import('@/views/payment/bill-ledger-form.vue'), component: loadView(() => import('@/views/payment/bill-ledger-form.vue')),
}, },
], ],
}, },
@@ -34,7 +37,7 @@ export default [
path: '', path: '',
name: '认领记录详情', name: '认领记录详情',
meta: { keepAlive: false, activeMenu: '/payment/receipt-claim-record' }, meta: { keepAlive: false, activeMenu: '/payment/receipt-claim-record' },
component: () => import('@/views/payment/receipt-claim-record-form.vue'), component: loadView(() => import('@/views/payment/receipt-claim-record-form.vue')),
}, },
], ],
}, },
@@ -46,7 +49,7 @@ export default [
path: '', path: '',
name: '收款流水认领', name: '收款流水认领',
meta: { keepAlive: false, activeMenu: '/payment/receipt-flow' }, meta: { keepAlive: false, activeMenu: '/payment/receipt-flow' },
component: () => import('@/views/payment/receipt-flow-form.vue'), component: loadView(() => import('@/views/payment/receipt-flow-form.vue')),
}, },
], ],
}, },
@@ -58,7 +61,7 @@ export default [
path: '', path: '',
name: '收票登记', name: '收票登记',
meta: { keepAlive: false, activeMenu: '/payment/invoice-receipt' }, meta: { keepAlive: false, activeMenu: '/payment/invoice-receipt' },
component: () => import('@/views/payment/invoice-receipt-form.vue'), component: loadView(() => import('@/views/payment/invoice-receipt-form.vue')),
}, },
], ],
}, },
@@ -70,7 +73,7 @@ export default [
path: '', path: '',
name: '开票申请', name: '开票申请',
meta: { keepAlive: false, activeMenu: '/payment/invoice-application' }, meta: { keepAlive: false, activeMenu: '/payment/invoice-application' },
component: () => import('@/views/payment/invoice-application-form.vue'), component: loadView(() => import('@/views/payment/invoice-application-form.vue')),
}, },
], ],
}, },
@@ -82,7 +85,7 @@ export default [
path: '', path: '',
name: '付款申请', name: '付款申请',
meta: { keepAlive: false, activeMenu: '/payment/payment-application' }, meta: { keepAlive: false, activeMenu: '/payment/payment-application' },
component: () => import('@/views/payment/payment-application-form.vue'), component: loadView(() => import('@/views/payment/payment-application-form.vue')),
}, },
], ],
}, },
@@ -97,7 +100,7 @@ export default [
keepAlive: false, keepAlive: false,
activeMenu: '/settlement/pre-settlement', activeMenu: '/settlement/pre-settlement',
}, },
component: () => import('@/views/settlement/pre-settlement-form.vue'), component: loadView(() => import('@/views/settlement/pre-settlement-form.vue')),
}, },
], ],
}, },
@@ -112,7 +115,7 @@ export default [
keepAlive: false, keepAlive: false,
activeMenu: '/settlement/formal-settlement', activeMenu: '/settlement/formal-settlement',
}, },
component: () => import('@/views/settlement/formal-settlement-form.vue'), component: loadView(() => import('@/views/settlement/formal-settlement-form.vue')),
}, },
], ],
}, },
@@ -127,7 +130,7 @@ export default [
keepAlive: false, keepAlive: false,
activeMenu: '/settlement/transport-reconciliation', activeMenu: '/settlement/transport-reconciliation',
}, },
component: () => import('@/views/settlement/transport-reconciliation-form.vue'), component: loadView(() => import('@/views/settlement/transport-reconciliation-form.vue')),
}, },
], ],
}, },
@@ -139,7 +142,7 @@ export default [
path: '', path: '',
name: '执行凭证批次详情', name: '执行凭证批次详情',
meta: { keepAlive: false, activeMenu: '/business/voucher-manage' }, meta: { keepAlive: false, activeMenu: '/business/voucher-manage' },
component: () => import('@/views/business/voucher-manage-detail.vue'), component: loadView(() => import('@/views/business/voucher-manage-detail.vue')),
}, },
], ],
}, },
@@ -151,7 +154,7 @@ export default [
path: '', path: '',
name: '新增合同管理', name: '新增合同管理',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/business/contract-manage.vue'), component: loadView(() => import('@/views/business/contract-manage.vue')),
}, },
], ],
}, },
@@ -163,7 +166,7 @@ export default [
path: '', path: '',
name: '合同详情', name: '合同详情',
meta: { keepAlive: false, activeMenu: '/business/contract-manage' }, meta: { keepAlive: false, activeMenu: '/business/contract-manage' },
component: () => import('@/views/business/contract-manage.vue'), component: loadView(() => import('@/views/business/contract-manage.vue')),
}, },
], ],
}, },
@@ -175,7 +178,7 @@ export default [
path: '', path: '',
name: '新增项目申请', name: '新增项目申请',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/business/project-apply.vue'), component: loadView(() => import('@/views/business/project-apply.vue')),
}, },
], ],
}, },
@@ -187,7 +190,7 @@ export default [
path: '', path: '',
name: '新增编辑运单管理', name: '新增编辑运单管理',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/business/waybill-manage.vue'), component: loadView(() => import('@/views/business/waybill-manage.vue')),
}, },
], ],
}, },
@@ -199,7 +202,7 @@ export default [
path: '', path: '',
name: '运单管理详情', name: '运单管理详情',
meta: { keepAlive: false, activeMenu: '/business/waybill-manage' }, meta: { keepAlive: false, activeMenu: '/business/waybill-manage' },
component: () => import('@/views/business/waybill-manage-detail.vue'), component: loadView(() => import('@/views/business/waybill-manage-detail.vue')),
}, },
], ],
}, },
@@ -211,7 +214,7 @@ export default [
path: '', path: '',
name: '导入运单', name: '导入运单',
meta: { keepAlive: false, activeMenu: '/business/waybill-import' }, meta: { keepAlive: false, activeMenu: '/business/waybill-import' },
component: () => import('@/views/business/waybill-import.vue'), component: loadView(() => import('@/views/business/waybill-import.vue')),
}, },
], ],
}, },
@@ -223,7 +226,7 @@ export default [
path: '', path: '',
name: '新建导入运单', name: '新建导入运单',
meta: { keepAlive: false, activeMenu: '/business/waybill-import' }, meta: { keepAlive: false, activeMenu: '/business/waybill-import' },
component: () => import('@/views/business/waybill-import-form.vue'), component: loadView(() => import('@/views/business/waybill-import-form.vue')),
}, },
], ],
}, },
@@ -235,7 +238,7 @@ export default [
path: '', path: '',
name: '新增编辑配载管理', name: '新增编辑配载管理',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/business/loading-manage.vue'), component: loadView(() => import('@/views/business/loading-manage.vue')),
}, },
], ],
}, },
@@ -259,7 +262,7 @@ export default [
path: '', path: '',
name: '新增编辑运输计划', name: '新增编辑运输计划',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/business/transport-plan.vue'), component: loadView(() => import('@/views/business/transport-plan.vue')),
}, },
], ],
}, },
@@ -271,7 +274,7 @@ export default [
path: '', path: '',
name: '导入运输计划', name: '导入运输计划',
meta: { keepAlive: false, activeMenu: '/business/transport-plan' }, meta: { keepAlive: false, activeMenu: '/business/transport-plan' },
component: () => import('@/views/business/transport-plan-import.vue'), component: loadView(() => import('@/views/business/transport-plan-import.vue')),
}, },
], ],
}, },
@@ -283,7 +286,7 @@ export default [
path: '', path: '',
name: '计划调度', name: '计划调度',
meta: { keepAlive: false, activeMenu: '/business/transport-plan' }, meta: { keepAlive: false, activeMenu: '/business/transport-plan' },
component: () => import('@/views/business/transport-plan-dispatch.vue'), component: loadView(() => import('@/views/business/transport-plan-dispatch.vue')),
}, },
], ],
}, },
@@ -295,7 +298,7 @@ export default [
path: '', path: '',
name: '新增编辑运单模板', name: '新增编辑运单模板',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/business/shipping-template.vue'), component: loadView(() => import('@/views/business/shipping-template.vue')),
}, },
], ],
}, },
@@ -307,19 +310,7 @@ export default [
path: '', path: '',
name: '新增客商档案', name: '新增客商档案',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/vehicle/customer-archive.vue'), component: loadView(() => import('@/views/vehicle/customer-archive.vue')),
},
],
},
{
path: '/business/contract-manage/detail',
component: Layout,
children: [
{
path: '',
name: '合同详情',
meta: { keepAlive: false, activeMenu: '/business/contract-manage' },
component: () => import('@/views/business/contract-manage.vue'),
}, },
], ],
}, },
@@ -331,7 +322,7 @@ export default [
path: '', path: '',
name: '合同变更', name: '合同变更',
meta: { keepAlive: false }, meta: { keepAlive: false },
component: () => import('@/views/business/contract-manage-change.vue'), component: loadView(() => import('@/views/business/contract-manage-change.vue')),
}, },
], ],
}, },
@@ -347,7 +338,7 @@ export default [
meta: { meta: {
i18n: 'dashboard', i18n: 'dashboard',
}, },
component: () => import(/* webpackChunkName: "views" */ '@/views/wel/index.vue'), component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/wel/index.vue')),
}, },
{ {
path: 'dashboard', path: 'dashboard',
@@ -356,7 +347,7 @@ export default [
i18n: 'dashboard', i18n: 'dashboard',
menu: false, menu: false,
}, },
component: () => import(/* webpackChunkName: "views" */ '@/views/wel/dashboard.vue'), component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/wel/dashboard.vue')),
}, },
], ],
}, },
@@ -371,7 +362,7 @@ export default [
meta: { meta: {
i18n: 'test', i18n: 'test',
}, },
component: () => import(/* webpackChunkName: "views" */ '@/views/util/test.vue'), component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/util/test.vue')),
}, },
], ],
}, },
@@ -386,8 +377,9 @@ export default [
meta: { meta: {
i18n: 'dict', i18n: 'dict',
}, },
component: () => component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-horizontal.vue'), import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-horizontal.vue')
),
}, },
], ],
}, },
@@ -402,8 +394,9 @@ export default [
meta: { meta: {
i18n: 'dict', i18n: 'dict',
}, },
component: () => component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-vertical.vue'), import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-vertical.vue')
),
}, },
], ],
}, },
@@ -418,7 +411,7 @@ export default [
meta: { meta: {
i18n: 'info', i18n: 'info',
}, },
component: () => import(/* webpackChunkName: "views" */ '@/views/system/userinfo.vue'), component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/system/userinfo.vue')),
}, },
], ],
}, },
@@ -433,8 +426,9 @@ export default [
meta: { meta: {
i18n: 'work', i18n: 'work',
}, },
component: () => component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/form.vue'), import(/* webpackChunkName: "views" */ '@/views/work/process/leave/form.vue')
),
}, },
{ {
path: 'handle/:taskId/:processInstanceId/:businessId', path: 'handle/:taskId/:processInstanceId/:businessId',
@@ -442,8 +436,9 @@ export default [
meta: { meta: {
i18n: 'work', i18n: 'work',
}, },
component: () => component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/handle.vue'), import(/* webpackChunkName: "views" */ '@/views/work/process/leave/handle.vue')
),
}, },
{ {
path: 'detail/:processInstanceId/:businessId', path: 'detail/:processInstanceId/:businessId',
@@ -451,8 +446,9 @@ export default [
meta: { meta: {
i18n: 'work', i18n: 'work',
}, },
component: () => component: loadView(() =>
import(/* webpackChunkName: "views" */ '@/views/work/process/leave/detail.vue'), import(/* webpackChunkName: "views" */ '@/views/work/process/leave/detail.vue')
),
}, },
], ],
}, },
+1
View File
@@ -119,6 +119,7 @@ export default {
return this.isAdmin || this.permission?.[code] === true; return this.isAdmin || this.permission?.[code] === true;
}, },
normalizeRow(row) { normalizeRow(row) {
row.unitCode = String(row.unitCode || '').trim();
row.unitName = String(row.unitName || '').trim(); row.unitName = String(row.unitName || '').trim();
row.dimension = String(row.dimension || '').trim(); row.dimension = String(row.dimension || '').trim();
row.remark = String(row.remark || '').trim(); row.remark = String(row.remark || '').trim();
@@ -157,12 +157,19 @@
<el-table-column label="计费单位" width="150" <el-table-column label="计费单位" width="150"
><template #default="{ row }" ><template #default="{ row }"
><span v-if="readonly">{{ displayValue(row.billingUnit) }}</span ><span v-if="readonly">{{ displayValue(row.billingUnit) }}</span
><el-select v-else v-model="row.billingUnit" clearable filterable :loading="unitLoading" ><el-select
v-else
v-model="row.billingUnit"
clearable
filterable
:disabled="!row.billingElement"
:loading="unitLoading"
:placeholder="row.billingElement ? '请选择' : '请先选择计费要素'"
><el-option ><el-option
v-for="item in unitOptions" v-for="item in unitOptionsFor(row)"
:key="item.id || item.dictKey || item.dictValue" :key="item.id || item.value"
:label="item.dictValue" :label="item.label"
:value="item.dictValue" /></el-select></template :value="item.value" /></el-select></template
></el-table-column> ></el-table-column>
<el-table-column label="单价" width="180" <el-table-column label="单价" width="180"
><template #default="{ row }" ><template #default="{ row }"
@@ -367,12 +374,19 @@
<script> <script>
import { getList as getCargoTypeList } from '@/api/base/cargo-type'; import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getList as getFeeItemList } from '@/api/base/fee-item'; import { getList as getFeeItemList } from '@/api/base/fee-item';
import { getList as getMeasurementUnitList } from '@/api/base/measurement-unit';
import { getLazyTree as getRegionLazyTree } from '@/api/base/region'; import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { InfoFilled } from '@element-plus/icons-vue'; import { InfoFilled } from '@element-plus/icons-vue';
import { getDictionary } from '@/api/system/dictbiz'; import { getDictionary } from '@/api/system/dictbiz';
import SectionCard from '@/components/section-card/main.vue'; import SectionCard from '@/components/section-card/main.vue';
const clone = value => JSON.parse(JSON.stringify(value)); const clone = value => JSON.parse(JSON.stringify(value));
/** 计费要素与计量单位维度的对应关系 */
const BILLING_ELEMENT_DIMENSION_MAP = {
按重量: '重量',
按体积: '体积',
按车辆: '数量',
};
const defaultRule = () => ({ const defaultRule = () => ({
feeType: '', feeType: '',
feeItem: '', feeItem: '',
@@ -417,6 +431,7 @@ export default {
feeItems: {}, feeItems: {},
feeItemLoadingMap: {}, feeItemLoadingMap: {},
unitOptions: [], unitOptions: [],
measurementUnits: [],
unitLoading: false, unitLoading: false,
billingElements: [ billingElements: [
'按重量', '按重量',
@@ -614,15 +629,54 @@ export default {
.finally(() => { .finally(() => {
this.feeCategoryLoading = false; this.feeCategoryLoading = false;
}); });
this.loadUnitOptions();
},
loadUnitOptions() {
this.unitLoading = true; this.unitLoading = true;
getDictionary({ code: 'unit_fee' }) Promise.all([
.then(res => { getDictionary({ code: 'unit_fee' }).then(res => {
this.unitOptions = res.data?.data || []; this.unitOptions = res.data?.data || [];
}) }),
.finally(() => { getMeasurementUnitList(1, 9999, { status: 1 }).then(res => {
const data = res?.data?.data || res?.data || {};
const records = Array.isArray(data) ? data : data.records || [];
this.measurementUnits = records.filter(
item => item.status === undefined || item.status === null || Number(item.status) === 1
);
}),
]).finally(() => {
this.unitLoading = false; this.unitLoading = false;
}); });
}, },
measurementDimension(billingElement) {
return BILLING_ELEMENT_DIMENSION_MAP[billingElement] || '';
},
unitOptionsFor(row) {
const dimension = this.measurementDimension(row?.billingElement);
if (dimension) {
return this.measurementUnits
.filter(item => String(item.dimension || '').trim() === dimension)
.map(item => ({
id: item.id,
label: item.unitName,
value: item.unitName,
}))
.filter(item => item.value);
}
return (this.unitOptions || [])
.map(item => ({
id: item.id || item.dictKey || item.dictValue,
label: item.dictValue,
value: item.dictValue,
}))
.filter(item => item.value);
},
syncBillingUnit(row) {
const options = this.unitOptionsFor(row);
if (!options.some(item => String(item.value) === String(row.billingUnit || ''))) {
row.billingUnit = '';
}
},
feeTypeKey(row) { feeTypeKey(row) {
const option = this.feeCategories.find( const option = this.feeCategories.find(
item => item =>
@@ -699,7 +753,14 @@ export default {
return this.typeMap[row.billingElement] || []; return this.typeMap[row.billingElement] || [];
}, },
handleElementChange(row) { handleElementChange(row) {
if (!this.billingTypes(row).includes(row.billingType)) row.billingType = ''; const billingTypes = this.billingTypes(row);
if (!billingTypes.includes(row.billingType)) {
row.billingType =
row.billingElement === '固定金额(整单一口价)' && billingTypes.includes('固定一口价')
? '固定一口价'
: '';
}
this.syncBillingUnit(row);
if (!this.canEditMinimum(row)) { if (!this.canEditMinimum(row)) {
row.minimumBillingWeight = ''; row.minimumBillingWeight = '';
row.limitRanges = (row.limitRanges || []).map(item => ({ row.limitRanges = (row.limitRanges || []).map(item => ({
@@ -3020,6 +3020,10 @@ export default {
Location, Location,
OfficeBuilding, OfficeBuilding,
Search, Search,
// watcher /
// dataOptions API data computed watch(immediate) created
// created immediate watcher
routePathLocked: this.$route.path,
form: {}, form: {},
suppressTransportTypeClear: false, suppressTransportTypeClear: false,
query: {}, query: {},
@@ -3596,6 +3600,11 @@ export default {
!this.dialogReadonly !this.dialogReadonly
); );
}, },
// tab.js fullPath path
// keep-alive $route id
isOwnedRouteActive() {
return this.$route.path === this.routePathLocked;
},
billingCargoTypeCascaderProps() { billingCargoTypeCascaderProps() {
return { return {
label: 'cargoName', label: 'cargoName',
@@ -3673,6 +3682,9 @@ export default {
detailId: { detailId: {
immediate: true, immediate: true,
handler(id) { handler(id) {
// 宿 view $route.query.detailId
// id
if (!this.isOwnedRouteActive) return;
if (id !== undefined && id !== null && id !== '') { if (id !== undefined && id !== null && id !== '') {
this.$nextTick(() => this.openDetail({ id })); this.$nextTick(() => this.openDetail({ id }));
} }
@@ -199,13 +199,17 @@
</el-dialog> </el-dialog>
<el-dialog v-model="detailVisible" title="导入运单详情" width="90%" append-to-body> <el-dialog v-model="detailVisible" title="导入运单详情" width="90%" append-to-body>
<section-card class="waybill-search">
<el-form :inline="true" :model="detailQuery" <el-form :inline="true" :model="detailQuery"
><el-form-item label="车牌号/船号" >
<el-form-item label="车牌号/船号"
><el-input v-model="detailQuery.vehicleNo" clearable /></el-form-item ><el-input v-model="detailQuery.vehicleNo" clearable /></el-form-item
><el-form-item label="司机/船长姓名" ><el-form-item label="司机/船长姓名"
><el-input v-model="detailQuery.driverName" clearable placeholder="请输入" /></el-form-item ><el-input v-model="detailQuery.driverName" clearable placeholder="请输入" /></el-form-item
><el-button type="primary" @click="loadDetails">查询</el-button></el-form ><el-button type="primary" @click="loadDetails">查询</el-button></el-form
> >
</section-card>
<el-table :data="details" border <el-table :data="details" border
><el-table-column type="index" label="序号" width="65" /><el-table-column ><el-table-column type="index" label="序号" width="65" /><el-table-column
prop="batchNo" prop="batchNo"
@@ -593,6 +597,7 @@ import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { getDictionary } from '@/api/system/dictbiz'; import { getDictionary } from '@/api/system/dictbiz';
import * as api from '@/api/business/waybill-manage'; import * as api from '@/api/business/waybill-manage';
import { downloadXls } from '@/utils/util'; import { downloadXls } from '@/utils/util';
import SectionCard from '@/components/section-card/main.vue';
const props = defineProps({ modelValue: Boolean, standalone: Boolean, createPage: Boolean }); const props = defineProps({ modelValue: Boolean, standalone: Boolean, createPage: Boolean });
const emit = defineEmits(['update:modelValue', 'closed']); const emit = defineEmits(['update:modelValue', 'closed']);
@@ -1617,4 +1622,7 @@ const confirmImport = async () => {
:global(.avue-layout--horizontal .waybill-import-create__footer) { :global(.avue-layout--horizontal .waybill-import-create__footer) {
left: 0; left: 0;
} }
.waybill-search .el-form-item{
margin-bottom: 0 !important;
}
</style> </style>
@@ -2212,13 +2212,105 @@
<section-card title="物流轨迹"> <section-card title="物流轨迹">
<template #extra> <template #extra>
<div class="waybill-manage-page__waybill-track-actions"> <div class="waybill-manage-page__waybill-track-actions">
<el-button plain @click="handleWaybillTrackAction('playback')" <el-button
plain
:type="waybillTrackMode === 'playback' ? 'primary' : undefined"
:loading="waybillTrackLoading"
@click="handleWaybillTrackAction('playback')"
>轨迹回放</el-button >轨迹回放</el-button
> >
<el-button plain @click="handleWaybillTrackAction('locate')">实时定位</el-button> <el-button
plain
:type="waybillTrackMode === 'locate' ? 'primary' : undefined"
:loading="waybillLocateLoading"
@click="handleWaybillTrackAction('locate')"
>实时定位</el-button
>
</div> </div>
</template> </template>
<div class="waybill-manage-page__waybill-map-empty">暂无数据</div> <div
v-if="waybillTrackMode === 'playback'"
class="waybill-manage-page__waybill-track-toolbar"
>
<span>时间段</span>
<el-date-picker
v-model="waybillTrackDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:clearable="false"
:disabled="waybillTrackLoading"
/>
<el-button
type="primary"
:loading="waybillTrackLoading"
@click="loadWaybillTrack"
>查询</el-button
>
<span class="waybill-manage-page__waybill-track-total"
>轨迹点 {{ waybillTrackInfo?.total || 0 }} </span
>
</div>
<div
v-if="waybillTrackMode === 'locate'"
class="waybill-manage-page__waybill-locate"
>
<div v-if="waybillLocateInfo" class="waybill-manage-page__waybill-locate-meta">
<div>
<span>车牌号</span><strong>{{ waybillLocateInfo.vehicleNo || '-' }}</strong>
</div>
<div>
<span>定位时间</span><strong>{{ waybillLocateInfo.locateTime || '-' }}</strong>
</div>
<div>
<span>速度</span><strong>{{ waybillLocateInfo.speed || '-' }}</strong>
</div>
<div>
<span>方向</span><strong>{{ waybillLocateInfo.direction || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-locate-address">
<span>地址</span><strong>{{ waybillLocateInfo.address || '-' }}</strong>
</div>
<div>
<span>坐标</span
><strong
>{{
waybillLocateInfo.longitude != null && waybillLocateInfo.latitude != null
? `${waybillLocateInfo.longitude}, ${waybillLocateInfo.latitude}`
: '-'
}}
</strong>
</div>
</div>
<div
ref="waybillLocateMap"
class="waybill-manage-page__waybill-locate-map"
></div>
<div
v-if="!waybillLocateInfo"
class="waybill-manage-page__waybill-map-tip"
>
{{ waybillLocateHint || '暂无数据' }}
</div>
</div>
<div
v-else-if="waybillTrackMode === 'playback'"
class="waybill-manage-page__waybill-locate"
>
<div
ref="waybillTrackMap"
class="waybill-manage-page__waybill-locate-map waybill-manage-page__waybill-locate-map--track"
></div>
<div
v-if="!(waybillTrackInfo?.points || []).length"
class="waybill-manage-page__waybill-map-tip"
>
{{ waybillTrackHint || '暂无数据' }}
</div>
</div>
<div v-else class="waybill-manage-page__waybill-map-empty">暂无数据</div>
</section-card> </section-card>
</div> </div>
</template> </template>
@@ -3029,7 +3121,7 @@ import {
getList as getProcessConfigList, getList as getProcessConfigList,
getVoucherImages as getProcessConfigVoucherImages, getVoucherImages as getProcessConfigVoucherImages,
} from '@/api/business/process-config'; } from '@/api/business/process-config';
import { getPunchRecords as getWaybillPunchRecords } from '@/api/business/waybill-manage'; import { getPunchRecords as getWaybillPunchRecords, locateVehicle, trackVehicle } from '@/api/business/waybill-manage';
import { getList as getDriverList } from '@/api/transportCapacity/driver'; import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { getDictionary } from '@/api/system/dictbiz'; import { getDictionary } from '@/api/system/dictbiz';
import { getDictionary as getSystemDictionary } from '@/api/system/dict'; import { getDictionary as getSystemDictionary } from '@/api/system/dict';
@@ -3292,6 +3384,21 @@ export default {
waybillRouteChangeRecords: [], waybillRouteChangeRecords: [],
waybillRouteChangeDragIndex: -1, waybillRouteChangeDragIndex: -1,
waybillRouteChangeSaving: false, waybillRouteChangeSaving: false,
waybillLocateLoading: false,
waybillLocateInfo: null,
waybillLocateHint: '',
waybillLocateMapInstance: null,
waybillLocateMarker: null,
waybillLocateInfoWindow: null,
waybillTrackMode: '',
waybillTrackLoading: false,
waybillTrackInfo: null,
waybillTrackHint: '',
waybillTrackDateRange: [],
waybillTrackMapInstance: null,
waybillTrackPolyline: null,
waybillTrackStartMarker: null,
waybillTrackEndMarker: null,
mileageDialog: { mileageDialog: {
visible: false, visible: false,
submitting: false, submitting: false,
@@ -3518,6 +3625,11 @@ export default {
selectionList: [], selectionList: [],
attachmentUploadMode: false, attachmentUploadMode: false,
standaloneFormKey: '', standaloneFormKey: '',
// $route
// data created Vue Options API
// data computed watch(immediate) created created
// immediate watcher
routePathLocked: this.$route.path,
// / / $route // / / $route
// keep-alive deactivate $route // keep-alive deactivate $route
// / append-to-body el-dialog // / append-to-body el-dialog
@@ -3600,6 +3712,12 @@ export default {
return this.standaloneDetailPage && this.$route.path === standaloneWaybillDetailRoute; return this.standaloneDetailPage && this.$route.path === standaloneWaybillDetailRoute;
}, },
// $route data formPageLocked // $route data formPageLocked
// tab.js fullPath path
// keep-alive $route id
// id
isOwnedRouteActive() {
return this.$route.path === this.routePathLocked;
},
detailContainer() { detailContainer() {
return this.detailPageLocked ? 'PageDetail' : 'el-dialog'; return this.detailPageLocked ? 'PageDetail' : 'el-dialog';
}, },
@@ -3867,6 +3985,10 @@ export default {
detailId: { detailId: {
immediate: true, immediate: true,
handler(id) { handler(id) {
// 宿 view $route.query.id / detailId
// id
// openDetail id
if (!this.isOwnedRouteActive) return;
if (id !== undefined && id !== null && id !== '') { if (id !== undefined && id !== null && id !== '') {
this.$nextTick(() => this.openDetail({ id })); this.$nextTick(() => this.openDetail({ id }));
} }
@@ -4449,6 +4571,9 @@ export default {
}, },
openDetail(row) { openDetail(row) {
if (!this.isStandaloneWaybillDetailPage) { if (!this.isStandaloneWaybillDetailPage) {
// row.id id
// push
if (!this.isOwnedRouteActive) return;
this.$router.push({ path: standaloneWaybillDetailRoute, query: { id: row.id } }); this.$router.push({ path: standaloneWaybillDetailRoute, query: { id: row.id } });
return; return;
} }
@@ -4505,6 +4630,13 @@ export default {
}); });
}, },
closeDetail() { closeDetail() {
this.destroyWaybillLocateMap();
this.destroyWaybillTrackMap();
this.waybillTrackMode = '';
this.waybillLocateInfo = null;
this.waybillLocateHint = '';
this.waybillTrackInfo = null;
this.waybillTrackHint = '';
if (this.isStandaloneWaybillDetailPage) { if (this.isStandaloneWaybillDetailPage) {
this.$router.$avueRouter?.closeTag?.(); this.$router.$avueRouter?.closeTag?.();
this.$router.push({ path: '/business/waybill-manage', query: {} }); this.$router.push({ path: '/business/waybill-manage', query: {} });
@@ -4849,7 +4981,262 @@ export default {
} }
}, },
handleWaybillTrackAction(action) { handleWaybillTrackAction(action) {
this.$message.info(action === 'playback' ? '暂无可回放的物流轨迹' : '暂无车辆实时定位数据'); if (action === 'playback') {
this.destroyWaybillLocateMap();
this.waybillTrackMode = 'playback';
this.waybillLocateInfo = null;
this.waybillLocateHint = '';
if (!Array.isArray(this.waybillTrackDateRange) || this.waybillTrackDateRange.length !== 2) {
this.waybillTrackDateRange = this.buildDefaultTrackDateRange();
}
this.loadWaybillTrack();
return;
}
this.destroyWaybillTrackMap();
this.waybillTrackMode = 'locate';
this.waybillTrackInfo = null;
this.waybillTrackHint = '';
this.loadWaybillLocate();
},
buildDefaultTrackDateRange() {
const pad = value => String(value).padStart(2, '0');
const formatDate = date =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const today = new Date();
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
return [formatDate(yesterday), formatDate(today)];
},
destroyWaybillLocateMap() {
if (this.waybillLocateMarker) {
this.waybillLocateMarker.setMap(null);
this.waybillLocateMarker = null;
}
if (this.waybillLocateInfoWindow) {
this.waybillLocateInfoWindow.close();
this.waybillLocateInfoWindow = null;
}
if (this.waybillLocateMapInstance) {
this.waybillLocateMapInstance.destroy();
this.waybillLocateMapInstance = null;
}
},
destroyWaybillTrackMap() {
if (this.waybillTrackPolyline) {
this.waybillTrackPolyline.setMap(null);
this.waybillTrackPolyline = null;
}
if (this.waybillTrackStartMarker) {
this.waybillTrackStartMarker.setMap(null);
this.waybillTrackStartMarker = null;
}
if (this.waybillTrackEndMarker) {
this.waybillTrackEndMarker.setMap(null);
this.waybillTrackEndMarker = null;
}
if (this.waybillTrackMapInstance) {
this.waybillTrackMapInstance.destroy();
this.waybillTrackMapInstance = null;
}
},
async loadWaybillLocate() {
const waybillId = this.detailRow?.id;
if (!waybillId) {
this.$message.warning('运单信息不完整');
return;
}
if (!this.detailRow.vehicleNo) {
this.$message.warning('运单未绑定车牌号,无法实时定位');
return;
}
this.waybillLocateLoading = true;
this.waybillLocateHint = '正在获取车辆实时定位...';
this.waybillLocateInfo = null;
try {
const locateApi =
typeof this.api.locateVehicle === 'function' ? this.api.locateVehicle : locateVehicle;
const res = await locateApi(waybillId);
const data = res?.data?.data || null;
if (!data || data.longitude == null || data.latitude == null) {
this.waybillLocateInfo = null;
this.waybillLocateHint = '暂无车辆实时定位数据';
this.$message.warning('暂无车辆实时定位数据');
return;
}
this.waybillLocateInfo = data;
this.waybillLocateHint = '';
await this.$nextTick();
await this.renderWaybillLocateMap(data);
} catch (error) {
this.waybillLocateInfo = null;
this.waybillLocateHint = '实时定位获取失败';
this.$message.error(error?.message || '实时定位获取失败');
} finally {
this.waybillLocateLoading = false;
}
},
async loadWaybillTrack() {
const waybillId = this.detailRow?.id;
if (!waybillId) {
this.$message.warning('运单信息不完整');
return;
}
if (!this.detailRow.vehicleNo) {
this.$message.warning('运单未绑定车牌号,无法查询历史轨迹');
return;
}
if (!Array.isArray(this.waybillTrackDateRange) || this.waybillTrackDateRange.length !== 2) {
this.waybillTrackDateRange = this.buildDefaultTrackDateRange();
}
const [startDate, endDate] = this.waybillTrackDateRange;
this.waybillTrackLoading = true;
this.waybillTrackHint = '正在获取历史轨迹...';
this.destroyWaybillTrackMap();
try {
const trackApi =
typeof this.api.trackVehicle === 'function' ? this.api.trackVehicle : trackVehicle;
const res = await trackApi(waybillId, startDate, endDate);
const data = res?.data?.data || null;
const points = Array.isArray(data?.points) ? data.points : [];
if (!data || points.length === 0) {
this.waybillTrackInfo = data || { total: 0, points: [] };
this.waybillTrackHint = '暂无可回放的物流轨迹';
this.$message.warning('暂无可回放的物流轨迹');
return;
}
this.waybillTrackInfo = data;
this.waybillTrackHint = '';
await this.$nextTick();
await this.renderWaybillTrackMap(points);
} catch (error) {
this.waybillTrackInfo = null;
this.waybillTrackHint = '历史轨迹获取失败';
this.$message.error(error?.message || '历史轨迹获取失败');
} finally {
this.waybillTrackLoading = false;
}
},
async renderWaybillLocateMap(locateInfo) {
const longitude = Number(locateInfo?.longitude);
const latitude = Number(locateInfo?.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
this.$message.warning('定位坐标无效,无法在地图上展示');
return;
}
try {
await this.loadAmap();
await this.$nextTick();
const container = this.$refs.waybillLocateMap;
if (!container || !window.AMap) {
this.$message.warning('高德地图容器未就绪');
return;
}
this.destroyWaybillLocateMap();
this.waybillLocateMapInstance = new window.AMap.Map(container, {
zoom: 15,
center: [longitude, latitude],
viewMode: '2D',
resizeEnable: true,
});
const content = [
`<div style="padding:4px 2px;line-height:1.6;font-size:12px;">`,
`<div><b>${locateInfo.vehicleNo || '车辆位置'}</b></div>`,
locateInfo.locateTime ? `<div>时间:${locateInfo.locateTime}</div>` : '',
locateInfo.address ? `<div>地址:${locateInfo.address}</div>` : '',
`<div>坐标:${longitude}, ${latitude}</div>`,
`</div>`,
].join('');
this.waybillLocateInfoWindow = new window.AMap.InfoWindow({
content,
offset: new window.AMap.Pixel(0, -30),
});
this.waybillLocateMarker = new window.AMap.Marker({
position: [longitude, latitude],
title: locateInfo.vehicleNo || '车辆位置',
anchor: 'bottom-center',
});
this.waybillLocateMarker.on('click', () => {
this.waybillLocateInfoWindow.open(this.waybillLocateMapInstance, [longitude, latitude]);
});
this.waybillLocateMapInstance.add(this.waybillLocateMarker);
this.waybillLocateInfoWindow.open(this.waybillLocateMapInstance, [longitude, latitude]);
this.waybillLocateMapInstance.setFitView([this.waybillLocateMarker], false, [40, 40, 40, 40]);
setTimeout(() => {
this.waybillLocateMapInstance?.resize?.();
}, 80);
} catch (error) {
window.console.warn('实时定位地图渲染失败', error);
this.$message.error('高德地图渲染失败,请稍后重试');
}
},
async renderWaybillTrackMap(points) {
const path = (points || [])
.map(item => [Number(item.longitude), Number(item.latitude)])
.filter(item => Number.isFinite(item[0]) && Number.isFinite(item[1]));
if (!path.length) {
this.$message.warning('轨迹坐标无效,无法在地图上展示');
return;
}
try {
await this.loadAmap();
await this.$nextTick();
const container = this.$refs.waybillTrackMap;
if (!container || !window.AMap) {
this.$message.warning('高德地图容器未就绪');
return;
}
this.destroyWaybillTrackMap();
this.waybillTrackMapInstance = new window.AMap.Map(container, {
zoom: 12,
center: path[0],
viewMode: '2D',
resizeEnable: true,
});
this.waybillTrackPolyline = new window.AMap.Polyline({
path,
strokeColor: '#409eff',
strokeWeight: 6,
strokeOpacity: 0.9,
lineJoin: 'round',
lineCap: 'round',
showDir: path.length > 1,
});
this.waybillTrackStartMarker = new window.AMap.Marker({
position: path[0],
title: '起点',
anchor: 'bottom-center',
label: {
content: '起',
direction: 'top',
offset: new window.AMap.Pixel(0, -6),
},
});
this.waybillTrackEndMarker = new window.AMap.Marker({
position: path[path.length - 1],
title: '终点',
anchor: 'bottom-center',
label: {
content: '终',
direction: 'top',
offset: new window.AMap.Pixel(0, -6),
},
});
this.waybillTrackMapInstance.add([
this.waybillTrackPolyline,
this.waybillTrackStartMarker,
this.waybillTrackEndMarker,
]);
this.waybillTrackMapInstance.setFitView(
[this.waybillTrackPolyline, this.waybillTrackStartMarker, this.waybillTrackEndMarker],
false,
[48, 48, 48, 48]
);
setTimeout(() => {
this.waybillTrackMapInstance?.resize?.();
}, 80);
} catch (error) {
window.console.warn('历史轨迹地图渲染失败', error);
this.$message.error('高德地图渲染失败,请稍后重试');
}
}, },
displayStatus(row, prop) { displayStatus(row, prop) {
const formatStatus = this.config.formatStatus; const formatStatus = this.config.formatStatus;
@@ -9535,6 +9922,81 @@ export default {
border: 1px solid #eff1f7; border: 1px solid #eff1f7;
} }
&__waybill-locate {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
&__waybill-locate-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
padding: 10px 12px;
border: 1px solid #eff1f7;
background: #fafafa;
color: #606266;
font-size: 13px;
span {
margin-right: 8px;
color: #909399;
}
strong {
color: #303133;
font-weight: 600;
}
}
&__waybill-locate-address {
grid-column: 1 / -1;
}
&__waybill-locate-map {
width: 100%;
min-height: 320px;
height: 320px;
border: 1px solid #eff1f7;
background: #f5f7fa;
}
&__waybill-locate-map--track {
min-height: 420px;
height: 420px;
}
&__waybill-map-tip {
position: absolute;
left: 50%;
top: 50%;
z-index: 2;
transform: translate(-50%, -50%);
padding: 8px 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
color: #909399;
font-size: 13px;
text-align: center;
pointer-events: none;
}
&__waybill-track-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 10px;
color: #606266;
font-size: 13px;
}
&__waybill-track-total {
color: #909399;
}
&__waybill-track-actions { &__waybill-track-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
+79 -23
View File
@@ -140,15 +140,14 @@
@load="onLoad(page, query)" @load="onLoad(page, query)"
/> />
<!-- <component
项目新增/编辑/查看/变更/补录全部是独立整页openProjectDialog 一律 push /business/project-apply/form :is="projectFormContainer"
这里固定渲染普通容器不再按 $route div el-dialog 之间切换 v-if="projectBox"
原因标签页 keep-alive 会缓存本页实例若容器在离开本页后又变回 el-dialog v-bind="projectFormContainerProps"
缓存实例会把 append-to-body 的弹窗渲染进 body 并永久停留Vue KeepAlive.deactivate @update:model-value="projectBox = $event"
Teleport.move 不会搬走 append-to-body 的弹窗 DOM表现为点左侧菜单又弹出新增项目管理弹窗 @closed="resetProjectDialog"
--> >
<div v-if="isProjectFormPage" class="project-apply-page-form"> <div v-if="isProjectFormPage" class="archive-page-form__title">
<div class="archive-page-form__title">
{{ projectDialogTitle }} {{ projectDialogTitle }}
</div> </div>
<el-form <el-form
@@ -319,7 +318,7 @@
<el-form-item prop="fundLimit" class="project-apply-form__tip-label"> <el-form-item prop="fundLimit" class="project-apply-form__tip-label">
<template #label> <template #label>
<span>项目资金使用额度</span> <span>项目资金使用额度</span>
<el-tooltip content="实际业务回款周期内付款额度" placement="top"> <el-tooltip content="实际业务回款周期内付款" placement="top">
<el-icon class="project-apply-form__label-tip"><QuestionFilled /></el-icon> <el-icon class="project-apply-form__label-tip"><QuestionFilled /></el-icon>
</el-tooltip> </el-tooltip>
</template> </template>
@@ -789,7 +788,8 @@
:class="{ 'project-apply-dialog__footer--change': isChangeDialog }" :class="{ 'project-apply-dialog__footer--change': isChangeDialog }"
> >
<template v-if="isChangeDialog"> <template v-if="isChangeDialog">
<el-button @click="closeProjectForm">取消</el-button> <el-button v-if="!isProjectFormPage" @click="handleCancelProject">取消</el-button>
<el-button v-if="isProjectFormPage" @click="closeProjectForm">取消</el-button>
<el-button type="primary" plain :loading="submitLoading" @click="saveChangeProject"> <el-button type="primary" plain :loading="submitLoading" @click="saveChangeProject">
保存 保存
</el-button> </el-button>
@@ -798,7 +798,8 @@
</el-button> </el-button>
</template> </template>
<template v-else> <template v-else>
<el-button @click="closeProjectForm">取消</el-button> <el-button v-if="!isProjectFormPage" @click="handleCancelProject">取消</el-button>
<el-button v-if="isProjectFormPage" @click="closeProjectForm">取消</el-button>
<el-button <el-button
v-if="!dialogReadonly" v-if="!dialogReadonly"
type="primary" type="primary"
@@ -818,7 +819,7 @@
</el-button> </el-button>
</template> </template>
</div> </div>
</div> </component>
<el-dialog <el-dialog
v-model="attachmentDocumentPreviewVisible" v-model="attachmentDocumentPreviewVisible"
@@ -1115,6 +1116,11 @@ export default {
total: 0, total: 0,
}, },
selectionList: [], selectionList: [],
// 表单页实例标记:创建时按路由落定,不随 keep-alive 切走后的 $route 变化翻转。
// 否则 isProjectFormPage 变 false 后容器会从 div 切成 el-dialogappend-to-body),
// 盖住后续打开的页面(如客商档案)。
isFormPageInstance: false,
projectBox: false,
dialogType: 'add', dialogType: 'add',
dialogReadonly: false, dialogReadonly: false,
submitLoading: false, submitLoading: false,
@@ -1306,7 +1312,23 @@ export default {
return ['add', 'majorSupplement'].includes(this.dialogType); return ['add', 'majorSupplement'].includes(this.dialogType);
}, },
isProjectFormPage() { isProjectFormPage() {
return this.$route.path === '/business/project-apply/form'; return this.isFormPageInstance;
},
projectFormContainer() {
// 表单页实例始终用页面容器,避免 keep-alive 失活时误切弹窗
return this.isFormPageInstance ? 'div' : 'el-dialog';
},
projectFormContainerProps() {
if (this.isFormPageInstance) return { class: 'project-apply-page-form' };
return {
modelValue: this.projectBox,
title: this.projectDialogTitle,
appendToBody: true,
destroyOnClose: true,
width: '1440px',
top: '4vh',
class: 'project-apply-dialog',
};
}, },
isBasicInfoReadonly() { isBasicInfoReadonly() {
return this.dialogReadonly || this.isChangeDialog; return this.dialogReadonly || this.isChangeDialog;
@@ -1342,10 +1364,13 @@ export default {
this.loadCargoTypeOptions(); this.loadCargoTypeOptions();
this.loadTransportTypeOptions(); this.loadTransportTypeOptions();
this.loadSettlementModeOptions(); this.loadSettlementModeOptions();
if (this.isProjectFormPage) { if (this.$route.path === '/business/project-apply/form') {
this.isFormPageInstance = true;
this.openProjectFormPage(); this.openProjectFormPage();
} }
}, },
// 标签切走(keep-alive 缓存)或销毁时,收起 append-to-body 的内层弹窗,
// 避免 Teleport 出去的 DOM 继续盖住后续页面。
deactivated() { deactivated() {
this.closeInnerDialogs(); this.closeInnerDialogs();
}, },
@@ -1353,6 +1378,12 @@ export default {
this.closeInnerDialogs(); this.closeInnerDialogs();
}, },
methods: { methods: {
closeInnerDialogs() {
this.changeRecordDetailVisible = false;
this.attachmentDocumentPreviewVisible = false;
this.attachmentImagePreviewVisible = false;
this.userBox = false;
},
buildTableOption() { buildTableOption() {
return { return {
...option, ...option,
@@ -1379,6 +1410,7 @@ export default {
const id = this.$route.query.id; const id = this.$route.query.id;
this.dialogType = type; this.dialogType = type;
this.dialogReadonly = type === 'view'; this.dialogReadonly = type === 'view';
this.projectBox = true;
if (['add', 'majorSupplement'].includes(type)) { if (['add', 'majorSupplement'].includes(type)) {
this.applyProjectDetail({ this.applyProjectDetail({
...emptyForm(), ...emptyForm(),
@@ -1399,15 +1431,11 @@ export default {
}); });
}, },
closeProjectForm() { closeProjectForm() {
if (this.isProjectFormPage) {
this.$router.push('/business/project-apply'); this.$router.push('/business/project-apply');
}, } else {
// 页面被 keep-alive 缓存/卸载时,关闭本页所有 append-to-body 的二级弹窗。 this.projectBox = false;
// 若不关闭,Teleport 出去的弹窗 DOM 在实例失活时不会被搬离 body,会残留盖在后续页面上。 }
closeInnerDialogs() {
this.changeRecordDetailVisible = false;
this.attachmentDocumentPreviewVisible = false;
this.attachmentImagePreviewVisible = false;
this.userBox = false;
}, },
statusValue(row) { statusValue(row) {
return row[this.config.statusProp || 'status']; return row[this.config.statusProp || 'status'];
@@ -1626,6 +1654,17 @@ export default {
else if (type === 'change') query.name = '项目变更'; else if (type === 'change') query.name = '项目变更';
this.$router.push({ path: '/business/project-apply/form', query }); this.$router.push({ path: '/business/project-apply/form', query });
}, },
resetProjectDialog() {
this.$refs.projectForm?.clearValidate();
this.form = emptyForm();
this.customerRows = [];
this.carrierRows = [];
this.attachmentRows = [];
this.selectedAttachmentRows = [];
this.changeRows = [];
this.selectedCustomerId = [];
this.selectedCarrierIds = [];
},
applyProjectDetail(row) { applyProjectDetail(row) {
const displayRow = { const displayRow = {
...row, ...row,
@@ -1748,6 +1787,23 @@ export default {
this.submitLoading = false; this.submitLoading = false;
}); });
}, },
handleCancelProject() {
if (this.isProjectFormPage) {
this.closeProjectForm();
return;
}
if (!['add', 'majorSupplement', 'change'].includes(this.dialogType)) {
this.closeProjectForm();
return;
}
this.$confirm('确认后直接关闭弹窗,列表无数据变更,是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
this.closeProjectForm();
});
},
saveChangeProject() { saveChangeProject() {
this.submitChangeForm(false); this.submitChangeForm(false);
}, },
+10 -1
View File
@@ -1,9 +1,11 @@
<template> <template>
<!-- waybill-manage-detail.vue只在当前路由属于本页时才透传 detailId
避免标签页缓存的实例被其它页面的 detailId 污染后误开详情 -->
<transport-plan-page <transport-plan-page
:api="api" :api="api"
:config="config" :config="config"
:crud-option="option" :crud-option="option"
:detail-id="$route.query.detailId" :detail-id="detailId"
:menu-width="220" :menu-width="220"
standalone-form-page standalone-form-page
/> />
@@ -14,6 +16,8 @@ import TransportPlanPage from './components/transport-plan-page.vue';
import * as api from '@/api/business/transport-plan'; import * as api from '@/api/business/transport-plan';
import { config, option } from '@/option/business/transport-plan'; import { config, option } from '@/option/business/transport-plan';
const listRoutePath = '/business/transport-plan';
export default { export default {
components: { TransportPlanPage }, components: { TransportPlanPage },
data() { data() {
@@ -23,5 +27,10 @@ export default {
option, option,
}; };
}, },
computed: {
detailId() {
return this.$route.path === listRoutePath ? this.$route.query.detailId : '';
},
},
}; };
</script> </script>
+14 -1
View File
@@ -1,9 +1,15 @@
<template> <template>
<!--
detailId 必须做路由守卫本组件对应 /business/waybill-manage/detail
$route 是全局响应式的标签页 keep-alive 会缓存本实例路由切到别的详情页后
实例仍会重渲染若无守卫就会把当前页面的 id灌进子组件导致它拿别的单据 id
去查运单 运单管理不存在并误生成一个又一个运单详情标签
-->
<waybill-manage-page <waybill-manage-page
:api="api" :api="api"
:config="config" :config="config"
:crud-option="option" :crud-option="option"
:detail-id="$route.query.id" :detail-id="detailId"
:menu-width="250" :menu-width="250"
standalone-detail-page standalone-detail-page
/> />
@@ -14,10 +20,17 @@ import WaybillManagePage from './components/waybill-manage-page.vue';
import * as api from '@/api/business/waybill-manage'; import * as api from '@/api/business/waybill-manage';
import { config, option } from '@/option/business/waybill-manage'; import { config, option } from '@/option/business/waybill-manage';
const detailRoutePath = '/business/waybill-manage/detail';
export default { export default {
components: { WaybillManagePage }, components: { WaybillManagePage },
data() { data() {
return { api, config, option }; return { api, config, option };
}, },
computed: {
detailId() {
return this.$route.path === detailRoutePath ? this.$route.query.id : '';
},
},
}; };
</script> </script>
+10 -1
View File
@@ -1,9 +1,11 @@
<template> <template>
<!-- waybill-manage-detail.vue保留旧链接 /business/waybill-manage?detailId=xxx 的能力
但只在当前路由确实属于本页时才透传避免被其它页面的 detailId 污染 -->
<waybill-manage-page <waybill-manage-page
:api="api" :api="api"
:config="config" :config="config"
:crud-option="option" :crud-option="option"
:detail-id="$route.query.detailId" :detail-id="detailId"
:menu-width="250" :menu-width="250"
standalone-form-page standalone-form-page
/> />
@@ -14,6 +16,8 @@ import WaybillManagePage from './components/waybill-manage-page.vue';
import * as api from '@/api/business/waybill-manage'; import * as api from '@/api/business/waybill-manage';
import { config, option } from '@/option/business/waybill-manage'; import { config, option } from '@/option/business/waybill-manage';
const listRoutePath = '/business/waybill-manage';
export default { export default {
components: { WaybillManagePage }, components: { WaybillManagePage },
data() { data() {
@@ -23,5 +27,10 @@ export default {
option, option,
}; };
}, },
computed: {
detailId() {
return this.$route.path === listRoutePath ? this.$route.query.detailId : '';
},
},
}; };
</script> </script>
+272 -30
View File
@@ -22,14 +22,6 @@
@tree-load="treeLoad" @tree-load="treeLoad"
> >
<template #menu-left> <template #menu-left>
<el-button
type="primary"
plain
:loading="iamSyncLoading"
v-if="userInfo.authority.includes('admin')"
@click="handleIamOrganizationSync"
>同步组织
</el-button>
<el-button <el-button
type="danger" type="danger"
icon="el-icon-delete" icon="el-icon-delete"
@@ -38,6 +30,14 @@
@click="handleDelete" @click="handleDelete"
>删除 >删除
</el-button> </el-button>
<el-button
type="primary"
plain
:loading="oaSyncLoading"
v-if="userInfo.authority.includes('admin')"
@click="handleOaOrgSync"
>自动同步组织
</el-button>
</template> </template>
<template #menu="scope"> <template #menu="scope">
<el-link <el-link
@@ -124,6 +124,46 @@
/> />
</div> </div>
</el-dialog> </el-dialog>
<el-dialog
v-model="oaSyncVisible"
width="480px"
append-to-body
:close-on-click-modal="false"
:close-on-press-escape="!oaSyncRunning"
:show-close="!oaSyncRunning"
class="oa-sync-dialog"
@close="closeOaSyncDialog"
>
<template #header>
<span class="dialog-title">{{ oaSyncDialogTitle }}</span>
</template>
<div class="oa-sync-body">
<el-progress
:percentage="oaSyncPercent"
:status="oaSyncProgressStatus"
:stroke-width="12"
/>
<div class="oa-sync-meta">
当前阶段{{ oaSyncStageLabel }} {{ oaSyncProgress.current || 0 }} / {{ oaSyncTotalPage }}
</div>
<div class="oa-sync-stats">
<div class="oa-sync-stat">
<span class="oa-sync-stat__label">同步成功</span>
<span class="oa-sync-stat__value oa-sync-stat__value--success">{{ oaSyncProgress.synced }}</span>
</div>
<div class="oa-sync-stat">
<span class="oa-sync-stat__label">跳过</span>
<span class="oa-sync-stat__value oa-sync-stat__value--skip">{{ oaSyncProgress.skipped }}</span>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button v-if="oaSyncRunning" @click="cancelOaSync">取消</el-button>
<el-button v-else type="primary" @click="closeOaSyncDialog">关闭</el-button>
</span>
</template>
</el-dialog>
</basic-container> </basic-container>
</template> </template>
@@ -135,7 +175,8 @@ import {
add, add,
getDept, getDept,
getDeptTree, getDeptTree,
syncIamOrganizations, syncOaCompany,
syncOaDepartment,
} from '@/api/system/dept'; } from '@/api/system/dept';
import { getLeaderList } from '@/api/system/user'; import { getLeaderList } from '@/api/system/user';
import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archive'; import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archive';
@@ -168,7 +209,20 @@ export default {
selectionList: [], selectionList: [],
query: {}, query: {},
loading: true, loading: true,
iamSyncLoading: false, oaSyncLoading: false,
oaSyncVisible: false,
oaSyncRunning: false,
oaSyncCancelled: false,
oaSyncStatus: 'running',
oaSyncStage: 'company',
oaSyncController: null,
oaSyncProgress: {
current: 0,
size: 20,
total: 0,
synced: 0,
skipped: 0,
},
parentId: 0, parentId: 0,
page: { page: {
pageSize: 10, pageSize: 10,
@@ -453,8 +507,153 @@ export default {
}); });
return ids.join(','); return ids.join(',');
}, },
oaSyncDialogTitle() {
if (this.oaSyncStatus === 'done') {
return '同步完成';
}
if (this.oaSyncStatus === 'cancelled') {
return '已取消同步';
}
if (this.oaSyncStatus === 'error') {
return '同步失败';
}
return '自动同步组织';
},
oaSyncStageLabel() {
return this.oaSyncStage === 'department' ? '同步部门' : '同步公司';
},
oaSyncTotalPage() {
const total = Number(this.oaSyncProgress.total) || 0;
const size = Number(this.oaSyncProgress.size) || 20;
if (total <= 0) {
return this.oaSyncProgress.current || 0;
}
return Math.max(1, Math.ceil(total / size));
},
oaSyncPercent() {
if (this.oaSyncStatus === 'done') {
return 100;
}
const totalPage = this.oaSyncTotalPage;
const current = Number(this.oaSyncProgress.current) || 0;
const stageBase = this.oaSyncStage === 'department' ? 50 : 0;
if (totalPage <= 0) {
return stageBase;
}
const stagePercent = Math.min(50, Math.round((current / totalPage) * 50));
return Math.min(99, stageBase + stagePercent);
},
oaSyncProgressStatus() {
if (this.oaSyncStatus === 'done') {
return 'success';
}
if (this.oaSyncStatus === 'error') {
return 'exception';
}
return undefined;
},
}, },
methods: { methods: {
handleOaOrgSync() {
this.$confirm('确定从OA先同步公司、再同步部门?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
this.startOaOrgSync();
});
},
startOaOrgSync() {
this.oaSyncLoading = true;
this.oaSyncVisible = true;
this.oaSyncRunning = true;
this.oaSyncCancelled = false;
this.oaSyncStatus = 'running';
this.oaSyncStage = 'company';
this.oaSyncController = new AbortController();
this.oaSyncProgress = {
current: 0,
size: 20,
total: 0,
synced: 0,
skipped: 0,
};
this.runOaOrgSyncPages();
},
isOaSyncCanceledError(error) {
return (
this.oaSyncCancelled ||
error?.code === 'ERR_CANCELED' ||
error?.name === 'CanceledError' ||
error?.name === 'AbortError'
);
},
async runOaOrgSyncStage(syncApi) {
const size = 20;
let current = 1;
while (!this.oaSyncCancelled) {
const res = await syncApi(current, size, this.oaSyncController?.signal);
const page = res?.data?.data || {};
this.oaSyncProgress.current = page.current || current;
this.oaSyncProgress.size = page.size || size;
this.oaSyncProgress.total = page.total || 0;
this.oaSyncProgress.synced += page.syncedCount || 0;
this.oaSyncProgress.skipped += page.skippedCount || 0;
if (page.finished) {
break;
}
current += 1;
}
},
async runOaOrgSyncPages() {
try {
this.oaSyncStage = 'company';
await this.runOaOrgSyncStage(syncOaCompany);
if (this.oaSyncCancelled) {
this.oaSyncStatus = 'cancelled';
return;
}
this.oaSyncStage = 'department';
this.oaSyncProgress.current = 0;
this.oaSyncProgress.total = 0;
await this.runOaOrgSyncStage(syncOaDepartment);
if (this.oaSyncCancelled) {
this.oaSyncStatus = 'cancelled';
return;
}
this.oaSyncStatus = 'done';
} catch (error) {
if (this.isOaSyncCanceledError(error)) {
this.oaSyncStatus = 'cancelled';
} else {
this.oaSyncStatus = 'error';
this.$message.error(error?.message || 'OA组织同步失败');
}
} finally {
this.oaSyncRunning = false;
this.oaSyncLoading = false;
if (this.oaSyncStatus === 'done' || this.oaSyncStatus === 'cancelled') {
this.parentId = 0;
this.data = [];
this.$refs.crud?.refreshTable?.();
this.onLoad(this.page, this.query);
}
}
},
cancelOaSync() {
if (!this.oaSyncRunning) {
return;
}
this.oaSyncCancelled = true;
this.oaSyncController?.abort();
},
closeOaSyncDialog() {
if (this.oaSyncRunning) {
this.cancelOaSync();
return;
}
this.oaSyncVisible = false;
},
initData(tenantId) { initData(tenantId) {
getDeptTree(tenantId).then(res => { getDeptTree(tenantId).then(res => {
const column = this.findColumn(this.option.column, 'parentId'); const column = this.findColumn(this.option.column, 'parentId');
@@ -708,26 +907,6 @@ export default {
done(row); done(row);
}); });
}, },
handleIamOrganizationSync() {
this.$confirm('确定从IAM同步组织信息?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
this.iamSyncLoading = true;
return syncIamOrganizations();
})
.then(res => {
const count = res?.data?.data ?? 0;
this.$message.success(`IAM组织同步完成,共处理${count}`);
this.parentId = 0;
this.onLoad(this.page, this.query);
})
.finally(() => {
this.iamSyncLoading = false;
});
},
handleDelete() { handleDelete() {
if (this.selectionList.length === 0) { if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据'); this.$message.warning('请选择至少一条数据');
@@ -895,3 +1074,66 @@ export default {
color: var(--el-color-danger-light-3); color: var(--el-color-danger-light-3);
} }
</style> </style>
<style>
.oa-sync-dialog .dialog-title {
display: inline-flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.oa-sync-dialog .dialog-title::before {
width: 4px;
height: 18px;
margin-right: 8px;
background: #409eff;
content: '';
}
.oa-sync-body {
padding: 8px 4px 0;
}
.oa-sync-meta {
margin-top: 12px;
color: #606266;
font-size: 13px;
}
.oa-sync-stats {
display: flex;
gap: 16px;
margin-top: 16px;
}
.oa-sync-stat {
flex: 1;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #eff1f7;
border-radius: 4px;
}
.oa-sync-stat__label {
display: block;
color: #909399;
font-size: 12px;
}
.oa-sync-stat__value {
display: block;
margin-top: 6px;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
.oa-sync-stat__value--success {
color: #409eff;
}
.oa-sync-stat__value--skip {
color: #909399;
}
</style>
+218 -15
View File
@@ -340,6 +340,45 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog
v-model="oaSyncVisible"
width="480px"
append-to-body
:close-on-click-modal="false"
:close-on-press-escape="!oaSyncRunning"
:show-close="!oaSyncRunning"
class="oa-sync-dialog"
@close="closeOaSyncDialog"
>
<template #header>
<span class="dialog-title">{{ oaSyncDialogTitle }}</span>
</template>
<div class="oa-sync-body">
<el-progress
:percentage="oaSyncPercent"
:status="oaSyncProgressStatus"
:stroke-width="12"
/>
<div class="oa-sync-meta">当前进度 {{ oaSyncProgress.current || 0 }} / {{ oaSyncTotalPage }} </div>
<div class="oa-sync-stats">
<div class="oa-sync-stat">
<span class="oa-sync-stat__label">同步成功</span>
<span class="oa-sync-stat__value oa-sync-stat__value--success">{{ oaSyncProgress.synced }}</span>
</div>
<div class="oa-sync-stat">
<span class="oa-sync-stat__label">跳过</span>
<span class="oa-sync-stat__value oa-sync-stat__value--skip">{{ oaSyncProgress.skipped }}</span>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button v-if="oaSyncRunning" @click="cancelOaSync">取消</el-button>
<el-button v-else type="primary" @click="closeOaSyncDialog">关闭</el-button>
</span>
</template>
</el-dialog>
<!-- 认证日志组件 --> <!-- 认证日志组件 -->
<auth-log ref="authLog" /> <auth-log ref="authLog" />
<!-- 认证锁定配置组件 --> <!-- 认证锁定配置组件 -->
@@ -403,6 +442,18 @@ export default {
query: {}, query: {},
loading: true, loading: true,
iamSyncLoading: false, iamSyncLoading: false,
oaSyncVisible: false,
oaSyncRunning: false,
oaSyncCancelled: false,
oaSyncStatus: 'running',
oaSyncController: null,
oaSyncProgress: {
current: 0,
size: 50,
total: 0,
synced: 0,
skipped: 0,
},
page: { page: {
pageSize: 10, pageSize: 10,
currentPage: 1, currentPage: 1,
@@ -453,6 +504,46 @@ export default {
}, },
computed: { computed: {
...mapGetters(['userInfo', 'permission']), ...mapGetters(['userInfo', 'permission']),
oaSyncDialogTitle() {
if (this.oaSyncStatus === 'done') {
return '同步完成';
}
if (this.oaSyncStatus === 'cancelled') {
return '已取消同步';
}
if (this.oaSyncStatus === 'error') {
return '同步失败';
}
return '同步人员';
},
oaSyncTotalPage() {
const total = Number(this.oaSyncProgress.total) || 0;
const size = Number(this.oaSyncProgress.size) || 50;
if (total <= 0) {
return this.oaSyncProgress.current || 0;
}
return Math.max(1, Math.ceil(total / size));
},
oaSyncPercent() {
if (this.oaSyncStatus === 'done') {
return 100;
}
const totalPage = this.oaSyncTotalPage;
const current = Number(this.oaSyncProgress.current) || 0;
if (totalPage <= 0) {
return 0;
}
return Math.min(100, Math.round((current / totalPage) * 100));
},
oaSyncProgressStatus() {
if (this.oaSyncStatus === 'done') {
return 'success';
}
if (this.oaSyncStatus === 'error') {
return 'exception';
}
return undefined;
},
// 所属组织级联配置:checkStrictly 为 false 时只能选择最后一级(叶子节点) // 所属组织级联配置:checkStrictly 为 false 时只能选择最后一级(叶子节点)
deptCascaderProps() { deptCascaderProps() {
return { return {
@@ -564,24 +655,88 @@ export default {
} }
}, },
handleIamSync() { handleIamSync() {
this.$confirm('确定从IAM同步人员信息?', '提示', { this.$confirm('确定从OA同步人员及组织信息?', '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning',
}) }).then(() => {
.then(() => { this.startOaSync();
this.iamSyncLoading = true;
return syncIamAccounts();
})
.then(res => {
const count = res?.data?.data ?? 0;
this.$message.success(`IAM人员同步完成,共处理${count}`);
this.onLoad(this.page, this.query);
})
.finally(() => {
this.iamSyncLoading = false;
}); });
}, },
startOaSync() {
this.iamSyncLoading = true;
this.oaSyncVisible = true;
this.oaSyncRunning = true;
this.oaSyncCancelled = false;
this.oaSyncStatus = 'running';
this.oaSyncController = new AbortController();
this.oaSyncProgress = {
current: 0,
size: 50,
total: 0,
synced: 0,
skipped: 0,
};
this.runOaSyncPages();
},
isOaSyncCanceledError(error) {
return (
this.oaSyncCancelled ||
error?.code === 'ERR_CANCELED' ||
error?.name === 'CanceledError' ||
error?.name === 'AbortError'
);
},
async runOaSyncPages() {
const size = 50;
let current = 1;
try {
while (!this.oaSyncCancelled) {
const res = await syncIamAccounts(current, size, this.oaSyncController?.signal);
const page = res?.data?.data || {};
this.oaSyncProgress.current = page.current || current;
this.oaSyncProgress.size = page.size || size;
this.oaSyncProgress.total = page.total || 0;
this.oaSyncProgress.synced += page.syncedCount || 0;
this.oaSyncProgress.skipped += page.skippedCount || 0;
if (page.finished) {
this.oaSyncStatus = 'done';
break;
}
current += 1;
}
if (this.oaSyncCancelled && this.oaSyncStatus === 'running') {
this.oaSyncStatus = 'cancelled';
}
} catch (error) {
if (this.isOaSyncCanceledError(error)) {
this.oaSyncStatus = 'cancelled';
} else {
this.oaSyncStatus = 'error';
this.$message.error(error?.message || 'OA人员同步失败');
}
} finally {
this.oaSyncRunning = false;
this.iamSyncLoading = false;
if (this.oaSyncStatus === 'done' || this.oaSyncStatus === 'cancelled') {
this.onLoad(this.page, this.query);
}
}
},
cancelOaSync() {
if (!this.oaSyncRunning) {
return;
}
this.oaSyncCancelled = true;
this.oaSyncController?.abort();
},
closeOaSyncDialog() {
if (this.oaSyncRunning) {
this.cancelOaSync();
return;
}
this.oaSyncVisible = false;
},
handleSetLeader(row) { handleSetLeader(row) {
const tip = row.isLeader === 1 ? '确定取消用户的主管职务?' : '确定设置用户为主管职务?'; const tip = row.isLeader === 1 ? '确定取消用户的主管职务?' : '确定设置用户为主管职务?';
const message = row.isLeader === 1 ? '取消主管成功!' : '设置主管成功!'; const message = row.isLeader === 1 ? '取消主管成功!' : '设置主管成功!';
@@ -1150,14 +1305,16 @@ export default {
margin-top: 8px; margin-top: 8px;
} }
.user-edit-dialog .dialog-title { .user-edit-dialog .dialog-title,
.oa-sync-dialog .dialog-title {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
} }
.user-edit-dialog .dialog-title::before { .user-edit-dialog .dialog-title::before,
.oa-sync-dialog .dialog-title::before {
width: 4px; width: 4px;
height: 18px; height: 18px;
margin-right: 8px; margin-right: 8px;
@@ -1165,6 +1322,52 @@ export default {
content: ''; content: '';
} }
.oa-sync-body {
padding: 8px 4px 0;
}
.oa-sync-meta {
margin-top: 12px;
color: #606266;
font-size: 13px;
}
.oa-sync-stats {
display: flex;
gap: 16px;
margin-top: 16px;
}
.oa-sync-stat {
flex: 1;
padding: 12px 16px;
background: #fafafa;
border: 1px solid #eff1f7;
border-radius: 4px;
}
.oa-sync-stat__label {
display: block;
color: #909399;
font-size: 12px;
}
.oa-sync-stat__value {
display: block;
margin-top: 6px;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
.oa-sync-stat__value--success {
color: #409eff;
}
.oa-sync-stat__value--skip {
color: #909399;
}
.user-edit-dialog .el-form-item { .user-edit-dialog .el-form-item {
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -0,0 +1,168 @@
<template>
<div ref="page" class="customer-archive-public-view">
<customer-archive ref="archive" />
</div>
</template>
<script>
import CustomerArchive from './customer-archive.vue';
import { postPublicProcessMessage } from '@/api/vehicle/customer-archive';
export default {
name: 'CustomerArchivePublicView',
components: {
CustomerArchive,
},
created() {
this.handleIframeHeight = () => this.sendIframeHeight();
this.handleProcessMessage = event => this.onProcessMessage(event);
document.addEventListener('DOMContentLoaded', this.handleIframeHeight, false);
window.addEventListener('message', this.handleProcessMessage);
},
mounted() {
document.documentElement.classList.add('mk-iframe-page');
document.body.classList.add('mk-iframe-page');
const app = document.getElementById('app');
if (app) app.classList.add('mk-iframe-page');
this.handleIframeHeight();
window.addEventListener('load', this.handleIframeHeight);
this.mkHeightTimers = [300, 800, 1600].map(delay =>
setTimeout(this.handleIframeHeight, delay)
);
if (typeof ResizeObserver === 'undefined') return;
this.mkHeightObserver = new ResizeObserver(() => this.handleIframeHeight());
this.$nextTick(() => {
if (this.$refs.page) this.mkHeightObserver.observe(this.$refs.page);
this.mkHeightObserver.observe(document.body);
});
},
beforeUnmount() {
document.removeEventListener('DOMContentLoaded', this.handleIframeHeight, false);
window.removeEventListener('load', this.handleIframeHeight);
window.removeEventListener('message', this.handleProcessMessage);
(this.mkHeightTimers || []).forEach(timer => clearTimeout(timer));
if (this.mkHeightObserver) {
this.mkHeightObserver.disconnect();
this.mkHeightObserver = null;
}
document.documentElement.classList.remove('mk-iframe-page');
document.body.classList.remove('mk-iframe-page');
const app = document.getElementById('app');
if (app) app.classList.remove('mk-iframe-page');
},
methods: {
sendIframeHeight() {
this.$nextTick(() => {
var tbody = document.body;
var height = tbody.clientHeight;
// 如需动态改变表单高度,可以直接发送此postMessage
window.parent.postMessage({ height: height }, '*');
});
},
onProcessMessage(event) {
const data = event && event.data;
if (!data || typeof data !== 'object') return;
if (data.height && !data.status && !data.type) return;
if (data.type === 'formValues' || data.type === 'afterSubmit') return;
console.log('客商公开页收到流程消息:', data);
const formValues = data.formValues;
if (data.status === 'submit') {
this.submitData(formValues);
} else if (data.status === 'save') {
this.saveData(formValues);
}
if (data.type === 'getFormValues') {
window.parent.postMessage({ type: 'formValues', formData: this.buildFormData() }, '*');
}
},
submitData(lbpmFormValues) {
const formData = this.buildFormData();
this.postFormData('submit', lbpmFormValues, formData);
if (!lbpmFormValues) return;
const parameters = Object.assign({}, lbpmFormValues, {
loginName: this.getLoginName(lbpmFormValues),
formInstanceId: this.getFormId(),
subject: formData.subject,
});
window.parent.postMessage(
{
type: 'afterSubmit',
success: true,
parameters,
},
'*'
);
},
saveData(lbpmFormValues) {
this.postFormData('save', lbpmFormValues, this.buildFormData());
},
postFormData(status, formValues, formData) {
const payload = {
status,
formValues: formValues || {},
formData,
};
console.log('客商公开页提交流程数据:', payload);
postPublicProcessMessage(payload).catch(error => {
console.error('客商公开页提交流程数据失败:', error);
});
},
buildFormData() {
const archive = this.getArchiveForm();
return {
...archive,
subject: archive.fullName || archive.shortName || '',
formInstanceId: this.getFormId(),
};
},
getArchiveForm() {
const archive = this.$refs.archive && this.$refs.archive.archiveForm;
if (!archive) return {};
try {
return JSON.parse(JSON.stringify(archive));
} catch (error) {
return { ...archive };
}
},
getFormId() {
return this.$route.query.id || this.getArchiveForm().id || '';
},
getLoginName(formValues = {}) {
return (
formValues.loginName ||
this.$route.query.loginName ||
this.$route.query.submitIdentity ||
''
);
},
},
};
</script>
<style lang="scss">
html.mk-iframe-page,
html.mk-iframe-page body,
html.mk-iframe-page #app,
html.mk-iframe-page #app.mk-iframe-page {
height: auto !important;
min-height: 100%;
overflow: visible;
}
</style>
<style lang="scss" scoped>
.customer-archive-public-view {
min-height: 100%;
padding: 12px 0 24px;
box-sizing: border-box;
background: #f0f2f5;
:deep(.basic-container) {
padding: 0 12px;
}
:deep(.archive-page-form .archive-form__footer) {
left: 0;
}
}
</style>
+119 -7
View File
@@ -1,5 +1,5 @@
<template> <template>
<basic-container class="customer-archive-page"> <basic-container class="customer-archive-page" :class="{ 'is-public-view': isPublicViewPage }">
<avue-crud <avue-crud
v-if="!isArchivePage" v-if="!isArchivePage"
:option="option" :option="option"
@@ -913,7 +913,7 @@
</template> </template>
</el-dialog> </el-dialog>
<div class="archive-form__footer"> <div class="archive-form__footer" v-if="!isPublicViewPage">
<!-- 次要:独立页返回 / 只读关闭 / 弹窗取消 --> <!-- 次要:独立页返回 / 只读关闭 / 弹窗取消 -->
<el-button @click="closeArchive">{{ readonly ? '关闭' : '取消' }}</el-button> <el-button @click="closeArchive">{{ readonly ? '关闭' : '取消' }}</el-button>
<el-button type="primary" plain v-if="!readonly" @click="saveArchive">保存</el-button> <el-button type="primary" plain v-if="!readonly" @click="saveArchive">保存</el-button>
@@ -1482,7 +1482,9 @@ import { ElCascader } from 'element-plus';
import { import {
getList, getList,
getDetail, getDetail,
getPublicDetail,
getChangeRecordList, getChangeRecordList,
getPublicChangeRecordList,
submit, submit,
submitApproval, submitApproval,
withdrawApproval, withdrawApproval,
@@ -1498,6 +1500,7 @@ import {
getDetail as getCreditScoreQuantificationDetail, getDetail as getCreditScoreQuantificationDetail,
} from '@/api/vehicle/credit-score-quantification'; } from '@/api/vehicle/credit-score-quantification';
import { getDictionary } from '@/api/system/dictbiz'; import { getDictionary } from '@/api/system/dictbiz';
import { processSubmit } from '@/api/system/business-process';
import { getDeptTree } from '@/api/system/dept'; import { getDeptTree } from '@/api/system/dept';
import { getLazyTree } from '@/api/base/region'; import { getLazyTree } from '@/api/base/region';
import { exportBlob } from '@/api/common'; import { exportBlob } from '@/api/common';
@@ -1989,6 +1992,17 @@ export default {
}; };
}, },
created() { created() {
if (this.isPublicViewPage) {
this.readonly = true;
const id = this.$route.query.id;
if (!id) {
this.archiveBox = true;
this.$message.error('缺少客商ID');
return;
}
this.openArchive({ id }, true);
return;
}
this.initDeptTree(); this.initDeptTree();
this.initRegionOptions(); this.initRegionOptions();
this.initBusinessDictionaries(); this.initBusinessDictionaries();
@@ -2010,7 +2024,12 @@ export default {
}; };
}, },
isArchivePage() { isArchivePage() {
return this.$route.path === '/vehicle/customer-archive/form'; return (
this.$route.path === '/vehicle/customer-archive/form' || this.isPublicViewPage
);
},
isPublicViewPage() {
return this.$route.path === '/vehicle/customer-archive/public-view';
}, },
archiveContainer() { archiveContainer() {
return 'div'; return 'div';
@@ -2190,6 +2209,45 @@ export default {
hasPermission(code) { hasPermission(code) {
return this.isAdmin || this.validData(this.permission[code], false); return this.isAdmin || this.validData(this.permission[code], false);
}, },
mergeSelectOptions(target, values) {
const list = Array.isArray(values) ? values : values ? [values] : [];
list.forEach(value => {
if (value === undefined || value === null || value === '') return;
if (!this[target].some(item => String(item.value) === String(value))) {
this[target].push({ label: String(value), value });
}
});
},
ensurePublicViewOptions(archive = {}) {
this.mergeSelectOptions('customerNatureOptions', archive.customerNature);
this.mergeSelectOptions('customerTypeOptions', archive.customerType);
this.mergeSelectOptions('businessScopeOptions', archive.businessScope);
this.mergeSelectOptions(
'qualificationTypeOptions',
(this.qualificationFiles || []).map(item => item.type)
);
const deptId = Array.isArray(archive.deptId) ? archive.deptId[0] : archive.deptId;
if (deptId && archive.deptName) {
this.deptTree = [
{
label: archive.deptName,
value: String(deptId),
children: [],
},
];
this.deptOptions = this.flattenDept(this.deptTree);
}
(archive.scores || []).forEach(score => {
if (!score.quantificationId) return;
const id = String(score.quantificationId);
if (!this.scoreQuantificationOptions.some(item => String(item.id) === id)) {
this.scoreQuantificationOptions.push({
id,
name: score.quantificationName || score.name || id,
});
}
});
},
// 检测输入框文本是否溢出,仅溢出时才允许 tooltip 显示完整地址 // 检测输入框文本是否溢出,仅溢出时才允许 tooltip 显示完整地址
checkOverflow(refName, flag) { checkOverflow(refName, flag) {
const inst = this.$refs[refName]; const inst = this.$refs[refName];
@@ -2278,7 +2336,8 @@ export default {
loadChangeRecords() { loadChangeRecords() {
if (!this.archiveForm.id) return; if (!this.archiveForm.id) return;
const page = this.changeRecordPage; const page = this.changeRecordPage;
getChangeRecordList(this.archiveForm.id, page.currentPage, page.pageSize).then(res => { const requestList = this.isPublicViewPage ? getPublicChangeRecordList : getChangeRecordList;
requestList(this.archiveForm.id, page.currentPage, page.pageSize).then(res => {
const data = res.data.data || {}; const data = res.data.data || {};
page.records = this.replaceNegativeOneWithBlank(data.records || []); page.records = this.replaceNegativeOneWithBlank(data.records || []);
page.total = Number(data.total || 0); page.total = Number(data.total || 0);
@@ -3890,6 +3949,9 @@ export default {
}); });
}, },
closeArchive() { closeArchive() {
if (this.isPublicViewPage) return;
this.archiveBox = false;
this.$router.$avueRouter?.closeTag?.();
this.$router.push('/vehicle/customer-archive'); this.$router.push('/vehicle/customer-archive');
}, },
openArchive(row, readonly = false) { openArchive(row, readonly = false) {
@@ -3898,7 +3960,9 @@ export default {
this.resetDetailPagination(); this.resetDetailPagination();
this.resetChangeRecordPage(); this.resetChangeRecordPage();
if (row && row.id) { if (row && row.id) {
getDetail(row.id).then(res => { const requestDetail = this.isPublicViewPage ? getPublicDetail : getDetail;
requestDetail(row.id)
.then(res => {
const archive = this.normalizeDetail(res.data.data); const archive = this.normalizeDetail(res.data.data);
this.archiveForm = readonly ? this.replaceNegativeOneWithBlank(archive) : archive; this.archiveForm = readonly ? this.replaceNegativeOneWithBlank(archive) : archive;
this.originalAccessType = this.archiveForm.accessType || ''; this.originalAccessType = this.archiveForm.accessType || '';
@@ -3912,8 +3976,13 @@ export default {
this.qualificationUploadFiles = []; this.qualificationUploadFiles = [];
this.ocrQualificationUploadFiles = []; this.ocrQualificationUploadFiles = [];
this.selectedQualificationFiles = []; this.selectedQualificationFiles = [];
if (this.isPublicViewPage) this.ensurePublicViewOptions(this.archiveForm);
this.archiveBox = true; this.archiveBox = true;
this.loadChangeRecords(); this.loadChangeRecords();
})
.catch(() => {
this.archiveBox = true;
if (this.isPublicViewPage) this.$message.error('客商信息加载失败');
}); });
return; return;
} }
@@ -4078,7 +4147,12 @@ export default {
this.$message({ type: 'success', message: '保存成功,请在列表提交审批' }); this.$message({ type: 'success', message: '保存成功,请在列表提交审批' });
return; return;
} }
submitApproval(id).then(() => { const fullName =
(data && typeof data === 'object' ? data.fullName : '') ||
archive.fullName ||
this.archiveForm.fullName ||
'';
this.submitCustomerApproval(id, fullName).then(() => {
this.closeArchive(); this.closeArchive();
this.onLoad(this.page); this.onLoad(this.page);
this.$message({ type: 'success', message: '提交成功!' }); this.$message({ type: 'success', message: '提交成功!' });
@@ -4086,6 +4160,38 @@ export default {
}); });
}); });
}, },
/**
* 列表 / 新增 / 编辑提交共用:提交 MK 审核流并更新客商审批状态
*/
submitCustomerApproval(id, fullName = '') {
return this.submitMkApprovalFlow(id, fullName).then(() => submitApproval(id));
},
/**
* 提交 MK 审核流:templateCode 取业务字典 mk_template 中「提交审核流」的键值
* 提交人手机号由后端从用户表读取真实值(前端接口会脱敏)
*/
async submitMkApprovalFlow(formInstanceId, subjectName = '') {
const templateCode = await this.resolveMkTemplateCode('提交审核流');
const subject = subjectName
? `客商准入审批:${subjectName}`
: `客商准入审批:${formInstanceId}`;
return processSubmit({
templateCode,
formInstanceId: String(formInstanceId),
subject,
});
},
async resolveMkTemplateCode(dictName = '提交审核流') {
const res = await getDictionary({ code: 'mk_template' });
const list = res?.data?.data || [];
const matched = list.find(item => String(item.dictValue || '').trim() === dictName);
const templateCode = matched?.dictKey;
if (!templateCode) {
this.$message.warning(`未配置业务字典 mk_template「${dictName}」,无法提交审核流`);
return Promise.reject(new Error(`未配置业务字典 mk_template「${dictName}」`));
}
return String(templateCode);
},
addScore() { addScore() {
this.scoreRecordIndex = -1; this.scoreRecordIndex = -1;
this.currentScore = this.normalizeScore({}); this.currentScore = this.normalizeScore({});
@@ -4676,7 +4782,7 @@ export default {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning', type: 'warning',
}) })
.then(() => submitApproval(row.id)) .then(() => this.submitCustomerApproval(row.id, archive.fullName || row.fullName))
.then(() => { .then(() => {
this.onLoad(this.page); this.onLoad(this.page);
this.$message({ type: 'success', message: '提交成功!' }); this.$message({ type: 'success', message: '提交成功!' });
@@ -5348,6 +5454,12 @@ export default {
font-size: 14px; font-size: 14px;
} }
.customer-archive-page.is-public-view {
:deep(.archive-page-form .archive-form__footer) {
left: 0;
}
}
.archive-page-form { .archive-page-form {
min-height: 100%; min-height: 100%;
margin-bottom: 60px; margin-bottom: 60px;
+13 -13
View File
@@ -47,26 +47,26 @@ export default ({ mode, command }) => {
__VUE_I18N_LEGACY_API__: true, __VUE_I18N_LEGACY_API__: true,
__INTLIFY_PROD_DEVTOOLS__: false, __INTLIFY_PROD_DEVTOOLS__: false,
}, },
// server: {
// port: 2888,
// proxy: {
// '/api': {
// target: 'http://localhost',
// //target: 'https://saber3.bladex.cn/api',
// changeOrigin: true,
// rewrite: path => path.replace(/^\/api/, ''),
// },
// },
// },
server: { server: {
port: 2889, port: 2888,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://172.16.203.228:8000', target: 'http://localhost',
//target: 'https://saber3.bladex.cn/api',
changeOrigin: true, changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
}, },
}, },
}, },
// server: {
// port: 2889,
// proxy: {
// '/api': {
// target: 'http://172.16.203.228:8000',
// changeOrigin: true,
// },
// },
// },
resolve: { resolve: {
alias: { alias: {
'~': resolve(__dirname, './'), '~': resolve(__dirname, './'),