diff --git a/src/views/business/loading-manage.vue b/src/views/business/loading-manage.vue index 459c6dc..7fc4461 100644 --- a/src/views/business/loading-manage.vue +++ b/src/views/business/loading-manage.vue @@ -609,31 +609,195 @@ show-overflow-tooltip />
- + + +
+ + +
+
+ {{ record.nodeName || record.nodeCode || '打卡' }} + {{ + record.statusName || (record.punched ? '已打卡' : '未打卡') + }} + 在途 + 异常 +
+ +
暂未打卡
+
+
+
+ +
+
+ +
+ + 返回文件夹 + + {{ voucherFolderName(voucherFolder) }} +
+
+
+ +
+ {{ voucherFolderName(image) }} +
+
+ +
+
+ +
+
+ +
+ {{ photo.label || '凭证' }} +
+
+
+
+
+
+ - + + + 常用 +
@@ -1374,8 +1570,11 @@
{{ tag }} + >
关闭 + +
+ + + 搜索 + +
+
+
+ +
+
+ {{ routeChangeMapStatus + }} 行政区划:{{ routeChangeMapSelected.regionName }} +
+ + + @@ -1488,13 +1743,18 @@ import { getList as getPlanList } from '@/api/business/transport-plan'; import { getList as getWaybillList, getDetail as getWaybillDetail, + getPunchRecords as getWaybillPunchRecords, } from '@/api/business/waybill-manage'; 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 getProcessConfigList, + getDetail as getProcessConfigDetail, + getVoucherImages as getProcessConfigVoucherImages, +} from '@/api/business/process-config'; import { getList as getCommonAddressList } from '@/api/base/common-address'; import { carrierTypeOptions, @@ -1504,9 +1764,10 @@ import { } from '@/option/business/loading-manage'; import { option as waybillOption } from '@/option/business/waybill-manage'; import { ElMessage, ElMessageBox } from 'element-plus'; -import { Location, Rank, Refresh, Setting } from '@element-plus/icons-vue'; +import { Location, Rank, Refresh, Search, Setting } from '@element-plus/icons-vue'; import dayjs from 'dayjs'; import { isMobile } from '@/utils/validate'; +import { normalizeMapSearchResults } from '@/utils/map-search'; const defaultTransportType = ''; const defaultCandidateTransportType = ''; @@ -1598,6 +1859,21 @@ const normalizeLoadingMileage = value => { return value; }; +// 货物等业务字段用 -1 表示未填写,展示时留空 +const blankCargoSentinel = value => { + if (value === undefined || value === null || value === '') return ''; + if (Number(value) === -1) return ''; + return value; +}; + +const sanitizeCargoRow = (row = {}) => { + const result = { ...row }; + Object.keys(result).forEach(key => { + result[key] = blankCargoSentinel(result[key]); + }); + return result; +}; + // 提交时未填写里程传 null,禁止自动填充 -1 const toSubmitMileage = value => { const normalized = normalizeLoadingMileage(value); @@ -1611,6 +1887,14 @@ const createCopiedDialogForm = (detail = {}) => { Object.keys(form).forEach(prop => { if (Object.prototype.hasOwnProperty.call(detail, prop)) form[prop] = detail[prop]; }); + let taskInfo = {}; + try { + const parsed = JSON.parse(form.taskInfoJson || '{}'); + taskInfo = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (error) { + taskInfo = {}; + } + const { routeChangeRecords, ...copiedTaskInfo } = taskInfo; return { ...form, id: '', @@ -1620,7 +1904,7 @@ const createCopiedDialogForm = (detail = {}) => { dataSource: '手工创建', currentProcessNode: '接单', mileage: normalizeLoadingMileage(form.mileage), - taskInfoJson: '', + taskInfoJson: JSON.stringify(copiedTaskInfo), businessStatus: 'draft', }; }; @@ -1680,12 +1964,14 @@ const uniqueText = (rows, prop) => export default { components: { Rank, + Search, }, data() { return { Rank, Location, Refresh, + Search, Setting, loading: false, tableSize: 'default', @@ -1740,9 +2026,22 @@ export default { statusOptions: loadingStatusOptions, dataSourceOptions: loadingManageConfig.dataSourceOptions, config: loadingManageConfig, - processNodes: [], - processProjectId: '', - processProjectOptions: [], + processDetailTab: 'punch', + processWaybillId: '', + processWaybillOptions: [], + punchRecords: [], + punchRecordsLoading: false, + driverUploads: [], + hasRelatedVoucher: false, + voucherImages: [], + voucherFolders: [], + voucherFolder: null, + voucherFolderView: false, + voucherImagesLoading: false, + voucherImagesLoaded: false, + attachmentImagePreviewVisible: false, + attachmentImagePreviewUrls: [], + attachmentImagePreviewIndex: 0, routeChangeVisible: false, routeChangeTab: 'change', routeChangeRows: [], @@ -1750,12 +2049,24 @@ export default { routeChangeRemark: '', routeChangeRecords: [], routeChangeDragIndex: -1, + routeChangeOriginalAddresses: [], + routeChangeOrderDirty: false, commonAddressVisible: false, commonAddressLoading: false, commonAddressRows: [], commonAddressQuery: { addressName: '' }, commonAddressPage: { currentPage: 1, pageSize: 10, total: 0 }, commonAddressTarget: null, + routeChangeMapVisible: false, + routeChangeMapLoading: false, + routeChangeMapKeyword: '', + routeChangeMapStatus: '可搜索地址或点击地图选点', + routeChangeMapSelected: {}, + routeChangeMapSearchResults: [], + routeChangeMapTarget: null, + routeChangeMapInstance: null, + routeChangeMapMarker: null, + routeChangeMapGeocoder: null, detailAmap: null, }; }, @@ -1763,6 +2074,24 @@ export default { dialogReadonly() { return this.dialogMode === 'view'; }, + // 打卡时间轴:已打卡按时间升序,未打卡保持原节点顺序排在其后 + orderedPunchRecords() { + const records = Array.isArray(this.punchRecords) ? this.punchRecords : []; + return records + .map((record, index) => ({ + record, + index, + punchedAt: this.punchRecordTimestamp(record), + })) + .sort((a, b) => { + const aPunched = a.punchedAt !== null; + const bPunched = b.punchedAt !== null; + if (aPunched && bPunched) return a.punchedAt - b.punchedAt || a.index - b.index; + if (aPunched !== bPunched) return aPunched ? -1 : 1; + return a.index - b.index; + }) + .map(item => item.record); + }, taskCarrierPlaceholder() { if (!this.selectedWaybillRows.length) return '请先选择运单'; if (!this.taskCarrierLoading && !this.taskCarrierOptions.length) return '暂无可用承运商合同'; @@ -2457,6 +2786,7 @@ export default { if (status === 'draft') { return [ { type: 'edit', label: '编辑' }, + { type: 'copy', label: '复制' }, { type: 'delete', label: '删除' }, ]; } @@ -2468,24 +2798,36 @@ export default { if (row.driverRejected) { actions.push({ type: 'reassign', label: '重新派单' }); } - actions.push({ type: 'edit', label: '编辑' }, { type: 'cancel', label: '取消' }); + actions.push( + { type: 'edit', label: '编辑' }, + { type: 'copy', label: '复制' }, + { type: 'cancel', label: '取消' } + ); return actions; } if (status === 'running') { return [ { type: 'view', label: '详情' }, { type: 'complete', label: '完成' }, + { type: 'copy', label: '复制' }, ]; } if (status === 'completed' || status === 'cancelled') { - return [{ type: 'view', label: '详情' }]; + return [ + { type: 'view', label: '详情' }, + { type: 'copy', label: '复制' }, + ]; } - return [{ type: 'view', label: '详情' }]; + return [ + { type: 'view', label: '详情' }, + { type: 'copy', label: '复制' }, + ]; }, handleRowAction(type, row) { const actionMap = { view: () => this.gotoLoadingDetail(row), edit: () => this.openLoadingDialog('edit', row), + copy: () => this.gotoLoadingCopy(row), reassign: () => this.gotoLoadingReassign(row), delete: () => this.deleteLoading(row), start: () => this.startLoading(row), @@ -2507,6 +2849,18 @@ export default { query: { id, name: '配载单详情' }, }); }, + // 复制:进入新增页并带回原配载数据 + gotoLoadingCopy(row = {}) { + const id = row.id || row.loadingId || row.loadingManageId; + if (!id) { + ElMessage.warning('配载单不存在,无法复制'); + return; + } + this.$router.push({ + path: '/business/loading-manage/form', + query: { mode: 'add', copyId: id, name: '新增配载管理' }, + }); + }, // 重新派单改为独立页面 gotoLoadingReassign(row = {}) { if (!row.id) { @@ -2585,10 +2939,11 @@ export default { this.dialogForm.waybillIdsJson, this.dialogForm.loadingSubNos ); - this.syncProcessProjectOptions(); + this.syncProcessWaybillOptions(); this.restoreRouteAndCargo(); this.restoreRouteChangeRecords(); - await this.loadProcessNodes(); + this.loadPunchRecords(); + this.probeHasRelatedVoucher(); this.$nextTick(() => this.initDetailAmap()); await this.loadCandidateWaybills(); } else { @@ -2622,23 +2977,38 @@ export default { this.candidatePage.currentPage = 1; this.candidatePage.total = 0; this.candidateSearchVisible = false; - this.processNodes = []; - this.processProjectId = ''; - this.processProjectOptions = []; + this.processDetailTab = 'punch'; + this.processWaybillId = ''; + this.processWaybillOptions = []; + this.punchRecords = []; + this.driverUploads = []; + this.hasRelatedVoucher = false; + this.voucherImages = []; + this.voucherFolders = []; + this.voucherFolder = null; + this.voucherFolderView = false; + this.voucherImagesLoading = false; + this.voucherImagesLoaded = false; + this.attachmentImagePreviewVisible = false; + this.attachmentImagePreviewUrls = []; + this.attachmentImagePreviewIndex = 0; }, // 离开本页(标签缓存/卸载)时收起页内子弹窗,避免 append-to-body 的弹窗 DOM 残留在 body 上 closeInnerDialogs() { this.routeChangeVisible = false; this.commonAddressVisible = false; + this.routeChangeMapVisible = false; this.candidateSearchVisible = false; }, restoreRouteAndCargo() { const routeRows = parseJsonArray(this.dialogForm.routeJson); const cargoRows = parseJsonArray(this.dialogForm.goodsJson); this.routeNodes = routeRows.length - ? routeRows + ? this.syncRouteNodeLockedFlags(routeRows, this.selectedWaybillRows) : this.buildRouteNodes(this.selectedWaybillRows); - this.cargoRows = cargoRows.length ? cargoRows : this.buildCargoRows(this.selectedWaybillRows); + this.cargoRows = (cargoRows.length ? cargoRows : this.buildCargoRows(this.selectedWaybillRows)).map( + row => sanitizeCargoRow(row) + ); }, restoreRouteChangeRecords() { try { @@ -2652,70 +3022,177 @@ export default { this.routeChangeRecords = []; } }, - async loadProcessNodes() { - if (!this.processProjectId) { - this.processNodes = []; + syncProcessWaybillOptions() { + this.processWaybillOptions = (this.selectedWaybillRows || []) + .filter(row => row?.id) + .map(row => ({ + id: row.id, + waybillNo: row.waybillNo || String(row.id), + })); + const currentExists = this.processWaybillOptions.some( + item => String(item.id) === String(this.processWaybillId) + ); + this.processWaybillId = currentExists + ? this.processWaybillId + : this.processWaybillOptions[0]?.id || ''; + this.processDetailTab = 'punch'; + this.resetVoucherState(); + }, + resetVoucherState() { + this.hasRelatedVoucher = false; + this.voucherImages = []; + this.voucherFolders = []; + this.voucherFolder = null; + this.voucherFolderView = false; + this.voucherImagesLoading = false; + this.voucherImagesLoaded = false; + }, + handleProcessWaybillChange() { + this.processDetailTab = 'punch'; + this.resetVoucherState(); + this.loadPunchRecords(); + this.probeHasRelatedVoucher(); + }, + handleProcessDetailTabChange(tab) { + if (tab === 'punch' || tab === 'driverUpload') this.loadPunchRecords(); + if (tab === 'batchSupplement') this.loadVoucherImages(); + }, + probeHasRelatedVoucher() { + const waybillId = this.processWaybillId; + const waybill = (this.selectedWaybillRows || []).find( + row => String(row.id) === String(waybillId) + ); + const projectId = waybill?.projectId; + if (!waybillId || !projectId) { + this.hasRelatedVoucher = false; return; } - try { - const projectId = String(this.processProjectId); - const rows = unwrapRecords( - await getProcessConfigList(1, 100, { projectIds: projectId, status: 1 }) - ); - const config = - rows.find(item => { - const projectIds = String(item.projectIds || '') + getProcessConfigList(1, 100, { projectIds: projectId, status: 1 }) + .then(res => { + const config = unwrapRecords(res).find(row => { + const projectIds = String(row.projectIds || '') .split(',') - .map(value => value.trim()) - .filter(Boolean); - return projectIds.includes(projectId) && item.nodeConfigJson; - }) || {}; - this.processNodes = this.parseProcessConfigNodes(config); - } catch (error) { - this.processNodes = []; - } - }, - syncProcessProjectOptions() { - const projectMap = new Map(); - this.selectedWaybillRows.forEach(row => { - const id = row.projectId; - if (!id || projectMap.has(String(id))) return; - projectMap.set(String(id), { - id, - projectName: row.projectName || row.projectNames || String(id), + .map(item => item.trim()); + return projectIds.includes(String(projectId)); + }); + if (!config?.id) { + this.hasRelatedVoucher = false; + return null; + } + return getProcessConfigDetail(config.id, waybillId); + }) + .then(detailRes => { + if (!detailRes) return; + const detail = detailRes?.data?.data || detailRes?.data || {}; + this.hasRelatedVoucher = Boolean(detail.hasRelatedVoucher); + }) + .catch(() => { + this.hasRelatedVoucher = false; }); - }); - if (!projectMap.size && this.dialogForm.projectId) { - projectMap.set(String(this.dialogForm.projectId), { - id: this.dialogForm.projectId, - projectName: this.dialogForm.projectName || String(this.dialogForm.projectId), - }); - } - this.processProjectOptions = [...projectMap.values()]; - this.processProjectId = this.processProjectOptions[0]?.id || ''; }, - parseProcessConfigNodes(config = {}) { - let nodes = []; - try { - const parsed = - typeof config.nodeConfigJson === 'string' - ? JSON.parse(config.nodeConfigJson) - : config.nodeConfigJson; - nodes = Array.isArray(parsed) ? parsed : parsed?.nodes || []; - } catch (error) { - nodes = []; - } - const includedNodes = String(config.includedNodes || '') - .split(',') - .map(value => value.trim()) - .filter(Boolean); - return nodes.filter( - node => - node.enabled !== false && (!includedNodes.length || includedNodes.includes(node.name)) + punchRecordTimestamp(record = {}) { + if (record.punched === false || record.statusName === '未打卡') return null; + const raw = record.punchTime || record.punchAt || record.punchDateTime || ''; + const text = String(raw || '').trim(); + if (!text) return null; + const parsed = dayjs(text); + if (parsed.isValid()) return parsed.valueOf(); + const fallback = new Date(text.replace(/-/g, '/')).getTime(); + return Number.isNaN(fallback) ? null : fallback; + }, + loadPunchRecords() { + const waybillId = this.processWaybillId; + if (!waybillId || this.punchRecordsLoading) return; + this.punchRecordsLoading = true; + getWaybillPunchRecords(waybillId) + .then(res => { + const data = res?.data?.data || res?.data || {}; + this.punchRecords = Array.isArray(data.records) ? data.records : []; + this.driverUploads = Array.isArray(data.driverUploads) ? data.driverUploads : []; + if (data.hasRelatedVoucher) this.hasRelatedVoucher = true; + }) + .catch(() => { + this.punchRecords = []; + this.driverUploads = []; + }) + .finally(() => { + this.punchRecordsLoading = false; + }); + }, + previewDriverUploadPhoto(photos, index) { + const list = (photos || []).map(item => item?.url).filter(Boolean); + if (!list.length) return; + this.attachmentImagePreviewUrls = list; + this.attachmentImagePreviewIndex = Math.min( + Math.max(Number(index) || 0, 0), + list.length - 1 ); + this.attachmentImagePreviewVisible = true; }, - async handleProcessProjectChange() { - await this.loadProcessNodes(); + loadVoucherImages() { + if (!this.processWaybillId || this.voucherImagesLoading || this.voucherImagesLoaded) { + return; + } + this.voucherImagesLoading = true; + getProcessConfigVoucherImages(this.processWaybillId) + .then(res => { + this.voucherImages = unwrapRecords(res); + this.voucherFolders = this.voucherImages.filter(item => this.isVoucherFolder(item)); + this.voucherFolder = null; + this.voucherFolderView = false; + if (this.voucherImages.length) this.hasRelatedVoucher = true; + this.voucherImagesLoaded = true; + }) + .finally(() => { + this.voucherImagesLoading = false; + }); + }, + isVoucherFolder(item) { + return Boolean(item && (item.isFolder === true || item.type === 'folder')); + }, + voucherFolderName(folder) { + return folder?.folderName || folder?.name || folder?.plateNo || '凭证文件夹'; + }, + openVoucherFolder(folder) { + if (!this.processWaybillId || !this.isVoucherFolder(folder)) return; + if (!folder.voucherId || !folder.folderName) { + ElMessage.warning('凭证文件夹信息不完整'); + return; + } + this.voucherImagesLoading = true; + getProcessConfigVoucherImages(this.processWaybillId, { + voucherId: folder.voucherId, + folderName: folder.folderName, + }) + .then(res => { + this.voucherFolder = folder; + this.voucherFolderView = true; + this.voucherImages = unwrapRecords(res).filter(item => !this.isVoucherFolder(item)); + }) + .finally(() => { + this.voucherImagesLoading = false; + }); + }, + backToVoucherFolders() { + this.voucherFolder = null; + this.voucherFolderView = false; + this.voucherImages = this.voucherFolders; + }, + previewVoucherImage(index) { + const list = (this.voucherImages || []) + .filter(item => !this.isVoucherFolder(item)) + .map(item => item?.url) + .filter(Boolean); + if (!list.length) return; + const current = this.voucherImages[index]; + const currentUrl = current?.url; + const currentIndex = Math.max( + 0, + list.findIndex(url => url === currentUrl) + ); + this.attachmentImagePreviewUrls = list; + this.attachmentImagePreviewIndex = currentIndex >= 0 ? currentIndex : 0; + this.attachmentImagePreviewVisible = true; }, previewAttachment(file) { if (!file.url) { @@ -2760,9 +3237,6 @@ export default { handleTrackAction(action) { ElMessage.info(action === 'playback' ? '暂无可回放的物流轨迹' : '暂无车辆实时定位数据'); }, - handleProcessAction(action) { - ElMessage.info(action === 'supplement' ? '补录执行功能暂未接入' : '批量补录功能暂未接入'); - }, openRouteChangeDialog() { this.routeChangeTab = 'change'; this.routeChangeRemark = ''; @@ -2773,7 +3247,18 @@ export default { departureAddress: row.departureAddress || row.departureName || '', arrivalAddress: row.arrivalAddress || row.arrivalName || '', })); - this.routeChangeNodes = this.buildRouteNodes(this.routeChangeRows); + const builtNodes = this.buildRouteNodes(this.routeChangeRows); + this.routeChangeNodes = this.routeNodes.length + ? this.syncRouteNodeLockedFlags( + this.routeNodes.map(node => ({ + ...node, + tags: [...(node.tags || [])], + })), + this.routeChangeRows + ) + : builtNodes; + this.routeChangeOriginalAddresses = this.routeChangeNodes.map(node => node.address); + this.routeChangeOrderDirty = false; this.routeChangeVisible = true; }, routeChangeTypeText(index) { @@ -2787,26 +3272,255 @@ export default { return 'middle'; }, handleRouteChangeDragStart(index) { + if (this.isRouteChangeNodeLocked(this.routeChangeNodes[index])) { + this.routeChangeDragIndex = -1; + return; + } this.routeChangeDragIndex = index; }, handleRouteChangeDrop(index) { if (this.routeChangeDragIndex < 0 || this.routeChangeDragIndex === index) return; + if ( + this.isRouteChangeNodeLocked(this.routeChangeNodes[this.routeChangeDragIndex]) || + this.isRouteChangeNodeLocked(this.routeChangeNodes[index]) + ) { + this.routeChangeDragIndex = -1; + return; + } const nodes = [...this.routeChangeNodes]; const [node] = nodes.splice(this.routeChangeDragIndex, 1); nodes.splice(index, 0, node); + const originalLockedKeys = this.routeChangeNodes.map(item => + this.isRouteChangeNodeLocked(item) ? item.key : null + ); + const nextLockedKeys = nodes.map(item => + this.isRouteChangeNodeLocked(item) ? item.key : null + ); + if ( + originalLockedKeys.some((key, lockedIndex) => key && key !== nextLockedKeys[lockedIndex]) + ) { + ElMessage.warning('已完成运单对应的路线不可调整'); + this.routeChangeDragIndex = -1; + return; + } this.routeChangeNodes = nodes; + this.routeChangeOrderDirty = true; this.routeChangeDragIndex = -1; }, updateRouteChangeNodes() { - this.routeChangeNodes = this.buildRouteNodes(this.routeChangeRows); + const previousAddresses = this.routeChangeNodes.map(node => node.address); + const builtNodes = this.buildRouteNodes(this.routeChangeRows); + const remaining = [...builtNodes]; + const orderedNodes = []; + previousAddresses.forEach(address => { + const index = remaining.findIndex(node => node.address === address); + if (index >= 0) { + orderedNodes.push(remaining.splice(index, 1)[0]); + } + }); + this.routeChangeNodes = [...orderedNodes, ...remaining]; + }, + isRouteChangeOrderChanged() { + if (!this.routeChangeOrderDirty) return false; + const currentAddresses = this.routeChangeNodes.map(node => node.address); + if (currentAddresses.length !== this.routeChangeOriginalAddresses.length) return true; + return currentAddresses.some( + (address, index) => address !== this.routeChangeOriginalAddresses[index] + ); + }, + formatRouteChangeOrderText(addresses = []) { + return addresses.filter(Boolean).join(' → ') || '-'; + }, + isWaybillCompleted(row = {}) { + const status = String(row.businessStatus || row.status || row.waybillStatus || ''); + if (status === 'completed') return true; + const statusName = String(row.businessStatusName || row.statusName || ''); + return statusName.includes('已完成'); + }, + isRouteChangeNodeLocked(node = {}) { + return Boolean(node?.locked); + }, + syncRouteNodeLockedFlags(nodes = [], rows = this.selectedWaybillRows) { + return (nodes || []).map(node => { + const locked = (node.tags || []).some(tag => { + const match = String(tag).match(/^(装|卸)(\d+)$/); + if (!match) return false; + const row = rows[Number(match[2]) - 1]; + return row ? this.isWaybillCompleted(row) : false; + }); + return { ...node, locked }; + }); }, openCommonAddressDialog(row, field) { + if (this.isWaybillCompleted(row)) return; this.commonAddressTarget = { row, field }; this.commonAddressQuery = { addressName: '' }; this.commonAddressPage.currentPage = 1; this.commonAddressVisible = true; this.loadCommonAddresses(); }, + openRouteChangeMapDialog(row, field) { + if (this.isWaybillCompleted(row)) return; + this.routeChangeMapTarget = { row, field }; + this.routeChangeMapKeyword = row[`${field}Address`] || row[`${field}Name`] || ''; + this.routeChangeMapSelected = { + longitude: row[`${field}Longitude`] || '', + latitude: row[`${field}Latitude`] || '', + detailAddress: this.routeChangeMapKeyword, + regionName: row[`${field}Name`] || '', + regionCode: row[`${field}RegionCode`] || '', + }; + this.routeChangeMapSearchResults = []; + this.routeChangeMapStatus = this.routeChangeMapSelected.longitude + ? '已加载当前选点,可重新选点' + : '可搜索地址或点击地图选点'; + this.routeChangeMapVisible = true; + }, + initRouteChangeMap() { + this.loadDetailAmap() + .then(() => + this.$nextTick(() => { + if (!this.$refs.routeChangeMap) return; + const center = this.routeChangeMapSelected.longitude + ? [ + Number(this.routeChangeMapSelected.longitude), + Number(this.routeChangeMapSelected.latitude), + ] + : [116.40769, 39.89945]; + if (!this.routeChangeMapInstance) { + this.routeChangeMapInstance = new window.AMap.Map(this.$refs.routeChangeMap, { + center, + zoom: this.routeChangeMapSelected.longitude ? 14 : 11, + }); + window.AMap.plugin(['AMap.Geocoder'], () => { + this.routeChangeMapGeocoder = new window.AMap.Geocoder(); + }); + this.routeChangeMapInstance.on('click', event => + this.pickRouteChangeMap(event.lnglat) + ); + } else { + this.routeChangeMapInstance.resize(); + } + if (this.routeChangeMapSelected.longitude) { + this.renderRouteChangeMapMarker(center); + } + }) + ) + .catch(() => ElMessage.error('高德地图组件加载失败')); + }, + destroyRouteChangeMap() { + this.routeChangeMapMarker?.setMap?.(null); + this.routeChangeMapMarker = null; + this.routeChangeMapInstance?.destroy?.(); + this.routeChangeMapInstance = null; + this.routeChangeMapGeocoder = null; + this.routeChangeMapSearchResults = []; + this.routeChangeMapTarget = null; + }, + ensureRouteChangeMapGeocoder() { + if (this.routeChangeMapGeocoder) return Promise.resolve(this.routeChangeMapGeocoder); + return new Promise(resolve => { + window.AMap.plugin(['AMap.Geocoder'], () => { + this.routeChangeMapGeocoder = new window.AMap.Geocoder(); + resolve(this.routeChangeMapGeocoder); + }); + }); + }, + searchRouteChangeMap() { + const keyword = String(this.routeChangeMapKeyword || '').trim(); + if (!keyword) { + ElMessage.warning('请输入地址关键词'); + return; + } + this.routeChangeMapLoading = true; + this.loadDetailAmap() + .then(() => this.ensureRouteChangeMapGeocoder()) + .then( + () => + new Promise((resolve, reject) => { + this.routeChangeMapGeocoder.getLocation(keyword, (status, result) => { + if (status === 'complete') resolve(result); + else reject(result); + }); + }) + ) + .then(result => { + this.routeChangeMapSearchResults = normalizeMapSearchResults(result, keyword); + const point = this.routeChangeMapSearchResults[0]?.location; + if (!point) { + this.routeChangeMapStatus = '未找到匹配地址'; + ElMessage.warning('地图搜索无匹配地址'); + return; + } + this.pickRouteChangeMap(point, keyword); + }) + .catch(() => { + this.routeChangeMapStatus = '地图搜索失败'; + ElMessage.error('地图搜索失败'); + }) + .finally(() => { + this.routeChangeMapLoading = false; + }); + }, + selectRouteChangeMapSearchResult(item) { + if (item?.location) { + this.pickRouteChangeMap(item.location, item.address || item.name || ''); + } + }, + pickRouteChangeMap(point, keyword) { + const longitude = point.getLng ? point.getLng() : point.lng; + const latitude = point.getLat ? point.getLat() : point.lat; + this.routeChangeMapSelected = { + longitude, + latitude, + detailAddress: keyword || this.routeChangeMapKeyword, + regionName: '', + regionCode: '', + }; + this.renderRouteChangeMapMarker([longitude, latitude]); + this.routeChangeMapStatus = '已选点,可确认回填'; + this.ensureRouteChangeMapGeocoder().then(() => { + this.routeChangeMapGeocoder.getAddress([longitude, latitude], (status, result) => { + if (status !== 'complete') return; + const component = result.regeocode?.addressComponent || {}; + this.routeChangeMapSelected = { + ...this.routeChangeMapSelected, + detailAddress: + result.regeocode?.formattedAddress || this.routeChangeMapSelected.detailAddress, + regionName: [component.province, component.city, component.district] + .filter(Boolean) + .join(''), + regionCode: component.adcode || '', + }; + this.routeChangeMapKeyword = this.routeChangeMapSelected.detailAddress; + this.routeChangeMapStatus = '已解析地址,可确认回填'; + }); + }); + }, + renderRouteChangeMapMarker(position) { + if (!this.routeChangeMapInstance) return; + this.routeChangeMapMarker?.setMap?.(null); + this.routeChangeMapMarker = new window.AMap.Marker({ position }); + this.routeChangeMapMarker.setMap(this.routeChangeMapInstance); + this.routeChangeMapInstance.setCenter(position); + }, + confirmRouteChangeMap() { + const target = this.routeChangeMapTarget; + if (!target || !this.routeChangeMapSelected.longitude) return; + const { row, field } = target; + row[`${field}Address`] = + this.routeChangeMapSelected.detailAddress || this.routeChangeMapKeyword || ''; + if (this.routeChangeMapSelected.regionName) { + row[`${field}Name`] = this.routeChangeMapSelected.regionName; + } + row[`${field}Longitude`] = this.routeChangeMapSelected.longitude; + row[`${field}Latitude`] = this.routeChangeMapSelected.latitude; + if (this.routeChangeMapSelected.regionCode) { + row[`${field}RegionCode`] = this.routeChangeMapSelected.regionCode; + } + this.updateRouteChangeNodes(); + this.routeChangeMapVisible = false; + }, async loadCommonAddresses() { this.commonAddressLoading = true; try { @@ -2841,19 +3555,34 @@ export default { row.departureAddress !== row.originalDepartureAddress || row.arrivalAddress !== row.originalArrivalAddress ); - if (!changeRows.length && !this.routeChangeRemark) { - ElMessage.warning('请修改地址或填写变更备注'); + const orderChanged = this.isRouteChangeOrderChanged(); + if (!changeRows.length && !orderChanged && !this.routeChangeRemark) { + ElMessage.warning('请修改地址、调整路线顺序或填写变更备注'); return; } - const content = - changeRows - .map( - row => - `${row.waybillNo || '运单'}:发货地 ${row.originalDepartureAddress} → ${ - row.departureAddress - };到货地 ${row.originalArrivalAddress} → ${row.arrivalAddress}` - ) - .join(';') || '调整运输路线顺序'; + const contentParts = []; + if (changeRows.length) { + contentParts.push( + changeRows + .map( + row => + `${row.waybillNo || '运单'}:发货地 ${row.originalDepartureAddress} → ${ + row.departureAddress + };到货地 ${row.originalArrivalAddress} → ${row.arrivalAddress}` + ) + .join(';') + ); + } + if (orderChanged) { + contentParts.push( + `路线顺序:${this.formatRouteChangeOrderText( + this.routeChangeOriginalAddresses + )} 调整为 ${this.formatRouteChangeOrderText( + this.routeChangeNodes.map(node => node.address) + )}` + ); + } + const content = contentParts.join(';') || '调整运输路线顺序'; this.routeChangeRecords.unshift({ changeTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), changeUser: '当前用户', @@ -3039,18 +3768,34 @@ export default { }, buildRouteNodes(rows) { const nodeMap = new Map(); - const appendNode = (address, displayAddress, tag) => { + const appendNode = (address, displayAddress, tag, locked) => { if (!address) return; - if (!nodeMap.has(address)) nodeMap.set(address, { address, displayAddress, tags: [] }); - nodeMap.get(address).tags.push(tag); + if (!nodeMap.has(address)) { + nodeMap.set(address, { address, displayAddress, tags: [], locked: false }); + } + const node = nodeMap.get(address); + node.tags.push(tag); + if (locked) node.locked = true; }; rows.forEach((row, rowIndex) => { + const locked = this.isWaybillCompleted(row); const departure = row.departureAddress || row.departureName; - appendNode(departure, this.formatCandidateAddress(row, 'departure'), `装${rowIndex + 1}`); + appendNode( + departure, + this.formatCandidateAddress(row, 'departure'), + `装${rowIndex + 1}`, + locked + ); }); rows.forEach((row, rowIndex) => { + const locked = this.isWaybillCompleted(row); const arrival = row.arrivalAddress || row.arrivalName; - appendNode(arrival, this.formatCandidateAddress(row, 'arrival'), `卸${rowIndex + 1}`); + appendNode( + arrival, + this.formatCandidateAddress(row, 'arrival'), + `卸${rowIndex + 1}`, + locked + ); }); return [...nodeMap.values()].map((node, index) => ({ ...node, @@ -3068,13 +3813,37 @@ export default { return 'middle'; }, handleRouteDragStart(index) { + if (this.dialogReadonly || this.isRouteChangeNodeLocked(this.routeNodes[index])) { + this.routeDragIndex = -1; + return; + } this.routeDragIndex = index; }, handleRouteDrop(index) { - if (this.routeDragIndex < 0 || this.routeDragIndex === index) return; + if (this.dialogReadonly || this.routeDragIndex < 0 || this.routeDragIndex === index) return; + if ( + this.isRouteChangeNodeLocked(this.routeNodes[this.routeDragIndex]) || + this.isRouteChangeNodeLocked(this.routeNodes[index]) + ) { + this.routeDragIndex = -1; + return; + } const rows = [...this.routeNodes]; const [target] = rows.splice(this.routeDragIndex, 1); rows.splice(index, 0, target); + const originalLockedKeys = this.routeNodes.map(item => + this.isRouteChangeNodeLocked(item) ? item.key : null + ); + const nextLockedKeys = rows.map(item => + this.isRouteChangeNodeLocked(item) ? item.key : null + ); + if ( + originalLockedKeys.some((key, lockedIndex) => key && key !== nextLockedKeys[lockedIndex]) + ) { + ElMessage.warning('已完成运单对应的路线不可调整'); + this.routeDragIndex = -1; + return; + } this.routeDragIndex = -1; if (!this.validateRouteEndpoints(rows)) return; this.routeNodes = rows; @@ -3112,7 +3881,7 @@ export default { }); }, normalizeCargoRow(waybill, item = {}) { - return { + return sanitizeCargoRow({ projectName: item.projectName || waybill.projectName || '', cargoName: item.cargoName || item.goodsName || waybill.cargoName || '', cargoType: item.cargoType || item.goodsType || waybill.cargoType || '', @@ -3147,7 +3916,7 @@ export default { waybill.specificationModel || '', remark: item.remark || '', - }; + }); }, applyLoadingMileageFromDetail(form = this.dialogForm) { let taskMileage = ''; @@ -3302,6 +4071,8 @@ export default { }; this.applyLoadingMileageFromDetail(this.dialogForm); ElMessage.success('暂存成功'); + sessionStorage.setItem(loadingManageListRefreshKey, '1'); + this.closeLoadingDialog(); this.loadTable(); } finally { this.dialogSaving = ''; @@ -3944,6 +4715,12 @@ export default { border: 1px solid #eff1f7; background: #fff; cursor: move; + + &.is-locked { + cursor: not-allowed; + background: #f5f7fa; + color: #909399; + } } .loading-manage-dialog__route-handle { @@ -4193,7 +4970,74 @@ export default { gap: 8px; } .loading-detail__process-actions .el-select { - width: 180px; + width: 200px; + } + .loading-detail__punch-record { + display: flex; + flex-direction: column; + gap: 6px; + } + .loading-detail__punch-record-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: #303133; + } + .loading-detail__punch-record-line { + font-size: 13px; + color: #606266; + + span + span { + margin-left: 12px; + } + } + .loading-detail__punch-record-photos, + .loading-detail__driver-upload-grid, + .loading-detail__voucher-image-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-top: 4px; + min-height: 80px; + } + .loading-detail__driver-upload-item, + .loading-detail__voucher-image-item { + display: flex; + flex-direction: column; + gap: 6px; + cursor: pointer; + + .el-image { + display: block; + width: 100%; + aspect-ratio: 1; + border: 1px solid #eff1f7; + border-radius: 4px; + overflow: hidden; + background: #f5f7fa; + } + } + .loading-detail__voucher-image-item--folder .el-image { + aspect-ratio: auto; + padding: 12px; + background: transparent; + border: none; + } + .loading-detail__driver-upload-label, + .loading-detail__voucher-folder-name { + font-size: 12px; + color: #606266; + line-height: 18px; + text-align: center; + word-break: break-all; + } + .loading-detail__voucher-folder-toolbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; } .loading-detail__map { width: 100%; @@ -4201,12 +5045,25 @@ export default { border: 1px solid #eff1f7; } } +:global(.loading-route-change-dialog .el-dialog__body) { + background: #fff; +} .loading-route-change-dialog { .loading-route-change-dialog__original { color: #a8abb2; line-height: 20px; margin-bottom: 4px; } + .loading-route-change-dialog__address-row { + display: flex; + align-items: center; + gap: 8px; + + .el-input { + flex: 1; + min-width: 0; + } + } .loading-route-change-dialog__location { cursor: pointer; color: #909399; @@ -4227,6 +5084,16 @@ export default { padding: 10px 12px; background: #f5f6f7; cursor: move; + + &.is-locked { + cursor: not-allowed; + background: #eef0f3; + color: #909399; + } + } + .loading-route-change-dialog__route-handle-placeholder { + width: 14px; + height: 14px; } .loading-route-change-dialog__route-type { width: 30px; @@ -4255,6 +5122,27 @@ export default { line-height: 22px; } } +.loading-route-change-map-dialog { + .loading-route-change-map-dialog__toolbar { + display: flex; + gap: 8px; + margin-bottom: 12px; + + .el-input { + flex: 1; + min-width: 0; + } + } + .loading-route-change-map-dialog__map { + width: 100%; + min-width: 0; + height: 440px; + } + .loading-route-change-map-dialog__status { + margin-top: 8px; + color: #606266; + } +} .loading-common-address-dialog__toolbar { display: flex; gap: 8px;