Files
tms-erp-web/src/views/business/transport-plan-dispatch.vue
T
2026-09-16 06:30:30 +08:00

839 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<basic-container class="transport-plan-dispatch-page">
<div v-loading="loading" class="transport-plan-dispatch-page__shell">
<!-- 顶部信息区对齐运单详情汇总样式 -->
<section-card class="transport-plan-dispatch-page__summary">
<div class="transport-plan-dispatch-page__heading">
<el-button icon="el-icon-arrow-left" text @click="handleBack">返回</el-button>
<strong>计划调度</strong>
<span>{{ planData.planNo || planData.loadingNo || '-' }}</span>
<span class="detail-status-text" :class="statusTextClass">
{{ planData.businessStatusName || '-' }}
</span>
<span class="detail-status-text detail-transport-type">
{{ planData.transportTypeName || '公路整车' }}
</span>
</div>
<div class="transport-plan-dispatch-page__summary-grid">
<div class="transport-plan-dispatch-page__summary-item">
<span class="transport-plan-dispatch-page__summary-label">客户</span>
<span class="transport-plan-dispatch-page__summary-value">
{{ planData.customerName || '-' }}
</span>
</div>
<div class="transport-plan-dispatch-page__summary-item">
<span class="transport-plan-dispatch-page__summary-label">合同编号</span>
<span class="transport-plan-dispatch-page__summary-value is-link">
{{ planData.contractNo || '-' }}
</span>
</div>
<div class="transport-plan-dispatch-page__summary-item">
<span class="transport-plan-dispatch-page__summary-label">项目</span>
<span class="transport-plan-dispatch-page__summary-value">
{{ planData.projectName || '-' }}
</span>
</div>
<div class="transport-plan-dispatch-page__summary-item">
<span class="transport-plan-dispatch-page__summary-label">计划执行时间</span>
<span class="transport-plan-dispatch-page__summary-value">
{{ formatDateRange(planData.planStartDate, planData.planEndDate) }}
</span>
</div>
<div class="transport-plan-dispatch-page__summary-item is-goods">
<span class="transport-plan-dispatch-page__summary-label">货物信息</span>
<span class="transport-plan-dispatch-page__summary-value is-wrap">
{{ planData.goodsInfo || formatGoodsInfo(planData) }}
</span>
</div>
<div class="transport-plan-dispatch-page__route">
<div class="transport-plan-dispatch-page__route-item is-start">
<div class="transport-plan-dispatch-page__route-marker"></div>
<div class="transport-plan-dispatch-page__route-detail">
<strong class="transport-plan-dispatch-page__route-name">
{{
formatRouteCityDistrict(planData.departureAddress || planData.departureName)
}}
</strong>
<el-tooltip
:content="planData.departureAddress || planData.departureName || '-'"
placement="top"
>
<span class="transport-plan-dispatch-page__route-addr">
{{ planData.departureAddress || planData.departureName || '-' }}
</span>
</el-tooltip>
<div class="transport-plan-dispatch-page__route-contact">
<span>联系人{{ planData.departureContact || '-' }}</span>
<span>联系方式{{ planData.departurePhone || '-' }}</span>
</div>
</div>
</div>
<span class="transport-plan-dispatch-page__route-line" aria-hidden="true" />
<div class="transport-plan-dispatch-page__route-item is-end">
<div class="transport-plan-dispatch-page__route-marker"></div>
<div class="transport-plan-dispatch-page__route-detail">
<strong class="transport-plan-dispatch-page__route-name">
{{ formatRouteCityDistrict(planData.arrivalAddress || planData.arrivalName) }}
</strong>
<el-tooltip
:content="planData.arrivalAddress || planData.arrivalName || '-'"
placement="top"
>
<span class="transport-plan-dispatch-page__route-addr">
{{ planData.arrivalAddress || planData.arrivalName || '-' }}
</span>
</el-tooltip>
<div class="transport-plan-dispatch-page__route-contact">
<span>联系人{{ planData.arrivalContact || '-' }}</span>
<span>联系方式{{ planData.arrivalPhone || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="transport-plan-dispatch-page__attachments">
<span class="transport-plan-dispatch-page__summary-label">附件</span>
<div>
<el-link
v-for="(item, index) in attachmentList"
:key="index"
type="primary"
:href="item.url"
:underline="false"
target="_blank"
>
{{ item.name || item.originalName || '附件' }}
</el-link>
<span v-if="!attachmentList.length">-</span>
</div>
</div>
</div>
</section-card>
<!-- 调度列表 -->
<section-card class="transport-plan-dispatch-page__list-card">
<template #title>
<span>待调度列表</span>
<span class="transport-plan-dispatch-page__list-summary">
{{ summaryText }}
</span>
</template>
<template #extra>
<el-button type="primary" :disabled="!canAddDispatch" @click="handleAdd">
新增
</el-button>
</template>
<el-table
:data="dispatchList"
border
height="100%"
row-key="id"
class="transport-plan-dispatch-page__table"
>
<el-table-column type="index" label="序号" width="70" align="center" fixed="left" />
<el-table-column label="运输方式" min-width="180" align="center" show-overflow-tooltip>
<template #default="{ row }">
{{ row.transportTypeName || '-' }}
</template>
</el-table-column>
<el-table-column prop="carrierType" label="承运类型" min-width="150" align="center" show-overflow-tooltip />
<el-table-column prop="carrierName" label="承运商" min-width="150" align="center" show-overflow-tooltip />
<el-table-column prop="driverName" label="司机" min-width="260" align="center" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ formatDriver(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="vehicleNo" label="车牌号" min-width="150" align="center" show-overflow-tooltip />
<el-table-column prop="cargoType" label="货物类型" min-width="170" align="center" show-overflow-tooltip />
<el-table-column prop="cargoInfo" label="货物信息" min-width="280" align="center" show-overflow-tooltip>
<template #default="{ row }">
{{ row.cargoInfo || formatCargoInfo(row) }}
</template>
</el-table-column>
<el-table-column label="发货地址" min-width="220" align="center" show-overflow-tooltip>
<template #default="{ row }">
{{ formatAddress(row.departureAddress) }}
</template>
</el-table-column>
<el-table-column label="收货地址" min-width="220" align="center" show-overflow-tooltip>
<template #default="{ row }">
{{ formatAddress(row.arrivalAddress) }}
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" min-width="120" align="center">
<template #default="{ row }">
{{ row.quantity ? `${row.quantity}${row.quantityUnit || '吨'}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="unitPrice" label="单价" min-width="120" align="center" />
<el-table-column prop="freight" label="运费" min-width="120" align="center" />
<el-table-column prop="otherFeeTotal" label="其他费用" min-width="120" align="center" />
<el-table-column prop="freightTotal" label="运费合计" min-width="120" align="center" />
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row, $index }">
<div class="transport-plan-dispatch-page__actions">
<el-link type="primary" @click="handleEdit(row, $index)">编辑</el-link>
<el-link type="danger" @click="handleDelete($index)">删除</el-link>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
<!-- 底部按钮 -->
<div class="transport-plan-dispatch-page__footer">
<el-button @click="handleBack">关闭</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">
确认生成
</el-button>
</div>
</div>
<!-- 调度明细编辑弹窗 - 将使用原组件的弹窗 -->
<transport-plan-page
v-if="transportPlanPageVisible"
ref="transportPlanPageRef"
:api="api"
:config="config"
:crud-option="option"
style="display: none"
/>
</basic-container>
</template>
<script>
import { getDetail, dispatch } from '@/api/business/transport-plan';
import SectionCard from '@/components/section-card/main.vue';
import TransportPlanPage from './components/transport-plan-page.vue';
import * as api from '@/api/business/transport-plan';
import { config, option } from '@/option/business/transport-plan';
const TRANSPORT_PLAN_QUANTITY_UNIT = '吨';
export default {
name: 'TransportPlanDispatch',
components: {
SectionCard,
TransportPlanPage,
},
data() {
return {
loading: false,
saving: false,
planData: {},
dispatchList: [],
attachmentList: [],
transportPlanPageVisible: false,
api,
config,
option,
};
},
computed: {
planId() {
return this.$route.query.planId;
},
statusTextClass() {
const status = this.planData.businessStatus;
if (status === 2 || status === '2') return 'status-text-success';
if (status === 1 || status === '1') return 'is-dispatching';
if (status === 3 || status === '3') return 'is-danger';
return 'is-info';
},
summaryText() {
const goodsList = this.parseGoodsList(this.planData.goodsJson);
if (!goodsList.length) {
const total = Number(this.planData.totalQuantity || 0);
const dispatched = this.calculateDispatchedQuantity();
if (total <= 0) {
return `已调度 ${dispatched.toFixed(2)}吨`;
}
const remaining = Math.max(total - dispatched, 0);
return `共${total}吨 | 已调度 ${dispatched.toFixed(2)}吨,剩余${remaining.toFixed(2)}吨`;
}
const summary = {};
goodsList.forEach(goods => {
const unit = goods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT;
if (!summary[unit]) {
summary[unit] = { total: 0, dispatched: 0 };
}
summary[unit].total += Number(goods.quantity || 0);
});
this.dispatchList.forEach(item => {
const unit = item.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT;
if (!summary[unit]) {
summary[unit] = { total: 0, dispatched: 0 };
}
summary[unit].dispatched += Number(item.quantity || 0);
});
const parts = Object.entries(summary).map(([unit, data]) => {
if (data.total <= 0) {
return `已调度 ${data.dispatched.toFixed(2)}${unit}`;
}
const remaining = Math.max(data.total - data.dispatched, 0);
return `${data.total}${unit} | 已调度 ${data.dispatched.toFixed(2)}${unit},剩余${remaining.toFixed(
2
)}${unit}`;
});
return parts.join(' | ');
},
canAddDispatch() {
const goodsList = this.parseGoodsList(this.planData.goodsJson);
if (!goodsList.length) {
const total = Number(this.planData.totalQuantity || 0);
if (total <= 0) return true;
const dispatched = this.calculateDispatchedQuantity();
return dispatched < total;
}
const hasPositiveTotal = goodsList.some(
goods => Number(goods.quantity || 0) > 0
);
if (!hasPositiveTotal) return true;
return goodsList.some(goods => {
const unit = goods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT;
const totalQty = Number(goods.quantity || 0);
if (totalQty <= 0) return true;
const dispatchedQty = this.dispatchList
.filter(item => item.quantityUnit === unit)
.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
return dispatchedQty < totalQty;
});
},
},
created() {
this.loadPlanData();
},
mounted() {
// 初始化 transport-plan-page 组件用于打开弹窗
this.transportPlanPageVisible = true;
},
methods: {
loadPlanData() {
if (!this.planId) {
this.$message.error('缺少计划ID参数');
this.handleBack();
return;
}
this.loading = true;
getDetail(this.planId)
.then(res => {
if (res.data?.success) {
this.planData = res.data.data || {};
this.attachmentList = this.parseAttachments(this.planData.attachmentsJson);
this.dispatchList = this.parseDispatchList(this.planData);
} else {
this.$message.error(res.data?.msg || '加载数据失败');
}
})
.catch(err => {
console.error('加载计划数据失败:', err);
this.$message.error('加载数据失败');
})
.finally(() => {
this.loading = false;
});
},
parseAttachments(json) {
try {
const data = JSON.parse(json || '[]');
return Array.isArray(data) ? data : [];
} catch {
return [];
}
},
parseDispatchList(planData) {
try {
const sources = [
planData.waybillList,
planData.dispatchRows,
planData.dispatchRecordList,
planData.dispatchList,
];
for (const source of sources) {
if (Array.isArray(source) && source.length) {
return source.map((item, index) => ({
...item,
id: item.id || `temp-${index}`,
}));
}
}
return [];
} catch {
return [];
}
},
parseGoodsList(json) {
try {
const data = JSON.parse(json || '[]');
return Array.isArray(data) ? data : [];
} catch {
return [];
}
},
calculateDispatchedQuantity() {
return this.dispatchList.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
},
formatDateRange(startDate, endDate) {
const dates = [startDate, endDate].filter(Boolean);
return dates.length ? dates.join(' ~ ') : '-';
},
formatAddress(address) {
if (!address) return '-';
const match = address.match(/^(.*?省)?(.*?市)?(.*?区|.*?县)?/);
return match ? match[0] || address : address;
},
formatProvinceCityDistrict(value) {
const text = String(value || '').trim();
if (!text) return '-';
const districtMatch = text.match(/(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/);
if (districtMatch) {
return text.slice(0, districtMatch.index + districtMatch[0].length);
}
const cityMatch = text.match(/市/);
return cityMatch ? text.slice(0, cityMatch.index + 1) : text;
},
formatRouteCityDistrict(value) {
const text = String(value || '').trim();
if (!text) return '-';
const provinceMatch = text.match(/^.+?(?:省|自治区|特别行政区)/);
const cityRegion = provinceMatch ? text.slice(provinceMatch[0].length) : text;
const cityMatch = cityRegion.match(/^(.+?市)/);
if (!cityMatch) return this.formatProvinceCityDistrict(text);
const city = cityMatch[1];
const remainder = cityRegion.slice(city.length);
const districtMatch = remainder.match(
/^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗){1,2})/
);
const district = districtMatch ? districtMatch[1] : '';
return [city, district].filter(Boolean).join(' ');
},
formatDriver(row) {
const parts = [row.driverName, row.driverPhone].filter(Boolean);
return parts.length ? parts.join(' / ') : '-';
},
formatGoodsInfo(data) {
const goodsList = this.parseGoodsList(data.goodsJson);
if (!goodsList.length) {
return data.cargoName || '-';
}
return goodsList
.map(goods => {
const parts = [goods.cargoName || goods.goodsName];
if (goods.quantity) {
parts.push(`${goods.quantity}${goods.quantityUnit || TRANSPORT_PLAN_QUANTITY_UNIT}`);
}
return parts.join(' ');
})
.join('');
},
formatCargoInfo(row) {
const parts = [row.cargoName];
if (row.specification) parts.push(row.specification);
if (row.model) parts.push(row.model);
return parts.filter(Boolean).join(' / ') || '-';
},
handleBack() {
this.$router.back();
},
handleAdd() {
if (!this.canAddDispatch) {
this.$message.warning('待调度列表货物总量已达到计划总量,不能新增调度明细');
return;
}
this.openPageDispatchItemDialog(-1);
},
handleEdit(row, index) {
this.openPageDispatchItemDialog(index, row);
},
openPageDispatchItemDialog(index = -1, row) {
this.$nextTick(() => {
const component = this.$refs.transportPlanPageRef;
if (!component) return;
component.dispatchRow = this.planData;
component.dispatchRows = [...this.dispatchList];
this.bindDispatchItemSaveSync(component);
if (index >= 0) component.openDispatchItemDialog(index, row);
else component.openDispatchItemDialog(-1);
});
},
bindDispatchItemSaveSync(component) {
if (component.__dispatchListSaveSynced) return;
const originalSave = component.saveDispatchItem;
component.saveDispatchItem = (...args) => {
originalSave.apply(component, args);
if (!component.dispatchItemBox) {
this.dispatchList = [...(component.dispatchRows || [])];
}
};
component.__dispatchListSaveSynced = true;
},
handleDelete(index) {
this.$confirm('确定删除该调度明细?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
this.dispatchList.splice(index, 1);
this.$message.success('删除成功');
});
},
handleSubmit() {
if (!this.dispatchList.length) {
this.$message.warning('请先添加调度明细');
return;
}
for (const [index, row] of this.dispatchList.entries()) {
if (!row.carrierName) {
this.$message.warning(`第${index + 1}条调度明细缺少承运商信息`);
return;
}
if (!row.driverName) {
this.$message.warning(`第${index + 1}条调度明细缺少司机信息`);
return;
}
if (!row.vehicleNo) {
this.$message.warning(`第${index + 1}条调度明细缺少车牌号`);
return;
}
}
this.$confirm(
`请确认调度信息,将生成 <span style="color: #409eff; font-size: 20px; padding: 0 4px;">${this.dispatchList.length}</span> 条运单,是否继续?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
dangerouslyUseHTMLString: true,
}
).then(() => {
this.submitDispatch();
});
},
submitDispatch() {
this.saving = true;
const payload = {
id: this.planId,
mode: 'submit',
waybills: this.dispatchList.map(row => ({
...row,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: row.estimatedStartTime || this.planData.planStartDate || '',
estimatedEndTime: row.estimatedEndTime || this.planData.planEndDate || '',
goodsJson:
row.goodsJson ||
JSON.stringify([
{
cargoName: row.cargoName,
cargoType: row.cargoType,
quantity: row.quantity,
quantityUnit: row.quantityUnit,
specification: row.specification,
model: row.model,
packageType: row.packageType,
brand: row.brand,
},
]),
})),
};
dispatch(payload)
.then(res => {
if (res.data?.success) {
this.$message.success('调度成功,运单已生成');
this.handleBack();
} else {
this.$message.error(res.data?.msg || '调度失败');
}
})
.catch(err => {
console.error('调度失败:', err);
this.$message.error('调度失败');
})
.finally(() => {
this.saving = false;
});
},
},
};
</script>
<style lang="scss" scoped>
.transport-plan-dispatch-page {
height: 100%;
display: flex;
flex-direction: column;
&__shell {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
}
&__summary {
:deep(.el-card__body) {
padding: 18px 22px;
}
}
&__heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 18px;
strong {
font-size: 20px;
}
.detail-status-text {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
line-height: 28px;
}
.status-text-success,
.is-dispatching {
color: #67c23a;
background: #e1f3d8;
}
.detail-transport-type {
color: #409eff;
background: #ecf5ff;
}
.is-danger {
color: #f56c6c;
background: #fef0f0;
}
.is-info {
color: #909399;
background: #f4f4f5;
}
}
&__summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr)) minmax(360px, 1.5fr);
gap: 16px 24px;
}
&__summary-item {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
&.is-goods {
grid-column: 1 / 5;
}
}
&__summary-label {
color: #909399;
}
&__summary-value {
overflow: hidden;
color: #303133;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
&.is-link {
color: #409eff;
}
&.is-wrap {
white-space: normal;
word-break: break-word;
}
}
&__route {
display: grid;
grid-template-columns: minmax(100px, 1fr) minmax(36px, 0.7fr) minmax(100px, 1fr);
grid-row: 1 / span 3;
grid-column: 5;
align-items: start;
min-width: 0;
padding: 4px 0;
}
&__route-item {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
text-align: center;
}
&__route-marker {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 8px;
background: #409eff;
font-size: 18px;
font-weight: 600;
.is-end & {
background: #67c23a;
}
}
&__route-line {
width: 100%;
height: 6px;
margin-top: 15px;
border-radius: 1px;
background: #e4e7ed;
}
&__route-detail {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 5px;
margin-top: 8px;
width: 100%;
}
&__route-name {
max-width: 100%;
overflow: hidden;
color: #303133;
font-size: 18px;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
&__route-addr {
display: block;
max-width: 100%;
overflow: hidden;
color: #606266;
font-size: 12px;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
&__route-contact {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px 8px;
max-width: 100%;
color: #909399;
font-size: 12px;
line-height: 1.5;
}
&__attachments {
grid-column: 1 / 5;
display: flex;
flex-direction: column;
gap: 8px;
div {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
}
&__list-card {
flex: 1;
display: flex;
flex-direction: column;
}
&__list-summary {
margin-left: 12px;
font-size: 14px;
color: #606266;
font-weight: normal;
}
&__table {
flex: 1;
}
&__actions {
display: flex;
gap: 8px;
justify-content: center;
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 4px;
}
@media (max-width: 1200px) {
&__summary-grid {
grid-template-columns: repeat(4, minmax(130px, 1fr));
}
&__summary-item.is-goods,
&__attachments {
grid-column: 1 / -1;
}
&__route {
grid-column: 1 / -1;
grid-row: auto;
}
}
@media (max-width: 800px) {
&__summary-grid {
grid-template-columns: 1fr;
}
&__route {
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
&__route-line {
width: 10px;
height: 32px;
margin: 0 auto;
}
&__summary-item.is-goods,
&__attachments {
grid-column: auto;
}
}
}
</style>