修复业务模块bug

This commit is contained in:
2026-08-13 18:53:49 +08:00
parent f62e36e846
commit b25b2fae20
7 changed files with 361 additions and 61 deletions
+16 -1
View File
@@ -1,9 +1,24 @@
import request from '@/axios';
import { createCrudApi } from './common'; import { createCrudApi } from './common';
const api = createCrudApi('/blade-transport/process-config'); const api = createCrudApi('/blade-transport/process-config');
export const getList = api.getList; export const getList = api.getList;
export const getDetail = api.getDetail; export const getDetail = (id, waybillId) =>
request({
url: '/blade-transport/process-config/detail',
method: 'get',
params: {
id,
...(waybillId === undefined ? {} : { waybillId }),
},
});
export const getVoucherImages = waybillId =>
request({
url: '/blade-transport/process-config/voucher-images',
method: 'get',
params: { waybillId },
});
export const submit = api.submit; export const submit = api.submit;
export const remove = api.remove; export const remove = api.remove;
export const copy = api.copy; export const copy = api.copy;
+1 -1
View File
@@ -30,7 +30,7 @@ export const getPartUploadUrl = (id, partNumber) =>
export const updateFileTask = data => export const updateFileTask = data =>
request({ url: `${fileTaskBaseUrl}/updateFileTask`, method: 'post', data }); request({ url: `${fileTaskBaseUrl}/updateFileTask`, method: 'post', data });
export const pauseFileTask = ids => export const pauseFileTask = ids =>
request({ url: `${fileTaskBaseUrl}/updatePaused`, method: 'post', params: { ids: ids.join(',') } }); request({ url: `${fileTaskBaseUrl}/updatePaused`, method: 'post', data: { ids } });
export const resumeFileTask = id => export const resumeFileTask = id =>
request({ url: `${fileTaskBaseUrl}/resume`, method: 'post', params: { id } }); request({ url: `${fileTaskBaseUrl}/resume`, method: 'post', params: { id } });
export const getFileTaskList = (current, size, params) => export const getFileTaskList = (current, size, params) =>
@@ -797,6 +797,7 @@
placeholder="请输入" placeholder="请输入"
clearable clearable
maxlength="10" maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly" :disabled="dialogReadonly"
@input="handleTaskMileageInput" @input="handleTaskMileageInput"
/> />
@@ -927,6 +928,7 @@
placeholder="请输入" placeholder="请输入"
clearable clearable
maxlength="10" maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly" :disabled="dialogReadonly"
@input="handleTaskMileageInput" @input="handleTaskMileageInput"
/> />
@@ -1347,6 +1349,7 @@
placeholder="请输入里程" placeholder="请输入里程"
clearable clearable
maxlength="10" maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly" :disabled="dialogReadonly"
@input="handleTaskMileageInput" @input="handleTaskMileageInput"
/> />
@@ -2334,6 +2337,7 @@
v-model="detailBox" v-model="detailBox"
:title="`${config.title}详情`" :title="`${config.title}详情`"
append-to-body append-to-body
top="10px"
width="96%" width="96%"
class="business-crud-page__detail-dialog" class="business-crud-page__detail-dialog"
> >
@@ -2386,6 +2390,7 @@
v-for="file in attachmentRows" v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName" :key="file.url || file.name || file.originalName"
type="primary" type="primary"
@click="previewAttachment(file)"
> >
{{ file.originalName || file.name || '-' }} {{ file.originalName || file.name || '-' }}
</el-link> </el-link>
@@ -2502,15 +2507,44 @@
</section-card> </section-card>
<div class="business-crud-page__waybill-detail-bottom"> <div class="business-crud-page__waybill-detail-bottom">
<section-card title="执行详情"> <section-card title="执行详情">
<el-timeline v-if="waybillDetailProcessNodes.length"> <el-tabs v-model="waybillProcessDetailTab" type="border-card">
<el-timeline-item <el-tab-pane label="打卡详情" name="punch">
v-for="(node, index) in waybillDetailProcessNodes" <el-timeline v-if="waybillDetailProcessNodes.length">
:key="node.key || index" <el-timeline-item
:timestamp="node.time || ''" v-for="(node, index) in waybillDetailProcessNodes"
>{{ node.name || node.nodeName || node.label }}</el-timeline-item :key="node.key || index"
:timestamp="node.time || ''"
>{{ node.name || node.nodeName || node.label }}</el-timeline-item
>
</el-timeline>
<el-empty v-else description="暂无打卡详情" :image-size="50" />
</el-tab-pane>
<el-tab-pane
v-if="waybillHasRelatedVoucher"
label="批量补录"
name="batchSupplement"
> >
</el-timeline> <div v-loading="waybillVoucherImagesLoading" class="business-crud-page__voucher-image-grid">
<el-empty v-else description="暂无执行详情" :image-size="50" /> <div
v-for="(image, index) in waybillVoucherImages"
:key="image.id || image.objectKey || index"
class="business-crud-page__voucher-image-item"
:title="image.imageName"
@click="previewWaybillVoucherImage(index)"
>
<el-image :src="image.url" fit="cover" lazy />
</div>
<el-empty
v-if="!waybillVoucherImagesLoading && !waybillVoucherImages.length"
description="暂无凭证图片"
:image-size="50"
/>
</div>
</el-tab-pane>
<!-- <el-tab-pane label="司机上传" name="driverUpload">
<el-empty description="暂无司机上传数据" :image-size="50" />
</el-tab-pane> -->
</el-tabs>
</section-card> </section-card>
<section-card title="物流轨迹"> <section-card title="物流轨迹">
<template #extra> <template #extra>
@@ -2565,7 +2599,11 @@
<el-table :data="attachmentRows" empty-text="暂无附件"> <el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" /> <el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260"> <el-table-column label="文件名" min-width="260">
<template #default="{ row }">{{ row.originalName || row.name || '-' }}</template> <template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
</template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="附件描述" min-width="220" /> <el-table-column prop="description" label="附件描述" min-width="220" />
<el-table-column label="文件大小" width="110"> <el-table-column label="文件大小" width="110">
@@ -2587,6 +2625,38 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog
v-model="attachmentDocumentPreviewVisible"
:title="attachmentPreviewFile.name || '附件预览'"
append-to-body
destroy-on-close
width="90%"
top="4vh"
>
<open-file-viewer
v-if="attachmentDocumentPreviewVisible && attachmentPreviewFile.url"
:file="attachmentPreviewFile.url"
:file-name="attachmentPreviewFile.name"
:mime-type="attachmentPreviewFile.mimeType"
width="100%"
height="72vh"
fit="contain"
theme="auto"
locale="zh-CN"
:toolbar="attachmentViewerToolbar"
:plugins="attachmentViewerPlugins"
@unsupported="handleAttachmentPreviewUnsupported"
@error="handleAttachmentPreviewError"
/>
</el-dialog>
<el-image-viewer
v-if="attachmentImagePreviewVisible"
:url-list="attachmentImagePreviewUrls"
:initial-index="attachmentImagePreviewIndex"
@close="attachmentImagePreviewVisible = false"
/>
<el-dialog <el-dialog
v-model="waybillRouteChangeBox" v-model="waybillRouteChangeBox"
title="变更运输路线" title="变更运输路线"
@@ -4674,6 +4744,7 @@ import {
import { import {
getDetail as getProcessConfigDetail, getDetail as getProcessConfigDetail,
getList as getProcessConfigList, getList as getProcessConfigList,
getVoucherImages as getProcessConfigVoucherImages,
} from '@/api/business/process-config'; } from '@/api/business/process-config';
import { getList as getCarrierCustomerList } from '@/api/vehicle/customer-archive'; import { getList as getCarrierCustomerList } from '@/api/vehicle/customer-archive';
import { getList as getDriverList } from '@/api/transportCapacity/driver'; import { getList as getDriverList } from '@/api/transportCapacity/driver';
@@ -4689,6 +4760,11 @@ import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util'; import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate'; import { isMobile } from '@/utils/validate';
import { List, Location, Rank, Search } from '@element-plus/icons-vue'; import { List, Location, Rank, Search } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import 'nprogress/nprogress.css'; import 'nprogress/nprogress.css';
@@ -4698,6 +4774,14 @@ const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a'; const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
let amapLoader; let amapLoader;
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const estimateMenuButtonCount = config => { const estimateMenuButtonCount = config => {
const rowActions = (config.actions || []).filter(action => action !== 'batchComplete'); const rowActions = (config.actions || []).filter(action => action !== 'batchComplete');
return 3 + rowActions.length + (config.operations || []).length; return 3 + rowActions.length + (config.operations || []).length;
@@ -4859,7 +4943,7 @@ const defaultDispatchRow = () => ({
const taskCarrierTypes = ['承运商', '自运', '网货平台']; const taskCarrierTypes = ['承运商', '自运', '网货平台'];
export default { export default {
components: { WaybillImportDialog, Rank }, components: { WaybillImportDialog, Rank, ElImageViewer, OpenFileViewer },
name: 'BusinessCrudPage', name: 'BusinessCrudPage',
props: { props: {
api: { api: {
@@ -4904,6 +4988,11 @@ export default {
detailLoading: false, detailLoading: false,
detailRow: {}, detailRow: {},
waybillDetailProcessNodes: [], waybillDetailProcessNodes: [],
waybillProcessDetailTab: 'punch',
waybillHasRelatedVoucher: false,
waybillVoucherImages: [],
waybillVoucherImagesLoading: false,
waybillVoucherImagesLoaded: false,
waybillRouteChangeBox: false, waybillRouteChangeBox: false,
waybillRouteChangeTab: 'change', waybillRouteChangeTab: 'change',
waybillRouteChangeRows: [], waybillRouteChangeRows: [],
@@ -4989,6 +5078,19 @@ export default {
selectedAttachmentRows: [], selectedAttachmentRows: [],
contractFileRows: [], contractFileRows: [],
selectedContractFileRows: [], selectedContractFileRows: [],
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
transportCargoRows: [], transportCargoRows: [],
taskFreightCurrency: 'CNY', taskFreightCurrency: 'CNY',
shippingTemplateFreight: defaultShippingTemplateFreight(), shippingTemplateFreight: defaultShippingTemplateFreight(),
@@ -5643,6 +5745,9 @@ export default {
} }
}, },
}, },
waybillProcessDetailTab(tab) {
if (tab === 'batchSupplement') this.loadWaybillVoucherImages();
},
'form.transportType'(value, oldValue) { 'form.transportType'(value, oldValue) {
if (!this.shippingInfoFormEnabled || this.suppressTransportTypeClear) return; if (!this.shippingInfoFormEnabled || this.suppressTransportTypeClear) return;
if (String(value ?? '') === String(oldValue ?? '')) return; if (String(value ?? '') === String(oldValue ?? '')) return;
@@ -5990,6 +6095,10 @@ export default {
this.detailBox = true; this.detailBox = true;
this.detailLoading = true; this.detailLoading = true;
this.detailRow = { ...row }; this.detailRow = { ...row };
this.waybillProcessDetailTab = 'punch';
this.waybillHasRelatedVoucher = false;
this.waybillVoucherImages = [];
this.waybillVoucherImagesLoaded = false;
const request = const request =
typeof this.api.getDetail === 'function' typeof this.api.getDetail === 'function'
? this.api.getDetail(row.id) ? this.api.getDetail(row.id)
@@ -6039,8 +6148,9 @@ export default {
return projectIds.includes(String(projectId)); return projectIds.includes(String(projectId));
}); });
if (!config?.id) return []; if (!config?.id) return [];
return getProcessConfigDetail(config.id).then(detailRes => { return getProcessConfigDetail(config.id, this.detailRow.id).then(detailRes => {
const detail = detailRes?.data?.data || detailRes?.data || config; const detail = detailRes?.data?.data || detailRes?.data || config;
this.waybillHasRelatedVoucher = Boolean(detail.hasRelatedVoucher);
return this.getProcessConfigEnabledNodes(detail); return this.getProcessConfigEnabledNodes(detail);
}); });
}) })
@@ -6052,6 +6162,25 @@ export default {
}) })
.catch(() => []); .catch(() => []);
}, },
loadWaybillVoucherImages() {
if (!this.detailRow.id || this.waybillVoucherImagesLoading || this.waybillVoucherImagesLoaded) {
return;
}
this.waybillVoucherImagesLoading = true;
getProcessConfigVoucherImages(this.detailRow.id)
.then(res => {
this.waybillVoucherImages = extractRecords(res);
this.waybillVoucherImagesLoaded = true;
})
.finally(() => {
this.waybillVoucherImagesLoading = false;
});
},
previewWaybillVoucherImage(index) {
this.attachmentImagePreviewUrls = this.waybillVoucherImages.map(image => image.url).filter(Boolean);
this.attachmentImagePreviewIndex = index;
this.attachmentImagePreviewVisible = this.attachmentImagePreviewUrls.length > 0;
},
buildWaybillRouteNodes(rows = []) { buildWaybillRouteNodes(rows = []) {
const nodes = []; const nodes = [];
rows.forEach((row, rowIndex) => { rows.forEach((row, rowIndex) => {
@@ -7021,6 +7150,7 @@ export default {
['quantity', 'mileage', 'unitPrice'].forEach(prop => { ['quantity', 'mileage', 'unitPrice'].forEach(prop => {
if (Number(this.form[prop]) === -1) this.form[prop] = ''; if (Number(this.form[prop]) === -1) this.form[prop] = '';
}); });
this.form.mileage = this.normalizeMileageValue(this.form.mileage);
this.$nextTick(() => { this.$nextTick(() => {
this.suppressTransportTypeClear = false; this.suppressTransportTypeClear = false;
}); });
@@ -7291,6 +7421,7 @@ export default {
}); });
}, },
applyShippingPlan(plan = {}) { applyShippingPlan(plan = {}) {
this.suppressTransportTypeClear = true;
this.form.planId = plan.id || ''; this.form.planId = plan.id || '';
this.form.planName = plan.planName || ''; this.form.planName = plan.planName || '';
[ [
@@ -7301,6 +7432,7 @@ export default {
'transportMode', 'transportMode',
'transportTypeName', 'transportTypeName',
'transportModeName', 'transportModeName',
'departureAddressId',
'departureName', 'departureName',
'departureAddress', 'departureAddress',
'departureContact', 'departureContact',
@@ -7308,6 +7440,7 @@ export default {
'departureProvince', 'departureProvince',
'departureCity', 'departureCity',
'departureDistrict', 'departureDistrict',
'arrivalAddressId',
'arrivalName', 'arrivalName',
'arrivalAddress', 'arrivalAddress',
'arrivalContact', 'arrivalContact',
@@ -7337,11 +7470,24 @@ export default {
].forEach(prop => { ].forEach(prop => {
if (firstGoods[prop] !== undefined) this.form[prop] = firstGoods[prop]; if (firstGoods[prop] !== undefined) this.form[prop] = firstGoods[prop];
}); });
this.form.cargoTypePath = this.normalizeBillingCargoTypePath(firstGoods.cargoTypePath);
this.initTaskInfoForm();
this.ensureBillingCargoTypeOptions().then(() => {
if (this.form.cargoTypePath.length) return;
const cargoType = this.findBillingCargoTypeByCodeOrName({
cargoTypeCode: this.form.cargoTypeCode,
cargoType: this.form.cargoType,
});
if (cargoType?.path) this.form.cargoTypePath = cargoType.path;
});
if (this.isTaskFullMode) { if (this.isTaskFullMode) {
this.initTaskFullCargoRows(goodsRows); this.initTaskFullCargoRows(goodsRows);
} }
if (this.contractSelectEnabled) this.syncCurrentContractOption(); if (this.contractSelectEnabled) this.syncCurrentContractOption();
this.$refs.crud?.validateField?.('planName'); this.$refs.crud?.validateField?.('planName');
this.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
}, },
clearShippingPlan() { clearShippingPlan() {
this.form.planId = ''; this.form.planId = '';
@@ -7417,7 +7563,7 @@ export default {
this.form.quantityUnit = this.form.quantityUnit =
this.form.quantityUnit || taskInfo.quantityUnit || goodsInfo.quantityUnit || '吨'; this.form.quantityUnit || taskInfo.quantityUnit || goodsInfo.quantityUnit || '吨';
const mileage = this.form.mileage || taskInfo.mileage || goodsInfo.mileage || ''; const mileage = this.form.mileage || taskInfo.mileage || goodsInfo.mileage || '';
this.form.mileage = Number(mileage) === -1 ? '' : mileage; this.form.mileage = this.normalizeMileageValue(mileage);
this.form.unitPrice = this.form.unitPrice || taskInfo.unitPrice || goodsInfo.unitPrice || ''; this.form.unitPrice = this.form.unitPrice || taskInfo.unitPrice || goodsInfo.unitPrice || '';
['quantity', 'mileage', 'unitPrice'].forEach(prop => { ['quantity', 'mileage', 'unitPrice'].forEach(prop => {
if (Number(this.form[prop]) === -1) this.form[prop] = ''; if (Number(this.form[prop]) === -1) this.form[prop] = '';
@@ -7702,6 +7848,11 @@ export default {
.replace(/\D/g, '') .replace(/\D/g, '')
.slice(0, 10); .slice(0, 10);
}, },
normalizeMileageValue(value) {
if (value === undefined || value === null || value === '' || Number(value) === -1) return '';
const mileage = Number(value);
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
},
handleDispatchMileageInput(value) { handleDispatchMileageInput(value) {
this.dispatchItemForm.mileage = String(value || '') this.dispatchItemForm.mileage = String(value || '')
.replace(/\D/g, '') .replace(/\D/g, '')
@@ -9015,13 +9166,54 @@ export default {
if (number < 1024 * 1024) return `${(number / 1024).toFixed(1)}KB`; if (number < 1024 * 1024) return `${(number / 1024).toFixed(1)}KB`;
return `${(number / 1024 / 1024).toFixed(1)}MB`; return `${(number / 1024 / 1024).toFixed(1)}MB`;
}, },
attachmentUrl(row = {}) {
return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || '';
},
attachmentName(row = {}) {
return row.originalName || row.name || row.fileName || '附件';
},
attachmentExtension(row = {}) {
const source = String(this.attachmentName(row) || this.attachmentUrl(row)).split('?')[0];
const index = source.lastIndexOf('.');
return index > -1 ? source.slice(index + 1).toLowerCase() : '';
},
isAttachmentImage(row) {
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(this.attachmentExtension(row));
},
previewAttachment(row) {
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法预览');
return;
}
if (this.isAttachmentImage(row)) {
this.attachmentImagePreviewUrls = this.attachmentRows
.filter(item => this.isAttachmentImage(item) && this.attachmentUrl(item))
.map(item => this.attachmentUrl(item));
this.attachmentImagePreviewIndex = Math.max(this.attachmentImagePreviewUrls.indexOf(url), 0);
this.attachmentImagePreviewVisible = true;
return;
}
this.attachmentPreviewFile = {
name: this.attachmentName(row),
url,
mimeType: row.mimeType || row.contentType || '',
};
this.attachmentDocumentPreviewVisible = true;
},
handleAttachmentPreviewUnsupported() {
this.$message.warning('当前文件暂不支持在线预览');
},
handleAttachmentPreviewError() {
this.$message.error('附件预览失败');
},
downloadAttachment(row) { downloadAttachment(row) {
const url = row.url || row.link; const url = this.attachmentUrl(row);
if (!url) { if (!url) {
this.$message.warning('附件地址为空'); this.$message.warning('附件地址为空');
return; return;
} }
downloadFileByUrl(url, row.originalName || row.name || '附件'); downloadFileByUrl(url, this.attachmentName(row));
}, },
handleBatchDownload() { handleBatchDownload() {
const rows = this.selectedAttachmentRows.length const rows = this.selectedAttachmentRows.length
@@ -12413,6 +12605,32 @@ export default {
gap: 8px; gap: 8px;
} }
&__waybill-process-actions {
display: flex;
gap: 8px;
}
&__voucher-image-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
min-height: 120px;
}
&__voucher-image-item {
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
border: 1px solid #eff1f7;
border-radius: 4px;
.el-image {
display: block;
width: 100%;
height: 100%;
}
}
&__route-change-dialog { &__route-change-dialog {
:deep(.el-dialog__body) { :deep(.el-dialog__body) {
padding-top: 12px; padding-top: 12px;
@@ -2,14 +2,19 @@
<div v-loading="loading" class="master-detail"> <div v-loading="loading" class="master-detail">
<section v-if="master" class="detail-overview"> <section v-if="master" class="detail-overview">
<div class="detail-heading"><div><h2>多联总单详情 <span>|</span> {{ master.masterNo }}</h2><el-tag :type="statusType(master.businessStatus)">{{ statusName(master.businessStatus) }}</el-tag></div><el-button @click="$emit('back')">返回</el-button></div> <div class="detail-heading"><div><h2>多联总单详情 <span>|</span> {{ master.masterNo }}</h2><el-tag :type="statusType(master.businessStatus)">{{ statusName(master.businessStatus) }}</el-tag></div><el-button @click="$emit('back')">返回</el-button></div>
<dl class="detail-meta"><div><dt>客户</dt><dd>{{ master.customerName || '-' }}</dd></div><div><dt>合同编号</dt><dd>{{ master.contractNo || '-' }}</dd></div><div><dt>项目</dt><dd>{{ master.projectName || '-' }}</dd></div><div><dt>货物名称</dt><dd>{{ goodsNames }}</dd></div><div><dt>货物类型</dt><dd>{{ goodsTypes }}</dd></div><div><dt>运输周期</dt><dd>{{ dateRange }}</dd></div></dl> <dl class="detail-meta"><div><dt>客户</dt><dd>{{ master.customerName || '-' }}</dd></div><div><dt>合同编号</dt><dd>{{ master.contractNo || '-' }}</dd></div><div><dt>项目</dt><dd>{{ master.projectName || '-' }}</dd></div><div class="detail-meta__attachments"><dt>附件</dt><dd><template v-if="attachments.length"><el-link v-for="file in attachments" :key="file.url || file.link || file.name || file.originalName" type="primary" @click="previewAttachment(file)">{{ attachmentName(file) }}</el-link></template><span v-else>-</span></dd></div></dl>
<div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ route.departureName || '-' }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ route.arrivalName || '-' }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }}</small></div></template></div> <div class="route-map"><template v-for="(route, index) in segments" :key="route.segmentNo"><div class="route-map__node"><span :class="['route-badge', index ? 'middle' : 'start']">{{ index ? '经' : '起' }}</span><strong>{{ route.departureName || '-' }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }}</small></div><div class="route-map__line"></div><div v-if="index === segments.length - 1" class="route-map__node"><span class="route-badge end">终</span><strong>{{ route.arrivalName || '-' }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }}</small></div></template></div>
</section> </section>
<el-dialog v-model="documentPreviewVisible" :title="previewFile.name || '附件预览'" append-to-body destroy-on-close width="90%" top="4vh">
<open-file-viewer v-if="documentPreviewVisible && previewFile.url" :file="previewFile.url" :file-name="previewFile.name" :mime-type="previewFile.mimeType" width="100%" height="72vh" fit="contain" theme="auto" locale="zh-CN" :toolbar="viewerToolbar" :plugins="viewerPlugins" />
</el-dialog>
<el-image-viewer v-if="imagePreviewVisible" :url-list="imagePreviewUrls" :initial-index="imagePreviewIndex" @close="imagePreviewVisible = false" />
<section v-if="master" class="execution-detail-card"> <section v-if="master" class="execution-detail-card">
<div class="execution-detail-heading"><h3>分段执行明细</h3></div> <div class="execution-detail-heading"><h3>分段执行明细</h3></div>
<section v-for="(route, index) in segments" :key="route.segmentNo" class="segment-detail"> <section v-for="(route, index) in segments" :key="route.segmentNo" class="segment-detail">
<header @click="toggleSegment(route.segmentNo)"><h3><span class="segment-index">{{ segmentNumber(route, index) }}</span><span>{{ route.departureName || '-' }} {{ route.arrivalName || '-' }}</span></h3><div class="segment-detail__header-right"><span>已调度 <em>{{ quantity(route.dispatchedQuantity) }}</em> / {{ quantity(master.totalQuantity) }}已到达 <em>{{ quantity(route.arrivedQuantity) }}</em> / {{ quantity(master.totalQuantity) }}</span><el-tooltip :content="isSegmentExpanded(route.segmentNo) ? '折叠' : '展开'" placement="top"><el-button circle :icon="isSegmentExpanded(route.segmentNo) ? ArrowUp : ArrowDown" @click.stop="toggleSegment(route.segmentNo)" /></el-tooltip></div></header> <header @click="toggleSegment(route.segmentNo)"><h3><span class="segment-index">{{ segmentNumber(route, index) }}</span><span class="segment-detail__transport-type">{{ segmentTransportType(route) || '-' }}</span><span class="segment-detail__progress-bar"><span :style="{ width: `${segmentProgress(route)}%` }"></span></span><span>{{ route.departureName || '-' }} {{ route.arrivalName || '-' }}</span><span class="segment-detail__progress">已调度 <em>{{ quantity(route.dispatchedQuantity) }}</em> / {{ quantity(plannedQuantity(route)) }} {{ plannedUnit(route) }}</span></h3><div class="segment-detail__header-right"><el-tooltip :content="isSegmentExpanded(route.segmentNo) ? '折叠' : '展开'" placement="top"><el-button circle :icon="isSegmentExpanded(route.segmentNo) ? ArrowUp : ArrowDown" @click.stop="toggleSegment(route.segmentNo)" /></el-tooltip></div></header>
<div v-show="isSegmentExpanded(route.segmentNo)" class="segment-execution"> <div v-show="isSegmentExpanded(route.segmentNo)" class="segment-execution">
<div class="segment-stats"><div><span>总量</span><strong>{{ quantity(master.totalQuantity) }}<small></small></strong></div><div><span>已调度</span><strong class="dispatched">{{ quantity(route.dispatchedQuantity) }}<small></small></strong></div><div><span>剩余</span><strong class="remaining">{{ quantity(remainingQuantity(route)) }}<small></small></strong></div></div> <div class="segment-stats"><div><span>总量</span><strong>{{ quantity(master.totalQuantity) }}<small></small></strong></div><div><span>已调度</span><strong class="dispatched">{{ quantity(route.dispatchedQuantity) }}<small></small></strong></div><div><span>剩余</span><strong class="remaining">{{ quantity(remainingQuantity(route)) }}<small></small></strong></div></div>
<el-table :data="route.waybills || []" border class="waybill-table"><el-table-column label="运单号" min-width="180"><template #default="{ row }"><el-link type="primary" @click="openWaybill(row)">{{ row.waybillNo }}</el-link></template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="190" /><el-table-column label="司机/车牌" min-width="180"><template #default="{ row }">{{ driverVehicle(row) }}</template></el-table-column><el-table-column prop="cargoType" label="货物类型" min-width="120" /><el-table-column label="数量(吨)" width="130"><template #default="{ row }">{{ quantity(row.quantity) }}</template></el-table-column><el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="waybillStatusType(row.businessStatus)" size="small">{{ waybillStatusName(row.businessStatus) }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table> <el-table :data="route.waybills || []" border class="waybill-table"><el-table-column label="运单号" min-width="180"><template #default="{ row }"><el-link type="primary" @click="openWaybill(row)">{{ row.waybillNo }}</el-link></template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="190" /><el-table-column label="司机/车牌" min-width="180"><template #default="{ row }">{{ driverVehicle(row) }}</template></el-table-column><el-table-column prop="cargoType" label="货物类型" min-width="120" /><el-table-column label="数量(吨)" width="130"><template #default="{ row }">{{ quantity(row.quantity) }}</template></el-table-column><el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="waybillStatusType(row.businessStatus)" size="small">{{ waybillStatusName(row.businessStatus) }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table>
@@ -29,11 +34,19 @@
<script> <script>
import * as api from '@/api/business/master-order'; import * as api from '@/api/business/master-order';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'; import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import { fallbackPlugin, imagePlugin, officePlugin, pdfPlugin, textPlugin } from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
const viewerPlugins = [imagePlugin(), pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }), officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }), textPlugin(), fallbackPlugin()];
export default { export default {
components: { ElImageViewer, OpenFileViewer },
props: { id: [String, Number] }, props: { id: [String, Number] },
emits: ['back'], emits: ['back'],
data() { return { loading: false, master: null, expandedSegments: {}, ArrowDown, ArrowUp }; }, data() { return { loading: false, master: null, expandedSegments: {}, ArrowDown, ArrowUp, imagePreviewVisible: false, imagePreviewUrls: [], imagePreviewIndex: 0, documentPreviewVisible: false, previewFile: {}, viewerPlugins, viewerToolbar: { download: true, fullscreen: true, print: true, rotate: true, zoom: true } }; },
computed: { computed: {
segments() { segments() {
const routes = this.master?.routeProgress || this.master?.routes || []; const routes = this.master?.routeProgress || this.master?.routes || [];
@@ -42,6 +55,7 @@ export default {
return { ...route, departureName: previous?.departureName, departureAddress: previous?.departureAddress, arrivalName: route.departureName || this.master?.arrivalName, arrivalAddress: route.departureAddress || this.master?.arrivalAddress }; return { ...route, departureName: previous?.departureName, departureAddress: previous?.departureAddress, arrivalName: route.departureName || this.master?.arrivalName, arrivalAddress: route.departureAddress || this.master?.arrivalAddress };
}); });
}, },
attachments() { const value = this.master?.attachmentsJson; if (Array.isArray(value)) return value; if (!value) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (error) { return []; } },
goodsNames() { return (this.master?.goods || []).map(item => item.cargoName).filter(Boolean).join('、') || '-'; }, goodsNames() { return (this.master?.goods || []).map(item => item.cargoName).filter(Boolean).join('、') || '-'; },
goodsTypes() { return [...new Set((this.master?.goods || []).map(item => item.cargoType).filter(Boolean))].join('、') || '-'; }, goodsTypes() { return [...new Set((this.master?.goods || []).map(item => item.cargoType).filter(Boolean))].join('、') || '-'; },
dateRange() { return this.master?.planStartTime && this.master?.planEndTime ? `${String(this.master.planStartTime).slice(0, 10)} ~ ${String(this.master.planEndTime).slice(0, 10)}` : '-'; }, dateRange() { return this.master?.planStartTime && this.master?.planEndTime ? `${String(this.master.planStartTime).slice(0, 10)} ~ ${String(this.master.planEndTime).slice(0, 10)}` : '-'; },
@@ -63,6 +77,26 @@ export default {
isSegmentExpanded(segmentNo) { return Boolean(this.expandedSegments[segmentNo]); }, isSegmentExpanded(segmentNo) { return Boolean(this.expandedSegments[segmentNo]); },
toggleSegment(segmentNo) { this.expandedSegments = { ...this.expandedSegments, [segmentNo]: !this.isSegmentExpanded(segmentNo) }; }, toggleSegment(segmentNo) { this.expandedSegments = { ...this.expandedSegments, [segmentNo]: !this.isSegmentExpanded(segmentNo) }; },
remainingQuantity(route) { return Math.max(0, Number(this.master?.totalQuantity || 0) - Number(route.dispatchedQuantity || 0)); }, remainingQuantity(route) { return Math.max(0, Number(this.master?.totalQuantity || 0) - Number(route.dispatchedQuantity || 0)); },
segmentTransportType(route = {}) { return route.transportTypeName || route.transportType || ''; },
plannedQuantity(route = {}) { return route.planQuantity || route.totalQuantity || this.master?.totalQuantity || 0; },
plannedUnit(route = {}) { return route.quantityUnit || this.master?.goods?.[0]?.quantityUnit || '吨'; },
segmentProgress(route = {}) { const planned = Number(this.plannedQuantity(route)); return planned > 0 ? Math.min(100, Math.max(0, (Number(route.dispatchedQuantity || 0) / planned) * 100)) : 0; },
attachmentUrl(file = {}) { return file.url || file.link || file.fileUrl || file.downloadUrl || file.domain || ''; },
attachmentName(file = {}) { return file.originalName || file.name || file.fileName || '附件'; },
attachmentExtension(file = {}) { const source = this.attachmentName(file).split('?')[0]; const index = source.lastIndexOf('.'); return index > -1 ? source.slice(index + 1).toLowerCase() : ''; },
isImageAttachment(file) { return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(this.attachmentExtension(file)); },
previewAttachment(file) {
const url = this.attachmentUrl(file);
if (!url) return this.$message.warning('附件地址为空,无法预览');
if (this.isImageAttachment(file)) {
this.imagePreviewUrls = this.attachments.filter(item => this.isImageAttachment(item)).map(item => this.attachmentUrl(item)).filter(Boolean);
this.imagePreviewIndex = Math.max(this.imagePreviewUrls.indexOf(url), 0);
this.imagePreviewVisible = true;
return;
}
this.previewFile = { name: this.attachmentName(file), url, mimeType: file.mimeType || file.contentType || '' };
this.documentPreviewVisible = true;
},
driverVehicle(row) { return [row.driverName, row.vehicleNo].filter(Boolean).join(' / ') || '-'; }, driverVehicle(row) { return [row.driverName, row.vehicleNo].filter(Boolean).join(' / ') || '-'; },
openWaybill(row) { this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.id } }); }, openWaybill(row) { this.$router.push({ path: '/business/waybill-manage', query: { detailId: row.id } }); },
openPlan(row) { this.$router.push({ path: '/business/transport-plan', query: { detailId: row.id } }); }, openPlan(row) { this.$router.push({ path: '/business/transport-plan', query: { detailId: row.id } }); },
@@ -78,14 +112,18 @@ export default {
.master-detail { padding-bottom: 20px; color: #303133; } .master-detail { padding-bottom: 20px; color: #303133; }
.detail-overview, .execution-detail-card { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; } .detail-overview, .execution-detail-card { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; }
.detail-heading { display: flex; align-items: center; justify-content: space-between; padding: 18px 24px; h2 { display: inline-block; margin: 0 16px 0 0; font-size: 20px; } h2 span { margin: 0 8px; color: #909399; font-weight: 400; } } .detail-heading { display: flex; align-items: center; justify-content: space-between; padding: 18px 24px; h2 { display: inline-block; margin: 0 16px 0 0; font-size: 20px; } h2 span { margin: 0 8px; color: #909399; font-weight: 400; } }
.detail-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { margin: 0; color: #409eff; font-size: 14px; word-break: break-all; } } .detail-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px 24px; margin: 0; padding: 0 24px 20px; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: #409eff; font-size: 14px; word-break: break-all; } }
.route-map { display: flex; align-items: flex-start; padding: 16px 24px 20px; border-top: 1px solid #eff1f7; overflow-x: auto; } .route-map { display: flex; align-items: flex-start; padding: 16px 24px 20px; border-top: 1px solid #eff1f7; overflow-x: auto; }
.route-map__node { display: grid; flex: 0 0 150px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; white-space: nowrap; } } .route-map__node { display: grid; flex: 0 0 150px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; white-space: nowrap; } }
.route-map__progress { color: #409eff !important; } .route-map__progress { color: #409eff !important; }
.route-badge { display: inline-flex; width: 32px; height: 32px; align-items: center; justify-content: center; border-radius: 4px; background: #67c23a; color: #fff; font-weight: 600; &.start { background: #409eff; } &.end { background: #e6a23c; } } .route-badge { display: inline-flex; width: 32px; height: 32px; align-items: center; justify-content: center; border-radius: 4px; background: #67c23a; color: #fff; font-weight: 600; &.start { background: #409eff; } &.end { background: #e6a23c; } }
.route-map__line { flex: 1 0 64px; min-width: 64px; height: 32px; border-bottom: 2px solid #606266; } .route-map__line { flex: 1 0 64px; min-width: 64px; height: 32px; border-bottom: 2px solid #606266; }
.execution-detail-heading { padding: 20px 24px 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } } .execution-detail-heading { padding: 20px 24px 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } }
.segment-detail { padding: 16px 24px; border-top: 1px solid #eff1f7; header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; cursor: pointer; h3 { display: flex; align-items: center; gap: 12px; margin: 0; font-size: 16px; } em { color: #409eff; font-style: normal; } } } .segment-detail { padding: 16px 24px; border-top: 1px solid #eff1f7; header { display: flex; align-items: center; justify-content: space-between; margin: -16px -24px 8px; padding: 12px 24px; cursor: pointer; background: #fafbfc; h3 { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 12px; margin: 0; font-size: 16px; } em { color: #409eff; font-style: normal; } } }
.segment-detail__progress { color: #606266; font-size: 14px; font-weight: 400; }
.segment-detail__transport-type { color: #409eff; font-size: 14px; font-weight: 500; }
.segment-detail__progress-bar { display: inline-flex; width: 72px; height: 6px; overflow: hidden; border-radius: 3px; background: #e4e7ed; }
.segment-detail__progress-bar span { display: block; height: 100%; background: #409eff; transition: width 0.2s ease; }
.segment-detail__header-right { display: flex; align-items: center; gap: 12px; } .segment-detail__header-right { display: flex; align-items: center; gap: 12px; }
.segment-index { display: inline-flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: 50%; background: #2088ee; color: #fff; font-size: 20px; font-weight: 500; } .segment-index { display: inline-flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: 50%; background: #2088ee; color: #fff; font-size: 20px; font-weight: 500; }
.segment-execution { padding-top: 12px; } .segment-execution { padding-top: 12px; }
@@ -46,7 +46,7 @@
<div v-show="route.selected" class="segment-content"> <div v-show="route.selected" class="segment-content">
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form"> <el-form :model="route" label-position="right" label-width="auto" class="dispatch-form">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="24"> <el-col :span="8">
<el-form-item label="运输方式" required class="transport-type-field"><el-input :model-value="route.transportType" readonly /></el-form-item> <el-form-item label="运输方式" required class="transport-type-field"><el-input :model-value="route.transportType" readonly /></el-form-item>
</el-col> </el-col>
<el-col v-if="isRoad(route)" :span="8"> <el-col v-if="isRoad(route)" :span="8">
@@ -56,13 +56,13 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="12">
<el-form-item label="发货地址" required><el-input :model-value="addressText(route.departureName, route.departureAddress)" readonly /></el-form-item> <el-form-item label="发货地址" required><el-input :model-value="addressText(route.departureName, route.departureAddress)" readonly /></el-form-item>
</el-col> </el-col>
<el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item></el-col> <el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="4"><el-form-item label="联系方式"><el-input v-model="route.departurePhone" placeholder="请输入" /></el-form-item></el-col> <el-col :span="4"><el-form-item label="联系方式"><el-input v-model="route.departurePhone" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="4"><el-form-item :label="dateStartLabel(route)" required><el-date-picker v-model="route.estimatedStartTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></el-form-item></el-col> <el-col :span="4"><el-form-item :label="dateStartLabel(route)" required><el-date-picker v-model="route.estimatedStartTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></el-form-item></el-col>
<el-col :span="8"> <el-col :span="12">
<el-form-item label="收货地址" required><el-input :model-value="addressText(route.arrivalName, route.arrivalAddress)" readonly /></el-form-item> <el-form-item label="收货地址" required><el-input :model-value="addressText(route.arrivalName, route.arrivalAddress)" readonly /></el-form-item>
</el-col> </el-col>
<el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.arrivalContact" placeholder="请输入" /></el-form-item></el-col> <el-col :span="4"><el-form-item label="联系人"><el-input v-model="route.arrivalContact" placeholder="请输入" /></el-form-item></el-col>
@@ -128,7 +128,7 @@
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="挂车车牌号" required><el-input v-model="route.trailerVehicleNo" placeholder="请输入" /></el-form-item></el-col> <el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="挂车车牌号" required><el-input v-model="route.trailerVehicleNo" placeholder="请输入" /></el-form-item></el-col>
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人" required><el-input v-model="route.escortName" placeholder="请输入" /></el-form-item></el-col> <el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人" required><el-input v-model="route.escortName" placeholder="请输入" /></el-form-item></el-col>
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人手机号" required><el-input v-model="route.escortPhone" placeholder="请输入" /></el-form-item></el-col> <el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="押运人手机号" required><el-input v-model="route.escortPhone" placeholder="请输入" /></el-form-item></el-col>
<el-col v-if="route.carrierType !== '承运商'" :span="6"><el-form-item label="里程(km)" required><el-input :model-value="route.mileage" inputmode="decimal" placeholder="请输入" @input="value => handleMileageInput(route, value)" /></el-form-item></el-col> <el-col :span="6"><el-form-item label="里程(km)" required><el-input :model-value="route.mileage" inputmode="numeric" maxlength="10" placeholder="请输入" @input="value => handleMileageInput(route, value)" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="备注"><el-input v-model="route.remark" maxlength="200" show-word-limit placeholder="请输入" /></el-form-item></el-col> <el-col :span="6"><el-form-item label="备注"><el-input v-model="route.remark" maxlength="200" show-word-limit placeholder="请输入" /></el-form-item></el-col>
</el-row> </el-row>
</el-form> </el-form>
@@ -155,6 +155,7 @@
</div> </div>
</section> </section>
</div> </div>
<div v-if="pendingExpanded" class="pending-list-spacer" aria-hidden="true"></div>
</div> </div>
</template> </template>
@@ -208,12 +209,25 @@ export default {
const res = await api.getDetail(this.id); const res = await api.getDetail(this.id);
this.master = res.data?.data || res.data || res; this.master = res.data?.data || res.data || res;
this.cargoTypeOptions = this.buildMasterCargoTypeOptions(this.master.goods || []); this.cargoTypeOptions = this.buildMasterCargoTypeOptions(this.master.goods || []);
this.routes = (this.master.routes || []).map((node, index) => this.createRoute(node, index)); this.routes = this.dispatchRouteNodes().map((node, index) => this.createRoute(node, index));
} finally { this.loading = false; } } finally { this.loading = false; }
}, },
dispatchRouteNodes() {
const routes = Array.isArray(this.master?.routes) ? this.master.routes : [];
if (routes.length) return routes;
return [{
segmentNo: '段1',
transportType: this.master?.transportType || '',
departureName: this.master?.arrivalName || '',
departureAddress: this.master?.arrivalAddress || '',
departureContact: this.master?.arrivalContact || '',
departurePhone: this.master?.arrivalPhone || '',
}];
},
createRoute(node, index) { createRoute(node, index) {
const previous = index ? this.master.routes[index - 1] : this.master; const routeNodes = this.dispatchRouteNodes();
return { const previous = index ? routeNodes[index - 1] : this.master;
const route = {
...node, ...node,
selected: false, selected: false,
documentType: '运单', documentType: '运单',
@@ -223,8 +237,10 @@ export default {
freightItems: [], freightItems: [],
departureName: previous.departureName || '', departureAddress: previous.departureAddress || '', departureContact: previous.departureContact || '', departurePhone: previous.departurePhone || '', departureName: previous.departureName || '', departureAddress: previous.departureAddress || '', departureContact: previous.departureContact || '', departurePhone: previous.departurePhone || '',
arrivalName: node.departureName || this.master.arrivalName || '', arrivalAddress: node.departureAddress || this.master.arrivalAddress || '', arrivalContact: node.departureContact || '', arrivalPhone: node.departurePhone || '', arrivalName: node.departureName || this.master.arrivalName || '', arrivalAddress: node.departureAddress || this.master.arrivalAddress || '', arrivalContact: node.departureContact || '', arrivalPhone: node.departurePhone || '',
goods: [], goods: (this.master.goods || []).map((goods, sourceIndex) => this.createGoodsRow(goods, sourceIndex)),
}; };
this.syncFreightItems(route);
return route;
}, },
buildMasterCargoTypeOptions(goods = []) { buildMasterCargoTypeOptions(goods = []) {
return [...new Set(goods.map(item => item.cargoType).filter(Boolean))].map(cargoType => ({ return [...new Set(goods.map(item => item.cargoType).filter(Boolean))].map(cargoType => ({
@@ -248,8 +264,10 @@ export default {
for (const option of options || []) { for (const option of options || []) {
const path = [...parentPath, option.id]; const path = [...parentPath, option.id];
if (option.cargoName === cargoType) return path; if (option.cargoName === cargoType) return path;
const result = this.findCargoTypePath(cargoType, option.children, path); if (option.children?.length) {
if (result.length) return result; const result = this.findCargoTypePath(cargoType, option.children, path);
if (result.length) return result;
}
} }
return []; return [];
}, },
@@ -417,8 +435,12 @@ export default {
if (goods) goods.quantityUnit = value; if (goods) goods.quantityUnit = value;
}, },
handleMileageInput(route, value) { handleMileageInput(route, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.'); route.mileage = String(value || '').replace(/\D/g, '').slice(0, 10);
route.mileage = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer; },
normalizeMileage(value) {
if (value === undefined || value === null || value === '') return '';
const mileage = Number(value);
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
}, },
addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; }, addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; },
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; }, contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
@@ -428,10 +450,7 @@ export default {
return { return {
...goods, ...goods,
sourceIndex, sourceIndex,
cargoTypePath: cargoTypePath: this.findCargoTypePath(goods.cargoType),
Array.isArray(goods.cargoTypePath) && goods.cargoTypePath.length
? goods.cargoTypePath
: this.findCargoTypePath(goods.cargoType),
dispatchQuantity: undefined, dispatchQuantity: undefined,
}; };
}, },
@@ -529,7 +548,7 @@ export default {
if (!route.estimatedEndTime) return this.$message.warning(`请选择${this.dateEndLabel(route)}`); if (!route.estimatedEndTime) return this.$message.warning(`请选择${this.dateEndLabel(route)}`);
if (!this.validateRoutePhones(route)) return; if (!this.validateRoutePhones(route)) return;
if (route.documentType === '运单') { if (route.documentType === '运单') {
if (route.carrierType === '承运商' && (!route.carrierName || !route.vehicleNo)) return this.$message.warning('请填写承运商车牌号'); if (route.carrierType === '承运商' && (!route.carrierName || !route.vehicleNo || !route.mileage)) return this.$message.warning('请填写承运商车牌号和里程');
if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo || !route.trailerVehicleNo || !route.escortName || !route.escortPhone || route.mileage === undefined || route.mileage === null || route.mileage === '')) return this.$message.warning('请补全自运或网货平台的车辆与人员信息'); if (route.carrierType !== '承运商' && (!route.driverName || !route.driverPhone || !route.vehicleNo || !route.trailerVehicleNo || !route.escortName || !route.escortPhone || route.mileage === undefined || route.mileage === null || route.mileage === '')) return this.$message.warning('请补全自运或网货平台的车辆与人员信息');
} }
const batchNo = `${route.segmentNo}-${Date.now()}`; const batchNo = `${route.segmentNo}-${Date.now()}`;
@@ -537,7 +556,7 @@ export default {
id: `${route.segmentNo}-${item.cargoName}-${Date.now()}-${Math.random()}`, id: `${route.segmentNo}-${item.cargoName}-${Date.now()}-${Math.random()}`,
batchNo, batchNo,
segmentNo: route.segmentNo, relationNo: route.segmentNo, documentType: route.documentType, transportType: route.transportType, carrierType: route.carrierType, segmentNo: route.segmentNo, relationNo: route.segmentNo, documentType: route.documentType, transportType: route.transportType, carrierType: route.carrierType,
carrierName: route.carrierName, driverName: route.driverName, driverPhone: route.driverPhone, vehicleNo: route.vehicleNo, trailerVehicleNo: route.trailerVehicleNo, escortName: route.escortName, escortPhone: route.escortPhone, mileage: route.mileage, unitPrice: this.freightItemsForGoods(route, item).unitPrice || '', priceUnit: this.freightItemsForGoods(route, item).priceUnit || '', currency: route.currency || 'CNY', freightAmount: this.freightAmount(this.freightItemsForGoods(route, item)), freightTotal: this.freightTotal(route), otherFeeTotal: route.otherFeeTotal || '', freightJson: this.buildFreightJson(route), remark: route.remark, carrierName: route.carrierName, driverName: route.driverName, driverPhone: route.driverPhone, vehicleNo: route.vehicleNo, trailerVehicleNo: route.trailerVehicleNo, escortName: route.escortName, escortPhone: route.escortPhone, mileage: this.normalizeMileage(route.mileage), unitPrice: this.freightItemsForGoods(route, item).unitPrice || '', priceUnit: this.freightItemsForGoods(route, item).priceUnit || '', currency: route.currency || 'CNY', freightAmount: this.freightAmount(this.freightItemsForGoods(route, item)), freightTotal: this.freightTotal(route), otherFeeTotal: route.otherFeeTotal || '', freightJson: this.buildFreightJson(route), remark: route.remark,
departureName: route.departureName, departureAddress: route.departureAddress, departureContact: route.departureContact, departurePhone: route.departurePhone, departureName: route.departureName, departureAddress: route.departureAddress, departureContact: route.departureContact, departurePhone: route.departurePhone,
arrivalName: route.arrivalName, arrivalAddress: route.arrivalAddress, arrivalContact: route.arrivalContact, arrivalPhone: route.arrivalPhone, arrivalName: route.arrivalName, arrivalAddress: route.arrivalAddress, arrivalContact: route.arrivalContact, arrivalPhone: route.arrivalPhone,
estimatedStartTime: route.estimatedStartTime, estimatedEndTime: route.estimatedEndTime, estimatedStartTime: route.estimatedStartTime, estimatedEndTime: route.estimatedEndTime,
@@ -589,7 +608,7 @@ export default {
if (!this.pending.length) return this.$message.warning('请加入待提交调度清单'); if (!this.pending.length) return this.$message.warning('请加入待提交调度清单');
if (this.pending.some(item => !this.validateRoutePhones(item))) return; if (this.pending.some(item => !this.validateRoutePhones(item))) return;
this.submitting = true; this.submitting = true;
try { await api.dispatch({ id: this.master.id, dispatches: this.pending.map(({ id, ...item }) => item) }); this.$message.success('调度成功'); this.$emit('back'); } finally { this.submitting = false; } try { await api.dispatch({ id: this.master.id, dispatches: this.pending.map(({ id, ...item }) => ({ ...item, mileage: this.normalizeMileage(item.mileage) })) }); this.$message.success('调度成功'); this.$emit('back'); } finally { this.submitting = false; }
}, },
statusName(value) { return ({ waiting_dispatch: '待调度', dispatching: '调度中', completed: '调度完成', closed: '调度关闭' })[value] || value || '-'; }, statusName(value) { return ({ waiting_dispatch: '待调度', dispatching: '调度中', completed: '调度完成', closed: '调度关闭' })[value] || value || '-'; },
statusType(value) { return ({ waiting_dispatch: 'warning', dispatching: 'success', completed: 'primary', closed: 'danger' })[value] || 'info'; }, statusType(value) { return ({ waiting_dispatch: 'warning', dispatching: 'success', completed: 'primary', closed: 'danger' })[value] || 'info'; },
@@ -599,6 +618,7 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
.master-dispatch { min-height: 100%; padding-bottom: 124px; background: #fff; color: #303133; } .master-dispatch { min-height: 100%; padding-bottom: 124px; background: #fff; color: #303133; }
.pending-list-spacer { height: min(436px, calc(100vh - 130px)); }
.master-overview, .dispatch-section { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; } .master-overview, .dispatch-section { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; }
.overview-heading, .segment-header, .pending-bar { display: flex; align-items: center; justify-content: space-between; } .overview-heading, .segment-header, .pending-bar { display: flex; align-items: center; justify-content: space-between; }
.overview-heading { padding: 18px 24px 12px; h2 { display: inline-block; margin: 0 16px 0 0; font-size: 20px; } h2 span { margin: 0 8px; color: #909399; font-weight: 400; } } .overview-heading { padding: 18px 24px 12px; h2 { display: inline-block; margin: 0 16px 0 0; font-size: 20px; } h2 span { margin: 0 8px; color: #909399; font-weight: 400; } }
+10 -2
View File
@@ -304,7 +304,11 @@
prop="transportType" prop="transportType"
min-width="150" min-width="150"
show-overflow-tooltip show-overflow-tooltip
/> >
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column label="数据来源" prop="dataSource" min-width="130" show-overflow-tooltip /> <el-table-column label="数据来源" prop="dataSource" min-width="130" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createTime" min-width="170" show-overflow-tooltip sortable/> <el-table-column label="创建时间" prop="createTime" min-width="170" show-overflow-tooltip sortable/>
<el-table-column label="更新时间" prop="updateTime" min-width="170" show-overflow-tooltip sortable/> <el-table-column label="更新时间" prop="updateTime" min-width="170" show-overflow-tooltip sortable/>
@@ -624,7 +628,11 @@
label="运输方式" label="运输方式"
min-width="130" min-width="130"
show-overflow-tooltip show-overflow-tooltip
/> >
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column <el-table-column
prop="cargoName" prop="cargoName"
label="货物名称" label="货物名称"
+20 -19
View File
@@ -521,6 +521,8 @@ const openUpload = async row => {
} }
}; };
const clearFile = () => Object.assign(editing, { fileName: '', fileUrl: '', fileTaskId: '' }); const clearFile = () => Object.assign(editing, { fileName: '', fileUrl: '', fileTaskId: '' });
const isCurrentUploadTask = taskId =>
taskId && activeUploadTaskId.value && String(taskId) === String(activeUploadTaskId.value);
const stopCurrentUpload = async taskId => { const stopCurrentUpload = async taskId => {
// 先使整个上传会话失效,阻止 MD5 计算、创建任务等异步流程返回后继续上传。 // 先使整个上传会话失效,阻止 MD5 计算、创建任务等异步流程返回后继续上传。
uploadCancelled.value = true; uploadCancelled.value = true;
@@ -529,7 +531,13 @@ const stopCurrentUpload = async taskId => {
const currentTaskId = taskId || activeUploadTaskId.value || editing.fileTaskId; const currentTaskId = taskId || activeUploadTaskId.value || editing.fileTaskId;
if (!currentTaskId) return; if (!currentTaskId) return;
cancelledTaskIds.value.add(String(currentTaskId)); cancelledTaskIds.value.add(String(currentTaskId));
if (isCurrentUploadTask(currentTaskId)) {
uploading.value = false;
activeUploadTaskId.value = undefined;
}
try { try {
const taskRes = await api.getFileTask(currentTaskId);
if (taskRes.data?.data?.status === 'completed') return;
await api.pauseFileTask([currentTaskId]); await api.pauseFileTask([currentTaskId]);
} catch (error) { } catch (error) {
ElMessage.error(error.message || '暂停上传失败'); ElMessage.error(error.message || '暂停上传失败');
@@ -740,7 +748,7 @@ const loadBatches = async () => {
createTimeEnd: batchCreateRange.value?.[1] ? `${batchCreateRange.value[1]} 23:59:59` : '', createTimeEnd: batchCreateRange.value?.[1] ? `${batchCreateRange.value[1]} 23:59:59` : '',
}); });
const data = res.data?.data || {}; const data = res.data?.data || {};
batchRows.value = (data.records || []).map(item => ({ ...item, id: item.waybillIds })); batchRows.value = data.records || [];
batchPage.total = data.total || 0; batchPage.total = data.total || 0;
}; };
const resetBatchQuery = () => { const resetBatchQuery = () => {
@@ -755,27 +763,18 @@ const confirmBatches = () => {
if (!batches.has(item.batchNo)) batches.set(item.batchNo, item); if (!batches.has(item.batchNo)) batches.set(item.batchNo, item);
}); });
selectedBatches.value = [...batches.values()]; selectedBatches.value = [...batches.values()];
editing.waybillImportBatchIds = selectedBatches.value.flatMap(item => editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
String(item.waybillIds || item.id)
.split(',')
.filter(Boolean)
);
batchVisible.value = false; batchVisible.value = false;
}; };
const removeBatch = row => { const removeBatch = row => {
selectedBatches.value = selectedBatches.value.filter(item => item.id !== row.id); selectedBatches.value = selectedBatches.value.filter(item => item.id !== row.id);
editing.waybillImportBatchIds = selectedBatches.value.flatMap(item => editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
String(item.waybillIds || item.id)
.split(',')
.filter(Boolean)
);
}; };
const submitUpload = async () => { const submitUpload = async () => {
saving.value = true; saving.value = true;
try { try {
if (uploading.value && editing.fileTaskId) { if (uploading.value && editing.fileTaskId) {
await api.pauseFileTask([editing.fileTaskId]); await stopCurrentUpload(editing.fileTaskId);
uploadAbortController.value?.abort();
} }
const valid = await uploadFormRef.value.validate().catch(() => false); const valid = await uploadFormRef.value.validate().catch(() => false);
if (!valid) return; if (!valid) return;
@@ -785,11 +784,7 @@ const submitUpload = async () => {
} }
await api.submit({ await api.submit({
...editing, ...editing,
waybillImportBatchIds: selectedBatches.value.flatMap(item => waybillImportBatchIds: selectedBatches.value.map(item => item.id),
String(item.waybillIds || item.id)
.split(',')
.filter(Boolean)
),
}); });
ElMessage.success('提交成功'); ElMessage.success('提交成功');
await closeUploadDialog(); await closeUploadDialog();
@@ -826,7 +821,13 @@ const taskStatusName = status =>
const formatSize = size => const formatSize = size =>
size >= 1024 ** 3 ? `${(size / 1024 ** 3).toFixed(1)}G` : `${(size / 1024 ** 2).toFixed(1)}M`; size >= 1024 ** 3 ? `${(size / 1024 ** 3).toFixed(1)}G` : `${(size / 1024 ** 2).toFixed(1)}M`;
const pauseTask = async row => { const pauseTask = async row => {
await api.pauseFileTask([row.id]); if (row.status === 'completed') return;
if (isCurrentUploadTask(row.id)) {
await stopCurrentUpload(row.id);
} else {
await api.pauseFileTask([row.id]);
cancelledTaskIds.value.add(String(row.id));
}
ElMessage.success('已取消上传'); ElMessage.success('已取消上传');
loadProgress(); loadProgress();
}; };