修复业务模块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';
const api = createCrudApi('/blade-transport/process-config');
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 remove = api.remove;
export const copy = api.copy;
+1 -1
View File
@@ -30,7 +30,7 @@ export const getPartUploadUrl = (id, partNumber) =>
export const updateFileTask = data =>
request({ url: `${fileTaskBaseUrl}/updateFileTask`, method: 'post', data });
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 =>
request({ url: `${fileTaskBaseUrl}/resume`, method: 'post', params: { id } });
export const getFileTaskList = (current, size, params) =>
@@ -797,6 +797,7 @@
placeholder="请输入"
clearable
maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly"
@input="handleTaskMileageInput"
/>
@@ -927,6 +928,7 @@
placeholder="请输入"
clearable
maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly"
@input="handleTaskMileageInput"
/>
@@ -1347,6 +1349,7 @@
placeholder="请输入里程"
clearable
maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly"
@input="handleTaskMileageInput"
/>
@@ -2334,6 +2337,7 @@
v-model="detailBox"
:title="`${config.title}详情`"
append-to-body
top="10px"
width="96%"
class="business-crud-page__detail-dialog"
>
@@ -2386,6 +2390,7 @@
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
@click="previewAttachment(file)"
>
{{ file.originalName || file.name || '-' }}
</el-link>
@@ -2502,15 +2507,44 @@
</section-card>
<div class="business-crud-page__waybill-detail-bottom">
<section-card title="执行详情">
<el-timeline v-if="waybillDetailProcessNodes.length">
<el-timeline-item
v-for="(node, index) in waybillDetailProcessNodes"
:key="node.key || index"
:timestamp="node.time || ''"
>{{ node.name || node.nodeName || node.label }}</el-timeline-item
<el-tabs v-model="waybillProcessDetailTab" type="border-card">
<el-tab-pane label="打卡详情" name="punch">
<el-timeline v-if="waybillDetailProcessNodes.length">
<el-timeline-item
v-for="(node, index) in waybillDetailProcessNodes"
: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>
<el-empty v-else description="暂无执行详情" :image-size="50" />
<div v-loading="waybillVoucherImagesLoading" class="business-crud-page__voucher-image-grid">
<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 title="物流轨迹">
<template #extra>
@@ -2565,7 +2599,11 @@
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<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 prop="description" label="附件描述" min-width="220" />
<el-table-column label="文件大小" width="110">
@@ -2587,6 +2625,38 @@
</template>
</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
v-model="waybillRouteChangeBox"
title="变更运输路线"
@@ -4674,6 +4744,7 @@ import {
import {
getDetail as getProcessConfigDetail,
getList as getProcessConfigList,
getVoucherImages as getProcessConfigVoucherImages,
} from '@/api/business/process-config';
import { getList as getCarrierCustomerList } from '@/api/vehicle/customer-archive';
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 { isMobile } from '@/utils/validate';
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 NProgress from 'nprogress';
import 'nprogress/nprogress.css';
@@ -4698,6 +4774,14 @@ const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
let amapLoader;
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const estimateMenuButtonCount = config => {
const rowActions = (config.actions || []).filter(action => action !== 'batchComplete');
return 3 + rowActions.length + (config.operations || []).length;
@@ -4859,7 +4943,7 @@ const defaultDispatchRow = () => ({
const taskCarrierTypes = ['承运商', '自运', '网货平台'];
export default {
components: { WaybillImportDialog, Rank },
components: { WaybillImportDialog, Rank, ElImageViewer, OpenFileViewer },
name: 'BusinessCrudPage',
props: {
api: {
@@ -4904,6 +4988,11 @@ export default {
detailLoading: false,
detailRow: {},
waybillDetailProcessNodes: [],
waybillProcessDetailTab: 'punch',
waybillHasRelatedVoucher: false,
waybillVoucherImages: [],
waybillVoucherImagesLoading: false,
waybillVoucherImagesLoaded: false,
waybillRouteChangeBox: false,
waybillRouteChangeTab: 'change',
waybillRouteChangeRows: [],
@@ -4989,6 +5078,19 @@ export default {
selectedAttachmentRows: [],
contractFileRows: [],
selectedContractFileRows: [],
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
transportCargoRows: [],
taskFreightCurrency: 'CNY',
shippingTemplateFreight: defaultShippingTemplateFreight(),
@@ -5643,6 +5745,9 @@ export default {
}
},
},
waybillProcessDetailTab(tab) {
if (tab === 'batchSupplement') this.loadWaybillVoucherImages();
},
'form.transportType'(value, oldValue) {
if (!this.shippingInfoFormEnabled || this.suppressTransportTypeClear) return;
if (String(value ?? '') === String(oldValue ?? '')) return;
@@ -5990,6 +6095,10 @@ export default {
this.detailBox = true;
this.detailLoading = true;
this.detailRow = { ...row };
this.waybillProcessDetailTab = 'punch';
this.waybillHasRelatedVoucher = false;
this.waybillVoucherImages = [];
this.waybillVoucherImagesLoaded = false;
const request =
typeof this.api.getDetail === 'function'
? this.api.getDetail(row.id)
@@ -6039,8 +6148,9 @@ export default {
return projectIds.includes(String(projectId));
});
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;
this.waybillHasRelatedVoucher = Boolean(detail.hasRelatedVoucher);
return this.getProcessConfigEnabledNodes(detail);
});
})
@@ -6052,6 +6162,25 @@ export default {
})
.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 = []) {
const nodes = [];
rows.forEach((row, rowIndex) => {
@@ -7021,6 +7150,7 @@ export default {
['quantity', 'mileage', 'unitPrice'].forEach(prop => {
if (Number(this.form[prop]) === -1) this.form[prop] = '';
});
this.form.mileage = this.normalizeMileageValue(this.form.mileage);
this.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
@@ -7291,6 +7421,7 @@ export default {
});
},
applyShippingPlan(plan = {}) {
this.suppressTransportTypeClear = true;
this.form.planId = plan.id || '';
this.form.planName = plan.planName || '';
[
@@ -7301,6 +7432,7 @@ export default {
'transportMode',
'transportTypeName',
'transportModeName',
'departureAddressId',
'departureName',
'departureAddress',
'departureContact',
@@ -7308,6 +7440,7 @@ export default {
'departureProvince',
'departureCity',
'departureDistrict',
'arrivalAddressId',
'arrivalName',
'arrivalAddress',
'arrivalContact',
@@ -7337,11 +7470,24 @@ export default {
].forEach(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) {
this.initTaskFullCargoRows(goodsRows);
}
if (this.contractSelectEnabled) this.syncCurrentContractOption();
this.$refs.crud?.validateField?.('planName');
this.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
},
clearShippingPlan() {
this.form.planId = '';
@@ -7417,7 +7563,7 @@ export default {
this.form.quantityUnit =
this.form.quantityUnit || taskInfo.quantityUnit || goodsInfo.quantityUnit || '吨';
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 || '';
['quantity', 'mileage', 'unitPrice'].forEach(prop => {
if (Number(this.form[prop]) === -1) this.form[prop] = '';
@@ -7702,6 +7848,11 @@ export default {
.replace(/\D/g, '')
.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) {
this.dispatchItemForm.mileage = String(value || '')
.replace(/\D/g, '')
@@ -9015,13 +9166,54 @@ export default {
if (number < 1024 * 1024) return `${(number / 1024).toFixed(1)}KB`;
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) {
const url = row.url || row.link;
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空');
return;
}
downloadFileByUrl(url, row.originalName || row.name || '附件');
downloadFileByUrl(url, this.attachmentName(row));
},
handleBatchDownload() {
const rows = this.selectedAttachmentRows.length
@@ -12413,6 +12605,32 @@ export default {
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 {
:deep(.el-dialog__body) {
padding-top: 12px;
@@ -2,14 +2,19 @@
<div v-loading="loading" class="master-detail">
<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>
<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>
</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">
<div class="execution-detail-heading"><h3>分段执行明细</h3></div>
<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 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>
@@ -29,11 +34,19 @@
<script>
import * as api from '@/api/business/master-order';
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 {
components: { ElImageViewer, OpenFileViewer },
props: { id: [String, Number] },
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: {
segments() {
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 };
});
},
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('、') || '-'; },
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)}` : '-'; },
@@ -63,6 +77,26 @@ export default {
isSegmentExpanded(segmentNo) { return Boolean(this.expandedSegments[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)); },
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(' / ') || '-'; },
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 } }); },
@@ -78,14 +112,18 @@ export default {
.master-detail { padding-bottom: 20px; color: #303133; }
.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-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__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-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; }
.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-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; }
@@ -46,7 +46,7 @@
<div v-show="route.selected" class="segment-content">
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form">
<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-col>
<el-col v-if="isRoad(route)" :span="8">
@@ -56,13 +56,13 @@
</el-radio-group>
</el-form-item>
</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-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="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-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.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="里程(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-row>
</el-form>
@@ -155,6 +155,7 @@
</div>
</section>
</div>
<div v-if="pendingExpanded" class="pending-list-spacer" aria-hidden="true"></div>
</div>
</template>
@@ -208,12 +209,25 @@ export default {
const res = await api.getDetail(this.id);
this.master = res.data?.data || res.data || res;
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; }
},
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) {
const previous = index ? this.master.routes[index - 1] : this.master;
return {
const routeNodes = this.dispatchRouteNodes();
const previous = index ? routeNodes[index - 1] : this.master;
const route = {
...node,
selected: false,
documentType: '运单',
@@ -223,8 +237,10 @@ export default {
freightItems: [],
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 || '',
goods: [],
goods: (this.master.goods || []).map((goods, sourceIndex) => this.createGoodsRow(goods, sourceIndex)),
};
this.syncFreightItems(route);
return route;
},
buildMasterCargoTypeOptions(goods = []) {
return [...new Set(goods.map(item => item.cargoType).filter(Boolean))].map(cargoType => ({
@@ -248,8 +264,10 @@ export default {
for (const option of options || []) {
const path = [...parentPath, option.id];
if (option.cargoName === cargoType) return path;
const result = this.findCargoTypePath(cargoType, option.children, path);
if (result.length) return result;
if (option.children?.length) {
const result = this.findCargoTypePath(cargoType, option.children, path);
if (result.length) return result;
}
}
return [];
},
@@ -417,8 +435,12 @@ export default {
if (goods) goods.quantityUnit = value;
},
handleMileageInput(route, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
route.mileage = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
route.mileage = String(value || '').replace(/\D/g, '').slice(0, 10);
},
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(' / ') || '-'; },
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
@@ -428,10 +450,7 @@ export default {
return {
...goods,
sourceIndex,
cargoTypePath:
Array.isArray(goods.cargoTypePath) && goods.cargoTypePath.length
? goods.cargoTypePath
: this.findCargoTypePath(goods.cargoType),
cargoTypePath: this.findCargoTypePath(goods.cargoType),
dispatchQuantity: undefined,
};
},
@@ -529,7 +548,7 @@ export default {
if (!route.estimatedEndTime) return this.$message.warning(`请选择${this.dateEndLabel(route)}`);
if (!this.validateRoutePhones(route)) return;
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('请补全自运或网货平台的车辆与人员信息');
}
const batchNo = `${route.segmentNo}-${Date.now()}`;
@@ -537,7 +556,7 @@ export default {
id: `${route.segmentNo}-${item.cargoName}-${Date.now()}-${Math.random()}`,
batchNo,
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,
arrivalName: route.arrivalName, arrivalAddress: route.arrivalAddress, arrivalContact: route.arrivalContact, arrivalPhone: route.arrivalPhone,
estimatedStartTime: route.estimatedStartTime, estimatedEndTime: route.estimatedEndTime,
@@ -589,7 +608,7 @@ export default {
if (!this.pending.length) return this.$message.warning('请加入待提交调度清单');
if (this.pending.some(item => !this.validateRoutePhones(item))) return;
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 || '-'; },
statusType(value) { return ({ waiting_dispatch: 'warning', dispatching: 'success', completed: 'primary', closed: 'danger' })[value] || 'info'; },
@@ -599,6 +618,7 @@ export default {
<style scoped lang="scss">
.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; }
.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; } }
+10 -2
View File
@@ -304,7 +304,11 @@
prop="transportType"
min-width="150"
show-overflow-tooltip
/>
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column label="数据来源" prop="dataSource" min-width="130" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createTime" 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="运输方式"
min-width="130"
show-overflow-tooltip
/>
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column
prop="cargoName"
label="货物名称"
+20 -19
View File
@@ -521,6 +521,8 @@ const openUpload = async row => {
}
};
const clearFile = () => Object.assign(editing, { fileName: '', fileUrl: '', fileTaskId: '' });
const isCurrentUploadTask = taskId =>
taskId && activeUploadTaskId.value && String(taskId) === String(activeUploadTaskId.value);
const stopCurrentUpload = async taskId => {
// 先使整个上传会话失效,阻止 MD5 计算、创建任务等异步流程返回后继续上传。
uploadCancelled.value = true;
@@ -529,7 +531,13 @@ const stopCurrentUpload = async taskId => {
const currentTaskId = taskId || activeUploadTaskId.value || editing.fileTaskId;
if (!currentTaskId) return;
cancelledTaskIds.value.add(String(currentTaskId));
if (isCurrentUploadTask(currentTaskId)) {
uploading.value = false;
activeUploadTaskId.value = undefined;
}
try {
const taskRes = await api.getFileTask(currentTaskId);
if (taskRes.data?.data?.status === 'completed') return;
await api.pauseFileTask([currentTaskId]);
} catch (error) {
ElMessage.error(error.message || '暂停上传失败');
@@ -740,7 +748,7 @@ const loadBatches = async () => {
createTimeEnd: batchCreateRange.value?.[1] ? `${batchCreateRange.value[1]} 23:59:59` : '',
});
const data = res.data?.data || {};
batchRows.value = (data.records || []).map(item => ({ ...item, id: item.waybillIds }));
batchRows.value = data.records || [];
batchPage.total = data.total || 0;
};
const resetBatchQuery = () => {
@@ -755,27 +763,18 @@ const confirmBatches = () => {
if (!batches.has(item.batchNo)) batches.set(item.batchNo, item);
});
selectedBatches.value = [...batches.values()];
editing.waybillImportBatchIds = selectedBatches.value.flatMap(item =>
String(item.waybillIds || item.id)
.split(',')
.filter(Boolean)
);
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
batchVisible.value = false;
};
const removeBatch = row => {
selectedBatches.value = selectedBatches.value.filter(item => item.id !== row.id);
editing.waybillImportBatchIds = selectedBatches.value.flatMap(item =>
String(item.waybillIds || item.id)
.split(',')
.filter(Boolean)
);
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
};
const submitUpload = async () => {
saving.value = true;
try {
if (uploading.value && editing.fileTaskId) {
await api.pauseFileTask([editing.fileTaskId]);
uploadAbortController.value?.abort();
await stopCurrentUpload(editing.fileTaskId);
}
const valid = await uploadFormRef.value.validate().catch(() => false);
if (!valid) return;
@@ -785,11 +784,7 @@ const submitUpload = async () => {
}
await api.submit({
...editing,
waybillImportBatchIds: selectedBatches.value.flatMap(item =>
String(item.waybillIds || item.id)
.split(',')
.filter(Boolean)
),
waybillImportBatchIds: selectedBatches.value.map(item => item.id),
});
ElMessage.success('提交成功');
await closeUploadDialog();
@@ -826,7 +821,13 @@ const taskStatusName = status =>
const formatSize = size =>
size >= 1024 ** 3 ? `${(size / 1024 ** 3).toFixed(1)}G` : `${(size / 1024 ** 2).toFixed(1)}M`;
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('已取消上传');
loadProgress();
};