185 lines
19 KiB
Vue
185 lines
19 KiB
Vue
<template>
|
|
<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)" class="status-text">{{ statusName(master.businessStatus) }}</el-tag><el-tag v-if="transportFlowLabel" type="info" class="transport-flow-tag">{{ transportFlowLabel }}</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 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>{{ segmentLocationName(route, 'departure') }}</strong><small>{{ route.departureAddress || '-' }}</small><small class="route-map__progress">{{ index ? `已到达 ${quantity(segments[index - 1].arrivedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(segments[index - 1])}` : `已调度 ${quantity(route.dispatchedQuantity)}/${quantity(master.totalQuantity)} ${plannedUnit(route)}` }}</small><small v-if="index" class="route-map__progress">已调度 {{ quantity(route.dispatchedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</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>{{ segmentLocationName(route, 'arrival') }}</strong><small>{{ route.arrivalAddress || '-' }}</small><small class="route-map__progress">已到达 {{ quantity(route.arrivedQuantity) }}/{{ quantity(master.totalQuantity) }} {{ plannedUnit(route) }}</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>{{ segmentLocationName(route, 'departure') }} → {{ segmentLocationName(route, 'arrival') }}</span><span class="segment-detail__transport-type">{{ segmentTransportType(route) || '-' }}</span><span class="segment-detail__progress-bar"><span :style="{ width: `${segmentProgress(route)}%` }"></span></span><span class="segment-detail__progress">已调度 <em>{{ quantity(route.dispatchedQuantity) }}</em> / {{ quantity(plannedQuantity(route)) }} {{ plannedUnit(route) }}</span><el-button text class="segment-toggle" @click.stop="toggleSegment(route.segmentNo)"><span>{{ isSegmentExpanded(route.segmentNo) ? '收起' : '展开' }}</span><el-icon class="segment-toggle__icon"><component :is="isSegmentExpanded(route.segmentNo) ? CaretTop : CaretBottom" /></el-icon></el-button></h3></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="isRoadTransport(route) ? '司机/车牌' : '船长/船号'" min-width="180"><template #default="{ row }">{{ driverVehicle(row, route) }}</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)" effect="dark" size="small">{{ waybillStatusName(row.businessStatus) }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></el-table>
|
|
<el-empty v-if="!(route.waybills || []).length" description="暂无运单明细" :image-size="56" />
|
|
<div v-if="(route.transportPlans || []).length" class="transport-plan-detail"><h4>关联计划单</h4><el-table :data="route.transportPlans" border><el-table-column label="计划单号" min-width="160"><template #default="{ row }"><el-link type="primary" @click="openPlan(row)">{{ row.planNo }}</el-link></template></el-table-column><el-table-column prop="planName" label="计划名称" min-width="180" /><el-table-column prop="transportType" label="运输方式" width="130" /><el-table-column prop="planStartDate" label="计划开始日期" width="130" /><el-table-column prop="planEndDate" label="计划结束日期" width="130" /><el-table-column prop="businessStatus" label="状态" width="110" /></el-table></div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
<section v-if="master" class="master-goods-card">
|
|
<div class="master-goods-card__heading"><h3>货物信息</h3></div>
|
|
<el-table :data="master.goods || []" border>
|
|
<el-table-column prop="cargoName" label="货物名称" min-width="150" />
|
|
<el-table-column prop="cargoType" label="类型" min-width="130" />
|
|
<el-table-column prop="packageType" label="包装" min-width="120" />
|
|
<el-table-column label="数量" min-width="100"><template #default="{ row }">{{ row.quantity ?? '-' }}{{ row.quantityUnit ? ` ${row.quantityUnit}` : '' }}</template></el-table-column>
|
|
<el-table-column prop="brand" label="品牌" min-width="120" />
|
|
<el-table-column prop="specification" label="规格" min-width="150" />
|
|
<el-table-column prop="model" label="型号" min-width="150" />
|
|
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
|
|
</el-table>
|
|
<el-empty v-if="!(master.goods || []).length" description="暂无货物信息" :image-size="56" />
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import * as api from '@/api/business/master-order';
|
|
import { CaretBottom, CaretTop } 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: {}, CaretBottom, CaretTop, 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 || [];
|
|
return routes.map((route, index) => {
|
|
const previous = index ? routes[index - 1] : this.master;
|
|
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 []; } },
|
|
transportFlowLabel() { return this.segments.map(route => this.segmentTransportType(route).slice(0, 1)).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('、') || '-'; },
|
|
dateRange() { return this.master?.planStartTime && this.master?.planEndTime ? `${String(this.master.planStartTime).slice(0, 10)} ~ ${String(this.master.planEndTime).slice(0, 10)}` : '-'; },
|
|
},
|
|
async mounted() {
|
|
this.loading = true;
|
|
try {
|
|
const res = await api.getDetail(this.id);
|
|
this.master = res.data?.data || res.data || res;
|
|
this.expandedSegments = (this.master.routeProgress || this.master.routes || []).reduce((result, route) => {
|
|
result[route.segmentNo] = Boolean(route.waybills?.length);
|
|
return result;
|
|
}, {});
|
|
} finally { this.loading = false; }
|
|
},
|
|
methods: {
|
|
quantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
|
|
segmentNumber(route, index) { return String(route.segmentNo || '').replace(/^段/, '') || index + 1; },
|
|
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 || ''; },
|
|
isRoadTransport(route = {}) {
|
|
const type = String(this.segmentTransportType(route)).trim().toLowerCase();
|
|
return type === 'road' || type.includes('公路');
|
|
},
|
|
formatRoadLocation(value) {
|
|
const text = String(value || '').replace(/\s+/g, '');
|
|
if (!text) return '';
|
|
const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, '');
|
|
const cityMatch = withoutProvince.match(/.+?(?:市|州(?!市)|盟(?!市))/);
|
|
if (!cityMatch) return text;
|
|
const city = cityMatch[0];
|
|
const districtSource = withoutProvince.slice(city.length);
|
|
const districtMatch = districtSource.match(/^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/);
|
|
if (!districtMatch) return city;
|
|
const district = `${districtMatch[0]}${districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : ''}`;
|
|
return `${city} ${district}`;
|
|
},
|
|
segmentLocationName(route = {}, field) {
|
|
const name = String(route[`${field}Name`] || '').trim();
|
|
const address = String(route[`${field}Address`] || '').trim();
|
|
const formattedName = this.formatRoadLocation(name);
|
|
if (/(?:市|州|盟)/.test(formattedName)) return formattedName;
|
|
const regionalName = /(?:省|自治区|特别行政区|市|州|盟|区|县|旗)/.test(name);
|
|
if (name && !regionalName) return name;
|
|
const formattedAddress = this.formatRoadLocation(address);
|
|
if (/(?:市|州|盟)/.test(formattedAddress)) return formattedAddress;
|
|
const city = String(route[`${field}CityName`] || '').trim();
|
|
const district = String(route[`${field}DistrictName`] || '').trim();
|
|
return [city, district].filter(Boolean).join(' ') || formattedName || formattedAddress || '-';
|
|
},
|
|
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 = {}, route = {}) {
|
|
const vehicle = row.vehicleNo || row.vehicleNumber || row.shipNo || row.flightNo || row.trainNo;
|
|
const values = this.isRoadTransport(route)
|
|
? [row.driverName, vehicle]
|
|
: [row.captainName, vehicle];
|
|
return values.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 } }); },
|
|
waybillStatusName(value) { return ({ pending: '待执行', waiting: '待执行', running: '进行中', processing: '进行中', completed: '已完成', cancelled: '已取消' })[value] || value || '-'; },
|
|
waybillStatusType(value) { return ({ pending: 'info', waiting: 'info', running: 'warning', processing: 'warning', completed: 'success', cancelled: 'danger' })[value] || 'info'; },
|
|
statusName(value) { return ({ waiting_dispatch: '待调度', dispatching: '调度中', completed: '调度完成', closed: '调度关闭' })[value] || value || '-'; },
|
|
statusType(value) { return ({ waiting_dispatch: 'warning', dispatching: 'success', completed: 'primary', closed: 'danger' })[value] || 'info'; },
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.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; } }
|
|
.transport-flow-tag { margin-left: 8px; }
|
|
.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: #303133 !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: 6px solid #e4e7ed; }
|
|
.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: -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: #67c23a; 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: 144px; 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-toggle { gap: 4px; padding: 4px 0; }
|
|
.segment-toggle__icon { font-size: 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; }
|
|
.segment-stats { display: flex; gap: 72px; padding: 4px 0 24px; div { display: flex; flex-direction: column; gap: 8px; } span { color: #8a9bb8; font-size: 14px; } strong { color: #1f2d3d; font-size: 32px; line-height: 1; } small { margin-left: 6px; color: #8a9bb8; font-size: 16px; font-weight: 400; } .dispatched { color: #67c23a; } .remaining { color: #e6a23c; } }
|
|
.waybill-table { :deep(th.el-table__cell) { background: #f5f7fa; color: #60738f; } }
|
|
.transport-plan-detail { margin-top: 20px; h4 { margin: 0 0 12px; padding-left: 12px; border-left: 4px solid #409eff; font-size: 15px; } }
|
|
.master-goods-card { margin-bottom: 8px; border: 1px solid #eff1f7; background: #fff; }
|
|
.master-goods-card__heading { padding: 20px 24px 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } }
|
|
@media screen and (max-width: 1200px) { .detail-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
</style>
|