diff --git a/.env.development b/.env.development index 03c69cc..3043a7e 100644 --- a/.env.development +++ b/.env.development @@ -3,8 +3,9 @@ VITE_APP_ENV = 'development' #接口地址 -# 开发环境建议填 [/api]:由下方 vite.config.mjs 的 proxy 同源转发到后端(172.16.203.228:8000),避免跨域(CORS)被浏览器拦截 -# 如需直连线上绝对地址(如 http://172.16.203.228:8000/api),必须让后端 CORS 把 Allow-Origin 改为具体前端域名,不能用 `*`(配合 credentials 会被浏览器拒绝) +# 开发环境建议填 [/api]:由 vite.config.mjs 的 proxy 同源转发到本地网关 http://localhost(80) +# 本地需先启动:Nacos + blade-gateway(80) + blade-auth + blade-system 等;8080 端口一般是 Nacos 控制台不是网关 +# 如需临时联调远程,把 vite proxy target 改为 http://172.16.203.228:8000 并去掉 rewrite VITE_APP_API=/api #调试参数 diff --git a/.workbuddy/memory/2026-09-10.md b/.workbuddy/memory/2026-09-10.md new file mode 100644 index 0000000..db2cbed --- /dev/null +++ b/.workbuddy/memory/2026-09-10.md @@ -0,0 +1,6 @@ +# 2026-09-10 + +- 移除 project-apply 的 fund-risk-stats 请求(后端未实现端点,每次列表加载都弹 No endpoint 报错):删除 onLoad 内 refreshFundRiskStats 调用与该方法、data 中 fundRiskStats;头部风险标签去掉「(数量)」只留「高风险/中风险」;api/business/project-apply.js 保留 getFundRiskStats 定义并注释「后端就绪后恢复」。 +EOF2 +} +cat >> /Users/gxwebsoft/VUE/tms-erp-web-ws/.workbuddy/memory/2026-09-10.md <<'EOF' diff --git a/.workbuddy/memory/2026-09-11.md b/.workbuddy/memory/2026-09-11.md new file mode 100644 index 0000000..e1c56db --- /dev/null +++ b/.workbuddy/memory/2026-09-11.md @@ -0,0 +1,28 @@ +# 2026-09-11 + +## 上传证件区域固定宽度导致跨机器错位(transportCapacity) +- 现象:driver.vue 新增弹窗的身份证/大头照上传框在本机对齐,他人电脑不对齐。 +- 根因:`--large` 上传框写死 `width:240px`(ship 写 248px),而弹窗 `width:90%` + `el-col :span=6` 列宽是弹性的、上方输入框 `width:100%`;列宽随分辨率 / Windows 系统缩放 / 浏览器缩放变化,240px 只在特定宽度下恰好等于列宽。叠加 `labelWidth:auto` 靠 JS 实测字体(苹方 vs 微软雅黑)宽度,label 列宽也不同 → 起始位置漂移。 +- 修复:三个文件改为 `width:100%; max-width:240px; flex:none`(ship 保留 248px 上限),窄屏跟随列宽、宽屏保持比例上限,左边缘恒与输入框对齐。 + - `src/views/transportCapacity/driver.vue`(.driver-uploader--large) + - `src/views/transportCapacity/vehicle.vue`(.vehicle-uploader--large) + - `src/views/transportCapacity/ship.vue`(.ship-uploader--large) +- 用 @vue/compiler-sfc(脚本放项目根目录)验证三个 SFC 编译通过。 +- 教训:本项目所有「固定 px 宽度的上传/控件」都要配 `max-width` + `width:100%`,否则高分屏/低分屏下必然错位。 + +## 合同详情由弹窗改为独立页面(contract-manage) +- 路由:新增 `/business/contract-manage/detail`(`src/router/views/index.js`,name『合同详情』,meta `keepAlive:false` + `activeMenu:'/business/contract-manage'`),组件仍复用 `contract-manage.vue`(与 form 页同一套路)。 +- 页面内三分支:`v-if="!isFormPage && !isDetailPage"`(列表)/ `v-else-if="isFormPage"`(表单)/ `v-else`(详情)。**v-else 必须紧邻 form 分支的 ``**,中间不能插其它组件,否则 Vue 编译报「v-else 无相邻 v-if」——弹窗类组件(billing-plan-editor / 附件预览 / 变更记录详情 / 流程)放在三个分支之后。 +- 关键改动:`openDetail` 改 `router.push({ path:'/business/contract-manage/detail', query:{ id, name } })`;新增 `initDetailPage()`(按 query.id 拉详情,`resetDetailState()` 先清空)、`closeDetailPage()`(closeTag + 回列表)、`isDetailPage` / `detailPageTitle` computed;`created` 与 `$route.fullPath` watch 都要加 `else if (this.isDetailPage)`。 +- 样式:`.contract-manage-detail--page { max-height:none; overflow:visible }`,去掉弹窗的 74vh 限制;底栏复用 `.contract-manage-page__footer`(fixed 浮动),只读详情页按钮保留「关闭」。 +- 校验方式:dev server 已运行时 `curl "http://localhost:2889/src/views/business/contract-manage.vue"` 返回 200 即 Vite 编译通过(比整包 build 快)。 + +## 只给「查看弹窗」加宽 label(temporary-credit-limit) +- 场景:详情页长 label(「剩余项目资金使用额度(万元)」)换行超 2 行撑坏详情表格,但又不能影响新增/编辑页。 +- **关键发现:Avue 的 `avue-form` 在 detail 模式下根节点会挂 `.avue--detail` 类**(源码 `node_modules/@smallwei/avue/lib/packages/element-plus/form/index3.js` 中 `class: normalizeClass([b(), { 'avue--detail': isDetail }])`)。所以「只针对详情态」的选择器就是: + ```scss + .temporary-credit-limit-dialog .avue--detail .el-form-item__label { width:210px !important; flex:0 0 210px !important; } + ``` + 新增/编辑弹窗无 `.avue--detail`,天然不受影响,不需要在 option 里做 mode 判断。 +- 校验样式块编译:dev server 请求 `?vue&type=style&index=1&lang.scss`(**lang 必须写 scss,写 css 会 500**),grep 规则确认产物。 +- 结论备忘:长 label 优先「加宽 + 换行 ≤2 行」,不要用单行省略 + tooltip —— 详情是纯阅读场景,hover 才能看全字段名的可发现性差。 diff --git a/.workbuddy/memory/2026-09-17.md b/.workbuddy/memory/2026-09-17.md new file mode 100644 index 0000000..1fb0550 --- /dev/null +++ b/.workbuddy/memory/2026-09-17.md @@ -0,0 +1,63 @@ +# 2026-09-17 工作日志 + +## 配载管理(loading-manage):弹窗全部改为独立表单页/详情页 + +- 需求:`/business/loading-manage` 不再用弹窗,改成与新增合同(`/business/contract-manage/form?mode=add&name=新增合同管理`)一致的独立页面。 +- 现状:新增/编辑**已经**是独立页(组件内 `isStandaloneFormPage` + `/business/loading-manage/form` 路由),但「详情」和「重新派单」仍是 el-dialog(用户截图即详情弹窗)。 +- 改动(3 个文件): + 1. `src/router/views/index.js`:新增 `/business/loading-manage/detail`(name 配载单详情,`meta.keepAlive:false, activeMenu:'/business/loading-manage'`),组件仍是 `loading-manage.vue`。 + 2. `src/views/business/loading-manage.vue`: + - computed 新增 `isStandaloneDetailPage`(path==='/business/loading-manage/detail')、`isStandalonePage`(form|detail 并集,作为隐藏列表容器的统一开关)、`detailPageTitle`、`formPageDefaultTitle`(add/edit/reassign 映射);`isStandaloneFormPage` 放行 `mode=reassign`。 + - 模板中所有控制「是否独立页/是否弹窗」的 `isStandaloneFormPage` 改为 `isStandalonePage`;顶部标题按详情/表单取 `detailPageTitle`/`formPageTitle`。 + - 新增 `gotoLoadingDetail(row)`(push `/detail?id=&name=配载单详情`,替换「配载单号」链接与操作列「详情」)、`gotoLoadingReassign(row)`(push `/form?mode=reassign&id=&name=重新派单`)、`initStandaloneDetailPage()`;`initStandaloneFormPage` 模式透传 edit/reassign;`openLoadingDialog` 对 add/edit/reassign 一律转独立页;`closeLoadingDialog` 判据改 `isStandalonePage`。 + - `mounted` / `watch $route` 增加详情页分支(直接打开与页内切换都要 init);detail 分支除 id 变化外,`dialogMode !== 'view'` 也重新 init(防止从表单页切详情时残留表单态)。 + - 兼容旧链接:`/business/loading-manage?detailId=x` 仍可用,会 replace 到详情页。 + - 样式:底栏去掉写死的 `gap:8px`(全站统一 12px);页面模式的内容区解除 `max-height:78vh` 内嵌滚动盒 —— 原 `:global(.loading-manage-form-page .loading-manage-dialog__body)` 权重 (0,2,0) 低于 scoped 规则 (0,3,0) 一直没生效,改为 `.loading-manage-page .loading-manage-form-dialog.loading-manage-form-page .loading-manage-dialog__body` (0,4,0),内容改随 `#avue-view` 自然滚动(与合同页一致)。 + 3. `src/views/business/components/waybill-manage-page.vue`:`openLoadingDetail` 由 `/business/loading-manage?detailId=` 改为 push `/business/loading-manage/detail?id=&name=配载单详情`。 +- 校验:dev server(2889)单独编译 4 个模块均 HTTP 200(含 scoped scss);`vite build` 仍会在无关文件 `src/page/login/facelogin.vue` 被沙箱敏感内容保护中断。 +- 踩坑:**` diff --git a/src/components/map-search-results/main.vue b/src/components/map-search-results/main.vue index 2cd3b73..c50ef14 100644 --- a/src/components/map-search-results/main.vue +++ b/src/components/map-search-results/main.vue @@ -1,20 +1,55 @@ diff --git a/src/components/vehicle-attachment-upload/main.vue b/src/components/vehicle-attachment-upload/main.vue index 436efec..81961de 100644 --- a/src/components/vehicle-attachment-upload/main.vue +++ b/src/components/vehicle-attachment-upload/main.vue @@ -212,7 +212,14 @@ export default { .filter(Boolean); }, acceptText() { - return this.acceptedList.join(','); + return this.acceptedList + .map(item => { + const value = String(item).trim(); + if (!value || value.includes('/') || value.startsWith('.')) return value; + return `.${value}`; + }) + .filter(Boolean) + .join(','); }, tipText() { return this.tip || `${UNIFIED_ATTACHMENT_TIP_PREFIX}${this.maxSize}M`; @@ -304,12 +311,22 @@ export default { }); }, handleSuccess(response, file, files) { - if (!response || response.success === false || (response.code && response.code !== 200)) { + const code = response?.code; + const codeInvalid = code !== undefined && code !== null && code !== '' && Number(code) !== 200; + if (!response || response.success === false || codeInvalid) { this.$message.error((response && response.msg) || '上传失败'); return; } - this.emitUploadFiles(this.multiple ? files : [file]); - this.$emit('success', this.normalizeUploadFile(file)); + if (file && !file.response) { + file.response = response; + } + const normalized = this.normalizeUploadFile(file, response); + if (!normalized.url) { + this.$message.error('上传成功但未返回文件地址'); + return; + } + this.$emit('success', normalized); + this.emitUploadFiles(this.multiple ? files : [file], normalized); this.$message.success('上传成功'); }, handleChange(file, files) { @@ -323,35 +340,89 @@ export default { this.emitUploadFiles(files); return true; }, - emitUploadFiles(files) { - const list = files - .filter(file => file.status === 'success' || this.getFileUrl(file)) - .map(this.normalizeUploadFile) + emitUploadFiles(files, preferredFile) { + const list = (files || []) + .filter(file => { + if (file?.status === 'fail') return false; + return ( + file?.status === 'success' || + this.getFileUrl(file) || + file?.response?.data || + file?.response?.link || + file?.response + ); + }) + .map(file => this.normalizeUploadFile(file)) .filter(item => item.url); - this.$emit('update:modelValue', list); - this.$emit('change', list); + + if (preferredFile?.url) { + const exists = list.some( + item => + (preferredFile.uid && item.uid && String(item.uid) === String(preferredFile.uid)) || + item.url === preferredFile.url + ); + if (!exists) list.push(preferredFile); + } + + const current = this.parseValue(this.modelValue).map(item => ({ + ...item, + url: this.getFileUrl(item), + })); + const merged = [...current]; + list.forEach(file => { + const index = merged.findIndex( + item => + (file.uid && item.uid && String(item.uid) === String(file.uid)) || + (file.url && this.getFileUrl(item) === file.url) + ); + if (index >= 0) merged.splice(index, 1, { ...merged[index], ...file }); + else merged.push(file); + }); + + const result = merged.filter(item => this.getFileUrl(item)); + this.$emit('update:modelValue', result); + this.$emit('change', result); }, - normalizeUploadFile(file) { - const data = (file.response && file.response.data) || file.data || file; - const url = data.link || data.url || data.domain || file.url || ''; + normalizeUploadFile(file, responseOverride) { + const response = responseOverride || file?.response; + let data = {}; + if (response && typeof response === 'object') { + if (response.data && typeof response.data === 'object') { + data = response.data; + } else if (response.link || response.url || response.domain) { + data = response; + } + } + if (!data.link && !data.url && !data.domain) { + data = file?.data && typeof file.data === 'object' ? file.data : file || {}; + } + const url = + data.link || + data.url || + data.domain || + data.fileLink || + data.fileUrl || + file?.url || + file?.link || + ''; const originalName = data.originalName || - file.originalName || - file.name || + file?.originalName || + file?.name || data.name || this.getFileName(url) || '附件'; const extension = this.getExtension({ name: originalName, url }); return { ...data, - uid: file.uid || data.uid, + uid: file?.uid || data.uid, originalName, name: originalName, url, link: data.link || url, - size: data.size || data.attachSize || file.size || '', + size: data.size || data.attachSize || file?.size || '', extension, - mimeType: data.mimeType || data.contentType || file.raw?.type || MIME_MAP[extension] || '', + mimeType: data.mimeType || data.contentType || file?.raw?.type || MIME_MAP[extension] || '', }; }, handlePreview(file) { diff --git a/src/docker/Dockerfile b/src/docker/Dockerfile index 88425be..88c648f 100644 --- a/src/docker/Dockerfile +++ b/src/docker/Dockerfile @@ -1,6 +1,7 @@ FROM nginx VOLUME /tmp ENV LANG en_US.UTF-8 +ADD ./src/docker/nginx.conf /etc/nginx/conf.d/default.conf ADD ./dist/ /usr/share/nginx/html/ EXPOSE 80 EXPOSE 443 \ No newline at end of file diff --git a/src/docker/nginx.conf b/src/docker/nginx.conf new file mode 100644 index 0000000..0155f82 --- /dev/null +++ b/src/docker/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + add_header Cache-Control "no-store, no-cache, must-revalidate"; + try_files $uri $uri/ /index.html; + } +} diff --git a/src/error.js b/src/error.js index d2b5611..1c507e5 100644 --- a/src/error.js +++ b/src/error.js @@ -1,8 +1,12 @@ import store from './store'; +import { isChunkLoadError, reloadForChunkError } from './utils/chunk-reload'; export default { install: app => { app.config.errorHandler = (err, vm, info) => { + if (isChunkLoadError(err) && reloadForChunkError()) { + return; + } store.commit('ADD_LOGS', { type: 'error', message: err.message, diff --git a/src/main.js b/src/main.js index 9e780e5..c53d2e6 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ import { createApp } from 'vue'; +import { installChunkReload } from './utils/chunk-reload'; import website from './config/website'; import axios from './axios'; import router from './router/'; @@ -46,6 +47,7 @@ import sectionCard from './components/section-card/main.vue'; import mapSearchResults from './components/map-search-results/main.vue'; window.$crudCommon = crudCommon; +installChunkReload(); debug(); window.axios = axios; const app = createApp(App); diff --git a/src/option/base/measurement-unit.js b/src/option/base/measurement-unit.js index c135823..7c68b87 100644 --- a/src/option/base/measurement-unit.js +++ b/src/option/base/measurement-unit.js @@ -38,6 +38,21 @@ export const createOption = () => ({ menuWidth: 240, menuFixed: 'right', column: [ + { + label: '计量单位编码', + prop: 'unitCode', + minWidth: 150, + span: 24, + search: true, + searchOrder: 4, + searchSpan: 6, + maxlength: 50, + showWordLimit: true, + rules: [ + { required: true, message: '请输入计量单位编码', trigger: 'blur' }, + { max: 50, message: '计量单位编码不能超过50个字', trigger: 'blur' }, + ], + }, { label: '计量单位', prop: 'unitName', diff --git a/src/option/business/shipping-template.js b/src/option/business/shipping-template.js index eb90940..ad2f735 100644 --- a/src/option/business/shipping-template.js +++ b/src/option/business/shipping-template.js @@ -55,8 +55,8 @@ export const config = { 'templateType', 'transportType', 'createUserName', - 'remark', 'updateTime', + 'remark', 'createTime', ], exportColumns: [ @@ -65,8 +65,8 @@ export const config = { { prop: 'templateType', label: '模板类型' }, { prop: 'transportType', label: '运输方式' }, { prop: 'createUserName', label: '创建人' }, - { prop: 'remark', label: '备注' }, { prop: 'updateTime', label: '更新时间' }, + { prop: 'remark', label: '备注' }, { prop: 'createTime', label: '创建时间' }, ], enableAllDept: false, @@ -82,12 +82,14 @@ export const config = { detailAttachmentDescriptionPlain: true, enableTransportPlanForm: true, enableShippingTemplateFreight: true, - editableRoadAddress: true, + editableRoadAddress: false, fixedTransportAddressType: true, enableTemplateCodePreview: true, attachmentTitle: '附件', defaultForm: { templateType: '运输计划', + transportType: '公路运输', + transportTypeName: '公路运输', }, transportFormRequiredFields: [ ['projectName', '项目'], @@ -119,15 +121,16 @@ export const option = { labelWidth: 0, }, { - label: '模板编号', - prop: 'templateCode', + label: '模板类型', + prop: 'templateType', + type: 'select', search: true, - searchOrder: 4, + searchOrder: 2, span: 8, order: 390, - minWidth: 170, - disabled: true, - placeholder: '系统自动生成', + dicData: templateTypeOptions, + minWidth: 120, + rules: selectRule('模板类型'), display: true, }, { @@ -142,16 +145,15 @@ export const option = { display: true, }, { - label: '模板类型', - prop: 'templateType', - type: 'select', + label: '模板编号', + prop: 'templateCode', search: true, - searchOrder: 2, + searchOrder: 4, span: 8, order: 370, - dicData: templateTypeOptions, - minWidth: 120, - rules: selectRule('模板类型'), + minWidth: 170, + disabled: true, + placeholder: '系统自动生成', display: true, }, { diff --git a/src/option/business/temporary-credit-limit.js b/src/option/business/temporary-credit-limit.js index 4ad6fb4..eab56af 100644 --- a/src/option/business/temporary-credit-limit.js +++ b/src/option/business/temporary-credit-limit.js @@ -334,7 +334,7 @@ export const option = { }, ...auditColumns.map(column => ({ ...column, hide: true })), ])), - dialogWidth: '96%', + dialogWidth: 1100, menuFixed: 'right', menuWidth: 320, index: false, diff --git a/src/option/business/transport-plan.js b/src/option/business/transport-plan.js index 45b9154..5fb8150 100644 --- a/src/option/business/transport-plan.js +++ b/src/option/business/transport-plan.js @@ -209,12 +209,12 @@ export const config = { attachmentTitle: '附件', searchRangeMap: { planStartDateRange: ['planStartDateStart', 'planStartDateEnd'], - planEndDateRange: ['planEndDateStart', 'planEndDateEnd'], + createTimeRange: ['createTimeStart', 'createTimeEnd', '00:00:00', '23:59:59'], }, actions: ['copy', 'complete'], statusProp: 'businessStatus', statusTextProp: 'businessStatusName', - deleteStatus: ['draft', 'waiting_dispatch'], + deleteStatus: ['draft'], editStatus: ['draft', 'waiting_dispatch', 'dispatching'], detailButton: true, detailSections: [ @@ -341,6 +341,7 @@ export const option = { { label: '发货地址', prop: 'departureAddress', + slot: true, formslot: true, search: true, searchOrder: 7, @@ -352,6 +353,7 @@ export const option = { { label: '到货地址', prop: 'arrivalAddress', + slot: true, formslot: true, search: true, searchOrder: 6, @@ -577,8 +579,8 @@ export const option = { viewDisplay: false, }, { - label: '计划结束日期', - prop: 'planEndDateRange', + label: '创建时间', + prop: 'createTimeRange', type: 'date', format: 'YYYY-MM-DD', valueFormat: 'YYYY-MM-DD', diff --git a/src/option/business/waybill-manage.js b/src/option/business/waybill-manage.js index fe51191..c981066 100644 --- a/src/option/business/waybill-manage.js +++ b/src/option/business/waybill-manage.js @@ -543,7 +543,8 @@ export const option = { prop: 'projectName', formslot: true, search: true, - searchLabel: '项目', + searchLabel: '项目名称', + searchPlaceholder: '请选择或输入', searchOrder: 22, span: 6, order: 890, @@ -555,6 +556,7 @@ export const option = { prop: 'customerName', search: true, searchLabel: '客户名称', + searchPlaceholder: '请选择或输入', searchOrder: 21, minWidth: 150, addDisplay: false, @@ -576,6 +578,7 @@ export const option = { prop: 'driverName', search: true, searchLabel: '司机名称', + searchPlaceholder: '请选择或输入', searchOrder: 14, minWidth: 120, display: false, @@ -626,6 +629,7 @@ export const option = { prop: 'carrierName', search: true, searchLabel: '承运商名称', + searchPlaceholder: '请选择或输入', searchOrder: 15, minWidth: 150, display: false, @@ -729,6 +733,7 @@ export const option = { prop: 'planName', formslot: true, search: true, + searchPlaceholder: '请选择或输入', searchOrder: 7, span: 6, order: 870, @@ -738,6 +743,7 @@ export const option = { label: '货物类型', prop: 'cargoType', search: true, + searchPlaceholder: '请选择或输入', searchOrder: 18, formatter: row => formatGoodsField(row, ['cargoType', 'goodsType', 'typeName']), minWidth: 130, diff --git a/src/option/system/authlog.js b/src/option/system/authlog.js index d550790..3e937f1 100644 --- a/src/option/system/authlog.js +++ b/src/option/system/authlog.js @@ -5,6 +5,7 @@ export const GRANT_TYPE_DIC = [ { label: '社交登录', value: 'social' }, { label: '客户端凭证', value: 'client_credentials' }, { label: '刷新令牌', value: 'refresh_token' }, + { label: '退出登录', value: 'logout' }, ]; export const authLogOption = { @@ -38,7 +39,7 @@ export const authLogOption = { prop: 'realName', }, { - label: '授权类型', + label: '操作类型', prop: 'grantType', type: 'select', search: true, @@ -80,7 +81,7 @@ export const authLogOption = { width: 120, }, { - label: '登录时间', + label: '操作时间', prop: 'loginTime', sortable: true, span: 24, diff --git a/src/option/system/user.js b/src/option/system/user.js index 197198b..9cc07c3 100644 --- a/src/option/system/user.js +++ b/src/option/system/user.js @@ -1,12 +1,9 @@ import { getDeptLazyTree } from '@/api/system/dept'; +import { validateLoginPassword } from '@/utils/validate'; export const userOption = safe => { const validatePass = (rule, value, callback) => { - if (value === '') { - callback(new Error('请输入密码')); - } else { - callback(); - } + validateLoginPassword(rule, value, callback); }; const validatePass2 = (rule, value, callback) => { if (value === '') { diff --git a/src/page/index/layout.vue b/src/page/index/layout.vue index 0439730..f0531f9 100644 --- a/src/page/index/layout.vue +++ b/src/page/index/layout.vue @@ -1,7 +1,18 @@ + + diff --git a/src/page/login/facelogin.vue b/src/page/login/facelogin.vue index e1da201..0c09cb7 100644 --- a/src/page/login/facelogin.vue +++ b/src/page/login/facelogin.vue @@ -13,8 +13,8 @@ export default { data() { return { loginForm: { - username: 'admin', - password: '123456', + username: '', + password: '', }, }; }, diff --git a/src/page/login/index.vue b/src/page/login/index.vue index a9ee607..0ab99ca 100644 --- a/src/page/login/index.vue +++ b/src/page/login/index.vue @@ -24,6 +24,7 @@ --> + @@ -61,7 +63,7 @@ export default { return { website: website, time: '', - activeName: 'iam', + activeName: 'user', socialForm: { tenantId: '000000', source: '', diff --git a/src/page/login/userlogin.vue b/src/page/login/userlogin.vue index d981745..c22a330 100644 --- a/src/page/login/userlogin.vue +++ b/src/page/login/userlogin.vue @@ -93,9 +93,9 @@ export default { //角色ID roleId: '', //用户名 - username: 'admin', + username: '', //密码 - password: 'admin', + password: '', //账号类型 type: 'account', //验证码的值 diff --git a/src/permission.js b/src/permission.js index 27b2028..9051363 100644 --- a/src/permission.js +++ b/src/permission.js @@ -3,6 +3,8 @@ import store from './store'; import { tabKeyOf } from '@/router/tab'; import { getToken } from '@/utils/auth'; import { + consumeReloadQuery, + hasReloadQuery, isChunkLoadError, reloadForChunkError, setPendingRoutePath, @@ -17,19 +19,20 @@ const lockPage = '/lock'; //锁屏页 router.onError(error => { if (!isChunkLoadError(error)) return; if (reloadForChunkError()) { - ElMessage.warning('页面资源加载失败,正在重新加载…'); - } -}); - -window.addEventListener('unhandledrejection', event => { - if (!isChunkLoadError(event.reason)) return; - if (reloadForChunkError()) { - event.preventDefault(); - ElMessage.warning('页面资源加载失败,正在重新加载…'); + ElMessage.warning('页面资源已更新,正在重新加载…'); } }); router.beforeEach((to, from, next) => { + if (hasReloadQuery(to.query)) { + next({ + path: to.path, + query: consumeReloadQuery(to.query), + hash: to.hash, + replace: true, + }); + return; + } setPendingRoutePath(to.fullPath); const meta = to.meta || {}; const isMenu = meta.menu === undefined ? to.query.menu : meta.menu; @@ -59,7 +62,7 @@ router.beforeEach((to, from, next) => { fullPath: tabKeyOf(to), params: to.params, query: to.query, - meta: meta, + meta: { ...meta, keepAlive: true }, }); } next(); diff --git a/src/router/avue-router.js b/src/router/avue-router.js index 7c0a38c..5538125 100644 --- a/src/router/avue-router.js +++ b/src/router/avue-router.js @@ -2,6 +2,9 @@ import website from '@/config/website'; import { getToken } from '@/utils/auth'; import store from '@/store'; import { generateIframePath, processUrlForQuery, isURL } from './router'; +import { wrapViewLoader } from '@/utils/chunk-reload'; + +// 保持懒加载,避免 eager 与 store 循环依赖(Cannot access 'store' before initialization) const modules = import.meta.glob('../**/**/*.vue'); // 将多级路由扁平化为二级路由,支持 keep-alive 跨层级缓存 @@ -96,21 +99,23 @@ RouterPlugin.install = function (option = {}) { component: (() => { // 判断是否为首路由 if (first) { - return modules[ - option.store.getters.isMacOs || !website.setting.menu - ? '../page/index/layout.vue' - : '../page/index/index.vue' - ]; + return wrapViewLoader( + modules[ + option.store.getters.isMacOs || !website.setting.menu + ? '../page/index/layout.vue' + : '../page/index/index.vue' + ] + ); // 判断是否为多层路由 } else if (isChild && !first) { - return modules['../page/index/layout.vue']; + return wrapViewLoader(modules['../page/index/layout.vue']); // 判断是否为最终的页面视图 } else { let result = modules[`../${component}.vue`]; if (!result) { isComponent = false; } - return result; + return wrapViewLoader(result); } })(), name, @@ -127,7 +132,7 @@ RouterPlugin.install = function (option = {}) { if (first) { oMenu[propsDefault.path] = `${path}`; let componentPath = oMenu.component || component; - let result = modules[`../${componentPath}.vue`]; + let result = wrapViewLoader(modules[`../${componentPath}.vue`]); if (!result) { isComponent = false; } @@ -173,7 +178,7 @@ export const formatPath = (ele, first) => { const icon = ele[propsDefault.icon]; ele[propsDefault.icon] = icon || ''; ele.meta = { - keepAlive: ele.isOpen === 2, + keepAlive: true, }; const iframeComponent = 'components/iframe/main'; const iframeSrc = href => { @@ -215,7 +220,7 @@ export const formatPath = (ele, first) => { ele[propsDefault.children].forEach(child => { child.component = 'views' + child[propsDefault.path]; child.meta = { - keepAlive: child.isOpen === 2, + keepAlive: true, }; if (isURL(child[propsDefault.href])) { let href = child[propsDefault.href]; diff --git a/src/router/page/index.js b/src/router/page/index.js index 9110d5c..ab93bb9 100644 --- a/src/router/page/index.js +++ b/src/router/page/index.js @@ -74,6 +74,82 @@ export default [ 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: '/business/project-apply/public-view', + name: '查看项目信息', + component: () => import('@/views/business/project-apply-public-view.vue'), + meta: { + keepAlive: false, + isTab: false, + isAuth: false, + bizType: 'project-apply', + }, + }, + { + path: '/business/contract-manage/public-view', + name: '查看合同信息', + component: () => import('@/views/business/contract-manage-public-view.vue'), + meta: { + keepAlive: false, + isTab: false, + isAuth: false, + bizType: 'contract-manage', + }, + }, + { + path: '/business/waybill-manage/public-view', + name: '查看运单信息', + component: () => import('@/views/business/waybill-manage-public-view.vue'), + meta: { + keepAlive: false, + isTab: false, + isAuth: false, + bizType: 'waybill-manage', + }, + }, + { + path: '/settlement/pre-settlement/public-view', + name: '查看预结算信息', + component: () => import('@/views/settlement/pre-settlement-public-view.vue'), + meta: { + keepAlive: false, + isTab: false, + isAuth: false, + bizType: 'pre-settlement', + }, + }, + { + path: '/settlement/formal-settlement/public-view', + name: '查看正式结算信息', + component: () => import('@/views/settlement/formal-settlement-public-view.vue'), + meta: { + keepAlive: false, + isTab: false, + isAuth: false, + bizType: 'formal-settlement', + }, + }, + { + path: '/payment/payment-application/public-view', + name: '查看付款申请信息', + component: () => import('@/views/payment/payment-application-public-view.vue'), + meta: { + keepAlive: false, + isTab: false, + isAuth: false, + bizType: 'payment-application', + }, + }, { path: '/', name: '主页', diff --git a/src/router/tab.js b/src/router/tab.js index f715c00..d904ad1 100644 --- a/src/router/tab.js +++ b/src/router/tab.js @@ -128,5 +128,5 @@ export function tabView(route, Component) { }; wrapperMap.set(tabKey, wrapper); } - return h(wrapper); + return wrapper; } diff --git a/src/router/views/index.js b/src/router/views/index.js index 3a380e6..11d72aa 100644 --- a/src/router/views/index.js +++ b/src/router/views/index.js @@ -1,5 +1,8 @@ import Layout from '@/page/index/index.vue'; import Store from '@/store/'; +import { wrapViewLoader } from '@/utils/chunk-reload'; + +const loadView = loader => wrapViewLoader(loader); export default [ { @@ -10,7 +13,7 @@ export default [ path: '', name: '汇票付款表单', meta: { keepAlive: false, activeMenu: '/payment/bill-payment' }, - component: () => import('@/views/payment/bill-payment-form.vue'), + component: loadView(() => import('@/views/payment/bill-payment-form.vue')), }, ], }, @@ -22,7 +25,7 @@ export default [ path: '', name: '汇票台账表单', meta: { keepAlive: false, activeMenu: '/payment/bill-ledger' }, - component: () => import('@/views/payment/bill-ledger-form.vue'), + component: loadView(() => import('@/views/payment/bill-ledger-form.vue')), }, ], }, @@ -34,7 +37,7 @@ export default [ path: '', name: '认领记录详情', meta: { keepAlive: false, activeMenu: '/payment/receipt-claim-record' }, - component: () => import('@/views/payment/receipt-claim-record-form.vue'), + component: loadView(() => import('@/views/payment/receipt-claim-record-form.vue')), }, ], }, @@ -46,7 +49,7 @@ export default [ path: '', name: '收款流水认领', meta: { keepAlive: false, activeMenu: '/payment/receipt-flow' }, - component: () => import('@/views/payment/receipt-flow-form.vue'), + component: loadView(() => import('@/views/payment/receipt-flow-form.vue')), }, ], }, @@ -58,7 +61,7 @@ export default [ path: '', name: '收票登记', meta: { keepAlive: false, activeMenu: '/payment/invoice-receipt' }, - component: () => import('@/views/payment/invoice-receipt-form.vue'), + component: loadView(() => import('@/views/payment/invoice-receipt-form.vue')), }, ], }, @@ -70,7 +73,7 @@ export default [ path: '', name: '开票申请', meta: { keepAlive: false, activeMenu: '/payment/invoice-application' }, - component: () => import('@/views/payment/invoice-application-form.vue'), + component: loadView(() => import('@/views/payment/invoice-application-form.vue')), }, ], }, @@ -82,7 +85,7 @@ export default [ path: '', name: '付款申请', meta: { keepAlive: false, activeMenu: '/payment/payment-application' }, - component: () => import('@/views/payment/payment-application-form.vue'), + component: loadView(() => import('@/views/payment/payment-application-form.vue')), }, ], }, @@ -97,7 +100,7 @@ export default [ keepAlive: false, activeMenu: '/settlement/pre-settlement', }, - component: () => import('@/views/settlement/pre-settlement-form.vue'), + component: loadView(() => import('@/views/settlement/pre-settlement-form.vue')), }, ], }, @@ -112,7 +115,7 @@ export default [ keepAlive: false, activeMenu: '/settlement/formal-settlement', }, - component: () => import('@/views/settlement/formal-settlement-form.vue'), + component: loadView(() => import('@/views/settlement/formal-settlement-form.vue')), }, ], }, @@ -127,7 +130,7 @@ export default [ keepAlive: false, 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: '', name: '执行凭证批次详情', 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: '', name: '新增合同管理', 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: '', name: '合同详情', 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: '', name: '新增项目申请', 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: '', name: '新增编辑运单管理', 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: '', name: '运单管理详情', 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: '', name: '导入运单', 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: '', name: '新建导入运单', 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,6 +238,18 @@ export default [ path: '', name: '新增编辑配载管理', meta: { keepAlive: false }, + component: loadView(() => import('@/views/business/loading-manage.vue')), + }, + ], + }, + { + path: '/business/loading-manage/detail', + component: Layout, + children: [ + { + path: '', + name: '配载单详情', + meta: { keepAlive: false, activeMenu: '/business/loading-manage' }, component: () => import('@/views/business/loading-manage.vue'), }, ], @@ -247,7 +262,7 @@ export default [ path: '', name: '新增编辑运输计划', meta: { keepAlive: false }, - component: () => import('@/views/business/transport-plan.vue'), + component: loadView(() => import('@/views/business/transport-plan.vue')), }, ], }, @@ -259,7 +274,7 @@ export default [ path: '', name: '导入运输计划', meta: { keepAlive: false, activeMenu: '/business/transport-plan' }, - component: () => import('@/views/business/transport-plan-import.vue'), + component: loadView(() => import('@/views/business/transport-plan-import.vue')), }, ], }, @@ -271,7 +286,7 @@ export default [ path: '', name: '计划调度', meta: { keepAlive: false, activeMenu: '/business/transport-plan' }, - component: () => import('@/views/business/transport-plan-dispatch.vue'), + component: loadView(() => import('@/views/business/transport-plan-dispatch.vue')), }, ], }, @@ -283,7 +298,7 @@ export default [ path: '', name: '新增编辑运单模板', meta: { keepAlive: false }, - component: () => import('@/views/business/shipping-template.vue'), + component: loadView(() => import('@/views/business/shipping-template.vue')), }, ], }, @@ -295,7 +310,7 @@ export default [ path: '', name: '新增客商档案', meta: { keepAlive: false }, - component: () => import('@/views/vehicle/customer-archive.vue'), + component: loadView(() => import('@/views/vehicle/customer-archive.vue')), }, ], }, @@ -307,7 +322,7 @@ export default [ path: '', name: '合同变更', meta: { keepAlive: false }, - component: () => import('@/views/business/contract-manage-change.vue'), + component: loadView(() => import('@/views/business/contract-manage-change.vue')), }, ], }, @@ -323,7 +338,7 @@ export default [ meta: { i18n: 'dashboard', }, - component: () => import(/* webpackChunkName: "views" */ '@/views/wel/index.vue'), + component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/wel/index.vue')), }, { path: 'dashboard', @@ -332,7 +347,7 @@ export default [ i18n: 'dashboard', menu: false, }, - component: () => import(/* webpackChunkName: "views" */ '@/views/wel/dashboard.vue'), + component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/wel/dashboard.vue')), }, ], }, @@ -347,7 +362,7 @@ export default [ meta: { i18n: 'test', }, - component: () => import(/* webpackChunkName: "views" */ '@/views/util/test.vue'), + component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/util/test.vue')), }, ], }, @@ -362,8 +377,9 @@ export default [ meta: { i18n: 'dict', }, - component: () => - import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-horizontal.vue'), + component: loadView(() => + import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-horizontal.vue') + ), }, ], }, @@ -378,8 +394,9 @@ export default [ meta: { i18n: 'dict', }, - component: () => - import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-vertical.vue'), + component: loadView(() => + import(/* webpackChunkName: "views" */ '@/views/util/demo/dict-vertical.vue') + ), }, ], }, @@ -394,7 +411,7 @@ export default [ meta: { i18n: 'info', }, - component: () => import(/* webpackChunkName: "views" */ '@/views/system/userinfo.vue'), + component: loadView(() => import(/* webpackChunkName: "views" */ '@/views/system/userinfo.vue')), }, ], }, @@ -409,8 +426,9 @@ export default [ meta: { i18n: 'work', }, - component: () => - import(/* webpackChunkName: "views" */ '@/views/work/process/leave/form.vue'), + component: loadView(() => + import(/* webpackChunkName: "views" */ '@/views/work/process/leave/form.vue') + ), }, { path: 'handle/:taskId/:processInstanceId/:businessId', @@ -418,8 +436,9 @@ export default [ meta: { i18n: 'work', }, - component: () => - import(/* webpackChunkName: "views" */ '@/views/work/process/leave/handle.vue'), + component: loadView(() => + import(/* webpackChunkName: "views" */ '@/views/work/process/leave/handle.vue') + ), }, { path: 'detail/:processInstanceId/:businessId', @@ -427,8 +446,9 @@ export default [ meta: { i18n: 'work', }, - component: () => - import(/* webpackChunkName: "views" */ '@/views/work/process/leave/detail.vue'), + component: loadView(() => + import(/* webpackChunkName: "views" */ '@/views/work/process/leave/detail.vue') + ), }, ], }, diff --git a/src/store/getters.js b/src/store/getters.js index 3edda43..fb53825 100644 --- a/src/store/getters.js +++ b/src/store/getters.js @@ -16,11 +16,8 @@ const getters = { lockPasswd: state => state.common.lockPasswd, tagList: state => state.tags.tagList, tagsKeep: (state, getters) => { - return getters.tagList - .filter(ele => { - return (ele.meta || {}).keepAlive; - }) - .map(ele => ele.fullPath); + // 所有已打开标签均纳入 keep-alive,关闭标签后自动从白名单移除并释放实例 + return getters.tagList.map(ele => ele.fullPath).filter(Boolean); }, tagWel: state => state.tags.tagWel, token: state => state.user.token, diff --git a/src/styles/common.scss b/src/styles/common.scss index 892329c..97be467 100644 --- a/src/styles/common.scss +++ b/src/styles/common.scss @@ -150,12 +150,15 @@ a { bottom: 0; } .map-picker-content { - display: flex; - gap: 12px; + position: relative; + display: block; + min-width: 0; + overflow: visible; > [class$='__map'], > .address-map { - flex: 1 1 auto; + position: relative; + z-index: 1; width: 100%; min-width: 0; box-sizing: border-box; @@ -165,4 +168,8 @@ a { height: 100% !important; } } + + > .map-search-results { + z-index: 2000; + } } diff --git a/src/utils/carrier-org-resource.js b/src/utils/carrier-org-resource.js new file mode 100644 index 0000000..65ffbc5 --- /dev/null +++ b/src/utils/carrier-org-resource.js @@ -0,0 +1,140 @@ +import { getDetail, getList as getCustomerList } from '@/api/vehicle/customer-archive'; +import { getList as getDriverList } from '@/api/transportCapacity/driver'; +import { getList as getVehicleList } from '@/api/transportCapacity/transport-vehicle'; + +const orgNameCache = new Map(); + +const extractRecords = res => { + const data = res?.data?.data ?? res?.data ?? res; + if (Array.isArray(data)) return data; + if (Array.isArray(data?.records)) return data.records; + if (Array.isArray(data?.data)) return data.data; + return []; +}; + +const normalizeOrgName = value => String(value || '').trim(); + +const uniqueOrgNames = (values = []) => { + const seen = new Set(); + const result = []; + values.forEach(value => { + const name = normalizeOrgName(value); + if (!name || seen.has(name)) return; + seen.add(name); + result.push(name); + }); + return result; +}; + +/** + * 解析承运商客商档案上联系人的所属组织(branchName),无联系人时回退客商所属组织。 + */ +export const resolveCarrierOrganizationNames = async ({ carrierId, carrierName } = {}) => { + const id = String(carrierId || '').trim(); + const name = normalizeOrgName(carrierName); + const cacheKey = id || name; + if (!cacheKey) return []; + if (orgNameCache.has(cacheKey)) return orgNameCache.get(cacheKey); + + let detail = null; + if (id) { + try { + const res = await getDetail(id); + detail = res?.data?.data || res?.data || null; + } catch (error) { + detail = null; + } + } + if (!detail && name) { + try { + const res = await getCustomerList(1, 20, { fullName: name }); + const records = extractRecords(res); + const matched = + records.find(item => normalizeOrgName(item.fullName || item.customerName) === name) || + records[0]; + if (matched?.id) { + const detailRes = await getDetail(matched.id); + detail = detailRes?.data?.data || detailRes?.data || matched; + } + } catch (error) { + detail = null; + } + } + + const contacts = Array.isArray(detail?.contacts) ? detail.contacts : []; + const orgNames = uniqueOrgNames([ + ...contacts.map(item => item.branchName), + detail?.deptName, + detail?.organizationName, + ]); + orgNameCache.set(cacheKey, orgNames); + if (id && name) orgNameCache.set(name, orgNames); + return orgNames; +}; + +export const clearCarrierOrganizationCache = (carrier = {}) => { + const id = String(carrier.carrierId || '').trim(); + const name = normalizeOrgName(carrier.carrierName); + if (id) orgNameCache.delete(id); + if (name) orgNameCache.delete(name); +}; + +export const matchOrganizationName = (value, orgNames = []) => { + const text = normalizeOrgName(value); + if (!text || !orgNames.length) return false; + return orgNames.some(org => text === org || text.includes(org) || org.includes(text)); +}; + +const filterByOrganizations = (records = [], orgNames = []) => { + if (!orgNames.length) return []; + return records.filter(item => matchOrganizationName(item.organizationName, orgNames)); +}; + +/** + * 按承运商联系人所属组织筛选司机。 + * 未选择承运商时返回空列表(需先选承运商)。 + */ +export const fetchDriversByCarrierOrganizations = async ( + query = {}, + { carrierId, carrierName } = {} +) => { + if (!String(carrierId || '').trim() && !normalizeOrgName(carrierName)) { + return []; + } + const orgNames = await resolveCarrierOrganizationNames({ carrierId, carrierName }); + if (!orgNames.length) return []; + + const size = Math.max(Number(query.size) || 50, 50); + const params = { ...query }; + delete params.size; + // 单组织时用后端模糊条件缩小范围;多组织再前端精确过滤 + if (orgNames.length === 1) { + params.organizationName = orgNames[0]; + } + const res = await getDriverList(1, Math.min(size * 5, 200), params); + return filterByOrganizations(extractRecords(res), orgNames).slice(0, size); +}; + +/** + * 按承运商联系人所属组织筛选车辆。 + * 未选择承运商时返回空列表。 + */ +export const fetchVehiclesByCarrierOrganizations = async ( + query = {}, + { carrierId, carrierName } = {} +) => { + if (!String(carrierId || '').trim() && !normalizeOrgName(carrierName)) { + return []; + } + const orgNames = await resolveCarrierOrganizationNames({ carrierId, carrierName }); + if (!orgNames.length) return []; + + const size = Math.max(Number(query.size) || 50, 50); + const params = { ...query }; + delete params.size; + if (orgNames.length === 1) { + params.organizationName = orgNames[0]; + } + const res = await getVehicleList(1, Math.min(size * 5, 200), params); + return filterByOrganizations(extractRecords(res), orgNames).slice(0, size); +}; diff --git a/src/utils/chunk-reload.js b/src/utils/chunk-reload.js index 2d42f54..3c30bb4 100644 --- a/src/utils/chunk-reload.js +++ b/src/utils/chunk-reload.js @@ -1,38 +1,168 @@ /** * 懒加载 chunk / CSS preload 失败后的整页恢复。 * 常见于:部署后旧 hash 失效、静态服务空闲断连、代理 CONNECTION_RESET。 + * + * 失败后必须整页刷新:旧入口里的 hashed 资源地址不会自行更新, + * 仅捕获路由错误而不刷新时,未打开过的页面会一直无法进入。 */ const RELOAD_FLAG = 'app:chunk-reload-ts'; -const RELOAD_COOLDOWN_MS = 10000; +const RELOAD_QUERY = '_chunkreload'; +const RELOAD_COOLDOWN_MS = 15000; const CHUNK_ERROR_RE = - /Failed to fetch dynamically imported module|Importing a module script failed|Unable to preload CSS|error loading dynamically imported module|Loading CSS chunk|Loading chunk .+ failed|ChunkLoadError/i; + /Failed to fetch dynamically imported module|Importing a module script failed|Unable to preload CSS|error loading dynamically imported module|Loading CSS chunk|Loading chunk .+ failed|ChunkLoadError|Unable to preload|error loading module|Load failed/i; /** 最近一次路由跳转目标,供 onError 时整页落到正确地址 */ let pendingFullPath = ''; +let installed = false; +let reloading = false; export function setPendingRoutePath(fullPath = '') { pendingFullPath = fullPath || ''; } +function collectErrorText(error, depth = 0) { + if (!error || depth > 3) return ''; + if (typeof error === 'string') return error; + const parts = [ + error.message, + error.msg, + error.name, + error.stack, + error.error && collectErrorText(error.error, depth + 1), + error.reason && collectErrorText(error.reason, depth + 1), + error.cause && collectErrorText(error.cause, depth + 1), + error.payload && collectErrorText(error.payload, depth + 1), + ]; + const target = error.target; + if (target && (target.src || target.href)) { + parts.push(target.src || target.href); + } + try { + parts.push(String(error)); + } catch (e) { + /* ignore */ + } + return parts.filter(Boolean).join(' '); +} + export function isChunkLoadError(error) { - if (!error) return false; - const message = error.message || String(error); - return CHUNK_ERROR_RE.test(message); + const text = collectErrorText(error); + if (!text) return false; + if (CHUNK_ERROR_RE.test(text)) return true; + return /Failed to fetch/i.test(text) && /\.m?js(\?|$|:)/i.test(text); +} + +export function hasReloadQuery(query = {}) { + return !!(query && query[RELOAD_QUERY] !== undefined); +} + +export function consumeReloadQuery(query = {}) { + if (!hasReloadQuery(query)) return query; + const next = { ...query }; + delete next[RELOAD_QUERY]; + return next; +} + +function buildReloadUrl(targetPath) { + const url = new URL(targetPath, window.location.origin); + url.searchParams.set(RELOAD_QUERY, String(Date.now())); + return url.pathname + url.search + url.hash; } /** * @returns {boolean} 是否已触发刷新(冷却期内返回 false,避免死循环) */ export function reloadForChunkError(targetPath) { + if (reloading) return false; const now = Date.now(); const last = Number(sessionStorage.getItem(RELOAD_FLAG) || 0); if (now - last < RELOAD_COOLDOWN_MS) { return false; } + reloading = true; sessionStorage.setItem(RELOAD_FLAG, String(now)); - const path = targetPath || pendingFullPath || window.location.pathname + window.location.search + window.location.hash; - window.location.assign(path); + const path = + targetPath || + pendingFullPath || + `${window.location.pathname}${window.location.search}${window.location.hash}`; + const dest = buildReloadUrl(path); + // cache: 'reload' 会回写 HTTP 缓存,降低 index.html 仍指向旧 hash 资源的概率 + fetch(`${window.location.pathname}?${RELOAD_QUERY}=${now}`, { + cache: 'reload', + credentials: 'same-origin', + headers: { + Pragma: 'no-cache', + 'Cache-Control': 'no-cache', + }, + }) + .catch(() => {}) + .finally(() => { + window.location.replace(dest); + }); return true; } + +export function wrapViewLoader(loader) { + if (typeof loader !== 'function') return loader; + return () => + Promise.resolve() + .then(() => loader()) + .catch(error => { + if (isChunkLoadError(error)) { + reloadForChunkError(); + } + throw error; + }); +} + +export function installChunkReload() { + if (installed || typeof window === 'undefined') return; + installed = true; + + window.addEventListener('vite:preloadError', event => { + if (reloadForChunkError()) { + event.preventDefault(); + } + }); + + window.addEventListener('unhandledrejection', event => { + if (!isChunkLoadError(event.reason)) return; + if (reloadForChunkError()) { + event.preventDefault(); + } + }); + + window.addEventListener( + 'error', + event => { + const el = event.target; + const isAssetNode = + el && + el !== window && + (el.tagName === 'SCRIPT' || + (el.tagName === 'LINK' && /modulepreload|stylesheet/i.test(el.rel || ''))); + if (isAssetNode || isChunkLoadError(event.error || event.message)) { + if (reloadForChunkError()) { + event.preventDefault(); + } + } + }, + true + ); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') checkDeployedAssets(); + }); +} + +function checkDeployedAssets() { + const el = document.querySelector('script[type="module"][src*="/assets/"]'); + if (!el || !el.src) return; + fetch(el.src, { method: 'HEAD', cache: 'no-store', credentials: 'same-origin' }) + .then(res => { + if (res.status === 404) reloadForChunkError(); + }) + .catch(() => {}); +} diff --git a/src/utils/map-search.js b/src/utils/map-search.js new file mode 100644 index 0000000..a2bf1dd --- /dev/null +++ b/src/utils/map-search.js @@ -0,0 +1,39 @@ +/** + * 将高德 Geocoder 结果规范化为地图选址浮层列表数据。 + * @param {Array|Object} geocodesOrResult geocodes 数组,或含 geocodes/location 的结果对象 + * @param {string} keyword 搜索关键词兜底文案 + * @returns {Array<{id: string|number, name: string, address: string, location: any, photo: string}>} + */ +export function normalizeMapSearchResults(geocodesOrResult, keyword = '') { + const fallback = String(keyword || '').trim(); + let list = []; + if (Array.isArray(geocodesOrResult)) { + list = geocodesOrResult; + } else if (geocodesOrResult && typeof geocodesOrResult === 'object') { + if (Array.isArray(geocodesOrResult.geocodes)) { + list = geocodesOrResult.geocodes; + } else if (geocodesOrResult.location) { + list = [geocodesOrResult]; + } + } + + return list.map((item, index) => { + const buildingName = pickNamedField(item?.building); + const neighborhoodName = pickNamedField(item?.neighborhood); + const address = item?.formattedAddress || item?.address || fallback || '-'; + return { + id: item?.id || index, + name: buildingName || neighborhoodName || address, + address, + location: item?.location, + photo: item?.photo || item?.image || '', + }; + }); +} + +function pickNamedField(value) { + if (!value) return ''; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'object' && value.name) return String(value.name).trim(); + return ''; +} diff --git a/src/utils/mk-approval.js b/src/utils/mk-approval.js new file mode 100644 index 0000000..5b02da7 --- /dev/null +++ b/src/utils/mk-approval.js @@ -0,0 +1,94 @@ +import { getDictionary } from '@/api/system/dictbiz'; +import { processDelete, processSubmit } from '@/api/system/business-process'; +import { ElMessage } from 'element-plus'; + +export const MK_BIZ = { + 'customer-archive': { + dictName: '提交客商审核流', + subjectPrefix: '客商准入审批', + rejected: ['rejected'], + }, + 'project-apply': { + dictName: '提交项目审核流', + subjectPrefix: '项目审批', + rejected: ['rejected', 'change_rejected', 'withdrawn'], + }, + 'contract-manage': { + dictName: '提交合同审核流', + subjectPrefix: '合同审批', + rejected: ['rejected', 'change_rejected', 'withdrawn'], + }, + 'waybill-manage': { + dictName: '提交运单审核流', + subjectPrefix: '运单审批', + rejected: ['rejected', 'returned'], + }, + 'pre-settlement': { + dictName: '提交预结算审核流', + subjectPrefix: '预结算审批', + rejected: ['returned'], + }, + 'formal-settlement': { + dictName: '提交正式结算审核流', + subjectPrefix: '正式结算审批', + rejected: ['returned'], + }, + 'payment-application': { + dictName: '提交付款审核流', + subjectPrefix: '付款审批', + rejected: ['returned'], + }, +}; + +const MK_SWITCH_NAME = '开启MK'; + +async function loadMkTemplateList() { + const res = await getDictionary({ code: 'mk_template' }); + return res?.data?.data || []; +} + +/** 业务字典 mk_template:名称「开启MK」且键值为 1 时才请求 MK */ +export function isMkEnabled(list = []) { + const matched = list.find(item => String(item.dictValue || '').trim() === MK_SWITCH_NAME); + return String(matched?.dictKey ?? '').trim() === '1'; +} + +export async function resolveMkTemplateCode(dictName, list) { + const dictList = list || (await loadMkTemplateList()); + const matched = dictList.find(item => String(item.dictValue || '').trim() === dictName); + const templateCode = matched?.dictKey; + if (!templateCode) { + ElMessage.warning(`未配置业务字典 mk_template「${dictName}」,无法提交审核流`); + return Promise.reject(new Error(`未配置业务字典 mk_template「${dictName}」`)); + } + return String(templateCode); +} + +export async function submitMkApprovalFlow({ + bizType, + formInstanceId, + subjectName = '', + approvalStatus = '', +}) { + const conf = MK_BIZ[bizType]; + if (!conf) { + return Promise.reject(new Error(`不支持的MK业务类型:${bizType}`)); + } + const list = await loadMkTemplateList(); + if (!isMkEnabled(list)) { + return; + } + if ((conf.rejected || []).includes(approvalStatus)) { + await processDelete({ formInstanceId: String(formInstanceId) }); + } + const templateCode = await resolveMkTemplateCode(conf.dictName, list); + const subject = subjectName + ? `${conf.subjectPrefix}:${subjectName}` + : `${conf.subjectPrefix}:${formInstanceId}`; + return processSubmit({ + templateCode, + formInstanceId: String(formInstanceId), + subject, + bizType, + }); +} diff --git a/src/utils/validate.js b/src/utils/validate.js index ccba6e7..6aed154 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -283,3 +283,31 @@ export function validatejson(val) { // 非对象、非数组、非字符串,或者字符串不是 JSON return false; } + +/** 登录密码规则提示 */ +export const LOGIN_PASSWORD_RULE_MESSAGE = + '密码须大于8位,且同时包含字母、数字和特殊字符(.!@#$%^&*)'; + +/** + * 校验登录密码强度:大于8位,同时包含字母、数字、特殊字符(.!@#$%^&*) + * @param {string} password + * @returns {boolean} + */ +export function isValidLoginPassword(password) { + return /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.!@#$%^&*]).{9,}$/.test(String(password || '')); +} + +/** + * Element Plus / Avue 表单校验器:登录密码强度 + */ +export function validateLoginPassword(rule, value, callback) { + if (value === undefined || value === null || String(value).trim() === '') { + callback(new Error('请输入登录密码')); + return; + } + if (!isValidLoginPassword(value)) { + callback(new Error(LOGIN_PASSWORD_RULE_MESSAGE)); + return; + } + callback(); +} diff --git a/src/views/base/airport-master.vue b/src/views/base/airport-master.vue index ad78455..4a2ba77 100644 --- a/src/views/base/airport-master.vue +++ b/src/views/base/airport-master.vue @@ -94,6 +94,17 @@ /> + @@ -134,6 +152,7 @@ import { openImportDialog } from '@/utils/import-excel'; import { formatUpdateUserName } from '@/utils/audit'; import { getToken } from '@/utils/auth'; import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate'; +import AddressMapPicker from '@/components/address-map-picker/main.vue'; import NProgress from 'nprogress'; import 'nprogress/nprogress.css'; @@ -181,6 +200,9 @@ const addExcelRequiredHeaderMarks = async blob => { }; export default { + components: { + AddressMapPicker, + }, data() { const validateIataCode = (rule, value, callback) => { if (!/^[A-Z]{3}$/.test(String(value || '').toUpperCase())) { @@ -220,6 +242,7 @@ export default { loading: true, data: [], excelBox: false, + addressMapPickerVisible: false, excelForm: {}, provinceOptions: [], cityOptions: [], @@ -368,15 +391,14 @@ export default { { label: '详细地址', prop: 'detailAddress', - type: 'textarea', - minRows: 2, + formslot: true, span: 24, minWidth: 220, overHidden: false, maxlength: 255, showWordLimit: true, rules: [ - { required: true, message: '请输入详细地址', trigger: 'blur' }, + { required: true, message: '请选择详细地址', trigger: 'change' }, { max: 255, message: '详细地址不能超过255字', trigger: 'blur' }, ], }, @@ -504,7 +526,7 @@ export default { propsHttp: { res: 'data', }, - tip: '请上传 .xls,.xlsx 标准格式文件', + tip: '请上传 .xls,.xlsx 标准格式文件;日期支持 2026-08-02、2026-8-2、2026/8/2 等写法', accept: '.xls,.xlsx', action: '/blade-system/airport-master/import-airport-master', }, @@ -669,6 +691,29 @@ export default { handleCoordinateInput(prop, value) { this.form[prop] = normalizeCoordinateInput(value); }, + openAddressMapPicker() { + this.addressMapPickerVisible = true; + }, + handleAddressMapConfirm(payload) { + const selection = typeof payload === 'string' ? { address: payload } : payload || {}; + if (selection.address) { + this.form.detailAddress = selection.address; + } + if ( + selection.longitude !== undefined && + selection.longitude !== null && + selection.longitude !== '' + ) { + this.form.longitude = Number(selection.longitude).toFixed(6); + } + if ( + selection.latitude !== undefined && + selection.latitude !== null && + selection.latitude !== '' + ) { + this.form.latitude = Number(selection.latitude).toFixed(6); + } + }, normalizeRow(row) { row.code = row.iataCode ? `JC-${row.iataCode}` : row.code; row.regionCode = String(row.regionCode || '').trim(); @@ -917,4 +962,13 @@ export default { white-space: nowrap; } } + +.detail-address-input { + cursor: pointer; + + :deep(.el-input__wrapper), + :deep(.el-input__inner) { + cursor: pointer; + } +} diff --git a/src/views/base/cargo-type.vue b/src/views/base/cargo-type.vue index cdc487f..b1f783b 100644 --- a/src/views/base/cargo-type.vue +++ b/src/views/base/cargo-type.vue @@ -274,7 +274,7 @@ export default { res: 'data', }, headers: getUploadHeaders(), - tip: '请上传 .xls,.xlsx 标准格式文件', + tip: '请上传 .xls,.xlsx 标准格式文件;日期支持 2026-08-02、2026-8-2、2026/8/2 等写法', action: '/blade-system/cargo-type/import-cargo-type', }, { diff --git a/src/views/base/common-address.vue b/src/views/base/common-address.vue index cc31086..724cb2a 100644 --- a/src/views/base/common-address.vue +++ b/src/views/base/common-address.vue @@ -233,8 +233,8 @@
-
+
{{ mapStatus }} @@ -270,6 +270,7 @@ import { createOption } from '@/option/base/common-address'; import { mapGetters } from 'vuex'; import { downloadXls } from '@/utils/util'; import { getToken } from '@/utils/auth'; +import { normalizeMapSearchResults } from '@/utils/map-search'; import { isMobile } from '@/utils/validate'; import NProgress from 'nprogress'; import 'nprogress/nprogress.css'; @@ -1117,12 +1118,7 @@ export default { this.ensureAmapGeocoder() .then(() => this.runAmapGeocode('location', keyword)) .then(result => { - this.mapSearchResults = (result.geocodes || []).map((item, index) => ({ - id: item.id || index, - name: item.formattedAddress || keyword, - address: item.formattedAddress || keyword, - location: item.location, - })); + this.mapSearchResults = normalizeMapSearchResults(result, keyword); const point = this.resolveMapPoint(result); if (!point) { this.mapStatus = '未找到匹配地址'; diff --git a/src/views/base/currency.vue b/src/views/base/currency.vue index f4441bb..0ad39fe 100644 --- a/src/views/base/currency.vue +++ b/src/views/base/currency.vue @@ -326,7 +326,7 @@ export default { propsHttp: { res: 'data', }, - tip: '请上传 .xls,.xlsx 标准格式文件', + tip: '请上传 .xls,.xlsx 标准格式文件;日期支持 2026-08-02、2026-8-2、2026/8/2 等写法', action: '/blade-system/currency/import-currency', }, { diff --git a/src/views/base/measurement-unit.vue b/src/views/base/measurement-unit.vue index 11d1181..ebd7613 100644 --- a/src/views/base/measurement-unit.vue +++ b/src/views/base/measurement-unit.vue @@ -119,6 +119,7 @@ export default { return this.isAdmin || this.permission?.[code] === true; }, normalizeRow(row) { + row.unitCode = String(row.unitCode || '').trim(); row.unitName = String(row.unitName || '').trim(); row.dimension = String(row.dimension || '').trim(); row.remark = String(row.remark || '').trim(); diff --git a/src/views/base/port-terminal.vue b/src/views/base/port-terminal.vue index 125e786..06ede97 100644 --- a/src/views/base/port-terminal.vue +++ b/src/views/base/port-terminal.vue @@ -194,10 +194,11 @@ + @@ -264,12 +272,16 @@ import { openImportDialog } from '@/utils/import-excel'; import { formatUpdateUserName } from '@/utils/audit'; import { getToken } from '@/utils/auth'; import { getCoordinateValidationMessage, normalizeCoordinateInput } from '@/utils/coordinate'; +import AddressMapPicker from '@/components/address-map-picker/main.vue'; import NProgress from 'nprogress'; import 'nprogress/nprogress.css'; const DEFAULT_COUNTRY_CODE = '+86'; const newLocal = '请选择类型'; export default { + components: { + AddressMapPicker, + }, data() { const validateCode = (rule, value, callback) => { const code = String(value || '').toUpperCase(); @@ -315,6 +327,7 @@ export default { loading: true, data: [], excelBox: false, + addressMapPickerVisible: false, excelForm: {}, portOptions: [], countryOptions: [], @@ -572,11 +585,11 @@ export default { minWidth: 220, overHidden: false, order: 85, - placeholder: '请输入', + placeholder: '点击选择地图地址', maxlength: 255, showWordLimit: true, rules: [ - { required: true, message: '请输入详细地址', trigger: 'blur' }, + { required: true, message: '请选择详细地址', trigger: 'change' }, { max: 255, message: '详细地址不能超过255字', trigger: 'blur' }, ], }, @@ -673,7 +686,7 @@ export default { propsHttp: { res: 'data', }, - tip: '请上传 .xls,.xlsx 标准格式文件', + tip: '请上传 .xls,.xlsx 标准格式文件;日期支持 2026-08-02、2026-8-2、2026/8/2 等写法', accept: '.xls,.xlsx', action: '/blade-system/port-terminal/import-port-terminal', }, @@ -1040,6 +1053,21 @@ export default { handleCoordinateInput(prop, value) { this.form[prop] = normalizeCoordinateInput(value); }, + openAddressMapPicker() { + this.addressMapPickerVisible = true; + }, + handleAddressMapConfirm(payload) { + const selection = typeof payload === 'string' ? { address: payload } : payload || {}; + if (selection.address) { + this.form.detailAddress = selection.address; + } + if (selection.longitude !== undefined && selection.longitude !== null && selection.longitude !== '') { + this.form.longitude = Number(selection.longitude).toFixed(6); + } + if (selection.latitude !== undefined && selection.latitude !== null && selection.latitude !== '') { + this.form.latitude = Number(selection.latitude).toFixed(6); + } + }, resolveCountryCode(country) { if (!country) { return Promise.resolve(''); @@ -1107,7 +1135,7 @@ export default { return false; } if (isBlank(row.detailAddress)) { - this.$message.warning('请输入详细地址'); + this.$message.warning('请选择详细地址'); return false; } if (isBlank(row.longitude)) { @@ -1399,6 +1427,15 @@ export default { line-height: 20px; padding: 4px 0; } + +.detail-address-input { + cursor: pointer; + + :deep(.el-input__wrapper), + :deep(.el-input__inner) { + cursor: pointer; + } +} diff --git a/src/views/business/components/waybill-manage-page.vue b/src/views/business/components/waybill-manage-page.vue index af185a0..71ea562 100644 --- a/src/views/business/components/waybill-manage-page.vue +++ b/src/views/business/components/waybill-manage-page.vue @@ -3,21 +3,21 @@ :class="[ 'waybill-manage-page', { - 'waybill-manage-page--form-page': isStandaloneWaybillFormPage, - 'waybill-manage-page--detail-page': isStandaloneWaybillDetailPage, + 'waybill-manage-page--form-page': formPageLocked, + 'waybill-manage-page--detail-page': detailPageLocked, }, ]" >
@@ -1747,7 +1747,7 @@
-
- - - +
- + @@ -1406,6 +1493,7 @@ import { getList as getContractList } from '@/api/business/contract-manage'; import { getParentOptions as getCargoTypeOptions } from '@/api/base/cargo-type'; import { getList as getCustomerList } from '@/api/vehicle/customer-archive'; import { getList as getDriverList } from '@/api/transportCapacity/driver'; +import { fetchDriversByCarrierOrganizations } from '@/utils/carrier-org-resource'; import { getList as getProcessConfigList } from '@/api/business/process-config'; import { getList as getCommonAddressList } from '@/api/base/common-address'; import { @@ -1425,6 +1513,7 @@ const defaultCandidateTransportType = ''; const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315'; const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a'; const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills'; +const loadingManageListRefreshKey = 'loading-manage-list-refresh'; let detailAmapLoader; const createSearchForm = () => ({ @@ -1509,6 +1598,14 @@ const normalizeLoadingMileage = value => { return value; }; +// 提交时未填写里程传 null,禁止自动填充 -1 +const toSubmitMileage = value => { + const normalized = normalizeLoadingMileage(value); + if (normalized === '') return null; + const number = Number(normalized); + return Number.isFinite(number) ? number : null; +}; + const createCopiedDialogForm = (detail = {}) => { const form = createDialogForm(); Object.keys(form).forEach(prop => { @@ -1602,7 +1699,6 @@ export default { searchExpanded: false, searchForm: createSearchForm(), selectionRows: [], - dialogVisible: false, dialogMode: 'add', dialogLoading: false, dialogSaving: '', @@ -1672,25 +1768,16 @@ export default { if (!this.taskCarrierLoading && !this.taskCarrierOptions.length) return '暂无可用承运商合同'; return '请选择'; }, - dialogTitle() { - const titleMap = { - add: '新建配载', - edit: '编辑配载单', - view: '配载单详情', - copy: '复制配载单', - reassign: '重新派单', - }; - return titleMap[this.dialogMode] || '配载管理'; - }, detailStatusType() { const typeMap = { pending: 'success', running: 'success', - completed: 'info', + processing: 'success', + completed: 'primary', cancelled: 'info', - draft: 'warning', + draft: 'info', }; - return typeMap[this.dialogForm.businessStatus] || 'info'; + return typeMap[this.dialogForm.businessStatus] || 'warning'; }, taskSummary() { return ( @@ -1699,6 +1786,12 @@ export default { .join(' / ') || '-' ); }, + isSelfCarrierDetail() { + return String(this.dialogForm.carrierType || '').trim() === '自运'; + }, + detailTaskMileage() { + return normalizeLoadingMileage(this.dialogForm.mileage); + }, detailAttachments() { return parseJsonArray(this.dialogForm.attachmentsJson || this.dialogForm.attachmentJson).map( item => @@ -1710,51 +1803,98 @@ export default { } ); }, + // 独立表单页:/business/loading-manage/form?mode=add|edit|reassign isStandaloneFormPage() { return ( this.$route.path === '/business/loading-manage/form' && - ['add', 'edit'].includes(this.$route.query.mode) + ['add', 'edit', 'reassign'].includes(this.$route.query.mode) ); }, + // 独立详情页:/business/loading-manage/detail?id=xxx + isStandaloneDetailPage() { + return this.$route.path === '/business/loading-manage/detail'; + }, + // 列表容器隐藏搜索栏 / 工具栏 / 表格 / 分页的统一开关 + isStandalonePage() { + return this.isStandaloneFormPage || this.isStandaloneDetailPage; + }, + formPageDefaultTitle() { + const titleMap = { + add: '新增配载管理', + edit: '编辑配载管理', + reassign: '重新派单', + }; + return titleMap[this.$route.query.mode] || '新增配载管理'; + }, formPageTitle() { - return ( - this.$route.query.name || `${this.$route.query.mode === 'edit' ? '编辑' : '新增'}配载管理` - ); + return this.$route.query.name || this.formPageDefaultTitle; + }, + detailPageTitle() { + return this.$route.query.name || '配载单详情'; }, }, mounted() { this.loadTransportTypeOptions(); this.loadTable(); - if (this.isStandaloneFormPage) { + if (this.isStandalonePage) { this.syncStandaloneTagTitle(); - this.initStandaloneFormPage(); + if (this.isStandaloneDetailPage) this.initStandaloneDetailPage(); + else this.initStandaloneFormPage(); } + // 兼容旧链接:/business/loading-manage?detailId=xxx 直接跳独立详情页 const detailId = this.$route.query.detailId; - if (detailId) this.openLoadingDialog('view', { id: detailId }); + if (detailId) this.gotoLoadingDetail({ id: detailId }); + }, + // 标签切走(实例被 keep-alive 缓存)或销毁时,先收起页内子弹窗 + deactivated() { + this.closeInnerDialogs(); + }, + activated() { + if (this.isStandalonePage) return; + if (sessionStorage.getItem(loadingManageListRefreshKey)) { + sessionStorage.removeItem(loadingManageListRefreshKey); + this.loadTable(); + } + }, + beforeUnmount() { + this.closeInnerDialogs(); }, watch: { $route(to, from) { - if (this.isStandaloneFormPage) { - this.syncStandaloneTagTitle(); + if (!this.isStandalonePage) { + // 从表单/详情返回列表时刷新,避免草稿确认后仍显示旧状态 if ( - to.query.mode !== from?.query?.mode || - String(to.query.id || '') !== String(from?.query.id || '') || - String(to.query.copyId || '') !== String(from?.query.copyId || '') + from?.path?.includes('/loading-manage/form') || + from?.path?.includes('/loading-manage/detail') ) { - this.initStandaloneFormPage(); + sessionStorage.removeItem(loadingManageListRefreshKey); + this.loadTable(); } - } else if (from?.query?.mode && this.dialogVisible) { - this.closeLoadingDialog(); + return; + } + this.syncStandaloneTagTitle(); + if (this.isStandaloneDetailPage) { + // 记录变化或上一个页面不是详情态(例如从表单页/列表页进来)时重新拉取详情 + const idChanged = String(to.query.id || '') !== String(from?.query?.id || ''); + if (idChanged || this.dialogMode !== 'view') this.initStandaloneDetailPage(); + return; + } + if ( + to.query.mode !== from?.query?.mode || + String(to.query.id || '') !== String(from?.query.id || '') || + String(to.query.copyId || '') !== String(from?.query.copyId || '') + ) { + this.initStandaloneFormPage(); } }, '$route.query.detailId'(detailId) { - if (detailId) this.openLoadingDialog('view', { id: detailId }); + if (detailId) this.gotoLoadingDetail({ id: detailId }); }, }, methods: { syncStandaloneTagTitle() { - if (!this.isStandaloneFormPage) return; - const title = this.$route.query.mode === 'edit' ? '编辑配载管理' : '新增配载管理'; + if (!this.isStandalonePage) return; + const title = this.isStandaloneDetailPage ? this.detailPageTitle : this.formPageDefaultTitle; if (this.$route.query.name === title) return; this.$store.commit('SET_TAG', { fullPath: this.$route.fullPath, @@ -1826,6 +1966,7 @@ export default { if (!row) return '-'; if (prop === 'businessStatus') return this.statusText(row); if (prop === 'transportType') return this.transportTypeLabel(row.transportType); + if (prop === 'goodsInfo') return this.formatCandidateGoodsInfo(row); const column = waybillOption.column.find(item => item.prop === prop); if (column?.formatter) { const value = column.formatter(row, {}, row[prop]); @@ -1834,14 +1975,281 @@ export default { const value = row[prop]; return value === undefined || value === null || value === '' ? '-' : value; }, + formatCandidateGoodsInfo(row = {}) { + const goodsRows = + [row.goodsJson, row.goodsList, row.goodsRows, row.cargoList, row.cargoRows, row.goods] + .map(source => { + if (source === undefined || source === null || source === '') return []; + if (Array.isArray(source)) return source; + if (typeof source === 'object') return [source]; + return parseJsonArray(source); + }) + .filter(source => source.length) + .sort((left, right) => right.length - left.length)[0] || []; + if (!goodsRows.length) return String(row.goodsInfo || row.cargoInfo || '') || '-'; + const groups = goodsRows.reduce((result, item = {}) => { + const unit = String( + item.quantityUnit || + item.goodsQuantityUnit || + item.cargoUnit || + item.unit || + row.quantityUnit || + row.goodsQuantityUnit || + row.cargoUnit || + row.unit || + '' + ).trim(); + const key = unit || '__empty__'; + if (!result[key]) { + result[key] = { + typeName: + item.secondCargoTypeName || + item.secondCargoType || + item.cargoType || + item.goodsType || + item.typeName || + '货物', + count: 0, + quantity: 0, + unit, + }; + } + result[key].count += 1; + result[key].quantity += this.parseGoodsQuantity( + item.quantity ?? item.cargoQuantity ?? item.goodsQuantity + ); + return result; + }, {}); + return ( + Object.values(groups) + .map(group => { + const quantity = this.formatGoodsQuantity(group.quantity); + return `${group.typeName}等${group.count}种货物 | ${quantity}${ + group.unit ? ` ${group.unit}` : '' + }`; + }) + .join('; ') || '-' + ); + }, + parseGoodsQuantity(value) { + if (value === undefined || value === null || value === '') return 0; + const text = String(value).replace(/,/g, ''); + const match = text.match(/-?\d+(\.\d+)?/); + if (!match) return 0; + const number = Number(match[0]); + return Number.isFinite(number) ? number : 0; + }, + formatGoodsQuantity(value) { + const number = Number(value || 0); + if (!Number.isFinite(number)) return '0'; + const fixed = Math.round((number + Number.EPSILON) * 1000) / 1000; + return Number.isInteger(fixed) ? String(fixed) : String(fixed).replace(/\.?0+$/, ''); + }, formatCandidateDate(value) { if (!value) return '-'; const date = dayjs(value); return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : value; }, formatCandidateAddress(row, type) { - const value = row?.[`${type}Address`] || row?.[`${type}Name`] || ''; - return this.formatProvinceCityDistrict(value) || '-'; + const prefix = type === 'departure' ? 'departure' : 'arrival'; + const name = String(row?.[`${prefix}Name`] || '').trim(); + const address = String(row?.[`${prefix}Address`] || '').trim(); + if (!this.isRoadTransportType(row?.transportType)) { + return name || address || '-'; + } + const region = this.mergeRouteAddressParts( + this.routeProvinceName(row, prefix), + row[`${prefix}CityName`] || row[`${prefix}City`], + row[`${prefix}DistrictName`] || row[`${prefix}District`] + ); + const source = this.mergeRouteAddressParts(region, '', address || name); + const parts = this.parseWaybillAddress(source); + return ( + [parts.province, parts.city, parts.district] + .map(value => String(value || '').trim()) + .filter(Boolean) + .join('') || + this.formatProvinceCityDistrict(source) || + name || + address || + '-' + ); + }, + formatCandidateAddressTooltip(row, type) { + if (!this.isRoadTransportType(row?.transportType)) return ''; + const prefix = type === 'departure' ? 'departure' : 'arrival'; + const address = String(row?.[`${prefix}Address`] || '').trim(); + const name = String(row?.[`${prefix}Name`] || '').trim(); + const region = this.mergeRouteAddressParts( + this.routeProvinceName(row, prefix), + row[`${prefix}CityName`] || row[`${prefix}City`], + row[`${prefix}DistrictName`] || row[`${prefix}District`] + ); + const detailAddress = + row[`${prefix}DetailAddress`] || row[`${prefix}AddressDetail`] || address; + return this.mergeRouteAddressParts(region, '', detailAddress || name) || ''; + }, + isRoadTransportType(type) { + const value = String(type || '') + .trim() + .toLowerCase(); + return ( + value === 'road' || + value.includes('公路') || + value.includes('道路') || + value.includes('highway') + ); + }, + // 与总单详情一致:公路地址压缩为「市 区县」 + formatRoadLocation(value) { + const text = String(value || '') + .replace(/[\//\s]+/g, '') + .trim(); + if (!text) return ''; + + const parts = this.parseWaybillAddress(text); + let city = String(parts.city || '').trim(); + let district = String(parts.district || '').trim(); + let detail = String(parts.detailAddress || '').trim(); + + // 自治州/地区/盟下还有「XX市 + 区县」时,以市级作为展示主体 + if (/(?:自治州|地区|盟)$/.test(city) && detail) { + const nestedCityMatch = detail.match(/^(.+?市)/); + if (nestedCityMatch) { + city = nestedCityMatch[1]; + detail = detail.slice(nestedCityMatch[1].length); + const nestedDistrictMatch = detail.match( + /^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/ + ); + if (nestedDistrictMatch) district = nestedDistrictMatch[1]; + } else if (!district) { + const nestedDistrictMatch = detail.match( + /^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/ + ); + if (nestedDistrictMatch) district = nestedDistrictMatch[1]; + } + } + + // 市级后紧跟区县,但 parse 未拆出时再补一次 + if (city && !district && detail) { + const nestedDistrictMatch = detail.match( + /^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/ + ); + if (nestedDistrictMatch) district = nestedDistrictMatch[1]; + } + + if (city && district) { + // 兼容「梅县区」:命中「县」后还需保留后面的「区」 + if (district.endsWith('县') && detail.startsWith(`${district}区`)) { + district = `${district}区`; + } + return `${city} ${district}`; + } + if (city) return city; + if (district) return district; + + // 兜底压缩 + const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, ''); + const cityMatch = withoutProvince.match(/.+?(?:市|自治州|州(?!市)|盟(?!市)|地区)/); + if (!cityMatch) return text; + const fallbackCity = cityMatch[0]; + const districtSource = withoutProvince.slice(fallbackCity.length); + const districtMatch = districtSource.match( + /^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/ + ); + if (!districtMatch) { + // 州后面是县级市时,继续解析市 + 区 + const nestedCityMatch = districtSource.match(/^(.+?市)/); + if (nestedCityMatch) { + const nestedDistrictSource = districtSource.slice(nestedCityMatch[1].length); + const nestedDistrictMatch = nestedDistrictSource.match( + /^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/ + ); + if (nestedDistrictMatch) { + return `${nestedCityMatch[1]} ${nestedDistrictMatch[0]}`; + } + return nestedCityMatch[1]; + } + return fallbackCity; + } + const fallbackDistrict = `${districtMatch[0]}${ + districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : '' + }`; + return `${fallbackCity} ${fallbackDistrict}`; + }, + isStationLikeName(value) { + const text = String(value || '').trim(); + if (!text) return false; + return ( + /(?:火车站|高铁站|客运站|货运站|港口|码头|机场|空港|航站楼)$/.test(text) || + /(? this.formatListRouteShort(row, value, value)) + .filter(value => value && value !== '-'); + return [...new Set(values)].join(',') || '-'; } - const values = [ - row[`${prefix}RegionName`], - row[`${prefix}DistrictName`], - row[`${prefix}Address`], - ] - .filter(Boolean) - .flatMap(value => this.splitText(value)) - .map(value => this.formatProvinceCityDistrict(value)) - .filter(Boolean); - return [...new Set(values)].join(',') || '-'; + const name = String(row[`${prefix}Name`] || '').trim(); + const address = String(row[`${prefix}Address`] || '').trim(); + const cityName = String( + row[`${prefix}CityName`] || row[`${prefix}City`] || '' + ).trim(); + const districtName = String( + row[`${prefix}DistrictName`] || row[`${prefix}District`] || '' + ).trim(); + if ( + this.isRoadTransportType(row?.transportType) && + cityName && + districtName + ) { + return `${cityName} ${districtName}`; + } + const region = this.mergeRouteAddressParts( + this.routeProvinceName(row, prefix), + cityName, + districtName + ); + const source = this.mergeRouteAddressParts(region, '', address || name); + return this.formatListRouteShort(row, name, source || address || name); }, formatFullRouteAddress(row, type) { + if (!this.isRoadTransportType(row?.transportType)) return ''; const prefix = type === 'departure' ? 'departure' : type === 'arrival' ? 'arrival' : 'transit'; - if (type !== 'transit') { - const address = row[`${prefix}Address`]; - const region = this.routeProvinceName(row, prefix); - const district = row[`${prefix}DistrictName`]; - return this.mergeRouteAddressParts(region, district, address) || '-'; + if (type === 'transit') { + const values = this.splitText(row.transitAddress || '') + .map(value => String(value || '').trim()) + .filter(Boolean); + const full = [...new Set(values)].join(','); + const short = this.formatRouteAddress(row, 'transit'); + return full && full !== short ? full : ''; } - const values = [row.transitRegionName, row.transitDistrictName, row.transitAddress] - .filter(Boolean) - .flatMap(value => this.splitText(value)); - return [...new Set(values)].join(',') || '-'; + const name = String(row[`${prefix}Name`] || '').trim(); + const address = String(row[`${prefix}Address`] || '').trim(); + const region = this.mergeRouteAddressParts( + this.routeProvinceName(row, prefix), + row[`${prefix}CityName`] || row[`${prefix}City`], + row[`${prefix}DistrictName`] || row[`${prefix}District`] + ); + const full = this.mergeRouteAddressParts(region, '', address || name); + const short = this.formatRouteAddress(row, type); + if (!full) return ''; + return full === short ? '' : full; + }, + // 列表短地址:公路「市 区县」,非公路仅名称 + formatListRouteShort(row = {}, name = '', address = '') { + const rawName = String(name || '').trim(); + const rawAddress = String(address || '').trim(); + if (!this.isRoadTransportType(row?.transportType)) { + if (rawName) return rawName; + if (this.isStationLikeName(rawAddress)) return rawAddress; + return rawAddress || '-'; + } + const source = rawAddress || rawName; + const formatted = this.formatRoadLocation(source); + if (/(?:市|州|盟)/.test(formatted)) return formatted; + return formatted || rawName || rawAddress || '-'; }, formatWaybillAddress(row, type) { const prefix = type === 'departure' ? 'departure' : 'arrival'; @@ -2014,49 +2457,81 @@ export default { if (status === 'draft') { return [ { type: 'edit', label: '编辑' }, - { type: 'copy', label: '复制' }, { type: 'delete', label: '删除' }, ]; } if (status === 'pending') { - return [ + const actions = [ { type: 'view', label: '详情' }, - { type: 'reassign', label: '重新派单' }, - { type: 'edit', label: '编辑' }, - { type: 'copy', label: '复制' }, - { type: 'cancel', label: '取消' }, + { type: 'start', label: '改为进行中' }, ]; + if (row.driverRejected) { + actions.push({ type: 'reassign', label: '重新派单' }); + } + actions.push({ type: 'edit', label: '编辑' }, { type: 'cancel', label: '取消' }); + return actions; } if (status === 'running') { return [ { type: 'view', label: '详情' }, - { type: 'copy', label: '复制' }, { type: 'complete', label: '完成' }, ]; } if (status === 'completed' || status === 'cancelled') { - return [ - { type: 'view', label: '详情' }, - { type: 'copy', label: '复制' }, - ]; + return [{ type: 'view', label: '详情' }]; } return [{ type: 'view', label: '详情' }]; }, handleRowAction(type, row) { const actionMap = { - view: () => this.openLoadingDialog('view', row), + view: () => this.gotoLoadingDetail(row), edit: () => this.openLoadingDialog('edit', row), - reassign: () => this.openLoadingDialog('reassign', row), - copy: () => this.copyLoading(row), + reassign: () => this.gotoLoadingReassign(row), delete: () => this.deleteLoading(row), + start: () => this.startLoading(row), cancel: () => this.cancelLoading(row), complete: () => this.completeLoading(row), }; actionMap[type]?.(); }, + // 详情改为独立页面(与新增/编辑一致) + gotoLoadingDetail(row = {}) { + const id = row.id || row.loadingId || row.loadingManageId; + if (!id) { + ElMessage.info('当前配载单暂无详情数据'); + return; + } + if (this.isStandaloneDetailPage && String(this.$route.query.id || '') === String(id)) return; + this.$router.push({ + path: '/business/loading-manage/detail', + query: { id, name: '配载单详情' }, + }); + }, + // 重新派单改为独立页面 + gotoLoadingReassign(row = {}) { + if (!row.id) { + ElMessage.warning('配载单不存在,无法重新派单'); + return; + } + this.$router.push({ + path: '/business/loading-manage/form', + query: { mode: 'reassign', id: row.id, name: '重新派单' }, + }); + }, + async initStandaloneDetailPage() { + if (!this.isStandaloneDetailPage) return; + const id = this.$route.query.id; + if (!id) { + ElMessage.warning('缺少配载单参数,无法查看详情'); + this.closeLoadingDialog(); + return; + } + await this.openLoadingDialog('view', { id }); + }, async initStandaloneFormPage() { if (!this.isStandaloneFormPage) return; - const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add'; + const queryMode = this.$route.query.mode; + const mode = ['edit', 'reassign'].includes(queryMode) ? queryMode : 'add'; const copyId = mode === 'add' ? this.$route.query.copyId : ''; await this.openLoadingDialog(mode, { id: copyId || this.$route.query.id, @@ -2077,7 +2552,12 @@ export default { } }, async openLoadingDialog(mode, row) { - if (['add', 'edit'].includes(mode) && !this.isStandaloneFormPage) { + // 列表页进入新增/编辑/重新派单:统一走独立页面 + if (['add', 'edit', 'reassign'].includes(mode) && !this.isStandaloneFormPage) { + if (mode === 'reassign') { + this.gotoLoadingReassign(row || {}); + return; + } this.$router.push({ path: '/business/loading-manage/form', query: { @@ -2089,7 +2569,6 @@ export default { return; } this.dialogMode = mode; - this.dialogVisible = true; this.dialogLoading = true; this.resetDialogData(); if (mode === 'add') this.candidateQuery.transportType = 'road'; @@ -2100,7 +2579,8 @@ export default { this.dialogForm = row.copy ? createCopiedDialogForm(detail) : { ...createDialogForm(), ...detail }; - this.dialogForm.mileage = normalizeLoadingMileage(this.dialogForm.mileage); + this.applyLoadingTaskInfoFromDetail(this.dialogForm); + this.applyLoadingMileageFromDetail(this.dialogForm); await this.restoreWaybillRows( this.dialogForm.waybillIdsJson, this.dialogForm.loadingSubNos @@ -2122,17 +2602,9 @@ export default { } }, closeLoadingDialog() { - if (this.isStandaloneFormPage) { - this.$router.$avueRouter?.closeTag?.(); - this.$router.push({ path: '/business/loading-manage', query: {} }); - return; - } - this.dialogVisible = false; - }, - resetLoadingDialog() { - this.resetDialogData(); - this.dialogSaving = ''; - this.dialogLoading = false; + if (!this.isStandalonePage) return; + this.$router.$avueRouter?.closeTag?.(); + this.$router.push({ path: '/business/loading-manage', query: {} }); }, resetDialogData() { this.detailAmap?.destroy?.(); @@ -2154,10 +2626,11 @@ export default { this.processProjectId = ''; this.processProjectOptions = []; }, - clearLoadingDialog() { - this.resetDialogData(); - if (this.dialogMode === 'add') this.candidateQuery.transportType = 'road'; - this.loadCandidateWaybills(); + // 离开本页(标签缓存/卸载)时收起页内子弹窗,避免 append-to-body 的弹窗 DOM 残留在 body 上 + closeInnerDialogs() { + this.routeChangeVisible = false; + this.commonAddressVisible = false; + this.candidateSearchVisible = false; }, restoreRouteAndCargo() { const routeRows = parseJsonArray(this.dialogForm.routeJson); @@ -2444,6 +2917,8 @@ export default { ...this.buildQueryParams(this.candidateQuery), businessStatus: 'pending', onlyUnassignedLoading: 1, + // 仅查当前账户所属组织及子组织下的运单 + allDept: 0, ...(this.dialogMode === 'add' ? { transportType: 'road' } : {}), }; const transportType = this.getCandidateTransportTypeQuery(query.transportType); @@ -2466,10 +2941,6 @@ export default { if (['road', '公路运输'].includes(normalized)) return 'road,公路运输'; return value || ''; }, - openCandidateSearch() { - this.candidateSearchVisible = true; - this.loadCandidateWaybills(); - }, handleCandidateSearch() { this.candidatePage.currentPage = 1; this.loadCandidateWaybills(); @@ -2542,12 +3013,16 @@ export default { .map(row => row.waybillNo) .filter(Boolean) .join(','); - this.dialogForm.waybillIdsJson = JSON.stringify( - rows - .map(row => row.id) - .filter(Boolean) - .map(id => String(id)) - ); + const rowIds = rows + .map(row => row.id) + .filter(Boolean) + .map(id => String(id)); + // 回显失败仅有运单号时,保留原 waybillIdsJson,避免确认提交清空运单绑定 + if (rowIds.length) { + this.dialogForm.waybillIdsJson = JSON.stringify(rowIds); + } else if (!String(this.dialogForm.waybillIdsJson || '').trim()) { + this.dialogForm.waybillIdsJson = '[]'; + } this.dialogForm.projectName = uniqueText(rows, 'projectName'); this.dialogForm.customerName = uniqueText(rows, 'customerName'); this.dialogForm.originalNo = uniqueText(rows, 'originalNo'); @@ -2571,11 +3046,11 @@ export default { }; rows.forEach((row, rowIndex) => { const departure = row.departureAddress || row.departureName; - appendNode(departure, this.formatWaybillAddress(row, 'departure'), `装${rowIndex + 1}`); + appendNode(departure, this.formatCandidateAddress(row, 'departure'), `装${rowIndex + 1}`); }); rows.forEach((row, rowIndex) => { const arrival = row.arrivalAddress || row.arrivalName; - appendNode(arrival, this.formatWaybillAddress(row, 'arrival'), `卸${rowIndex + 1}`); + appendNode(arrival, this.formatCandidateAddress(row, 'arrival'), `卸${rowIndex + 1}`); }); return [...nodeMap.values()].map((node, index) => ({ ...node, @@ -2674,29 +3149,95 @@ export default { remark: item.remark || '', }; }, + applyLoadingMileageFromDetail(form = this.dialogForm) { + let taskMileage = ''; + try { + const taskInfo = JSON.parse(form.taskInfoJson || '{}'); + taskMileage = taskInfo?.mileage; + } catch (error) { + taskMileage = ''; + } + form.mileage = normalizeLoadingMileage( + form.mileage !== undefined && form.mileage !== null && form.mileage !== '' + ? form.mileage + : taskMileage + ); + }, + applyLoadingTaskInfoFromDetail(form = this.dialogForm) { + let taskInfo = {}; + try { + taskInfo = JSON.parse(form.taskInfoJson || '{}') || {}; + } catch (error) { + taskInfo = {}; + } + if (!taskInfo || typeof taskInfo !== 'object' || Array.isArray(taskInfo)) return; + const fields = [ + 'carrierType', + 'carrierName', + 'carrierContractId', + 'driverName', + 'driverPhone', + 'vehicleNo', + 'trailerVehicleNo', + 'escortName', + 'escortPhone', + 'estimatedStartDate', + 'estimatedEndDate', + 'taskRemark', + ]; + fields.forEach(prop => { + if ( + (form[prop] === undefined || form[prop] === null || form[prop] === '') && + taskInfo[prop] !== undefined && + taskInfo[prop] !== null && + taskInfo[prop] !== '' + ) { + form[prop] = taskInfo[prop]; + } + }); + if ( + (form.mileage === undefined || form.mileage === null || form.mileage === '') && + taskInfo.mileage !== undefined + ) { + form.mileage = normalizeLoadingMileage(taskInfo.mileage); + } + }, + detailTaskField(prop) { + const value = String(this.dialogForm?.[prop] ?? '').trim(); + return value || '-'; + }, + detailTaskDate(prop) { + const value = String(this.dialogForm?.[prop] ?? '').trim(); + return value ? value.slice(0, 10) : '-'; + }, normalizePayload() { this.syncRouteAddressFields(); this.rebuildSummaryFromWaybills(false); this.dialogForm.mileage = normalizeLoadingMileage(this.dialogForm.mileage); + const mileage = toSubmitMileage(this.dialogForm.mileage); + const taskInfo = { + carrierType: this.dialogForm.carrierType, + carrierName: this.dialogForm.carrierName, + carrierContractId: this.dialogForm.carrierContractId, + driverName: this.dialogForm.driverName, + driverPhone: this.dialogForm.driverPhone, + vehicleNo: this.dialogForm.vehicleNo, + trailerVehicleNo: this.dialogForm.trailerVehicleNo, + escortName: this.dialogForm.escortName, + escortPhone: this.dialogForm.escortPhone, + estimatedStartDate: this.dialogForm.estimatedStartDate, + estimatedEndDate: this.dialogForm.estimatedEndDate, + taskRemark: this.dialogForm.taskRemark, + }; + if (mileage !== null) { + taskInfo.mileage = mileage; + } return { ...this.dialogForm, + mileage, routeJson: JSON.stringify(this.routeNodes), goodsJson: JSON.stringify(this.cargoRows), - taskInfoJson: JSON.stringify({ - carrierType: this.dialogForm.carrierType, - carrierName: this.dialogForm.carrierName, - carrierContractId: this.dialogForm.carrierContractId, - driverName: this.dialogForm.driverName, - driverPhone: this.dialogForm.driverPhone, - vehicleNo: this.dialogForm.vehicleNo, - trailerVehicleNo: this.dialogForm.trailerVehicleNo, - escortName: this.dialogForm.escortName, - escortPhone: this.dialogForm.escortPhone, - mileage: this.dialogForm.mileage, - estimatedStartDate: this.dialogForm.estimatedStartDate, - estimatedEndDate: this.dialogForm.estimatedEndDate, - taskRemark: this.dialogForm.taskRemark, - }), + taskInfoJson: JSON.stringify(taskInfo), }; }, validatePayload(draftMode = false) { @@ -2751,7 +3292,7 @@ export default { return true; }, async saveDraft() { - if (!this.validatePayload(true)) return; + // 暂存不做任何业务校验,直接保存 this.dialogSaving = 'draft'; try { const res = await loadingApi.saveDraft(this.normalizePayload()); @@ -2759,7 +3300,7 @@ export default { ...this.dialogForm, ...(res.data?.data || res.data || {}), }; - this.dialogForm.mileage = normalizeLoadingMileage(this.dialogForm.mileage); + this.applyLoadingMileageFromDetail(this.dialogForm); ElMessage.success('暂存成功'); this.loadTable(); } finally { @@ -2770,7 +3311,11 @@ export default { if (!this.validatePayload(false)) return; this.dialogSaving = 'submit'; try { - const payload = this.normalizePayload(); + const payload = { + ...this.normalizePayload(), + // 确认生成正式配载单(待执行),覆盖草稿状态 + businessStatus: 'pending', + }; if (this.dialogMode === 'reassign') { await loadingApi.reassign(payload); ElMessage.success('派单成功'); @@ -2778,22 +3323,13 @@ export default { await loadingApi.submit(payload); ElMessage.success('确认成功'); } + sessionStorage.setItem(loadingManageListRefreshKey, '1'); this.closeLoadingDialog(); this.loadTable(); } finally { this.dialogSaving = ''; } }, - copyLoading(row) { - this.$router.push({ - path: '/business/loading-manage/form', - query: { - mode: 'add', - copyId: row.id, - name: '新增配载管理', - }, - }); - }, deleteLoading(row) { ElMessageBox.confirm(`确定删除配载单 ${row.loadingNo}?`, '提示', { type: 'warning' }) .then(async () => { @@ -2813,6 +3349,15 @@ export default { }) .catch(() => {}); }, + startLoading(row) { + ElMessageBox.confirm(`确定将配载单 ${row.loadingNo} 改为进行中?`, '提示', { type: 'warning' }) + .then(async () => { + await loadingApi.start(row.id); + ElMessage.success('已改为进行中'); + this.loadTable(); + }) + .catch(() => {}); + }, completeLoading(row) { ElMessageBox.confirm(`确定完成配载单 ${row.loadingNo}?`, '提示', { type: 'warning' }) .then(async () => { @@ -2864,6 +3409,7 @@ export default { this.dialogForm.carrierContractId = ''; this.dialogForm.carrierName = ''; this.dialogForm.carrierId = ''; + this.clearDialogDriverVehicleFields(); if (this.dialogForm.carrierType === '承运商') { this.dialogForm.trailerVehicleNo = ''; this.dialogForm.escortName = ''; @@ -2883,6 +3429,23 @@ export default { this.dialogForm.carrierContractId = this.dialogForm.carrierType === '承运商' && contract?.id ? contract.id : ''; this.dialogForm.carrierName = contract?.carrierName || ''; + this.dialogForm.carrierId = contract?.carrierId || contract?.customerId || ''; + this.clearDialogDriverVehicleFields(); + }, + clearDialogDriverVehicleFields() { + this.dialogForm.driverName = ''; + this.dialogForm.driverPhone = ''; + this.dialogForm.vehicleNo = ''; + this.dialogForm.trailerVehicleNo = ''; + this.dialogForm.escortName = ''; + this.dialogForm.escortPhone = ''; + this.driverOptions = []; + }, + getDialogCarrierFilter() { + return { + carrierId: this.dialogForm.carrierId || '', + carrierName: this.dialogForm.carrierName || '', + }; }, handleDriverChange(value) { const driver = this.driverOptions.find(item => (item.driverName || item.name) === value); @@ -2894,7 +3457,7 @@ export default { } }, fetchDriverSuggestions(queryString, callback) { - this.loadDriverOptions(String(queryString || '').trim()) + this.loadDialogDriverOptions(String(queryString || '').trim()) .then(() => callback( this.driverOptions.map(item => ({ @@ -2917,11 +3480,19 @@ export default { }, fetchEscortSuggestions(queryString, callback) { const keyword = String(queryString || '').trim(); + const carrier = this.getDialogCarrierFilter(); + if (!carrier.carrierId && !carrier.carrierName) { + callback([]); + return; + } this.escortLoading = true; - getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '押运员' }) - .then(res => { + fetchDriversByCarrierOrganizations( + { size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '押运员' }, + carrier + ) + .then(records => { callback( - unwrapRecords(res).map(item => ({ + records.map(item => ({ ...item, value: item.driverName || item.name || '', })) @@ -2941,6 +3512,7 @@ export default { handleMileageInput(value) { this.dialogForm.mileage = String(value || '').replace(/[^\d.]/g, ''); }, + normalizeLoadingMileage, transportTypeLabel(value) { if (!value) return '-'; return ( @@ -2990,12 +3562,29 @@ export default { this.driverLoading = true; try { this.driverOptions = unwrapRecords( - await getDriverList(1, 20, { driverName: keyword, posts: '司机' }) + await getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '司机' }) ); } finally { this.driverLoading = false; } }, + async loadDialogDriverOptions(keyword = '') { + const carrier = this.getDialogCarrierFilter(); + if (!carrier.carrierId && !carrier.carrierName) { + this.driverOptions = []; + return []; + } + this.driverLoading = true; + try { + this.driverOptions = await fetchDriversByCarrierOrganizations( + { size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '司机' }, + carrier + ); + return this.driverOptions; + } finally { + this.driverLoading = false; + } + }, async loadCarrierOptions(keyword = '') { this.carrierLoading = true; try { @@ -3057,15 +3646,39 @@ export default { } else { this.dialogForm.carrierContractId = ''; this.dialogForm.carrierName = ''; + this.dialogForm.carrierId = ''; } return this.taskCarrierOptions; } if (this.dialogForm.carrierType === '自运' && contracts.length) { - this.taskCarrierOptions = contracts; - const current = contracts.find(item => item.carrierName === this.dialogForm.carrierName); + this.taskCarrierOptions = contracts + .map(item => ({ + ...item, + carrierName: + item.partyB || + item.partyBName || + item.contractPartyB || + item.customerContractPartyB || + item.carrierName || + '', + carrierId: + item.partyBId || + item.partyBUserId || + item.contractPartyBId || + item.customerContractPartyBId || + item.carrierId || + '', + })) + .filter(item => item.carrierName); + const current = this.taskCarrierOptions.find( + item => item.carrierName === this.dialogForm.carrierName + ); this.dialogForm.carrierContractId = ''; - this.dialogForm.carrierName = current?.carrierName || contracts[0].carrierName || ''; - return contracts; + this.dialogForm.carrierName = + current?.carrierName || this.taskCarrierOptions[0]?.carrierName || ''; + this.dialogForm.carrierId = + current?.carrierId || this.taskCarrierOptions[0]?.carrierId || ''; + return this.taskCarrierOptions; } if (this.dialogForm.carrierType !== '自运') { this.taskCarrierOptions = []; @@ -3099,6 +3712,7 @@ export default { })); this.dialogForm.carrierContractId = ''; this.dialogForm.carrierName = partyBNames[0] || ''; + this.dialogForm.carrierId = ''; return this.taskCarrierOptions; } catch (error) { if (requestId !== this.taskCarrierRequestId) return []; @@ -3271,8 +3885,8 @@ export default { } .loading-manage-dialog__candidate-search { - padding: 4px 0; - margin-bottom: 8px; + padding: 0; + margin-bottom: 0; } .loading-manage-dialog__waybill-route-layout { @@ -3293,7 +3907,6 @@ export default { display: flex; align-items: center; justify-content: flex-end; - gap: 8px; } .loading-manage-dialog__table-toolbar { @@ -3437,13 +4050,17 @@ export default { display: flex; align-items: center; flex-wrap: wrap; - gap: 24px; + gap: 12px; padding: 4px 0 16px; - } - .loading-detail__heading { - font-size: 20px; - font-weight: 600; - margin-right: 8px; + + :deep(.el-tag) { + height: 28px; + padding: 0 12px; + border: 0; + border-radius: 8px; + font-size: 13px; + line-height: 28px; + } } .loading-detail__route-title { display: inline-flex; @@ -3456,9 +4073,24 @@ export default { } .loading-detail__task-row { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1fr) 220px; gap: 24px; padding-bottom: 4px; + align-items: start; + } + .loading-detail__task-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + } + .loading-detail__task-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px 24px; + } + .loading-detail__attachment-field { + min-width: 0; } .loading-detail__field { display: flex; @@ -3474,6 +4106,8 @@ export default { flex-wrap: wrap; gap: 8px; text-align: left; + color: #303133; + word-break: break-all; } .loading-detail__steps { display: flex; @@ -3530,6 +4164,14 @@ export default { overflow: hidden; text-overflow: ellipsis; } + .loading-detail__step-address-text { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + cursor: default; + } .loading-detail__step-tags { display: flex; justify-content: center; @@ -3627,7 +4269,9 @@ export default { box-sizing: border-box; background: #f5f6fa; } -:global(.loading-manage-form-page .loading-manage-dialog__body) { +/* 独立页面(新增/编辑/重新派单/详情):内容随 #avue-view 自然滚动,不做内嵌 78vh 滚动盒。 + 此处权重必须高于 .loading-manage-dialog .loading-manage-dialog__body,故带上 .loading-manage-page 外壳。 */ +:global(.loading-manage-page .loading-manage-form-dialog.loading-manage-form-page .loading-manage-dialog__body) { max-height: none; overflow: visible; padding-right: 0; @@ -3714,5 +4358,9 @@ export default { .loading-detail__bottom-grid { grid-template-columns: 1fr; } + + .loading-detail__task-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/src/views/business/master-order.vue b/src/views/business/master-order.vue index 13fb57a..5642ebf 100644 --- a/src/views/business/master-order.vue +++ b/src/views/business/master-order.vue @@ -1,57 +1,58 @@