1、新增总单

2、fix bug
This commit is contained in:
2026-08-11 23:50:51 +08:00
parent 1f669bfd29
commit 3e585b89fd
11 changed files with 2356 additions and 18 deletions
@@ -3755,7 +3755,7 @@
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="站点类型">
<el-form-item label="地址类型">
<el-input :model-value="transportStationTypeLabel" disabled />
</el-form-item>
</el-col>
@@ -4254,6 +4254,10 @@ export default {
type: Number,
default: null,
},
detailId: {
type: [String, Number],
default: null,
},
},
data() {
return {
@@ -4920,6 +4924,14 @@ export default {
},
},
watch: {
detailId: {
immediate: true,
handler(id) {
if (id !== undefined && id !== null && id !== '') {
this.$nextTick(() => this.openDetail({ id }));
}
},
},
'form.transportType'(value, oldValue) {
if (!this.shippingInfoFormEnabled || this.suppressTransportTypeClear) return;
if (String(value ?? '') === String(oldValue ?? '')) return;
@@ -0,0 +1,98 @@
<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)">{{ 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>
<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 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>
<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>
<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 type="index" label="序号" width="64" /><el-table-column prop="cargoName" label="货物名称" min-width="150" /><el-table-column prop="cargoType" label="货物类型" min-width="130" /><el-table-column label="数量" width="130"><template #default="{ row }">{{ quantity(row.quantity) }} {{ row.quantityUnit || '' }}</template></el-table-column><el-table-column prop="packageType" label="包装" min-width="120" /><el-table-column prop="brand" label="品牌" min-width="120" /><el-table-column prop="specification" label="规格" min-width="120" /><el-table-column prop="model" label="型号" min-width="120" /></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 { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
export default {
props: { id: [String, Number] },
emits: ['back'],
data() { return { loading: false, master: null, expandedSegments: {}, ArrowDown, ArrowUp }; },
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 };
});
},
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)); },
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 } }); },
waybillStatusName(value) { return ({ pending: '待执行', running: '进行中', completed: '已完成', cancelled: '已取消' })[value] || value || '-'; },
waybillStatusType(value) { return ({ pending: 'info', running: '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; } }
.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; } }
.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__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; }
.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: #16a34a; } .remaining { color: #f59e0b; } }
.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>
@@ -0,0 +1,385 @@
<template>
<div v-loading="loading" class="master-dispatch">
<section v-if="master" class="master-overview">
<div class="overview-heading">
<div>
<h2>多联总单调度 <span>|</span> {{ master.masterNo }}</h2>
<el-tag :type="statusType(master.businessStatus)">{{ statusName(master.businessStatus) }}</el-tag>
</div>
</div>
<div class="overview-content">
<dl class="overview-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>
<div class="route-overview">
<template v-for="(route, index) in routes" :key="route.segmentNo">
<div class="route-overview__node">
<span :class="['route-badge', index === 0 ? 'start' : 'middle']">{{ index === 0 ? '起' : '经' }}</span>
<strong>{{ route.departureName || '-' }}</strong>
<small>{{ route.departureAddress || '-' }}</small>
<small>{{ contactText(route.departureContact, route.departurePhone) }}</small>
</div>
<div class="route-overview__line"></div>
<div v-if="index === routes.length - 1" class="route-overview__node">
<span class="route-badge end"></span>
<strong>{{ route.arrivalName || '-' }}</strong>
<small>{{ route.arrivalAddress || '-' }}</small>
<small>{{ contactText(route.arrivalContact, route.arrivalPhone) }}</small>
</div>
</template>
</div>
</div>
</section>
<section v-for="(route, index) in routes" :key="route.segmentNo" class="dispatch-section" :data-segment="route.segmentNo">
<header class="segment-header">
<div>
<el-checkbox v-model="route.selected" :label="`${route.segmentNo}${route.departureName} → ${route.arrivalName}`" />
</div>
<div>总量 {{ formatQuantity(totalQuantity) }}已调度 <em>{{ formatQuantity(route.dispatchedQuantity) }}</em>剩余 <em>{{ formatQuantity(remaining(route)) }}</em></div>
</header>
<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-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">
<el-form-item label="单据类型" required>
<el-radio-group v-model="route.documentType">
<el-radio value="计划单">计划单</el-radio><el-radio value="运单">运单</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<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-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>
<el-col :span="4"><el-form-item label="联系方式"><el-input v-model="route.arrivalPhone" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="4"><el-form-item :label="dateEndLabel(route)" required><el-date-picker v-model="route.estimatedEndTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择" /></el-form-item></el-col>
</el-row>
</el-form>
<div class="goods-heading"><h3>货物信息</h3><div><el-link type="primary" @click="addGoods(route)">新增货物</el-link></div></div>
<el-table :data="route.goods" border class="goods-table">
<el-table-column type="index" label="序号" width="64" />
<el-table-column label="货物名称" min-width="180"><template #default="{ row }"><el-select v-model="row.sourceIndex" placeholder="请选择总单货物" @change="selectGoods(route, row)"><el-option v-for="item in masterGoods" :key="item.sourceIndex" :label="item.label" :value="item.sourceIndex" /></el-select></template></el-table-column>
<el-table-column label="货物类型" min-width="130"><template #default="{ row }"><el-input :model-value="row.cargoType" readonly /></template></el-table-column>
<el-table-column label="剩余数量" width="120"><template #default="{ row }">{{ formatQuantity(row.remainingQuantity) }}</template></el-table-column>
<el-table-column label="本次数量" width="160"><template #default="{ row }"><el-input :model-value="row.dispatchQuantity" inputmode="decimal" placeholder="请输入" @input="value => handleDispatchQuantityInput(row, value)" /></template></el-table-column>
<el-table-column prop="quantityUnit" label="数量单位" width="110" />
<el-table-column prop="packageType" label="包装" min-width="110" />
<el-table-column prop="brand" label="品牌" min-width="110" />
<el-table-column prop="specification" label="规格" min-width="110" />
<el-table-column prop="model" label="型号" min-width="110" />
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column label="操作" width="90" fixed="right"><template #default="{ $index }"><el-link type="danger" @click="removeGoods(route, $index)">删除</el-link></template></el-table-column>
</el-table>
<div class="freight-heading"><h3>运费信息</h3></div>
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form freight-form">
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="单价"><el-input :model-value="route.unitPrice" inputmode="decimal" placeholder="请输入" @input="value => handleUnitPriceInput(route, value)" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="计价单位"><el-select v-model="route.priceUnit"><el-option label="元/吨" value="元/吨" /><el-option label="元/件" value="元/件" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item label="重量合计"><el-input :model-value="formatQuantity(dispatchWeight(route))" readonly><template #append></template></el-input></el-form-item></el-col>
<el-col :span="6"><el-form-item label="运费"><el-input :model-value="formatAmount(freightAmount(route))" readonly><template #append></template></el-input></el-form-item></el-col>
</el-row>
</el-form>
<template v-if="route.documentType === '运单'">
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-type-form"><el-form-item label="承运类型" required><el-radio-group v-model="route.carrierType" @change="value => handleCarrierTypeChange(route, value)"><el-radio-button label="承运商" /><el-radio-button label="自运" /><el-radio-button label="网货平台" /></el-radio-group></el-form-item></el-form>
<el-form :model="route" label-position="right" label-width="auto" class="dispatch-form carrier-form">
<el-row :gutter="16">
<el-col v-if="route.carrierType === '承运商'" :span="6"><el-form-item label="承运商" required><el-select v-model="route.carrierName" filterable remote clearable placeholder="请选择" :remote-method="loadCarrierOptions" :loading="carrierLoading" @visible-change="visible => visible && loadCarrierOptions()"><el-option v-for="item in carrierOptions" :key="carrierOptionKey(item)" :label="carrierOptionLabel(item)" :value="carrierOptionLabel(item)" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item label="司机" :required="route.carrierType !== '承运商'"><el-select :ref="element => setDriverInput(route.segmentNo, element)" v-model="route.driverName" filterable remote clearable placeholder="请选择" :remote-method="loadDriverOptions" :loading="driverLoading" @visible-change="visible => visible && loadDriverOptions()" @change="value => handleDriverChange(route, value)"><el-option v-for="item in driverOptions" :key="driverOptionKey(item)" :label="driverOptionLabel(item)" :value="driverOptionLabel(item)" /></el-select></el-form-item></el-col>
<el-col :span="6"><el-form-item :label="route.carrierType === '承运商' ? '手机号' : '司机手机号'" :required="route.carrierType !== '承运商'"><el-input v-model="route.driverPhone" placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="车牌号" required><el-input v-model="route.vehicleNo" 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.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="备注"><el-input v-model="route.remark" maxlength="200" show-word-limit placeholder="请输入" /></el-form-item></el-col>
</el-row>
</el-form>
</template>
<div class="segment-actions"><el-button plain @click="addToPending(route, true)">加入并继续调度</el-button><el-button plain @click="addToPending(route, false)">加入调度清单</el-button></div>
</div>
</section>
<div class="dispatch-bottom">
<section class="pending-panel">
<div class="pending-bar">
<div class="pending-summary">
<strong>待提交 <em>{{ pending.length }}</em> <span class="pending-summary-text">{{ pendingSegmentSummary }}</span></strong>
<el-link type="primary" @click="pendingExpanded = !pendingExpanded">{{ pendingExpanded ? '收起待调度列表' : '展开待调度列表' }}</el-link>
</div>
<div class="pending-actions"><el-button @click="$emit('back')">返回</el-button><el-button type="primary" :loading="submitting" @click="submit">确认调度</el-button></div>
</div>
<div v-show="pendingExpanded" class="pending-list">
<template v-for="(items, segmentNo) in pendingGroups" :key="segmentNo">
<h3>{{ segmentNo }}{{ items[0].departureName }} {{ items[0].arrivalName }}</h3>
<el-table :data="items" border><el-table-column type="index" label="序号" width="70" /><el-table-column label="单据类型" prop="documentType" width="100" /><el-table-column label="货物" min-width="220"><template #default="{ row }">{{ row.cargoName }}{{ row.cargoType ? `${row.cargoType}` : '' }}</template></el-table-column><el-table-column label="本次数量" width="150"><template #default="{ row }">{{ formatQuantity(row.quantity) }} {{ row.quantityUnit }}</template></el-table-column><el-table-column prop="carrierName" label="承运商" min-width="180" /><el-table-column prop="driverName" label="司机" width="130" /><el-table-column prop="vehicleNo" label="车牌" width="130" /><el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><div class="table-actions"><el-link type="primary" @click="editPending(row)">编辑</el-link><el-link type="danger" @click="removePending(row.id)">删除</el-link></div></template></el-table-column></el-table>
</template>
<el-empty v-if="!pending.length" description="暂无待提交调度条目" :image-size="60" />
</div>
</section>
</div>
</div>
</template>
<script>
import * as api from '@/api/business/master-order';
import { getList as getCustomerList } from '@/api/vehicle/customer-archive';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
const unwrapRecords = res => {
const data = res?.data || res;
if (Array.isArray(data)) return data;
if (Array.isArray(data?.records)) return data.records;
if (Array.isArray(data?.data)) return data.data;
if (Array.isArray(data?.data?.records)) return data.data.records;
return [];
};
export default {
props: { id: [String, Number] },
emits: ['back'],
data() {
return { loading: false, submitting: false, master: null, routes: [], pending: [], pendingExpanded: false, editingId: null, driverInputs: {}, carrierOptions: [], driverOptions: [], carrierLoading: false, driverLoading: false };
},
computed: {
totalQuantity() { return (this.master?.goods || []).reduce((sum, item) => sum + Number(item.quantity || 0), 0); },
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('、') || '-'; },
masterGoods() { return (this.master?.goods || []).map((item, sourceIndex) => ({ ...item, sourceIndex, label: [item.cargoName, 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)}` : '-'; },
pendingGroups() { return this.pending.reduce((groups, item) => { (groups[item.segmentNo] ||= []).push(item); return groups; }, {}); },
pendingSegmentSummary() {
return this.routes.map(route => {
const items = this.pending.filter(item => item.segmentNo === route.segmentNo);
if (!items.length) return `${route.segmentNo}:无`;
const documentSummary = items.reduce((result, item) => {
result[item.documentType] = (result[item.documentType] || 0) + 1;
return result;
}, {});
return `${route.segmentNo}${Object.entries(documentSummary).map(([type, count]) => `${type} x ${count}`).join('、')}`;
}).join(' | ');
},
},
async mounted() {
await Promise.all([this.load(), this.loadCarrierOptions(), this.loadDriverOptions()]);
},
methods: {
async load() {
this.loading = true;
try {
const res = await api.getDetail(this.id);
this.master = res.data?.data || res.data || res;
this.routes = (this.master.routes || []).map((node, index) => this.createRoute(node, index));
} finally { this.loading = false; }
},
createRoute(node, index) {
const previous = index ? this.master.routes[index - 1] : this.master;
return {
...node,
selected: false,
documentType: '运单',
carrierType: '承运商',
unitPrice: undefined,
priceUnit: '元/吨',
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: [],
};
},
isRoad(route) { return String(route.transportType || '').includes('公路'); },
remaining(route) { return Math.max(0, this.totalQuantity - Number(route.dispatchedQuantity || 0)); },
formatQuantity(value) { const number = Number(value || 0); return Number.isInteger(number) ? number : number.toFixed(3).replace(/\.?0+$/, ''); },
formatAmount(value) { return Number(value || 0).toFixed(2); },
dispatchWeight(route) { return (route.goods || []).reduce((sum, item) => sum + Number(item.dispatchQuantity || 0), 0); },
freightAmount(route) { return Number(route.unitPrice || 0) * this.dispatchWeight(route); },
handleDispatchQuantityInput(row, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
row.dispatchQuantity = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 3)}` : integer;
},
handleUnitPriceInput(route, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
route.unitPrice = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
},
handleMileageInput(route, value) {
const [integer = '', decimal = ''] = String(value || '').replace(/[^\d.]/g, '').split('.');
route.mileage = decimal || String(value || '').includes('.') ? `${integer}.${decimal.slice(0, 2)}` : integer;
},
addressText(name, address) { return [name, address].filter(Boolean).join(' / ') || '-'; },
contactText(name, phone) { return [name, phone].filter(Boolean).join(' - ') || '-'; },
dateStartLabel(route) { return route.documentType === '计划单' ? '计划开始日期' : '预计发货日期'; },
dateEndLabel(route) { return route.documentType === '计划单' ? '计划结束日期' : '预计完成日期'; },
createGoodsRow(goods = {}, sourceIndex, dispatchedGoods = {}) {
return {
...goods,
sourceIndex,
remainingQuantity: Math.max(0, Number(goods.quantity || 0) - Number(dispatchedGoods[`${goods.cargoName || ''}\u0000${goods.cargoType || ''}`] || 0)),
dispatchQuantity: undefined,
};
},
addGoods(route) { route.goods.push(this.createGoodsRow()); },
selectGoods(route, row) {
const source = (this.master.goods || [])[Number(row.sourceIndex)];
if (!source) return;
Object.assign(row, this.createGoodsRow(source, Number(row.sourceIndex), route.dispatchedGoods || {}));
},
removeGoods(route, index) { route.goods.splice(index, 1); },
handleCarrierTypeChange(route, value) {
route.carrierType = value;
if (value === '承运商') {
route.trailerVehicleNo = '';
route.escortName = '';
route.escortPhone = '';
route.mileage = undefined;
} else {
route.carrierName = '';
}
},
carrierOptionLabel(item) { return item.customerName || item.carrierName || item.fullName || item.name || ''; },
carrierOptionKey(item) { return item.id || item.customerId || item.carrierId || this.carrierOptionLabel(item); },
driverOptionLabel(item) { return item.driverName || item.name || ''; },
driverOptionKey(item) { return item.id || item.driverId || this.driverOptionLabel(item) || item.mobile; },
handleDriverChange(route, value) {
const driver = this.driverOptions.find(item => this.driverOptionLabel(item) === value);
if (driver) route.driverPhone = driver.mobile || driver.driverPhone || driver.phone || '';
},
async loadCarrierOptions(keyword = '') {
this.carrierLoading = true;
try {
this.carrierOptions = unwrapRecords(
await getCustomerList(1, 20, { customerName: keyword, customerType: '承运商' })
);
} finally {
this.carrierLoading = false;
}
},
async loadDriverOptions(keyword = '') {
this.driverLoading = true;
try {
this.driverOptions = unwrapRecords(await getDriverList(1, 20, { driverName: keyword }));
} finally {
this.driverLoading = false;
}
},
setDriverInput(segmentNo, element) {
if (element) this.driverInputs[segmentNo] = element;
else delete this.driverInputs[segmentNo];
},
resetForNextDispatch(route) {
route.driverName = '';
route.driverPhone = '';
route.vehicleNo = '';
route.trailerVehicleNo = '';
route.escortName = '';
route.escortPhone = '';
route.goods.forEach(item => { item.dispatchQuantity = undefined; });
this.$nextTick(() => this.driverInputs[route.segmentNo]?.focus());
},
addToPending(route, continueDispatch) {
const goods = route.goods.filter(item => Number(item.dispatchQuantity) > 0);
if (!goods.length) return this.$message.warning('请填写本次数量');
if (goods.some(item => Number(item.dispatchQuantity) > Number(item.remainingQuantity))) return this.$message.warning('本次数量不能超过该货物的可调度数量');
if (!route.estimatedStartTime) return this.$message.warning(`请选择${this.dateStartLabel(route)}`);
if (!route.estimatedEndTime) return this.$message.warning(`请选择${this.dateEndLabel(route)}`);
if (route.documentType === '运单') {
if (route.carrierType === '承运商' && (!route.carrierName || !route.vehicleNo)) 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()}`;
goods.forEach(item => this.pending.push({
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: route.unitPrice, priceUnit: route.priceUnit, 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,
cargoName: item.cargoName, cargoType: item.cargoType, quantity: item.dispatchQuantity, quantityUnit: item.quantityUnit, packageType: item.packageType, brand: item.brand, specification: item.specification, model: item.model, materialCode: item.materialCode,
}));
this.editingId = null;
this.pendingExpanded = true;
if (continueDispatch) this.resetForNextDispatch(route);
else {
route.goods.forEach(item => { item.dispatchQuantity = undefined; });
route.selected = false;
}
this.$message.success('已加入调度清单');
},
editPending(item) {
const route = this.routes.find(routeItem => routeItem.segmentNo === item.segmentNo);
if (!route) return;
const sourceIndex = (this.master.goods || []).findIndex(goods => goods.cargoName === item.cargoName && goods.cargoType === item.cargoType);
let goods = route.goods.find(goodsItem => goodsItem.cargoName === item.cargoName && goodsItem.cargoType === item.cargoType);
if (!goods && sourceIndex >= 0) {
goods = this.createGoodsRow(this.master.goods[sourceIndex], sourceIndex, route.dispatchedGoods || {});
route.goods.push(goods);
}
if (goods) goods.dispatchQuantity = item.quantity;
Object.assign(route, item);
this.editingId = item.id;
this.removePending(item.id);
this.$nextTick(() => document.querySelector(`[data-segment="${item.segmentNo}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
},
removePending(id) { this.pending = this.pending.filter(item => item.id !== id); if (this.editingId === id) this.editingId = null; },
async submit() {
if (!this.pending.length) return this.$message.warning('请加入待提交调度清单');
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; }
},
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-dispatch { min-height: 100%; padding-bottom: 124px; background: #fff; color: #303133; }
.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; } }
.overview-content { display: grid; grid-template-columns: minmax(400px, .9fr) minmax(620px, 1.6fr); gap: 28px; padding: 8px 24px 20px; }
.overview-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 24px; margin: 0; dt { margin-bottom: 6px; color: #909399; font-size: 13px; } dd { margin: 0; color: #409eff; font-size: 14px; word-break: break-all; } }
.route-overview { display: flex; align-items: flex-start; justify-content: flex-end; padding-top: 8px; overflow-x: auto; }
.route-overview__node { display: grid; flex: 0 0 130px; justify-items: center; gap: 6px; text-align: center; strong { font-size: 16px; white-space: nowrap; } small { color: #606266; white-space: nowrap; } }
.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-overview__line { flex: 1 0 56px; min-width: 56px; height: 32px; margin-top: 0; border-bottom: 2px solid #606266; }
.segment-header { min-height: 46px; padding: 0 20px; border-bottom: 1px solid #eff1f7; font-weight: 600; em { color: #409eff; font-style: normal; } }
.segment-content { padding: 16px 24px; }
.dispatch-form { :deep(.el-form-item) { margin-bottom: 16px; } :deep(.el-input), :deep(.el-select), :deep(.el-date-editor), :deep(.el-input-number) { width: 100%; } }
.transport-type-field :deep(.el-input) { width: 240px; }
.goods-heading { display: flex; align-items: center; justify-content: space-between; margin: 0 -24px 12px; padding: 14px 24px; border-top: 1px solid #eff1f7; border-bottom: 1px solid #eff1f7; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } span { color: #909399; font-size: 13px; } }
.freight-heading { margin: 16px 0 12px; h3 { margin: 0; padding-left: 12px; border-left: 4px solid #409eff; font-size: 16px; } }
.goods-table { :deep(th.el-table__cell), :deep(td.el-table__cell) { border-color: #eff1f7; } :deep(.el-table__row--striped td.el-table__cell) { background: #fafafa; } }
.carrier-type-form { margin-top: 16px; }
.carrier-form { padding-top: 8px; }
.segment-actions { display: flex; justify-content: flex-end; gap: 12px; padding-top: 4px; }
.dispatch-bottom { position: fixed; z-index: 10; bottom: 0; left: $sidebar_width; width: calc(100% - #{$sidebar_width}); border-top: 1px solid #eff1f7; background: #fff; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04); }
:global(.avue--collapse) .dispatch-bottom { left: $sidebar_collapse; width: calc(100% - #{$sidebar_collapse}); }
:global(.avue-layout--horizontal) .dispatch-bottom { left: 0; width: 100%; }
.pending-panel { background: #fff; }
.pending-bar { min-height: 58px; padding: 0 32px; em { color: #409eff; font-style: normal; } }
.pending-summary, .pending-actions { display: flex; align-items: center; }
.pending-summary { gap: 16px; }
.pending-summary-text { color: #606266; font-weight: 400; }
.pending-actions { gap: 12px; margin-left: auto; }
.pending-list { max-height: min(420px, calc(100vh - 130px)); padding: 0 32px 16px; overflow: auto; h3 { margin: 14px 0 10px; font-size: 14px; } }
.table-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media screen and (max-width: 1200px) { .overview-content { grid-template-columns: 1fr; } .route-overview { justify-content: flex-start; } }
@media screen and (max-width: 992px) { .dispatch-bottom { left: 0; width: 100%; } }
</style>
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -356,7 +356,7 @@
<div class="loading-detail__heading">配载单详情</div>
<div>{{ dialogForm.loadingNo || '-' }}</div>
<div><el-tag :type="detailStatusType">{{ statusText(dialogForm) }}</el-tag></div>
<div><el-tag type="warning">{{ dialogForm.transportType || '-' }}</el-tag></div>
<div><el-tag type="warning">{{ transportTypeLabel(dialogForm.transportType) }}</el-tag></div>
</div>
<div class="loading-detail__task-row">
<div class="loading-detail__field"><span class="loading-detail__label">任务信息</span><span class="loading-detail__value">{{ taskSummary }}</span></div>
@@ -369,7 +369,7 @@
</section-card>
<section-card title="货物信息"><el-table :data="cargoRows" border height="220"><el-table-column type="index" label="序号" width="70"/><el-table-column prop="projectName" label="项目名称" min-width="140"/><el-table-column prop="cargoName" label="货物名称" min-width="140"/><el-table-column prop="cargoType" label="货物类型" min-width="120"/><el-table-column prop="packageType" label="包装" min-width="100"/><el-table-column prop="weight" label="重量" min-width="110"/><el-table-column prop="volume" label="体积" min-width="110"/><el-table-column prop="quantity" label="数量" min-width="90"/><el-table-column prop="materialCode" label="物料编码" min-width="120"/><el-table-column prop="equipmentCode" label="设备编码" min-width="120"/><el-table-column prop="brand" label="品牌" min-width="120"/><el-table-column prop="specificationModel" label="规格型号" min-width="140"/><el-table-column prop="remark" label="备注" min-width="160"/><el-table-column prop="unitPrice" label="货物单价" min-width="140"/><el-table-column prop="planDate" label="计划日期" min-width="130"/></el-table></section-card>
<section-card title="关联运单信息"><el-table :data="selectedWaybillRows" border><el-table-column type="index" label="序号" width="70"/><el-table-column prop="waybillNo" label="运单号" min-width="150"><template #default="{ row }"><el-link type="primary">{{ row.waybillNo || '-' }}</el-link></template></el-table-column><el-table-column label="配载单号" min-width="150"><template #default>{{ dialogForm.loadingNo || '-' }}</template></el-table-column><el-table-column prop="projectName" label="项目名称" min-width="150"/><el-table-column prop="customerName" label="客户" min-width="140"/><el-table-column prop="departureAddress" label="发货地址" min-width="220" show-overflow-tooltip/><el-table-column prop="arrivalAddress" label="到货地址" min-width="220" show-overflow-tooltip/></el-table></section-card>
<div class="loading-detail__bottom-grid"><section-card title="运输节点"><el-timeline v-if="processNodes.length"><el-timeline-item v-for="(item, index) in processNodes" :key="item.id || index" :timestamp="item.time || ''">{{ item.name || item.nodeName || item.label }}</el-timeline-item></el-timeline><el-empty v-else description="暂无运输节点" :image-size="50"/></section-card><section-card title="物流轨迹"><template #extra><div class="loading-detail__track-actions"><el-button plain @click="handleTrackAction('playback')">轨迹回放</el-button><el-button plain @click="handleTrackAction('locate')">实时定位</el-button></div></template><div ref="detailAmap" class="loading-detail__map"></div></section-card></div>
<div class="loading-detail__bottom-grid"><section-card title="运输节点"><template #extra><div class="loading-detail__process-actions"><el-select v-model="processProjectId" filterable remote clearable placeholder="切换项目" :remote-method="loadProjectOptions" :loading="projectLoading" @visible-change="visible => visible && loadProjectOptions()" @change="handleProcessProjectChange"><el-option v-for="item in projectOptions" :key="item.id" :label="item.projectName || item.name" :value="item.id" /></el-select><el-button plain @click="handleProcessAction('supplement')">补录执行</el-button><el-button plain @click="handleProcessAction('batch')">批量补录</el-button></div></template><el-timeline v-if="processNodes.length"><el-timeline-item v-for="(item, index) in processNodes" :key="item.id || index" :timestamp="item.time || ''">{{ item.name || item.nodeName || item.label }}</el-timeline-item></el-timeline><el-empty v-else description="暂无运输节点" :image-size="50"/></section-card><section-card title="物流轨迹"><template #extra><div class="loading-detail__track-actions"><el-button plain @click="handleTrackAction('playback')">轨迹回放</el-button><el-button plain @click="handleTrackAction('locate')">实时定位</el-button></div></template><div ref="detailAmap" class="loading-detail__map"></div></section-card></div>
</div>
<el-form
v-else
@@ -1110,6 +1110,7 @@ export default {
dataSourceOptions: loadingManageConfig.dataSourceOptions,
config: loadingManageConfig,
processNodes: [],
processProjectId: '',
routeChangeVisible: false,
routeChangeTab: 'change',
routeChangeRows: [],
@@ -1285,6 +1286,8 @@ export default {
...createDialogForm(),
...(res.data?.data || res.data || {}),
};
this.processProjectId = this.dialogForm.projectId || '';
await this.loadProjectOptions(this.dialogForm.projectName || '');
await this.restoreWaybillRows(
this.dialogForm.waybillIdsJson,
this.dialogForm.loadingSubNos
@@ -1322,6 +1325,7 @@ export default {
this.candidatePage.total = 0;
this.candidateSearchVisible = false;
this.processNodes = [];
this.processProjectId = '';
},
clearLoadingDialog() {
this.resetDialogData();
@@ -1346,19 +1350,21 @@ export default {
}
},
async loadProcessNodes() {
const waybillProcess = parseJsonArray(this.selectedWaybillRows[0]?.processJson);
if (waybillProcess.length) {
this.processNodes = waybillProcess;
if (!this.processProjectId) {
this.processNodes = [];
return;
}
try {
const rows = unwrapRecords(await getProcessConfigList(1, 20, { projectId: this.dialogForm.projectId, status: 1 }));
const rows = unwrapRecords(await getProcessConfigList(1, 20, { projectId: this.processProjectId, status: 1 }));
const config = rows.find(item => item.nodeConfigJson) || {};
this.processNodes = parseJsonArray(config.nodeConfigJson);
} catch (error) {
this.processNodes = [];
}
},
async handleProcessProjectChange() {
await this.loadProcessNodes();
},
previewAttachment(file) {
if (!file.url) {
ElMessage.info('暂无可预览的附件地址');
@@ -1400,6 +1406,9 @@ export default {
handleTrackAction(action) {
ElMessage.info(action === 'playback' ? '暂无可回放的物流轨迹' : '暂无车辆实时定位数据');
},
handleProcessAction(action) {
ElMessage.info(action === 'supplement' ? '补录执行功能暂未接入' : '批量补录功能暂未接入');
},
openRouteChangeDialog() {
this.routeChangeTab = 'change';
this.routeChangeRemark = '';
@@ -1868,6 +1877,10 @@ export default {
handleMileageInput(value) {
this.dialogForm.mileage = String(value || '').replace(/[^\d.]/g, '');
},
transportTypeLabel(value) {
if (!value) return '-';
return this.transportTypeOptions.find(item => String(item.value) === String(value))?.label || value;
},
async loadTransportTypeOptions() {
if (this.transportTypeOptions.length) return;
const res = await request({
@@ -2214,6 +2227,8 @@ export default {
.loading-detail__step-tags { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
.loading-detail__bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.loading-detail__track-actions { display: flex; gap: 8px; }
.loading-detail__process-actions { display: flex; align-items: center; gap: 8px; }
.loading-detail__process-actions .el-select { width: 180px; }
.loading-detail__map { width: 100%; min-height: 260px; border: 1px solid #eff1f7; }
}
.loading-route-change-dialog { .loading-route-change-dialog__original { color: #a8abb2; line-height: 20px; margin-bottom: 4px; }.loading-route-change-dialog__location { cursor: pointer; color: #909399; }.loading-route-change-dialog__hint { color: #909399; }.loading-route-change-dialog__route-list { display: flex; flex-direction: column; gap: 8px; }.loading-route-change-dialog__route-item { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto 24px; align-items: center; gap: 12px; padding: 10px 12px; background: #f5f6f7; cursor: move; }.loading-route-change-dialog__route-type { width: 30px; height: 30px; border-radius: 50%; background: #dcdfe6; text-align: center; line-height: 30px; }.loading-route-change-dialog__tags { display: flex; gap: 4px; }.loading-route-change-dialog__record-content { white-space: normal; overflow-wrap: anywhere; line-height: 22px; } }
+549
View File
@@ -0,0 +1,549 @@
<template>
<basic-container class="master-order-page">
<template v-if="mode === 'list'">
<el-form :model="query" class="master-search" label-width="160px" @submit.prevent>
<el-row :gutter="16">
<el-col v-for="field in primaryFields" :key="field.prop" :span="6"
><el-form-item :label="field.label"
><el-input v-model="query[field.prop]" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="状态"
><el-select v-model="query.businessStatus" placeholder="全部"
><el-option label="全部" value="" /><el-option
v-for="item in statuses"
:key="item.value"
:label="item.label"
:value="item.value" /></el-select></el-form-item
></el-col>
<template v-if="searchExpanded">
<el-col v-for="field in secondaryFields" :key="field.prop" :span="6"
><el-form-item :label="field.label"
><el-input v-model="query[field.prop]" placeholder="请输入" /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="计划开始日期"
><el-date-picker
v-model="query.planStartRange"
type="datetimerange"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="请选择"
end-placeholder="请选择" /></el-form-item
></el-col>
<el-col :span="6"
><el-form-item label="计划结束日期"
><el-date-picker
v-model="query.planEndRange"
type="datetimerange"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="请选择"
end-placeholder="请选择" /></el-form-item
></el-col>
</template>
<el-col :span="24" class="search-actions"
><el-button type="primary" @click="search">查询</el-button
><el-button @click="reset">重置</el-button
><el-link type="primary" @click="searchExpanded = !searchExpanded"
><el-icon><component :is="searchExpanded ? ArrowUp : ArrowDown" /></el-icon
>{{ searchExpanded ? '折叠' : '展开' }}</el-link
></el-col
>
</el-row>
</el-form>
<section class="master-list-panel">
<div class="toolbar">
<el-button type="primary" @click="goCreate()">新建多联总单</el-button
><el-button type="primary" plain @click="download">批量导出</el-button>
</div>
<div v-loading="loading" class="master-cards">
<article v-for="row in records" :key="row.id" class="master-card">
<section class="card-info">
<div class="card-title-row">
<el-link type="primary" @click="goDetail(row.id)">{{ row.masterNo }}</el-link
><el-tag :type="statusType(row.businessStatus)" class="status-tag">{{
statusName(row.businessStatus)
}}</el-tag>
</div>
<p class="project-name">{{ row.projectName || '-' }}</p>
<p>{{ timeRange(row) }}</p>
<p>{{ goodsSummary(row) }}</p>
</section>
<section class="route-progress">
<div class="route-track">
<template v-for="(node, index) in routeNodes(row)" :key="node.key">
<div class="route-point">
<span :class="['route-node', node.type]">{{ node.text }}</span>
<div class="route-detail">
<strong>{{ node.name }}</strong>
<span v-for="line in node.lines" :key="line">{{ line }}</span>
</div>
</div>
<div v-if="index < routeNodes(row).length - 1" class="route-connector">
<small>{{ (row.routeProgress || [])[index]?.transportType }}</small>
<el-progress
:percentage="progress((row.routeProgress || [])[index], row.totalQuantity)"
:show-text="false"
/>
</div>
</template>
</div>
</section>
<aside class="card-actions">
<header>操作</header>
<el-link
v-for="action in actions(row)"
:key="action.text"
:type="action.danger ? 'danger' : 'primary'"
@click="action.run"
>{{ action.text }}</el-link
>
</aside>
</article>
<el-empty v-if="!loading && !records.length" description="暂无数据" />
</div>
<div class="page-bar">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:page-sizes="[10, 20, 50, 100]"
layout="prev, pager, next, total, sizes"
:total="page.total"
@current-change="load"
@size-change="sizeChange"
/>
</div>
</section>
</template>
<master-order-editor
v-else-if="mode === 'editor'"
:id="routeId"
@back="goList"
@dispatch="goDispatch"
/>
<master-order-dispatch v-else-if="mode === 'dispatch'" :id="routeId" @back="goList" />
<master-order-detail v-else :id="routeId" @back="goList" />
<el-dialog v-model="confirm.visible" title="提示" width="400px"
><span>{{ confirm.message }}</span
><template #footer
><el-button @click="confirm.visible = false">关闭</el-button
><el-button type="primary" @click="confirm.run">确认</el-button></template
></el-dialog
>
</basic-container>
</template>
<script>
import * as api from '@/api/business/master-order';
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import MasterOrderEditor from './components/master-order-editor.vue';
import MasterOrderDispatch from './components/master-order-dispatch.vue';
import MasterOrderDetail from './components/master-order-detail.vue';
export default {
components: { MasterOrderEditor, MasterOrderDispatch, MasterOrderDetail },
data() {
return {
ArrowDown,
ArrowUp,
searchExpanded: false,
loading: false,
records: [],
query: { businessStatus: '' },
page: { current: 1, size: 10, total: 0 },
confirm: { visible: false, message: '', run: () => {} },
statuses: [
{ label: '草稿', value: 'draft' },
{ label: '待调度', value: 'waiting_dispatch' },
{ label: '调度中', value: 'dispatching' },
{ label: '调度完成', value: 'completed' },
{ label: '调度关闭', value: 'closed' },
],
primaryFields: [
{ label: '总单号', prop: 'masterNos' },
{ label: '项目名称', prop: 'projectName' },
{ label: '客户名称', prop: 'customerName' },
],
secondaryFields: [
{ label: '货物名称', prop: 'cargoName' },
{ label: '货物类型', prop: 'cargoType' },
{ label: '发货地址', prop: 'departureAddress' },
{ label: '收货地址', prop: 'arrivalAddress' },
],
};
},
computed: {
mode() {
return this.$route.query.mode || 'list';
},
routeId() {
return this.$route.query.id;
},
},
watch: {
'$route.query': {
immediate: true,
handler() {
this.syncTagTitle();
if (this.mode === 'list') this.load();
},
},
},
methods: {
async load() {
this.loading = true;
const params = { ...this.query };
if (params.planStartRange) [params.planStartTime] = params.planStartRange;
if (params.planEndRange) [, params.planEndTime] = params.planEndRange;
try {
const res = await api.getList(this.page.current, this.page.size, params);
const data = res.data?.data || res.data || res;
this.records = data.records || [];
this.page.total = data.total || 0;
} finally {
this.loading = false;
}
},
search() {
this.page.current = 1;
this.load();
},
reset() {
this.query = { businessStatus: '' };
this.search();
},
sizeChange() {
this.page.current = 1;
this.load();
},
goList() {
this.$router.push('/business/master-order');
},
goCreate(id) {
this.$router.push({ path: '/business/master-order', query: { mode: 'editor', id } });
},
goDetail(id) {
this.$router.push({ path: '/business/master-order', query: { mode: 'detail', id } });
},
goDispatch(id) {
this.$router.push({ path: '/business/master-order', query: { mode: 'dispatch', id } });
},
syncTagTitle() {
const title = this.mode === 'detail'
? '总单详情'
: this.mode === 'dispatch'
? '多联调度'
: this.mode === 'editor'
? (this.routeId ? '编辑总单' : '新增总单')
: '总单管理';
this.$store.commit('SET_TAG', { fullPath: this.$route.fullPath, name: title });
this.$router.$avueRouter.setTitle(title);
},
actions(row) {
const result = [
{
text: '复制',
run: async () => {
const res = await api.copy(row.id);
this.$message.success(`复制成功,新总单号:${(res.data || res).masterNo}`);
this.load();
},
},
];
if (['draft', 'waiting_dispatch', 'dispatching', 'completed'].includes(row.businessStatus))
result.unshift({ text: '编辑', run: () => this.goCreate(row.id) });
if (['waiting_dispatch', 'dispatching'].includes(row.businessStatus))
result.unshift({ text: '调度', run: () => this.goDispatch(row.id) });
if (row.businessStatus !== 'draft')
result.unshift({ text: '详情', run: () => this.goDetail(row.id) });
if (['dispatching', 'completed'].includes(row.businessStatus))
result.push({
text: '关闭调度',
run: () =>
this.confirmAction('确认关闭调度?关闭后将不可恢复!', () => api.closeDispatch(row.id)),
});
if (['draft', 'waiting_dispatch'].includes(row.businessStatus))
result.push({
text: '删除',
danger: true,
run: () =>
this.confirmAction('确认删除这条数据?删除后将不可恢复!', () => api.remove(row.id)),
});
return result;
},
confirmAction(message, request) {
this.confirm = {
visible: true,
message,
run: async () => {
await request();
this.confirm.visible = false;
this.$message.success('操作成功');
this.load();
},
};
},
statusName(value) {
return (this.statuses.find(item => item.value === value) || {}).label || '-';
},
statusType(value) {
return {
draft: 'info',
waiting_dispatch: 'warning',
dispatching: 'success',
completed: 'primary',
closed: 'danger',
}[value];
},
timeRange(row) {
return row.planStartTime && row.planEndTime
? `${String(row.planStartTime).slice(0, 16)} - ${String(row.planEndTime).slice(0, 16)}`
: '-';
},
goodsSummary(row) {
const item = (row.goods || [])[0] || {};
if (!item.cargoName && !item.cargoType) return '-';
return `${item.cargoName || '-'}${item.cargoType || '-'} | ${this.number(row.totalQuantity)}`;
},
number(value) {
return Number(value || 0).toFixed(2);
},
routeNumber(value) {
const number = Number(value || 0);
return Number.isInteger(number) ? String(number) : number.toFixed(2);
},
routeArrivedQuantity(route) {
const keys = [
'arrivedQuantity',
'arrivalQuantity',
'arriveQuantity',
'reachedQuantity',
'receiveQuantity',
'unloadQuantity',
];
const key = keys.find(item => route[item] !== undefined && route[item] !== null);
return key ? route[key] : 0;
},
routeNodes(row = {}) {
const routes = row.routeProgress || [];
const total = this.routeNumber(row.totalQuantity);
if (!routes.length) {
return [
{
key: 'start',
type: 'start',
text: '起',
name: row.departureName || row.departureAddress || '-',
lines: [`已调度0/${total}`],
},
{
key: 'end',
type: 'end',
text: '终',
name: row.arrivalName || row.arrivalAddress || '-',
lines: [`到达0/${total}`],
},
];
}
return [
{
key: 'start',
type: 'start',
text: '起',
name: row.departureName || row.departureAddress || '-',
lines: [`已调度${this.routeNumber(routes[0]?.dispatchedQuantity)}/${total}`],
},
...routes.map((route, index) => {
const isEnd = index === routes.length - 1;
const arrived = this.routeNumber(this.routeArrivedQuantity(route));
const dispatched = this.routeNumber(routes[index + 1]?.dispatchedQuantity);
return {
key: route.segmentNo || `route-${index}`,
type: isEnd ? 'end' : 'middle',
text: isEnd ? '终' : '经',
name: route.arrivalName || route.departureName || row.arrivalName || '-',
lines: isEnd
? [`到达${arrived}/${total}`]
: [`到达 ${arrived}/${total}`, `已调度 ${dispatched}/到达${arrived}/${total}`],
};
}),
];
},
progress(route, total) {
return total
? Math.min(100, (Number(route?.dispatchedQuantity || 0) / Number(total)) * 100)
: 0;
},
async download() {
const blob = await api.exportList(this.query);
const url = URL.createObjectURL(new Blob([blob.data || blob]));
const link = document.createElement('a');
link.href = url;
link.download = '总单运单明细.xlsx';
link.click();
URL.revokeObjectURL(url);
},
},
};
</script>
<style scoped lang="scss">
.master-search {
margin-bottom: 8px;
padding: 12px 12px 4px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.el-form-item {
margin-bottom: 8px;
}
.el-input,
.el-select,
.el-date-editor {
width: 100%;
}
.search-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
min-height: 40px;
.el-icon {
margin-right: 4px;
}
}
}
.master-list-panel {
background: #fff;
.toolbar {
padding: 12px 12px 0;
}
}
.master-card {
display: flex;
min-height: 150px;
margin-bottom: 8px;
border: 1px solid #eff1f7;
background: #fff;
.card-info {
flex: 0 0 380px;
padding: 18px 20px;
font-size: 14px;
color: #303133;
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
p {
margin: 18px 0 0;
line-height: 1.4;
}
.project-name {
font-weight: 600;
}
}
.status-tag {
flex-shrink: 0;
}
.route-progress {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
min-height: 170px;
padding: 22px 24px 18px;
border-right: 1px solid #eff1f7;
border-left: 1px solid #eff1f7;
}
.route-track {
display: flex;
align-items: flex-start;
justify-content: center;
min-width: max-content;
}
.route-point {
width: 150px;
flex: 0 0 150px;
text-align: center;
}
.route-node {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 6px;
color: #fff;
font-size: 16px;
font-weight: 600;
line-height: 1;
text-align: center;
&.start {
background: #409eff;
}
&.middle {
background: #67c23a;
}
&.end {
background: #e6a23c;
}
}
.route-detail {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 12px;
color: #303133;
font-size: 14px;
line-height: 1.8;
white-space: nowrap;
strong {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
}
.route-connector {
width: clamp(180px, 20vw, 344px);
flex: 0 0 clamp(180px, 20vw, 344px);
padding-top: 2px;
margin: 0 -6px;
small {
display: block;
text-align: center;
margin-bottom: 6px;
color: #a8abb2;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
}
:deep(.el-progress-bar__outer) {
height: 8px !important;
background-color: #f1f2f5;
}
:deep(.el-progress-bar__inner) {
background-color: #409eff;
}
}
.card-actions {
width: 110px;
padding: 16px;
header {
margin-bottom: 10px;
font-weight: 600;
}
.el-link {
margin: 0 8px 8px 0;
}
}
}
.page-bar {
display: flex;
justify-content: flex-end;
align-items: center;
padding-top: 8px;
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<business-crud-page :api="api" :config="config" :crud-option="option" :menu-width="220" />
<business-crud-page :api="api" :config="config" :crud-option="option" :detail-id="$route.query.detailId" :menu-width="220" />
</template>
<script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<business-crud-page :api="api" :config="config" :crud-option="option" :menu-width="250" />
<business-crud-page :api="api" :config="config" :crud-option="option" :detail-id="$route.query.detailId" :menu-width="250" />
</template>
<script>