Files
tms-erp-web/src/views/business/loading-manage.vue
T
2026-11-04 18:24:39 +08:00

5717 lines
207 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="loading-manage-page">
<div v-if="!isStandalonePage && searchVisible" class="loading-manage-page__search">
<el-form :model="searchForm" label-position="right" label-width="88px">
<div class="loading-manage-page__search-grid">
<el-form-item label="配载单号">
<el-input v-model="searchForm.loadingNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="项目名称">
<el-select
v-model="searchForm.projectName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadProjectOptions"
:loading="projectLoading"
placeholder="请输入"
@visible-change="visible => visible && loadProjectOptions(searchForm.projectName)"
>
<el-option
v-for="item in projectOptions"
:key="item.id || item.projectName"
:label="item.projectName"
:value="item.projectName"
/>
</el-select>
</el-form-item>
<el-form-item label="客户名称">
<el-select
v-model="searchForm.customerName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadCustomerOptions"
:loading="customerLoading"
placeholder="请输入"
@visible-change="visible => visible && loadCustomerOptions(searchForm.customerName)"
>
<el-option
v-for="item in customerOptions"
:key="item.id || item.customerName"
:label="item.customerName"
:value="item.customerName"
/>
</el-select>
</el-form-item>
<el-form-item label="运输方式">
<el-select
v-model="searchForm.transportType"
clearable
filterable
placeholder="请选择"
@visible-change="visible => visible && loadTransportTypeOptions()"
>
<el-option
v-for="item in transportTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<template v-if="searchExpanded">
<el-form-item label="运单号">
<el-input v-model="searchForm.waybillNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="货物类型">
<el-select
v-model="searchForm.cargoType"
clearable
filterable
remote
reserve-keyword
:remote-method="loadCargoTypeOptions"
:loading="cargoTypeLoading"
placeholder="请输入"
@visible-change="visible => visible && loadCargoTypeOptions(searchForm.cargoType)"
>
<el-option
v-for="item in cargoTypeOptions"
:key="item.id || item.cargoName || item.cargoType"
:label="item.cargoName || item.cargoType || item.label"
:value="item.cargoName || item.cargoType || item.label"
/>
</el-select>
</el-form-item>
<el-form-item label="发货地址">
<el-input v-model="searchForm.departureAddress" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="收货地址">
<el-input v-model="searchForm.arrivalAddress" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="货物名称">
<el-input v-model="searchForm.cargoName" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="司机名称">
<el-select
v-model="searchForm.driverName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadDriverOptions"
:loading="driverLoading"
placeholder="请输入"
@visible-change="visible => visible && loadDriverOptions(searchForm.driverName)"
>
<el-option
v-for="item in driverOptions"
:key="item.id || item.driverName || item.name"
:label="item.driverName || item.name"
:value="item.driverName || item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="车牌号/船班号/船次号/班次号">
<el-input v-model="searchForm.vehicleNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="原始单号">
<el-input v-model="searchForm.originalNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="承运商名称">
<el-select
v-model="searchForm.carrierName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadCarrierOptions"
:loading="carrierLoading"
placeholder="请输入"
@visible-change="visible => visible && loadCarrierOptions(searchForm.carrierName)"
>
<el-option
v-for="item in carrierOptions"
:key="item.id || item.fullName || item.customerName || item.carrierName"
:label="item.fullName || item.customerName || item.carrierName || item.name"
:value="item.fullName || item.customerName || item.carrierName || item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="数据来源">
<el-select v-model="searchForm.dataSource" clearable placeholder="请选择">
<el-option
v-for="item in dataSourceOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="searchForm.startDateRange"
type="daterange"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
start-placeholder="请选择"
end-placeholder="请选择"
range-separator="-"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="searchForm.endDateRange"
type="daterange"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
start-placeholder="请选择"
end-placeholder="请选择"
range-separator="-"
/>
</el-form-item>
</template>
<el-form-item label="状态">
<el-select v-model="searchForm.businessStatus" clearable placeholder="请选择">
<el-option
v-for="item in statusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="运单批次号">
<el-input v-model="searchForm.batchNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="计划名称">
<el-select
v-model="searchForm.planName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadPlanOptions"
:loading="planLoading"
placeholder="请输入"
@visible-change="visible => visible && loadPlanOptions(searchForm.planName)"
>
<el-option
v-for="item in planOptions"
:key="item.id || item.planName"
:label="item.planName"
:value="item.planName"
/>
</el-select>
</el-form-item>
<div class="loading-manage-page__search-actions">
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleResetSearch">重置</el-button>
<el-link type="primary" @click="searchExpanded = !searchExpanded">{{
searchExpanded ? '收起' : '展开'
}}</el-link>
</div>
</div>
</el-form>
</div>
<div v-if="!isStandalonePage" class="loading-manage-page__toolbar">
<div class="loading-manage-page__toolbar-left">
<el-button type="primary" @click="openLoadingDialog('add')">新建配载</el-button>
<el-button type="primary" plain @click="handleExport">批量导出</el-button>
<el-button type="primary" plain @click="handleBatchComplete">批量完成</el-button>
</div>
<table-header-tools
:columns="columnSettingOptions"
v-model:visible-column-keys="visibleColumnKeys"
v-model:table-size="tableSize"
v-model:search-visible="searchVisible"
@refresh="loadTable"
/>
</div>
<el-table
v-if="!isStandalonePage"
ref="tableRef"
v-loading="loading"
:data="tableData"
:size="tableSize"
border
row-key="id"
class="loading-manage-page__table"
@selection-change="selectionChange"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="58" fixed="left" align="center" />
<el-table-column
v-if="isColumnVisible('loadingNo')"
label="配载单号"
prop="loadingNo"
min-width="170"
>
<template #default="{ row }">
<el-link
type="primary"
class="loading-manage-page__link"
@click="gotoLoadingDetail(row)"
>{{ row.loadingNo }}</el-link
>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('loadingSubNos')"
label="运单号"
prop="loadingSubNos"
min-width="220"
>
<template #default="{ row }">
<span class="loading-manage-page__sub-nos">
<template v-if="splitText(row.loadingSubNos).length">
<el-link
v-for="(waybill, index) in splitText(row.loadingSubNos)"
:key="`${waybill}-${index}`"
type="primary"
class="loading-manage-page__sub-no-link"
@click="openWaybillDetail({ waybillNo: waybill, id: findWaybillId(row, waybill) })"
>
{{ waybill }}
<template v-if="index < splitText(row.loadingSubNos).length - 1">,</template>
</el-link>
</template>
<span v-else>-</span>
</span>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('vehicleNo')"
label="车牌号"
prop="vehicleNo"
min-width="170"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverName')"
label="司机"
prop="driverName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('driverPhone')"
label="联系电话"
prop="driverPhone"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('carrierType')"
label="承运类型"
prop="carrierType"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('departureAddress')"
label="发货地"
prop="departureAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'departure')"
:content="formatFullRouteAddress(row, 'departure')"
placement="top"
:show-after="120"
>
<div class="loading-manage-page__address-cell">
{{ formatRouteAddress(row, 'departure') }}
</div>
</el-tooltip>
<div v-else class="loading-manage-page__address-cell">
{{ formatRouteAddress(row, 'departure') }}
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('transitAddress')"
label="途经地"
prop="transitAddress"
min-width="190"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'transit')"
:content="formatFullRouteAddress(row, 'transit')"
placement="top"
:show-after="120"
>
<div class="loading-manage-page__address-cell">
{{ formatRouteAddress(row, 'transit') }}
</div>
</el-tooltip>
<div v-else class="loading-manage-page__address-cell">
{{ formatRouteAddress(row, 'transit') }}
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('arrivalAddress')"
label="到货地"
prop="arrivalAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip
v-if="formatFullRouteAddress(row, 'arrival')"
:content="formatFullRouteAddress(row, 'arrival')"
placement="top"
:show-after="120"
>
<div class="loading-manage-page__address-cell">
{{ formatRouteAddress(row, 'arrival') }}
</div>
</el-tooltip>
<div v-else class="loading-manage-page__address-cell">
{{ formatRouteAddress(row, 'arrival') }}
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('carrierName')"
label="承运商"
prop="carrierName"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('transportType')"
label="运输方式"
prop="transportType"
min-width="150"
show-overflow-tooltip
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('dataSource')"
label="数据来源"
prop="dataSource"
min-width="130"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createUserName')"
label="创建人"
prop="createUserName"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('createTime')"
label="创建时间"
prop="createTime"
min-width="170"
show-overflow-tooltip
sortable
/>
<el-table-column
v-if="isColumnVisible('updateTime')"
label="更新时间"
prop="updateTime"
min-width="170"
show-overflow-tooltip
sortable
/>
<el-table-column
v-if="isColumnVisible('currentProcessNode')"
label="过程节点"
prop="currentProcessNode"
min-width="130"
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('businessStatus')"
label="状态"
prop="businessStatus"
width="110"
fixed="right"
align="center"
>
<template #default="{ row }">
<span :class="['loading-manage-page__status', `is-${row.businessStatus || 'unknown'}`]">
{{ statusText(row) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right" align="left">
<template #default="{ row }">
<div class="loading-manage-page__actions">
<el-link
v-for="action in rowActions(row)"
:key="action.type"
:type="['delete', 'cancel'].includes(action.type) ? 'danger' : 'primary'"
@click="handleRowAction(action.type, row)"
>{{ action.label }}</el-link
>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="!isStandalonePage" class="loading-manage-page__pagination">
<el-pagination
v-model:current-page="page.currentPage"
v-model:page-size="page.pageSize"
background
:page-sizes="page.pageSizes"
layout="total, sizes, prev, pager, next, jumper"
:total="page.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!--
配载单的新增/编辑/重新派单/详情全部是独立整页,这里固定渲染普通容器,不再按路由切换 el-dialog。
原因:标签页 keep-alive 会把本页实例缓存下来,若容器随 $route 由 div 变回 el-dialog,
被缓存的实例会在离开本页后渲染出 append-to-body 的弹窗并永久停留在 body 上(Vue 的
KeepAlive.deactivate → Teleport.move 不会搬走 append-to-body 的弹窗 DOM),表现为“点左侧菜单又弹出弹窗”。
-->
<div
v-if="isStandalonePage"
class="loading-manage-dialog loading-manage-form-dialog loading-manage-form-page"
>
<div v-if="!isStandaloneDetailPage" class="archive-page-form__title">{{ formPageTitle }}</div>
<div v-loading="dialogLoading" class="loading-manage-dialog__body">
<div v-if="dialogReadonly" class="loading-detail">
<section-card title="配载单详情">
<div class="loading-detail__summary">
<div>{{ dialogForm.loadingNo || '-' }}</div>
<el-tag :type="detailStatusType">{{ statusText(dialogForm) }}</el-tag>
<el-tag type="primary">{{ transportTypeLabel(dialogForm.transportType) }}</el-tag>
</div>
<div class="loading-detail__task-row">
<div class="loading-detail__task-main">
<span class="loading-detail__label">任务信息</span>
<div class="loading-detail__task-grid">
<div class="loading-detail__field">
<span class="loading-detail__label">承运商</span>
<span class="loading-detail__value">{{ detailTaskField('carrierName') }}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">司机</span>
<span class="loading-detail__value">{{ detailTaskField('driverName') }}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">车牌号</span>
<span class="loading-detail__value">{{ detailTaskField('vehicleNo') }}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">手机号</span>
<span class="loading-detail__value">{{ detailTaskField('driverPhone') }}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">里程</span>
<span class="loading-detail__value">{{
detailTaskMileage ? `${detailTaskMileage} km` : '-'
}}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">预计发货日期</span>
<span class="loading-detail__value">{{
detailTaskDate('estimatedStartDate')
}}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">预计完成日期</span>
<span class="loading-detail__value">{{
detailTaskDate('estimatedEndDate')
}}</span>
</div>
<template v-if="isSelfCarrierDetail">
<div class="loading-detail__field">
<span class="loading-detail__label">挂车车牌号</span>
<span class="loading-detail__value">{{
detailTaskField('trailerVehicleNo')
}}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">押运人</span>
<span class="loading-detail__value">{{ detailTaskField('escortName') }}</span>
</div>
<div class="loading-detail__field">
<span class="loading-detail__label">押运人手机号</span>
<span class="loading-detail__value">{{ detailTaskField('escortPhone') }}</span>
</div>
</template>
</div>
</div>
<div class="loading-detail__field loading-detail__attachment-field">
<span class="loading-detail__label">附件</span>
<span class="loading-detail__value"
><span
v-for="file in detailAttachments"
:key="file.url || file.name"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.name }}</span
><span v-if="!detailAttachments.length">-</span></span
>
</div>
</div>
</section-card>
<section-card>
<template #title
><span class="loading-detail__route-title"
>运输路线<el-link type="primary" @click="openRouteChangeDialog"
>变更运输路线</el-link
></span
></template
>
<div v-if="routeNodes.length" class="loading-detail__steps">
<div
v-for="(node, index) in routeNodes"
:key="node.key || index"
class="loading-detail__step"
>
<div class="loading-detail__step-line">
<span
:class="[
'loading-detail__step-dot',
index === 0 ? 'start' : index === routeNodes.length - 1 ? 'end' : 'middle',
]"
>{{ index === 0 ? '起' : index === routeNodes.length - 1 ? '终' : '经' }}</span
>
</div>
<div class="loading-detail__step-address">
<el-tooltip
v-if="formatDetailRouteTooltip(node)"
:content="formatDetailRouteTooltip(node)"
placement="top"
:show-after="120"
>
<span class="loading-detail__step-address-text">{{
formatDetailRouteShort(node)
}}</span>
</el-tooltip>
<span v-else class="loading-detail__step-address-text">{{
formatDetailRouteShort(node)
}}</span>
</div>
<div class="loading-detail__step-tags">
<el-tag v-for="tag in node.tags || []" :key="tag" size="small">{{ tag }}</el-tag>
</div>
</div>
</div>
<el-empty v-else description="暂无路线" :image-size="60" />
</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="quantity"
label="数量"
min-width="120"
><template #default="{ row }">{{
formatCargoQuantityDisplay(row)
}}</template></el-table-column
><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"
><template #default="{ row }">{{
Number(row.unitPrice) === -1 ? '' : row.unitPrice
}}</template></el-table-column
><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
v-if="row.waybillNo"
type="primary"
@click="openWaybillDetail(row)"
>{{ row.waybillNo }}</el-link
><span v-else>-</span></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="执行详情">
<template #extra>
<div class="loading-detail__process-actions">
<el-select
v-model="processWaybillId"
filterable
placeholder="切换运单"
@change="handleProcessWaybillChange"
>
<el-option
v-for="item in processWaybillOptions"
:key="item.id"
:label="item.waybillNo || String(item.id)"
:value="item.id"
/>
</el-select>
</div>
</template>
<el-tabs
v-model="processDetailTab"
type="border-card"
@tab-change="handleProcessDetailTabChange"
>
<el-tab-pane label="打卡详情" name="punch">
<div v-loading="punchRecordsLoading">
<el-timeline v-if="orderedPunchRecords.length">
<el-timeline-item
v-for="(record, index) in orderedPunchRecords"
:key="`${record.nodeCode || record.id || 'punch'}-${index}`"
:timestamp="record.punchTime || record.statusName || '未打卡'"
:type="record.punched ? 'primary' : 'info'"
:color="record.punched ? '#2b6ce8' : '#c0c4cc'"
placement="top"
>
<div class="loading-detail__punch-record">
<div class="loading-detail__punch-record-title">
{{ record.nodeName || record.nodeCode || '打卡' }}
<el-tag
:type="record.punched ? 'success' : 'info'"
size="small"
effect="plain"
>{{
record.statusName || (record.punched ? '已打卡' : '未打卡')
}}</el-tag
>
<el-tag
v-if="record.exceptionFlag"
size="small"
type="danger"
effect="plain"
>异常</el-tag
>
</div>
<template v-if="record.punched">
<div
v-if="record.address"
class="loading-detail__punch-record-line"
>
地点:{{ record.address }}
</div>
<div
v-if="record.weight || record.volume || record.quantity"
class="loading-detail__punch-record-line"
>
<span v-if="record.weight">重量 {{ record.weight }}吨</span>
<span v-if="record.volume">体积 {{ record.volume }}方</span>
<span v-if="record.quantity">数量 {{ record.quantity }}</span>
</div>
<div
v-if="record.remark"
class="loading-detail__punch-record-line"
>
备注:{{ record.remark }}
</div>
<punch-record-photos
v-if="record.photos && record.photos.length"
:photos="record.photos"
lazy
@preview="previewDriverUploadPhoto(record.photos, $event)"
/>
</template>
<div v-else class="loading-detail__punch-record-line">暂未打卡</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty
v-else-if="!punchRecordsLoading"
description="暂无打卡记录"
:image-size="50"
/>
</div>
</el-tab-pane>
<el-tab-pane v-if="hasRelatedVoucher" label="批量补录" name="batchSupplement">
<voucher-execution-detail
v-if="isCarrierExecutionVoucher"
:key="processWaybillId"
:waybill-id="processWaybillId"
/>
<div
v-else-if="voucherFolderView"
class="loading-detail__voucher-folder-toolbar"
>
<el-button text type="primary" @click="backToVoucherFolders">
返回文件夹
</el-button>
<span>{{ voucherFolderName(voucherFolder) }}</span>
</div>
<div
v-if="!isCarrierExecutionVoucher"
v-loading="voucherImagesLoading"
class="loading-detail__voucher-image-grid"
>
<div
v-for="(image, index) in voucherImages"
:key="
image.id ||
image.objectKey ||
(isVoucherFolder(image)
? `${image.voucherId || 'folder'}-${image.folderName || image.name || index}`
: index)
"
:class="[
'loading-detail__voucher-image-item',
{
'loading-detail__voucher-image-item--folder': isVoucherFolder(image),
},
]"
:title="
isVoucherFolder(image)
? voucherFolderName(image)
: image.imageName || image.name
"
@click="
isVoucherFolder(image)
? openVoucherFolder(image)
: previewVoucherImage(index)
"
>
<el-image
:src="
isVoucherFolder(image) ? image.icon || '/img/文件夹.png' : image.url
"
:fit="isVoucherFolder(image) ? 'contain' : 'cover'"
lazy
/>
<div
v-if="isVoucherFolder(image)"
class="loading-detail__voucher-folder-name"
>
{{ voucherFolderName(image) }}
</div>
</div>
<el-empty
v-if="!voucherImagesLoading && !voucherImages.length"
description="暂无凭证图片"
:image-size="50"
/>
</div>
</el-tab-pane>
<el-tab-pane v-if="driverUploads.length" label="司机上传" name="driverUpload">
<div
v-loading="punchRecordsLoading"
class="loading-detail__driver-upload-grid"
>
<div
v-for="(photo, index) in driverUploads"
:key="`${photo.url}-${index}`"
class="loading-detail__driver-upload-item"
:title="photo.label"
@click="previewDriverUploadPhoto(driverUploads, index)"
>
<el-image :src="photo.url" fit="cover" lazy />
<div class="loading-detail__driver-upload-label">
{{ photo.label || '凭证' }}
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</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
ref="loadingFormRef"
:model="dialogForm"
:disabled="dialogReadonly"
label-position="right"
label-width="auto"
class="loading-manage-dialog__form archive-form"
>
<div title="配载信息" class="loading-manage-dialog__load-info-card">
<section-card>
<div class="loading-manage-dialog__candidate-search">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="运单号">
<el-input v-model="candidateQuery.waybillNo" clearable placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="项目名称">
<el-select
v-model="candidateQuery.projectName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadProjectOptions"
:loading="projectLoading"
placeholder="请输入"
@visible-change="
visible => visible && loadProjectOptions(candidateQuery.projectName)
"
>
<el-option
v-for="item in projectOptions"
:key="item.id || item.projectName"
:label="item.projectName"
:value="item.projectName"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="发货地址">
<el-input
v-model="candidateQuery.departureAddress"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<template v-if="candidateSearchVisible">
<el-col :span="6">
<el-form-item label="货物名称">
<el-input
v-model="candidateQuery.cargoName"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="收货地址">
<el-input
v-model="candidateQuery.arrivalAddress"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="客户名称">
<el-select
v-model="candidateQuery.customerName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadCustomerOptions"
:loading="customerLoading"
placeholder="请输入"
@visible-change="
visible => visible && loadCustomerOptions(candidateQuery.customerName)
"
>
<el-option
v-for="item in customerOptions"
:key="item.id || item.customerName"
:label="item.customerName"
:value="item.customerName"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="货物类型">
<el-select
v-model="candidateQuery.cargoType"
clearable
filterable
remote
reserve-keyword
:remote-method="loadCargoTypeOptions"
:loading="cargoTypeLoading"
placeholder="请输入"
@visible-change="
visible => visible && loadCargoTypeOptions(candidateQuery.cargoType)
"
>
<el-option
v-for="item in cargoTypeOptions"
:key="item.id || item.cargoName || item.cargoType"
:label="item.cargoName || item.cargoType || item.label"
:value="item.cargoName || item.cargoType || item.label"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="原始单号">
<el-input
v-model="candidateQuery.originalNo"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="车牌号/船班号/船次号/班次号">
<el-input
v-model="candidateQuery.vehicleNo"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="承运商名称">
<el-select
v-model="candidateQuery.carrierName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadCarrierOptions"
:loading="carrierLoading"
placeholder="请输入"
@visible-change="
visible => visible && loadCarrierOptions(candidateQuery.carrierName)
"
>
<el-option
v-for="item in carrierOptions"
:key="item.id || item.fullName || item.customerName || item.carrierName"
:label="
item.fullName || item.customerName || item.carrierName || item.name
"
:value="
item.fullName || item.customerName || item.carrierName || item.name
"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="司机">
<el-select
v-model="candidateQuery.driverName"
clearable
filterable
remote
reserve-keyword
:remote-method="loadDriverOptions"
:loading="driverLoading"
placeholder="请输入"
@visible-change="
visible => visible && loadDriverOptions(candidateQuery.driverName)
"
>
<el-option
v-for="item in driverOptions"
:key="item.id || item.driverName || item.name"
:label="driverOptionText(item)"
:value="item.driverName || item.name"
/>
</el-select>
</el-form-item>
</el-col>
</template>
<el-col
:span="candidateSearchVisible ? 24 : 6"
class="loading-manage-dialog__candidate-actions"
>
<el-button type="primary" @click="handleCandidateSearch">查询</el-button>
<el-button @click="handleCandidateReset">重置</el-button>
<el-link
type="primary"
@click="candidateSearchVisible = !candidateSearchVisible"
>{{ candidateSearchVisible ? '收起' : '展开' }}</el-link
>
</el-col>
</el-row>
</div>
<el-table
ref="candidateTableRef"
v-loading="candidateLoading"
:data="candidateRows"
border
height="287"
row-key="id"
@selection-change="candidateSelectionChange"
>
<el-table-column type="selection" width="52" fixed="left" align="center" />
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column
prop="waybillNo"
label="运单号"
min-width="150"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<el-link v-if="row.waybillNo" type="primary" @click="openWaybillDetail(row)">
{{ row.waybillNo }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
prop="projectName"
label="项目名称"
min-width="150"
show-overflow-tooltip
/>
<el-table-column
prop="transportType"
label="运输方式"
min-width="130"
show-overflow-tooltip
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column
prop="goodsInfo"
label="货物信息"
min-width="260"
show-overflow-tooltip
>
<template #default="{ row }">
{{ formatCandidateGoodsInfo(row) }}
</template>
</el-table-column>
<el-table-column
prop="departureAddress"
label="发货地址"
min-width="220"
>
<template #default="{ row }">
<el-tooltip
v-if="formatCandidateAddressTooltip(row, 'departure')"
:content="formatCandidateAddressTooltip(row, 'departure')"
placement="top"
>
<div class="loading-manage-page__address-cell">
{{ formatCandidateAddress(row, 'departure') }}
</div>
</el-tooltip>
<span v-else>{{ formatCandidateAddress(row, 'departure') }}</span>
</template>
</el-table-column>
<el-table-column
prop="arrivalAddress"
label="收货地址"
min-width="220"
>
<template #default="{ row }">
<el-tooltip
v-if="formatCandidateAddressTooltip(row, 'arrival')"
:content="formatCandidateAddressTooltip(row, 'arrival')"
placement="top"
>
<div class="loading-manage-page__address-cell">
{{ formatCandidateAddress(row, 'arrival') }}
</div>
</el-tooltip>
<span v-else>{{ formatCandidateAddress(row, 'arrival') }}</span>
</template>
</el-table-column>
<el-table-column
prop="contractName"
label="客户合同"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
prop="carrierContractName"
label="承运商合同"
min-width="160"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.carrierContractName || '-' }}
</template>
</el-table-column>
<el-table-column
prop="planName"
label="计划名称"
min-width="150"
show-overflow-tooltip
/>
<el-table-column
prop="originalNo"
label="原始单号"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="relationNo"
label="关联单号"
min-width="140"
show-overflow-tooltip
/>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column
prop="startDate"
label="实际发货时间"
min-width="170"
show-overflow-tooltip
>
<template #default="{ row }">
{{ formatCandidateDate(row.startDate) }}
</template>
</el-table-column>
<el-table-column
prop="endDate"
label="实际完成时间"
min-width="170"
show-overflow-tooltip
>
<template #default="{ row }">
{{ formatCandidateDate(row.endDate) }}
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="170"
show-overflow-tooltip
>
<template #default="{ row }">
{{ formatCandidateDate(row.createTime) }}
</template>
</el-table-column>
<el-table-column
prop="updateTime"
label="更新时间"
min-width="170"
show-overflow-tooltip
>
<template #default="{ row }">
{{ formatCandidateDate(row.updateTime) }}
</template>
</el-table-column>
<el-table-column
prop="businessStatus"
label="状态"
width="110"
fixed="right"
align="center"
>
<template #default="{ row }">
<span
:class="[
'loading-manage-page__status',
`is-${row.businessStatus || 'unknown'}`,
]"
>
{{ formatCandidateWaybillValue(row, 'businessStatus') }}
</span>
</template>
</el-table-column>
</el-table>
<div class="loading-manage-dialog__table-toolbar">
<el-button
type="primary"
:disabled="!candidateSelection.length || candidateAdding"
:loading="candidateAdding"
@click="addSelectedWaybills"
>添加配载</el-button
>
<el-pagination
v-model:current-page="candidatePage.currentPage"
v-model:page-size="candidatePage.pageSize"
:page-sizes="candidatePage.pageSizes"
layout="total, prev, pager, next, sizes"
:total="candidatePage.total"
@size-change="handleCandidateSizeChange"
@current-change="handleCandidateCurrentChange"
/>
</div>
</section-card>
<div class="loading-manage-dialog__waybill-route-layout">
<section-card title="待配载列表" style="min-height: 257px">
<el-table
:data="selectedWaybillRows"
border
class="loading-manage-dialog__selected-table"
>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="waybillNo" label="运单号" min-width="150">
<template #default="{ row }">
<el-link
v-if="row.waybillNo"
type="primary"
@click.stop="openWaybillDetail(row)"
>
{{ row.waybillNo }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="projectName" label="项目名称" min-width="160" />
<el-table-column
prop="departureAddress"
label="发货地"
min-width="220"
>
<template #default="{ row }">
<el-tooltip
v-if="formatCandidateAddressTooltip(row, 'departure')"
:content="formatCandidateAddressTooltip(row, 'departure')"
placement="top"
>
<div class="loading-manage-page__address-cell">
{{ formatCandidateAddress(row, 'departure') }}
</div>
</el-tooltip>
<span v-else>{{ formatCandidateAddress(row, 'departure') }}</span>
</template>
</el-table-column>
<el-table-column
prop="arrivalAddress"
label="到货地"
min-width="220"
>
<template #default="{ row }">
<el-tooltip
v-if="formatCandidateAddressTooltip(row, 'arrival')"
:content="formatCandidateAddressTooltip(row, 'arrival')"
placement="top"
>
<div class="loading-manage-page__address-cell">
{{ formatCandidateAddress(row, 'arrival') }}
</div>
</el-tooltip>
<span v-else>{{ formatCandidateAddress(row, 'arrival') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row, $index }">
<el-link
v-if="!dialogReadonly && !isSelectedWaybillRemoveLocked(row)"
type="danger"
@click="removeSelectedWaybill($index)"
>删除</el-link
>
<span
v-else-if="!dialogReadonly && isSelectedWaybillRemoveLocked(row)"
class="loading-manage-dialog__locked-tip"
>已打卡</span
>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card title="线路编辑">
<template v-if="isRunningEdit" #extra>
<span class="loading-manage-dialog__route-hint"
>已打卡地点置灰不可拖拽,新增地址仅可在未打卡节点间调整</span
>
</template>
<div class="loading-manage-dialog__route-list" v-if="routeNodes.length">
<div
v-for="(node, index) in routeNodes"
:key="node.key"
:class="[
'loading-manage-dialog__route-item',
{ 'is-locked': isRouteChangeNodeLocked(node) },
]"
:draggable="!dialogReadonly && !isRouteChangeNodeLocked(node)"
@dragstart="handleRouteDragStart(index)"
@dragover.prevent
@drop="handleRouteDrop(index)"
>
<el-icon
v-if="!dialogReadonly && !isRouteChangeNodeLocked(node)"
class="loading-manage-dialog__route-handle"
><Rank
/></el-icon>
<span v-else class="loading-manage-dialog__route-handle" />
<span
:class="[
'loading-manage-dialog__route-type',
`loading-manage-dialog__route-type--${routeTypeClass(index)}`,
]"
>{{ routeTypeText(index) }}</span
>
<span class="loading-manage-dialog__route-address">{{
node.displayAddress || formatAddressText(node.address)
}}</span>
<span class="loading-manage-dialog__route-tags">
<el-tag v-for="tag in node.tags" :key="tag" size="small">{{ tag }}</el-tag>
</span>
</div>
</div>
<el-empty v-else description="添加待配载运单后自动生成路线" :image-size="72" />
</section-card>
</div>
<section-card title="货物信息" class="loading-manage-dialog__cargo-section">
<el-table :data="cargoRows" border class="loading-manage-dialog__cargo-table">
<el-table-column type="index" label="序号" width="70" align="center" fixed="left" />
<el-table-column prop="projectName" label="项目名称" min-width="150" />
<el-table-column prop="cargoName" label="货物名称" min-width="140" />
<el-table-column prop="cargoType" label="货物类型" min-width="140" />
<el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="quantity" label="数量" min-width="120">
<template #default="{ row }">{{ formatCargoQuantityDisplay(row) }}</template>
</el-table-column>
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="equipmentCode" label="设备编码" min-width="130" />
<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="180" show-overflow-tooltip />
<el-table-column prop="unitPrice" label="货物单价(元)" min-width="150">
<template #default="{ row }">{{
Number(row.unitPrice) === -1 ? '' : row.unitPrice
}}</template>
</el-table-column>
<el-table-column prop="planDate" label="计划日期" min-width="130" sortable />
</el-table>
</section-card>
<section-card title="任务信息">
<div class="loading-manage-dialog__carrier-tabs">
<el-segmented
v-model="dialogForm.carrierType"
:options="carrierTypeOptions"
:disabled="dialogReadonly || isRunningTaskRequiredLocked"
@change="handleCarrierTypeChange"
/>
</div>
<div class="loading-manage-dialog__grid loading-manage-dialog__task-grid">
<el-form-item :required="dialogForm.carrierType === '承运商'" label="承运商">
<el-select
:model-value="
dialogForm.carrierType === '承运商'
? taskCarrierSelectedValue
: dialogForm.carrierName
"
clearable
filterable
:disabled="
dialogReadonly ||
isRunningTaskRequiredLocked ||
!selectedWaybillRows.length
"
:loading="taskCarrierLoading"
:placeholder="taskCarrierPlaceholder"
@change="handleTaskCarrierChange"
@visible-change="visible => visible && loadTaskCarrierOptions()"
>
<el-option
v-for="item in taskCarrierOptions"
:key="carrierOptionValue(item)"
:label="
dialogForm.carrierType === '承运商'
? formatTaskCarrierContractLabel(item)
: item.carrierName || item.partyB
"
:value="
dialogForm.carrierType === '承运商'
? carrierOptionValue(item)
: item.carrierName || item.partyB
"
/>
</el-select>
</el-form-item>
<el-form-item
label="司机"
:required="dialogForm.carrierType === '自运'"
>
<el-autocomplete
v-model="dialogForm.driverName"
clearable
:debounce="300"
:fetch-suggestions="fetchDriverSuggestions"
:loading="driverLoading"
placeholder="请输入"
:disabled="dialogReadonly || isRunningSelfDriverLocked"
@select="item => handleDriverSuggestionSelect('dialog', item)"
>
<template #default="{ item }">
<span>{{ driverOptionText(item) }}</span>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item
label="手机号"
:required="dialogForm.carrierType === '自运'"
>
<el-input
v-model="dialogForm.driverPhone"
clearable
placeholder="请输入"
:disabled="dialogReadonly || isRunningSelfDriverLocked"
/>
</el-form-item>
<el-form-item label="车牌号" required>
<el-input
v-model="dialogForm.vehicleNo"
clearable
placeholder="请输入"
:disabled="dialogReadonly || isRunningTaskRequiredLocked"
/>
</el-form-item>
<el-form-item v-if="dialogForm.carrierType !== '承运商'" label="挂车车牌号">
<el-input
v-model="dialogForm.trailerVehicleNo"
clearable
placeholder="请输入"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item v-if="dialogForm.carrierType !== '承运商'" label="押运人">
<el-autocomplete
v-model="dialogForm.escortName"
clearable
:debounce="300"
:fetch-suggestions="fetchEscortSuggestions"
:loading="escortLoading"
placeholder="请输入"
:disabled="dialogReadonly"
@select="item => handleEscortSuggestionSelect('dialog', item)"
>
<template #default="{ item }">
<span>{{ driverOptionText(item) }}</span>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item v-if="dialogForm.carrierType !== '承运商'" label="押运人手机号">
<el-input
v-model="dialogForm.escortPhone"
clearable
placeholder="请输入"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="里程(km)">
<el-input
:model-value="normalizeLoadingMileage(dialogForm.mileage)"
clearable
placeholder="请输入"
:disabled="dialogReadonly"
@input="handleMileageInput"
/>
</el-form-item>
<el-form-item label="预计发货日期">
<el-date-picker
v-model="dialogForm.estimatedStartDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="预计完成日期">
<el-date-picker
v-model="dialogForm.estimatedEndDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="备注" class="loading-manage-dialog__span-2">
<el-input
v-model="dialogForm.taskRemark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="请输入,500字以内"
:disabled="dialogReadonly"
/>
</el-form-item>
</div>
</section-card>
</div>
</el-form>
</div>
<div class="loading-manage-dialog__footer">
<template v-if="dialogReadonly">
<el-button @click="closeLoadingDialog">关闭</el-button>
</template>
<template v-else>
<el-button @click="closeLoadingDialog">关闭</el-button>
<el-button
v-if="!isRunningEdit"
type="primary"
plain
:loading="dialogSaving === 'draft'"
@click="saveDraft"
>
暂存
</el-button>
<el-button type="primary" :loading="dialogSaving === 'submit'" @click="submitLoading">
{{ dialogMode === 'reassign' ? '确认派单' : isRunningEdit ? '保存' : '确认' }}
</el-button>
</template>
</div>
</div>
<el-dialog
v-model="routeChangeVisible"
title="变更运输路线"
append-to-body
destroy-on-close
width="78%"
class="loading-route-change-dialog"
>
<el-tabs type="border-card" v-model="routeChangeTab">
<el-tab-pane label="变更路线" name="change">
<section-card title="关联子运单地址">
<el-table :data="routeChangeRows" 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 prop="projectName" label="项目名称" min-width="160" />
<el-table-column label="发货地" min-width="360"
><template #default="{ row }"
><div class="loading-route-change-dialog__original">
原:{{ row.originalDepartureAddress || '-' }}
</div>
<div class="loading-route-change-dialog__address-row">
<el-input
:model-value="row.departureAddress"
readonly
placeholder="请点击地图选点"
:disabled="isWaybillCompleted(row)"
@click="openRouteChangeMapDialog(row, 'departure')"
><template v-if="!isWaybillCompleted(row)" #suffix
><el-icon
class="loading-route-change-dialog__location"
@click.stop="openRouteChangeMapDialog(row, 'departure')"
><Location /></el-icon
></template>
</el-input>
</div></template
></el-table-column>
<el-table-column label="到货地" min-width="360"
><template #default="{ row }"
><div class="loading-route-change-dialog__original">
原:{{ row.originalArrivalAddress || '-' }}
</div>
<div class="loading-route-change-dialog__address-row">
<el-input
:model-value="row.arrivalAddress"
readonly
placeholder="请点击地图选点"
:disabled="isWaybillCompleted(row)"
@click="openRouteChangeMapDialog(row, 'arrival')"
><template v-if="!isWaybillCompleted(row)" #suffix
><el-icon
class="loading-route-change-dialog__location"
@click.stop="openRouteChangeMapDialog(row, 'arrival')"
><Location /></el-icon
></template>
</el-input>
</div></template
></el-table-column>
</el-table>
</section-card>
<section-card title="路线编辑"
><template #extra
><span class="loading-route-change-dialog__hint">拖拽调整顺序</span></template
>
<div v-if="routeChangeNodes.length" class="loading-route-change-dialog__route-list">
<div
v-for="(node, index) in routeChangeNodes"
:key="node.key || index"
:class="[
'loading-route-change-dialog__route-item',
{ 'is-locked': isRouteChangeNodeLocked(node) },
]"
:draggable="!isRouteChangeNodeLocked(node)"
@dragstart="handleRouteChangeDragStart(index)"
@dragover.prevent
@drop="handleRouteChangeDrop(index)"
>
<span
:class="[
'loading-route-change-dialog__route-type',
`loading-route-change-dialog__route-type--${routeChangeTypeClass(index)}`,
]"
>{{ routeChangeTypeText(index) }}</span
><span>{{ node.address }}</span
><span class="loading-route-change-dialog__tags"
><el-tag v-for="tag in node.tags || []" :key="tag" size="small">{{
tag
}}</el-tag></span
><el-icon v-if="!isRouteChangeNodeLocked(node)"><Rank /></el-icon
><span v-else class="loading-route-change-dialog__route-handle-placeholder" />
</div>
</div>
<el-empty v-else description="暂无路线" :image-size="60"
/></section-card>
<el-form label-width="auto"
><el-form-item label="变更备注"
><el-input
v-model="routeChangeRemark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入" /></el-form-item
></el-form>
</el-tab-pane>
<el-tab-pane label="变更记录" name="records"
><el-table :data="routeChangeRecords" border
><el-table-column prop="changeTime" label="变更时间" min-width="170" /><el-table-column
prop="changeUser"
label="变更人"
min-width="120" /><el-table-column
prop="content"
label="变更内容"
min-width="360"
:show-overflow-tooltip="false"
><template #default="{ row }"
><div class="loading-route-change-dialog__record-content">
<div
v-for="(group, groupIndex) in parseRouteChangeContent(row.content)"
:key="groupIndex"
class="loading-route-change-dialog__change-group"
>
<div
v-if="group.title"
class="loading-route-change-dialog__change-group-title"
>
{{ group.title }}
</div>
<div v-if="group.text" class="loading-route-change-dialog__change-group-text">
{{ group.text }}
</div>
<div
v-for="(field, fieldIndex) in group.fields"
:key="fieldIndex"
class="loading-route-change-dialog__change-field"
>
<span class="loading-route-change-dialog__change-field-label">
{{ field.label }}
</span>
<template v-if="field.changed">
<span class="loading-route-change-dialog__change-before">
{{ field.before }}
</span>
<span class="loading-route-change-dialog__change-arrow">→</span>
<span class="loading-route-change-dialog__change-after">
{{ field.after }}
</span>
</template>
<span v-else class="loading-route-change-dialog__change-single">
{{ field.single }}
<span
v-if="field.unchanged"
class="loading-route-change-dialog__change-flag"
>
未变更
</span>
</span>
</div>
</div>
</div></template
></el-table-column
><el-table-column
prop="remark"
label="备注"
min-width="200"
show-overflow-tooltip /></el-table
><el-empty v-if="!routeChangeRecords.length" description="暂无变更记录" :image-size="60"
/></el-tab-pane>
</el-tabs>
<template #footer
><el-button @click="routeChangeVisible = false">关闭</el-button
><el-button v-if="routeChangeTab === 'change'" type="primary" @click="confirmRouteChange"
>确认变更</el-button
></template
>
</el-dialog>
<el-dialog
v-model="routeChangeMapVisible"
title="地图选择地址"
append-to-body
width="920px"
class="loading-route-change-map-dialog"
@opened="initRouteChangeMap"
@closed="destroyRouteChangeMap"
>
<div class="loading-route-change-map-dialog__toolbar">
<el-input
v-model="routeChangeMapKeyword"
clearable
placeholder="输入地址关键词"
@keyup.enter="searchRouteChangeMap"
/>
<el-button
type="primary"
:icon="Search"
:loading="routeChangeMapLoading"
@click="searchRouteChangeMap"
>
搜索
</el-button>
</div>
<div class="map-picker-content">
<div ref="routeChangeMap" class="loading-route-change-map-dialog__map" />
<map-search-results
:results="routeChangeMapSearchResults"
@select="selectRouteChangeMapSearchResult"
/>
</div>
<div class="loading-route-change-map-dialog__status">
{{ routeChangeMapStatus
}}<span v-if="routeChangeMapSelected.regionName"
> 行政区划:{{ routeChangeMapSelected.regionName }}</span
>
</div>
<template #footer>
<el-button @click="routeChangeMapVisible = false">取消</el-button>
<el-button
type="primary"
:disabled="!routeChangeMapSelected.longitude"
@click="confirmRouteChangeMap"
>
确定
</el-button>
</template>
</el-dialog>
<el-image-viewer
v-if="attachmentImagePreviewVisible"
:url-list="attachmentImagePreviewUrls"
:initial-index="attachmentImagePreviewIndex"
@close="attachmentImagePreviewVisible = false"
/>
</basic-container>
</template>
<script>
import request from '@/axios';
import { exportBlob } from '@/api/common';
import { downloadXls } from '@/utils/util';
import * as loadingApi from '@/api/business/loading-manage';
import { getList as getProjectList } from '@/api/business/project-apply';
import { getList as getPlanList } from '@/api/business/transport-plan';
import {
getList as getWaybillList,
getDetail as getWaybillDetail,
getPunchRecords as getWaybillPunchRecords,
} from '@/api/business/waybill-manage';
import { getList as getContractList } from '@/api/business/contract-manage';
import { getParentOptions as getCargoTypeOptions } from '@/api/base/cargo-type';
import { getList as getCustomerList } from '@/api/vehicle/customer-archive';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { fetchDriverCandidates, driverOptionText } from '@/utils/carrier-org-resource';
import { formatContractOptionLabel } from '@/utils/contract';
import {
getList as getProcessConfigList,
getDetail as getProcessConfigDetail,
getVoucherImages as getProcessConfigVoucherImages,
} from '@/api/business/process-config';
import VoucherExecutionDetail from './components/voucher-execution-detail.vue';
import PunchRecordPhotos from './components/punch-record-photos.vue';
import {
carrierTypeOptions,
loadingManageConfig,
loadingStatusMap,
loadingStatusOptions,
} from '@/option/business/loading-manage';
import { option as waybillOption } from '@/option/business/waybill-manage';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Location, Rank, Search } from '@element-plus/icons-vue';
import TableHeaderTools from '@/components/table-header-tools/main.vue';
import dayjs from 'dayjs';
import { isMobile } from '@/utils/validate';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { parseRouteChangeContent } from '@/utils/route-change';
const defaultTransportType = '';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills';
const loadingManageListRefreshKey = 'loading-manage-list-refresh';
let detailAmapLoader;
const createSearchForm = () => ({
loadingNo: '',
projectName: '',
customerName: '',
transportType: defaultTransportType,
waybillNo: '',
cargoType: '',
departureAddress: '',
arrivalAddress: '',
cargoName: '',
driverName: '',
vehicleNo: '',
originalNo: '',
carrierName: '',
dataSource: '',
startDateRange: [],
endDateRange: [],
businessStatus: '',
planName: '',
batchNo: '',
});
const createCandidateSearchForm = () => ({
waybillNo: '',
projectName: '',
departureAddress: '',
cargoName: '',
arrivalAddress: '',
customerName: '',
cargoType: '',
originalNo: '',
vehicleNo: '',
carrierName: '',
driverName: '',
});
const createDialogForm = () => ({
id: '',
loadingNo: '',
loadingSubNos: '',
waybillIdsJson: '',
projectName: '',
customerName: '',
transportType: '',
cargoType: '',
cargoName: '',
vehicleNo: '',
trailerVehicleNo: '',
driverName: '',
driverPhone: '',
carrierType: '承运商',
carrierName: '',
carrierContractId: '',
carrierPartyId: '',
carrierPartyRole: '',
escortName: '',
escortPhone: '',
departureAddress: '',
transitAddress: '',
arrivalAddress: '',
originalNo: '',
dataSource: '手工创建',
startDate: '',
endDate: '',
planName: '',
batchNo: '',
currentProcessNode: '接单',
mileage: '',
estimatedStartDate: '',
estimatedEndDate: '',
taskRemark: '',
goodsJson: '',
routeJson: '',
taskInfoJson: '',
businessStatus: 'draft',
});
// 后端以 -1 表示未填写里程,表单中应保持为空,避免给用户造成已填写的错觉。
const normalizeLoadingMileage = value => {
if (value === undefined || value === null || value === '' || Number(value) === -1) return '';
return value;
};
// 货物等业务字段用 -1 表示未填写,展示时留空
const blankCargoSentinel = value => {
if (value === undefined || value === null || value === '') return '';
if (Number(value) === -1) return '';
return value;
};
const sanitizeCargoRow = (row = {}) => {
const result = { ...row };
Object.keys(result).forEach(key => {
result[key] = blankCargoSentinel(result[key]);
});
return result;
};
// 提交时未填写里程传 null,禁止自动填充 -1
const toSubmitMileage = value => {
const normalized = normalizeLoadingMileage(value);
if (normalized === '') return null;
const number = Number(normalized);
return Number.isFinite(number) ? number : null;
};
const createCopiedDialogForm = (detail = {}) => {
const form = createDialogForm();
Object.keys(form).forEach(prop => {
if (Object.prototype.hasOwnProperty.call(detail, prop)) form[prop] = detail[prop];
});
let taskInfo = {};
try {
const parsed = JSON.parse(form.taskInfoJson || '{}');
taskInfo = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (error) {
taskInfo = {};
}
const { routeChangeRecords, ...copiedTaskInfo } = taskInfo;
return {
...form,
id: '',
loadingNo: '',
startDate: '',
endDate: '',
dataSource: '手工创建',
currentProcessNode: '接单',
mileage: normalizeLoadingMileage(form.mileage),
taskInfoJson: JSON.stringify(copiedTaskInfo),
businessStatus: 'draft',
};
};
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 [];
};
const unwrapPage = res => {
const data = res?.data?.data || res?.data || res;
return {
records: data?.records || [],
total: Number(data?.total || 0),
};
};
const parseJsonArray = value => {
if (Array.isArray(value)) return value;
if (typeof value !== 'string' || !value.trim()) return [];
try {
const result = JSON.parse(value);
if (Array.isArray(result)) return result;
if (Array.isArray(result.records)) return result.records;
if (Array.isArray(result.rows)) return result.rows;
return [];
} catch (error) {
return [];
}
};
const parseIdJsonArray = value => {
if (Array.isArray(value)) return value.map(id => String(id));
if (typeof value !== 'string' || !value.trim()) return [];
try {
const normalizedValue = value.replace(/(^\s*\[\s*|,\s*)(-?\d{16,})(\s*(?=,|\]))/g, '$1"$2"$3');
const result = JSON.parse(normalizedValue);
return Array.isArray(result) ? result.map(id => String(id)) : [];
} catch (error) {
return [];
}
};
const uniqueText = (rows, prop) =>
[
...new Set(
rows
.map(row => row?.[prop])
.filter(value => value !== undefined && value !== null && value !== '')
),
].join('、');
const LOADING_TABLE_COLUMNS = [
{ prop: 'loadingNo', label: '配载单号' },
{ prop: 'loadingSubNos', label: '运单号' },
{ prop: 'vehicleNo', label: '车牌号' },
{ prop: 'driverName', label: '司机' },
{ prop: 'driverPhone', label: '联系电话' },
{ prop: 'carrierType', label: '承运类型' },
{ prop: 'departureAddress', label: '发货地' },
{ prop: 'transitAddress', label: '途经地' },
{ prop: 'arrivalAddress', label: '到货地' },
{ prop: 'carrierName', label: '承运商' },
{ prop: 'transportType', label: '运输方式' },
{ prop: 'dataSource', label: '数据来源' },
{ prop: 'createUserName', label: '创建人' },
{ prop: 'createTime', label: '创建时间' },
{ prop: 'updateTime', label: '更新时间' },
{ prop: 'currentProcessNode', label: '过程节点' },
{ prop: 'businessStatus', label: '状态' },
];
export default {
components: {
Rank,
Search,
TableHeaderTools,
VoucherExecutionDetail,
PunchRecordPhotos,
},
data() {
return {
Rank,
Location,
Search,
loading: false,
tableSize: 'default',
searchVisible: true,
columnSettingOptions: LOADING_TABLE_COLUMNS,
visibleColumnKeys: LOADING_TABLE_COLUMNS.map(column => column.prop),
tableData: [],
page: {
currentPage: 1,
pageSize: 10,
total: 0,
pageSizes: [10, 20, 50, 100],
},
searchExpanded: false,
searchForm: createSearchForm(),
selectionRows: [],
dialogMode: 'add',
dialogLoading: false,
dialogSaving: '',
dialogForm: createDialogForm(),
candidateSearchVisible: false,
candidateQuery: createCandidateSearchForm(),
candidateLoading: false,
candidateAdding: false,
candidateRows: [],
candidateSelection: [],
candidatePage: {
currentPage: 1,
pageSize: 10,
total: 0,
pageSizes: [10, 20, 50, 100],
},
selectedWaybillRows: [],
routeNodes: [],
cargoRows: [],
routeDragIndex: -1,
/** 进行中编辑:子运单打卡记录映射 waybillId -> records[] */
waybillPunchMap: {},
transportTypeOptions: [],
projectOptions: [],
customerOptions: [],
cargoTypeOptions: [],
driverOptions: [],
carrierOptions: [],
taskCarrierOptions: [],
planOptions: [],
projectLoading: false,
customerLoading: false,
cargoTypeLoading: false,
driverLoading: false,
escortLoading: false,
carrierLoading: false,
taskCarrierLoading: false,
planLoading: false,
taskCarrierRequestId: 0,
carrierTypeOptions,
statusOptions: loadingStatusOptions,
dataSourceOptions: loadingManageConfig.dataSourceOptions,
config: loadingManageConfig,
processDetailTab: 'punch',
processWaybillId: '',
processWaybillOptions: [],
punchRecords: [],
punchRecordsLoading: false,
driverUploads: [],
hasRelatedVoucher: false,
voucherImages: [],
voucherFolders: [],
voucherFolder: null,
voucherFolderView: false,
voucherImagesLoading: false,
voucherImagesLoaded: false,
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
routeChangeVisible: false,
routeChangeTab: 'change',
routeChangeRows: [],
routeChangeNodes: [],
routeChangeRemark: '',
routeChangeRecords: [],
routeChangeDragIndex: -1,
routeChangeOriginalAddresses: [],
routeChangeOrderDirty: false,
routeChangeMapVisible: false,
routeChangeMapLoading: false,
routeChangeMapKeyword: '',
routeChangeMapStatus: '可搜索地址或点击地图选点',
routeChangeMapSelected: {},
routeChangeMapSearchResults: [],
routeChangeMapTarget: null,
routeChangeMapInstance: null,
routeChangeMapMarker: null,
routeChangeMapGeocoder: null,
detailAmap: null,
};
},
computed: {
dialogReadonly() {
return this.dialogMode === 'view';
},
/** 进行中配载编辑:可加子单/调未打卡路线,但任务必填字段锁定 */
isRunningEdit() {
return (
this.dialogMode === 'edit' &&
String(this.dialogForm.businessStatus || '') === 'running'
);
},
/** 进行中任务必填字段(已派车)不可改 */
isRunningTaskRequiredLocked() {
return this.isRunningEdit;
},
/** 自运进行中:司机/手机号同属必填派车信息,不可改 */
isRunningSelfDriverLocked() {
return (
this.isRunningEdit && String(this.dialogForm.carrierType || '').trim() === '自运'
);
},
// 打卡时间轴:已打卡按时间升序,未打卡保持原节点顺序排在其后
orderedPunchRecords() {
const records = Array.isArray(this.punchRecords) ? this.punchRecords : [];
return records
.map((record, index) => ({
record,
index,
punchedAt: this.punchRecordTimestamp(record),
}))
.sort((a, b) => {
const aPunched = a.punchedAt !== null;
const bPunched = b.punchedAt !== null;
if (aPunched && bPunched) return a.punchedAt - b.punchedAt || a.index - b.index;
if (aPunched !== bPunched) return aPunched ? -1 : 1;
return a.index - b.index;
})
.map(item => item.record);
},
taskCarrierPlaceholder() {
if (!this.selectedWaybillRows.length) return '请先选择运单';
if (!this.taskCarrierLoading && !this.taskCarrierOptions.length) return '暂无可用承运商合同';
return '请选择';
},
taskCarrierSelectedValue() {
return this.carrierOptionValue({
id: this.dialogForm.carrierContractId,
carrierPartyId: this.dialogForm.carrierPartyId,
carrierName: this.dialogForm.carrierName,
});
},
detailStatusType() {
const typeMap = {
pending: 'success',
running: 'success',
processing: 'success',
completed: 'primary',
cancelled: 'info',
draft: 'info',
};
return typeMap[this.dialogForm.businessStatus] || 'warning';
},
taskSummary() {
return (
[this.dialogForm.carrierName, this.dialogForm.driverName, this.dialogForm.vehicleNo]
.filter(Boolean)
.join(' / ') || '-'
);
},
isSelfCarrierDetail() {
return String(this.dialogForm.carrierType || '').trim() === '自运';
},
isCarrierExecutionVoucher() {
if (String(this.dialogForm.carrierType || '').trim() === '承运商') return true;
const waybill = (this.selectedWaybillRows || []).find(
item => String(item.id) === String(this.processWaybillId)
);
return String(waybill?.carrierTypeName || waybill?.carrierType || '').trim() === '承运商';
},
detailTaskMileage() {
return normalizeLoadingMileage(this.dialogForm.mileage);
},
detailAttachments() {
return parseJsonArray(this.dialogForm.attachmentsJson || this.dialogForm.attachmentJson).map(
item =>
typeof item === 'string'
? { name: item.split('/').pop(), url: item }
: {
name: item.name || item.fileName || '附件',
url: item.url || item.link || item.fileUrl,
}
);
},
// 独立表单页:/business/loading-manage/form?mode=add|edit|reassign
isStandaloneFormPage() {
return (
this.$route.path === '/business/loading-manage/form' &&
['add', 'edit', 'reassign'].includes(this.$route.query.mode)
);
},
// 独立详情页:/business/loading-manage/detail?id=xxx
isStandaloneDetailPage() {
return this.$route.path === '/business/loading-manage/detail';
},
// 列表容器隐藏搜索栏 / 工具栏 / 表格 / 分页的统一开关
isStandalonePage() {
return this.isStandaloneFormPage || this.isStandaloneDetailPage;
},
formPageDefaultTitle() {
const titleMap = {
add: '新增配载管理',
edit: '编辑配载管理',
reassign: '重新派单',
};
return titleMap[this.$route.query.mode] || '新增配载管理';
},
formPageTitle() {
return this.$route.query.name || this.formPageDefaultTitle;
},
detailPageTitle() {
return this.$route.query.name || '配载单详情';
},
},
mounted() {
this.loadTransportTypeOptions();
this.loadTable();
if (this.isStandalonePage) {
this.syncStandaloneTagTitle();
if (this.isStandaloneDetailPage) this.initStandaloneDetailPage();
else this.initStandaloneFormPage();
}
// 兼容旧链接:/business/loading-manage?detailId=xxx 直接跳独立详情页
const detailId = this.$route.query.detailId;
if (detailId) this.gotoLoadingDetail({ id: detailId });
},
// 标签切走(实例被 keep-alive 缓存)或销毁时,先收起页内子弹窗
deactivated() {
this.closeInnerDialogs();
},
activated() {
if (this.isStandalonePage) return;
if (sessionStorage.getItem(loadingManageListRefreshKey)) {
sessionStorage.removeItem(loadingManageListRefreshKey);
this.loadTable();
}
},
beforeUnmount() {
this.closeInnerDialogs();
},
watch: {
$route(to, from) {
if (!this.isStandalonePage) {
// 从表单/详情返回列表时刷新,避免草稿确认后仍显示旧状态
if (
from?.path?.includes('/loading-manage/form') ||
from?.path?.includes('/loading-manage/detail')
) {
sessionStorage.removeItem(loadingManageListRefreshKey);
this.loadTable();
}
return;
}
this.syncStandaloneTagTitle();
if (this.isStandaloneDetailPage) {
// 记录变化或上一个页面不是详情态(例如从表单页/列表页进来)时重新拉取详情
const idChanged = String(to.query.id || '') !== String(from?.query?.id || '');
if (idChanged || this.dialogMode !== 'view') this.initStandaloneDetailPage();
return;
}
if (
to.query.mode !== from?.query?.mode ||
String(to.query.id || '') !== String(from?.query.id || '') ||
String(to.query.copyId || '') !== String(from?.query.copyId || '')
) {
this.initStandaloneFormPage();
}
},
'$route.query.detailId'(detailId) {
if (detailId) this.gotoLoadingDetail({ id: detailId });
},
},
methods: {
parseRouteChangeContent,
driverOptionText,
formatTaskCarrierContractLabel(item = {}) {
const carrierName = item.carrierName || item.partyB || '';
const roleLabel =
item.carrierPartyRole === 'C' || item.carrierPartyRole === '丙'
? '丙方'
: item.carrierPartyRole === 'B' || item.carrierPartyRole === '乙'
? '乙方'
: '';
const contractLabel = formatContractOptionLabel(item);
const nameWithRole = roleLabel && carrierName ? `${carrierName}(${roleLabel})` : carrierName;
if (nameWithRole && contractLabel) {
return `${nameWithRole} - ${contractLabel}`;
}
return nameWithRole || contractLabel;
},
carrierOptionValue(item = {}) {
return [item.id || '', item.carrierPartyId || '', item.carrierName || item.partyB || ''].join(
'__'
);
},
syncStandaloneTagTitle() {
if (!this.isStandalonePage) return;
const title = this.isStandaloneDetailPage ? this.detailPageTitle : this.formPageDefaultTitle;
if (this.$route.query.name === title) return;
this.$store.commit('SET_TAG', {
fullPath: this.$route.fullPath,
query: { ...this.$route.query, name: title },
});
},
buildQueryParams(form) {
const params = { ...form };
const [startDateStart, startDateEnd] = params.startDateRange || [];
const [endDateStart, endDateEnd] = params.endDateRange || [];
delete params.startDateRange;
delete params.endDateRange;
return {
...params,
startDateStart,
startDateEnd,
endDateStart,
endDateEnd,
};
},
async loadTable() {
this.loading = true;
try {
const res = await loadingApi.getList(
this.page.currentPage,
this.page.pageSize,
this.buildQueryParams(this.searchForm)
);
const page = unwrapPage(res);
this.tableData = page.records;
this.page.total = page.total;
} finally {
this.loading = false;
}
},
handleSearch() {
this.page.currentPage = 1;
this.loadTable();
},
handleResetSearch() {
this.searchForm = createSearchForm();
this.page.currentPage = 1;
this.loadTable();
},
handleSizeChange(size) {
this.page.currentPage = 1;
this.page.pageSize = size;
this.loadTable();
},
handleCurrentChange(current) {
this.page.currentPage = current;
this.loadTable();
},
selectionChange(rows) {
this.selectionRows = rows || [];
},
isColumnVisible(prop) {
return this.visibleColumnKeys.includes(prop);
},
statusText(row) {
return row.businessStatusName || loadingStatusMap[row.businessStatus] || '未知';
},
formatCandidateWaybillValue(row, prop) {
if (!row) return '-';
if (prop === 'businessStatus') return this.statusText(row);
if (prop === 'transportType') return this.transportTypeLabel(row.transportType);
if (prop === 'goodsInfo') return this.formatCandidateGoodsInfo(row);
const column = waybillOption.column.find(item => item.prop === prop);
if (column?.formatter) {
const value = column.formatter(row, {}, row[prop]);
return value === undefined || value === null || value === '' ? '-' : value;
}
const value = row[prop];
return value === undefined || value === null || value === '' ? '-' : value;
},
formatCandidateGoodsInfo(row = {}) {
const goodsRows =
[row.goodsJson, row.goodsList, row.goodsRows, row.cargoList, row.cargoRows, row.goods]
.map(source => {
if (source === undefined || source === null || source === '') return [];
if (Array.isArray(source)) return source;
if (typeof source === 'object') return [source];
return parseJsonArray(source);
})
.filter(source => source.length)
.sort((left, right) => right.length - left.length)[0] || [];
if (!goodsRows.length) return String(row.goodsInfo || row.cargoInfo || '') || '-';
const groups = goodsRows.reduce((result, item = {}) => {
const unit = String(
item.quantityUnit ||
item.goodsQuantityUnit ||
item.cargoUnit ||
item.unit ||
row.quantityUnit ||
row.goodsQuantityUnit ||
row.cargoUnit ||
row.unit ||
''
).trim();
const key = unit || '__empty__';
if (!result[key]) {
result[key] = {
typeName:
item.secondCargoTypeName ||
item.secondCargoType ||
item.cargoType ||
item.goodsType ||
item.typeName ||
'货物',
count: 0,
quantity: 0,
unit,
};
}
result[key].count += 1;
result[key].quantity += this.parseGoodsQuantity(
item.quantity ?? item.cargoQuantity ?? item.goodsQuantity
);
return result;
}, {});
return (
Object.values(groups)
.map(group => {
const quantity = this.formatGoodsQuantity(group.quantity);
const typeName =
group.count > 1 ? `${group.typeName}等${group.count}种货物` : group.typeName;
return `${typeName} | ${quantity}${group.unit ? ` ${group.unit}` : ''}`;
})
.join('; ') || '-'
);
},
parseGoodsQuantity(value) {
if (value === undefined || value === null || value === '') return 0;
const text = String(value).replace(/,/g, '');
const match = text.match(/-?\d+(\.\d+)?/);
if (!match) return 0;
const number = Number(match[0]);
return Number.isFinite(number) ? number : 0;
},
formatGoodsQuantity(value) {
const number = Number(value || 0);
if (!Number.isFinite(number)) return '0';
const fixed = Math.round((number + Number.EPSILON) * 1000) / 1000;
return Number.isInteger(fixed) ? String(fixed) : String(fixed).replace(/\.?0+$/, '');
},
formatCandidateDate(value) {
if (!value) return '-';
const date = dayjs(value);
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : value;
},
formatCandidateAddress(row, type) {
const prefix = type === 'departure' ? 'departure' : 'arrival';
const name = String(row?.[`${prefix}Name`] || '').trim();
const address = String(row?.[`${prefix}Address`] || '').trim();
if (!this.isRoadTransportType(row?.transportType)) {
return name || address || '-';
}
const region = this.mergeRouteAddressParts(
this.routeProvinceName(row, prefix),
row[`${prefix}CityName`] || row[`${prefix}City`],
row[`${prefix}DistrictName`] || row[`${prefix}District`]
);
const source = this.mergeRouteAddressParts(region, '', address || name);
const parts = this.parseWaybillAddress(source);
return (
[parts.province, parts.city, parts.district]
.map(value => String(value || '').trim())
.filter(Boolean)
.join('') ||
this.formatProvinceCityDistrict(source) ||
name ||
address ||
'-'
);
},
formatCandidateAddressTooltip(row, type) {
if (!this.isRoadTransportType(row?.transportType)) return '';
const prefix = type === 'departure' ? 'departure' : 'arrival';
const address = String(row?.[`${prefix}Address`] || '').trim();
const name = String(row?.[`${prefix}Name`] || '').trim();
const region = this.mergeRouteAddressParts(
this.routeProvinceName(row, prefix),
row[`${prefix}CityName`] || row[`${prefix}City`],
row[`${prefix}DistrictName`] || row[`${prefix}District`]
);
const detailAddress =
row[`${prefix}DetailAddress`] || row[`${prefix}AddressDetail`] || address;
return this.mergeRouteAddressParts(region, '', detailAddress || name) || '';
},
isRoadTransportType(type) {
const value = String(type || '')
.trim()
.toLowerCase();
return (
value === 'road' ||
value.includes('公路') ||
value.includes('道路') ||
value.includes('highway')
);
},
// 与总单详情一致:公路地址压缩为「市 区县」
formatRoadLocation(value) {
const text = String(value || '')
.replace(/[\//\s]+/g, '')
.trim();
if (!text) return '';
const parts = this.parseWaybillAddress(text);
let city = String(parts.city || '').trim();
let district = String(parts.district || '').trim();
let detail = String(parts.detailAddress || '').trim();
// 自治州/地区/盟下还有「XX市 + 区县」时,以市级作为展示主体
if (/(?:自治州|地区|盟)$/.test(city) && detail) {
const nestedCityMatch = detail.match(/^(.+?市)/);
if (nestedCityMatch) {
city = nestedCityMatch[1];
detail = detail.slice(nestedCityMatch[1].length);
const nestedDistrictMatch = detail.match(
/^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/
);
if (nestedDistrictMatch) district = nestedDistrictMatch[1];
} else if (!district) {
const nestedDistrictMatch = detail.match(
/^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/
);
if (nestedDistrictMatch) district = nestedDistrictMatch[1];
}
}
// 市级后紧跟区县,但 parse 未拆出时再补一次
if (city && !district && detail) {
const nestedDistrictMatch = detail.match(
/^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/
);
if (nestedDistrictMatch) district = nestedDistrictMatch[1];
}
if (city && district) {
// 兼容「梅县区」:命中「县」后还需保留后面的「区」
if (district.endsWith('县') && detail.startsWith(`${district}区`)) {
district = `${district}区`;
}
return `${city} ${district}`;
}
if (city) return city;
if (district) return district;
// 兜底压缩
const withoutProvince = text.replace(/^.*?(?:省|自治区|特别行政区)/, '');
const cityMatch = withoutProvince.match(/.+?(?:市|自治州|州(?!市)|盟(?!市)|地区)/);
if (!cityMatch) return text;
const fallbackCity = cityMatch[0];
const districtSource = withoutProvince.slice(fallbackCity.length);
const districtMatch = districtSource.match(
/^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/
);
if (!districtMatch) {
// 州后面是县级市时,继续解析市 + 区
const nestedCityMatch = districtSource.match(/^(.+?市)/);
if (nestedCityMatch) {
const nestedDistrictSource = districtSource.slice(nestedCityMatch[1].length);
const nestedDistrictMatch = nestedDistrictSource.match(
/^[^市州盟]*?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)/
);
if (nestedDistrictMatch) {
return `${nestedCityMatch[1]} ${nestedDistrictMatch[0]}`;
}
return nestedCityMatch[1];
}
return fallbackCity;
}
const fallbackDistrict = `${districtMatch[0]}${
districtSource.slice(districtMatch[0].length).startsWith('区') ? '区' : ''
}`;
return `${fallbackCity} ${fallbackDistrict}`;
},
isStationLikeName(value) {
const text = String(value || '').trim();
if (!text) return false;
return (
/(?:火车站|高铁站|客运站|货运站|港口|码头|机场|空港|航站楼)$/.test(text) ||
/(?<!市|州|盟|区|县|旗)站$/.test(text)
);
},
// 详情运输路线短展示:公路仅市区,非公路仅名称
formatDetailRouteShort(node = {}) {
const name = String(node.name || '').trim();
const address = String(node.address || '').trim();
const displayAddress = String(node.displayAddress || '').trim();
if (!this.isRoadTransportType(this.dialogForm.transportType)) {
if (name) return name;
if (this.isStationLikeName(displayAddress)) return displayAddress;
if (this.isStationLikeName(address)) return address;
return displayAddress || address || '-';
}
const enriched = this.enrichRouteNodeRegion(node);
// 合并节点地址与运单上的市/区字段,避免只解析到市
const region = this.mergeRouteAddressParts(enriched.cityName, enriched.districtName, '');
const source = this.mergeRouteAddressParts(
address,
displayAddress,
region,
enriched.name || name
);
const formatted = this.formatRoadLocation(source);
if (enriched.cityName && enriched.districtName) {
return `${enriched.cityName} ${enriched.districtName}`;
}
if (/(?:市|州|盟|区|县|旗)/.test(formatted)) return formatted;
return formatted || name || displayAddress || address || '-';
},
// 公路悬停展示完整地址;非公路不展示 tooltip
formatDetailRouteTooltip(node = {}) {
if (!this.isRoadTransportType(this.dialogForm.transportType)) return '';
const enriched = this.enrichRouteNodeRegion(node);
const region = this.mergeRouteAddressParts(enriched.cityName, enriched.districtName, '');
const full = this.mergeRouteAddressParts(
String(node.address || '').trim(),
String(node.displayAddress || '').trim(),
region,
String(node.name || enriched.name || '').trim()
);
const short = this.formatDetailRouteShort(node);
if (!full) return '';
return full === short ? '' : full;
},
// 按装/卸标签从已选运单回填市、区,补充 routeJson 中缺失的区县
enrichRouteNodeRegion(node = {}) {
const tags = Array.isArray(node.tags) ? node.tags : [];
const result = { cityName: '', districtName: '', name: '' };
for (const tag of tags) {
const text = String(tag || '');
const loadMatch = text.match(/^装(\d+)$/);
const unloadMatch = text.match(/^卸(\d+)$/);
const index = Number((loadMatch || unloadMatch)?.[1] || 0) - 1;
if (index < 0) continue;
const row = this.selectedWaybillRows[index];
if (!row) continue;
const prefix = loadMatch ? 'departure' : 'arrival';
result.cityName = String(
row[`${prefix}CityName`] || row[`${prefix}City`] || ''
).trim();
result.districtName = String(
row[`${prefix}DistrictName`] || row[`${prefix}District`] || ''
).trim();
result.name = String(row[`${prefix}Name`] || '').trim();
if (result.cityName || result.districtName || result.name) break;
}
return result;
},
splitText(value) {
if (!value) return [];
return String(value)
.split(/[\n,,]+/)
.map(item => item.trim())
.filter(Boolean);
},
findWaybillId(row, waybillNo) {
const rows = this.splitText(row.loadingSubNos);
const index = rows.findIndex(item => item === waybillNo);
const linkedRows = parseJsonArray(row.waybillList || row.waybillRows || row.waybills);
if (linkedRows[index]?.id || linkedRows[index]?.waybillId) {
return linkedRows[index].id || linkedRows[index].waybillId;
}
const ids = parseIdJsonArray(row.waybillIdsJson);
return ids[index] || '';
},
async openWaybillDetail(row = {}) {
let id = row.id || row.waybillId || '';
if (!id && row.waybillNo) {
try {
const res = await getWaybillList(1, 1, { waybillNo: row.waybillNo });
const page = unwrapPage(res);
id = page.records?.[0]?.id || page[0]?.id || '';
} catch (error) {
id = '';
}
}
if (!id) {
ElMessage.info('当前子运单暂无详情数据');
return;
}
this.$router.push({
path: '/business/waybill-manage/detail',
query: { id, name: '运单详情' },
});
},
formatRouteAddress(row, type) {
const prefix =
type === 'departure' ? 'departure' : type === 'arrival' ? 'arrival' : 'transit';
if (type === 'transit') {
const values = this.splitText(row.transitAddress || row.transitName || '')
.map(value => this.formatListRouteShort(row, value, value))
.filter(value => value && value !== '-');
return [...new Set(values)].join(',') || '-';
}
const name = String(row[`${prefix}Name`] || '').trim();
const address = String(row[`${prefix}Address`] || '').trim();
const cityName = String(
row[`${prefix}CityName`] || row[`${prefix}City`] || ''
).trim();
const districtName = String(
row[`${prefix}DistrictName`] || row[`${prefix}District`] || ''
).trim();
if (
this.isRoadTransportType(row?.transportType) &&
cityName &&
districtName
) {
return `${cityName} ${districtName}`;
}
const region = this.mergeRouteAddressParts(
this.routeProvinceName(row, prefix),
cityName,
districtName
);
const source = this.mergeRouteAddressParts(region, '', address || name);
return this.formatListRouteShort(row, name, source || address || name);
},
formatFullRouteAddress(row, type) {
if (!this.isRoadTransportType(row?.transportType)) return '';
const prefix =
type === 'departure' ? 'departure' : type === 'arrival' ? 'arrival' : 'transit';
if (type === 'transit') {
const values = this.splitText(row.transitAddress || '')
.map(value => String(value || '').trim())
.filter(Boolean);
const full = [...new Set(values)].join(',');
const short = this.formatRouteAddress(row, 'transit');
return full && full !== short ? full : '';
}
const name = String(row[`${prefix}Name`] || '').trim();
const address = String(row[`${prefix}Address`] || '').trim();
const region = this.mergeRouteAddressParts(
this.routeProvinceName(row, prefix),
row[`${prefix}CityName`] || row[`${prefix}City`],
row[`${prefix}DistrictName`] || row[`${prefix}District`]
);
const full = this.mergeRouteAddressParts(region, '', address || name);
const short = this.formatRouteAddress(row, type);
if (!full) return '';
return full === short ? '' : full;
},
// 列表短地址:公路「市 区县」,非公路仅名称
formatListRouteShort(row = {}, name = '', address = '') {
const rawName = String(name || '').trim();
const rawAddress = String(address || '').trim();
if (!this.isRoadTransportType(row?.transportType)) {
if (rawName) return rawName;
if (this.isStationLikeName(rawAddress)) return rawAddress;
return rawAddress || '-';
}
const source = rawAddress || rawName;
const formatted = this.formatRoadLocation(source);
if (/(?:市|州|盟)/.test(formatted)) return formatted;
return formatted || rawName || rawAddress || '-';
},
formatWaybillAddress(row, type) {
const prefix = type === 'departure' ? 'departure' : 'arrival';
const address = row[`${prefix}Address`] || row[`${prefix}Name`] || '';
const region = this.mergeRouteAddressParts(
this.routeProvinceName(row, prefix),
row[`${prefix}CityName`] || row[`${prefix}City`],
row[`${prefix}DistrictName`] || row[`${prefix}District`]
);
const source = this.mergeRouteAddressParts(region, '', address);
const parts = this.parseWaybillAddress(source);
const detailAddress =
row[`${prefix}DetailAddress`] ||
row[`${prefix}AddressDetail`] ||
parts.detailAddress ||
address;
return (
[parts.province, parts.city, parts.district, detailAddress]
.map(value => String(value || '').trim())
.filter(Boolean)
.join(' / ') || '-'
);
},
parseWaybillAddress(address) {
let rest = String(address || '').trim();
const result = { province: '', city: '', district: '', detailAddress: '' };
if (!rest) return result;
const municipalityMatch = rest.match(/^(北京市|天津市|上海市|重庆市)/);
if (municipalityMatch) {
result.province = municipalityMatch[0];
result.city = municipalityMatch[0];
rest = rest.slice(municipalityMatch[0].length);
} else {
const provinceMatch = rest.match(/^(.+?(?:省|自治区|特别行政区))/);
if (provinceMatch) {
result.province = provinceMatch[0];
rest = rest.slice(provinceMatch[0].length);
}
const cityMatch = rest.match(/^(.+?(?:市|自治州|地区|盟))/);
if (cityMatch) {
result.city = cityMatch[0];
rest = rest.slice(cityMatch[0].length);
}
}
const districtMatch = rest.match(/^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/);
if (districtMatch) {
result.district = districtMatch[0];
rest = rest.slice(districtMatch[0].length);
}
result.detailAddress = rest;
return result;
},
formatAddressText(address) {
const parts = this.parseWaybillAddress(address);
return (
[parts.province, parts.city, parts.district, parts.detailAddress]
.map(value => String(value || '').trim())
.filter(Boolean)
.join(' / ') ||
String(address || '').trim() ||
'-'
);
},
mergeRouteAddressParts(region, district, address) {
const values = [region, district, address]
.map(value => String(value || '').trim())
.filter(Boolean);
if (!values.length) return '';
return values.reduce((result, value) => {
if (!result) return value;
if (result.includes(value) || value.includes(result))
return result.length >= value.length ? result : value;
return `${result}${value}`;
}, '');
},
routeProvinceName(row, prefix) {
return (
[
row[`${prefix}ProvinceName`],
row[`${prefix}Province`],
row[`${prefix}RegionName`],
row[`${prefix}Region`],
row[`${prefix}Name`],
row[`${prefix}AdministrativeRegionName`],
row[`${prefix}AddressRegionName`],
].find(value => String(value || '').trim()) || ''
);
},
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;
},
rowActions(row) {
const status = row.businessStatus;
if (status === 'draft') {
return [
{ type: 'edit', label: '编辑' },
{ type: 'delete', label: '删除' },
];
}
if (status === 'pending') {
const actions = [
{ type: 'view', label: '详情' },
{ type: 'start', label: '改为进行中' },
];
if (row.driverRejected) {
actions.push({ type: 'reassign', label: '重新派单' });
}
actions.push({ type: 'edit', label: '编辑' }, { type: 'cancel', label: '取消' });
return actions;
}
if (status === 'running') {
return [
{ type: 'view', label: '详情' },
{ type: 'edit', label: '编辑' },
{ type: 'complete', label: '完成' },
];
}
if (status === 'completed' || status === 'cancelled') {
return [{ type: 'view', label: '详情' }];
}
return [{ type: 'view', label: '详情' }];
},
handleRowAction(type, row) {
const actionMap = {
view: () => this.gotoLoadingDetail(row),
edit: () => this.openLoadingDialog('edit', row),
reassign: () => this.gotoLoadingReassign(row),
delete: () => this.deleteLoading(row),
start: () => this.startLoading(row),
cancel: () => this.cancelLoading(row),
complete: () => this.completeLoading(row),
};
actionMap[type]?.();
},
// 详情改为独立页面(与新增/编辑一致)
gotoLoadingDetail(row = {}) {
const id = row.id || row.loadingId || row.loadingManageId;
if (!id) {
ElMessage.info('当前配载单暂无详情数据');
return;
}
if (this.isStandaloneDetailPage && String(this.$route.query.id || '') === String(id)) return;
this.$router.push({
path: '/business/loading-manage/detail',
query: { id, name: '配载单详情' },
});
},
// 复制:进入新增页并带回原配载数据
gotoLoadingCopy(row = {}) {
const id = row.id || row.loadingId || row.loadingManageId;
if (!id) {
ElMessage.warning('配载单不存在,无法复制');
return;
}
this.$router.push({
path: '/business/loading-manage/form',
query: { mode: 'add', copyId: id, name: '新增配载管理' },
});
},
// 重新派单改为独立页面
gotoLoadingReassign(row = {}) {
if (!row.id) {
ElMessage.warning('配载单不存在,无法重新派单');
return;
}
this.$router.push({
path: '/business/loading-manage/form',
query: { mode: 'reassign', id: row.id, name: '重新派单' },
});
},
async initStandaloneDetailPage() {
if (!this.isStandaloneDetailPage) return;
const id = this.$route.query.id;
if (!id) {
ElMessage.warning('缺少配载单参数,无法查看详情');
this.closeLoadingDialog();
return;
}
await this.openLoadingDialog('view', { id });
},
async initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const queryMode = this.$route.query.mode;
const mode = ['edit', 'reassign'].includes(queryMode) ? queryMode : 'add';
const copyId = mode === 'add' ? this.$route.query.copyId : '';
await this.openLoadingDialog(mode, {
id: copyId || this.$route.query.id,
copy: Boolean(copyId),
});
if (mode === 'add' && !copyId) await this.consumeLoadingCreateWaybills();
},
async consumeLoadingCreateWaybills() {
const raw = sessionStorage.getItem(loadingCreateWaybillsStorageKey);
if (!raw) return;
sessionStorage.removeItem(loadingCreateWaybillsStorageKey);
try {
const payload = JSON.parse(raw);
if (payload?.source !== 'waybill-manage' || !Array.isArray(payload.rows)) return;
await this.addWaybillRows(payload.rows);
} catch (error) {
ElMessage.warning('待配载运单数据读取失败,请重新选择');
}
},
async openLoadingDialog(mode, row) {
// 列表页进入新增/编辑/重新派单:统一走独立页面
if (['add', 'edit', 'reassign'].includes(mode) && !this.isStandaloneFormPage) {
if (mode === 'reassign') {
this.gotoLoadingReassign(row || {});
return;
}
this.$router.push({
path: '/business/loading-manage/form',
query: {
mode,
...(mode === 'edit' && row?.id ? { id: row.id } : {}),
name:
mode === 'edit'
? row?.businessStatus === 'running'
? '编辑进行中配载'
: '编辑配载管理'
: '新增配载管理',
},
});
return;
}
this.dialogMode = mode;
this.dialogLoading = true;
this.resetDialogData();
try {
if (row?.id) {
const res = await loadingApi.getDetail(row.id);
const detail = res.data?.data || res.data || {};
this.dialogForm = row.copy
? createCopiedDialogForm(detail)
: { ...createDialogForm(), ...detail };
this.applyLoadingTaskInfoFromDetail(this.dialogForm);
this.applyLoadingMileageFromDetail(this.dialogForm);
await this.restoreWaybillRows(
this.dialogForm.waybillIdsJson,
this.dialogForm.loadingSubNos
);
this.syncProcessWaybillOptions();
if (this.isRunningEdit) {
await this.refreshWaybillPunchMap();
}
this.restoreRouteAndCargo();
this.restoreRouteChangeRecords();
this.loadPunchRecords();
this.probeHasRelatedVoucher();
this.$nextTick(() => this.initDetailAmap());
await this.loadCandidateWaybills();
} else {
this.dialogForm = createDialogForm();
this.candidateQuery = createCandidateSearchForm();
await this.loadCandidateWaybills();
}
} finally {
this.dialogLoading = false;
}
},
closeLoadingDialog() {
if (!this.isStandalonePage) return;
this.$router.$avueRouter?.closeTag?.();
this.$router.push({ path: '/business/loading-manage', query: {} });
},
resetDialogData() {
this.detailAmap?.destroy?.();
this.detailAmap = null;
this.dialogForm = createDialogForm();
this.candidateQuery = createCandidateSearchForm();
this.candidateRows = [];
this.candidateSelection = [];
this.selectedWaybillRows = [];
this.taskCarrierRequestId += 1;
this.taskCarrierOptions = [];
this.taskCarrierLoading = false;
this.routeNodes = [];
this.cargoRows = [];
this.routeDragIndex = -1;
this.waybillPunchMap = {};
this.candidatePage.currentPage = 1;
this.candidatePage.total = 0;
this.candidateSearchVisible = false;
this.processDetailTab = 'punch';
this.processWaybillId = '';
this.processWaybillOptions = [];
this.punchRecords = [];
this.driverUploads = [];
this.hasRelatedVoucher = false;
this.voucherImages = [];
this.voucherFolders = [];
this.voucherFolder = null;
this.voucherFolderView = false;
this.voucherImagesLoading = false;
this.voucherImagesLoaded = false;
this.attachmentImagePreviewVisible = false;
this.attachmentImagePreviewUrls = [];
this.attachmentImagePreviewIndex = 0;
},
// 离开本页(标签缓存/卸载)时收起页内子弹窗,避免 append-to-body 的弹窗 DOM 残留在 body 上
closeInnerDialogs() {
this.routeChangeVisible = false;
this.routeChangeMapVisible = false;
this.candidateSearchVisible = false;
},
restoreRouteAndCargo() {
const routeRows = parseJsonArray(this.dialogForm.routeJson);
const cargoRows = parseJsonArray(this.dialogForm.goodsJson);
this.routeNodes = routeRows.length
? this.syncRouteNodeLockedFlags(routeRows, this.selectedWaybillRows)
: this.buildRouteNodes(this.selectedWaybillRows);
this.cargoRows = (cargoRows.length ? cargoRows : this.buildCargoRows(this.selectedWaybillRows)).map(
row => sanitizeCargoRow(row)
);
// 运单回填时 routeNodes 尚为空会清空发到货地,路线恢复后需再同步
this.syncRouteAddressFields();
},
restoreRouteChangeRecords() {
try {
const taskInfo = JSON.parse(this.dialogForm.taskInfoJson || '{}');
this.routeChangeRecords = Array.isArray(taskInfo.routeChangeRecords)
? [...taskInfo.routeChangeRecords].sort((a, b) =>
String(b.changeTime).localeCompare(String(a.changeTime))
)
: [];
} catch (error) {
this.routeChangeRecords = [];
}
},
syncProcessWaybillOptions() {
this.processWaybillOptions = (this.selectedWaybillRows || [])
.filter(row => row?.id)
.map(row => ({
id: row.id,
waybillNo: row.waybillNo || String(row.id),
}));
const currentExists = this.processWaybillOptions.some(
item => String(item.id) === String(this.processWaybillId)
);
this.processWaybillId = currentExists
? this.processWaybillId
: this.processWaybillOptions[0]?.id || '';
this.processDetailTab = 'punch';
this.resetVoucherState();
},
resetVoucherState() {
this.hasRelatedVoucher = false;
this.voucherImages = [];
this.voucherFolders = [];
this.voucherFolder = null;
this.voucherFolderView = false;
this.voucherImagesLoading = false;
this.voucherImagesLoaded = false;
},
handleProcessWaybillChange() {
this.processDetailTab = 'punch';
this.resetVoucherState();
this.loadPunchRecords();
this.probeHasRelatedVoucher();
},
handleProcessDetailTabChange(tab) {
if (tab === 'punch' || tab === 'driverUpload') this.loadPunchRecords();
if (tab === 'batchSupplement') this.loadVoucherImages();
},
probeHasRelatedVoucher() {
const waybillId = this.processWaybillId;
const waybill = (this.selectedWaybillRows || []).find(
row => String(row.id) === String(waybillId)
);
const projectId = waybill?.projectId;
if (!waybillId || !projectId) {
this.hasRelatedVoucher = false;
return;
}
getProcessConfigList(1, 100, { projectIds: projectId, status: 1 })
.then(res => {
const config = unwrapRecords(res).find(row => {
const projectIds = String(row.projectIds || '')
.split(',')
.map(item => item.trim());
return projectIds.includes(String(projectId));
});
if (!config?.id) {
this.hasRelatedVoucher = false;
return null;
}
return getProcessConfigDetail(config.id, waybillId);
})
.then(detailRes => {
if (!detailRes) return;
const detail = detailRes?.data?.data || detailRes?.data || {};
this.hasRelatedVoucher = Boolean(detail.hasRelatedVoucher);
})
.catch(() => {
this.hasRelatedVoucher = false;
});
},
punchRecordTimestamp(record = {}) {
if (record.punched === false || record.statusName === '未打卡') return null;
const raw = record.punchTime || record.punchAt || record.punchDateTime || '';
const text = String(raw || '').trim();
if (!text) return null;
const parsed = dayjs(text);
if (parsed.isValid()) return parsed.valueOf();
const fallback = new Date(text.replace(/-/g, '/')).getTime();
return Number.isNaN(fallback) ? null : fallback;
},
loadPunchRecords() {
const waybillId = this.processWaybillId;
if (!waybillId || this.punchRecordsLoading) return;
this.punchRecordsLoading = true;
getWaybillPunchRecords(waybillId)
.then(res => {
const data = res?.data?.data || res?.data || {};
this.punchRecords = Array.isArray(data.records) ? data.records : [];
this.driverUploads = Array.isArray(data.driverUploads) ? data.driverUploads : [];
if (data.hasRelatedVoucher) this.hasRelatedVoucher = true;
})
.catch(() => {
this.punchRecords = [];
this.driverUploads = [];
})
.finally(() => {
this.punchRecordsLoading = false;
});
},
previewDriverUploadPhoto(photos, index) {
const list = (photos || []).map(item => item?.url).filter(Boolean);
if (!list.length) return;
this.attachmentImagePreviewUrls = list;
this.attachmentImagePreviewIndex = Math.min(
Math.max(Number(index) || 0, 0),
list.length - 1
);
this.attachmentImagePreviewVisible = true;
},
loadVoucherImages() {
if (!this.processWaybillId || this.voucherImagesLoading || this.voucherImagesLoaded) {
return;
}
this.voucherImagesLoading = true;
getProcessConfigVoucherImages(this.processWaybillId)
.then(res => {
this.voucherImages = unwrapRecords(res);
this.voucherFolders = this.voucherImages.filter(item => this.isVoucherFolder(item));
this.voucherFolder = null;
this.voucherFolderView = false;
if (this.voucherImages.length) this.hasRelatedVoucher = true;
this.voucherImagesLoaded = true;
})
.finally(() => {
this.voucherImagesLoading = false;
});
},
isVoucherFolder(item) {
return Boolean(item && (item.isFolder === true || item.type === 'folder'));
},
voucherFolderName(folder) {
return folder?.folderName || folder?.name || folder?.plateNo || '凭证文件夹';
},
openVoucherFolder(folder) {
if (!this.processWaybillId || !this.isVoucherFolder(folder)) return;
if (!folder.voucherId || !folder.folderName) {
ElMessage.warning('凭证文件夹信息不完整');
return;
}
this.voucherImagesLoading = true;
getProcessConfigVoucherImages(this.processWaybillId, {
voucherId: folder.voucherId,
folderName: folder.folderName,
})
.then(res => {
this.voucherFolder = folder;
this.voucherFolderView = true;
this.voucherImages = unwrapRecords(res).filter(item => !this.isVoucherFolder(item));
})
.finally(() => {
this.voucherImagesLoading = false;
});
},
backToVoucherFolders() {
this.voucherFolder = null;
this.voucherFolderView = false;
this.voucherImages = this.voucherFolders;
},
previewVoucherImage(index) {
const list = (this.voucherImages || [])
.filter(item => !this.isVoucherFolder(item))
.map(item => item?.url)
.filter(Boolean);
if (!list.length) return;
const current = this.voucherImages[index];
const currentUrl = current?.url;
const currentIndex = Math.max(
0,
list.findIndex(url => url === currentUrl)
);
this.attachmentImagePreviewUrls = list;
this.attachmentImagePreviewIndex = currentIndex >= 0 ? currentIndex : 0;
this.attachmentImagePreviewVisible = true;
},
previewAttachment(file) {
if (!file.url) {
ElMessage.info('暂无可预览的附件地址');
return;
}
window.open(file.url, '_blank', 'noopener,noreferrer');
},
loadDetailAmap() {
if (window.AMap?.Map) return Promise.resolve();
if (!detailAmapLoader) {
detailAmapLoader = new Promise((resolve, reject) => {
window._AMapSecurityConfig = { securityJsCode: AMAP_SECURITY_CODE };
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}`;
script.async = true;
script.onload = resolve;
script.onerror = () => reject(new Error('高德地图组件加载失败'));
document.body.appendChild(script);
});
}
return detailAmapLoader;
},
initDetailAmap() {
if (!this.$refs.detailAmap) return;
this.loadDetailAmap()
.then(() => {
if (!this.detailAmap) {
this.detailAmap = new window.AMap.Map(this.$refs.detailAmap, {
center: [116.40769, 39.89945],
zoom: 11,
});
window.AMap.plugin(['AMap.ToolBar'], () =>
this.detailAmap?.addControl(new window.AMap.ToolBar())
);
} else {
this.detailAmap.resize?.();
}
})
.catch(() => ElMessage.warning('高德地图加载失败'));
},
handleTrackAction(action) {
ElMessage.info(action === 'playback' ? '暂无可回放的物流轨迹' : '暂无车辆实时定位数据');
},
openRouteChangeDialog() {
this.routeChangeTab = 'change';
this.routeChangeRemark = '';
this.routeChangeRows = this.selectedWaybillRows.map(row => ({
...row,
originalDepartureAddress: row.departureAddress || row.departureName || '',
originalArrivalAddress: row.arrivalAddress || row.arrivalName || '',
departureAddress: row.departureAddress || row.departureName || '',
arrivalAddress: row.arrivalAddress || row.arrivalName || '',
}));
const builtNodes = this.buildRouteNodes(this.routeChangeRows);
this.routeChangeNodes = this.routeNodes.length
? this.syncRouteNodeLockedFlags(
this.routeNodes.map(node => ({
...node,
tags: [...(node.tags || [])],
})),
this.routeChangeRows
)
: builtNodes;
this.routeChangeOriginalAddresses = this.routeChangeNodes.map(node => node.address);
this.routeChangeOrderDirty = false;
this.routeChangeVisible = true;
},
routeChangeTypeText(index) {
if (index === 0) return '起';
if (index === this.routeChangeNodes.length - 1) return '终';
return '经';
},
routeChangeTypeClass(index) {
if (index === 0) return 'start';
if (index === this.routeChangeNodes.length - 1) return 'end';
return 'middle';
},
handleRouteChangeDragStart(index) {
if (this.isRouteChangeNodeLocked(this.routeChangeNodes[index])) {
this.routeChangeDragIndex = -1;
return;
}
this.routeChangeDragIndex = index;
},
handleRouteChangeDrop(index) {
if (this.routeChangeDragIndex < 0 || this.routeChangeDragIndex === index) return;
if (
this.isRouteChangeNodeLocked(this.routeChangeNodes[this.routeChangeDragIndex]) ||
this.isRouteChangeNodeLocked(this.routeChangeNodes[index])
) {
this.routeChangeDragIndex = -1;
return;
}
const nodes = [...this.routeChangeNodes];
const [node] = nodes.splice(this.routeChangeDragIndex, 1);
nodes.splice(index, 0, node);
const originalLockedKeys = this.routeChangeNodes.map(item =>
this.isRouteChangeNodeLocked(item) ? item.key : null
);
const nextLockedKeys = nodes.map(item =>
this.isRouteChangeNodeLocked(item) ? item.key : null
);
if (
originalLockedKeys.some((key, lockedIndex) => key && key !== nextLockedKeys[lockedIndex])
) {
ElMessage.warning('已完成运单对应的路线不可调整');
this.routeChangeDragIndex = -1;
return;
}
this.routeChangeNodes = nodes;
this.routeChangeOrderDirty = true;
this.routeChangeDragIndex = -1;
},
updateRouteChangeNodes() {
const previousAddresses = this.routeChangeNodes.map(node => node.address);
const builtNodes = this.buildRouteNodes(this.routeChangeRows);
const remaining = [...builtNodes];
const orderedNodes = [];
previousAddresses.forEach(address => {
const index = remaining.findIndex(node => node.address === address);
if (index >= 0) {
orderedNodes.push(remaining.splice(index, 1)[0]);
}
});
this.routeChangeNodes = [...orderedNodes, ...remaining];
},
isRouteChangeOrderChanged() {
if (!this.routeChangeOrderDirty) return false;
const currentAddresses = this.routeChangeNodes.map(node => node.address);
if (currentAddresses.length !== this.routeChangeOriginalAddresses.length) return true;
return currentAddresses.some(
(address, index) => address !== this.routeChangeOriginalAddresses[index]
);
},
formatRouteChangeOrderText(addresses = []) {
return addresses.filter(Boolean).join(' → ') || '-';
},
isWaybillCompleted(row = {}) {
const status = String(row.businessStatus || row.status || row.waybillStatus || '');
if (status === 'completed') return true;
const statusName = String(row.businessStatusName || row.statusName || '');
return statusName.includes('已完成');
},
isPunchRecordDone(record = {}) {
if (record.punched === false || record.statusName === '未打卡') return false;
return Boolean(record.punched || record.punchTime || record.punchAt || record.punchDateTime);
},
isLoadPunchRecord(record = {}) {
if (!this.isPunchRecordDone(record)) return false;
const code = String(record.nodeCode || '')
.trim()
.toLowerCase();
const name = String(record.nodeName || '').trim();
return code === 'load' || name.includes('装货');
},
isUnloadPunchRecord(record = {}) {
if (!this.isPunchRecordDone(record)) return false;
const code = String(record.nodeCode || '')
.trim()
.toLowerCase();
const name = String(record.nodeName || '').trim();
return (
code === 'unload' ||
name.includes('卸货') ||
code === 'arrival' ||
name.includes('到货') ||
code === 'sign' ||
name.includes('签收')
);
},
waybillPunchRecords(row = {}) {
const key = String(row?.id || '');
if (!key) return [];
const records = this.waybillPunchMap[key];
return Array.isArray(records) ? records : [];
},
isWaybillLoadPunched(row = {}) {
return this.waybillPunchRecords(row).some(record => this.isLoadPunchRecord(record));
},
isWaybillUnloadPunched(row = {}) {
return this.waybillPunchRecords(row).some(record => this.isUnloadPunchRecord(record));
},
isWaybillPunchLocked(row = {}) {
return this.isWaybillLoadPunched(row) || this.isWaybillUnloadPunched(row);
},
isSelectedWaybillRemoveLocked(row = {}) {
if (this.isWaybillCompleted(row)) return true;
return this.isRunningEdit && this.isWaybillPunchLocked(row);
},
isRouteNodeTagLocked(tag, rows = this.selectedWaybillRows) {
const match = String(tag || '').match(/^(装|卸)(\d+)$/);
if (!match) return false;
const row = rows[Number(match[2]) - 1];
if (!row) return false;
if (this.isRunningEdit) {
return match[1] === '装' ? this.isWaybillLoadPunched(row) : this.isWaybillUnloadPunched(row);
}
return this.isWaybillCompleted(row);
},
isRouteChangeNodeLocked(node = {}) {
return Boolean(node?.locked);
},
syncRouteNodeLockedFlags(nodes = [], rows = this.selectedWaybillRows) {
return (nodes || []).map(node => {
const locked = (node.tags || []).some(tag => this.isRouteNodeTagLocked(tag, rows));
return { ...node, locked };
});
},
async refreshWaybillPunchMap() {
const rows = (this.selectedWaybillRows || []).filter(row => row?.id);
if (!rows.length) {
this.waybillPunchMap = {};
return;
}
const entries = await Promise.all(
rows.map(async row => {
try {
const res = await getWaybillPunchRecords(row.id);
const data = res?.data?.data || res?.data || {};
return [String(row.id), Array.isArray(data.records) ? data.records : []];
} catch (error) {
return [String(row.id), []];
}
})
);
this.waybillPunchMap = Object.fromEntries(entries);
},
/**
* 进行中编辑:已打卡地点保持在前且不可插入,新增地址只能排在已打卡节点之后。
*/
mergeRouteNodesKeepingPunched(prevNodes = [], builtNodes = []) {
const prev = Array.isArray(prevNodes) ? prevNodes : [];
const built = Array.isArray(builtNodes) ? builtNodes : [];
if (!this.isRunningEdit) {
return built;
}
const builtByAddress = new Map();
built.forEach(node => {
if (node?.address && !builtByAddress.has(node.address)) {
builtByAddress.set(node.address, node);
}
});
const lockedAddresses = new Set(
built.filter(node => node?.locked && node.address).map(node => node.address)
);
const used = new Set();
const lockedResult = [];
prev.forEach(node => {
if (!node?.address || !lockedAddresses.has(node.address) || used.has(node.address)) {
return;
}
const latest = builtByAddress.get(node.address);
if (!latest) return;
lockedResult.push({ ...latest, locked: true });
used.add(node.address);
});
built.forEach(node => {
if (!node?.locked || !node.address || used.has(node.address)) return;
lockedResult.push({ ...node, locked: true });
used.add(node.address);
});
const unlockedResult = [];
prev.forEach(node => {
if (!node?.address || lockedAddresses.has(node.address) || used.has(node.address)) {
return;
}
const latest = builtByAddress.get(node.address);
if (!latest) return;
unlockedResult.push({ ...latest, locked: false });
used.add(node.address);
});
built.forEach(node => {
if (node?.locked || !node?.address || used.has(node.address)) return;
unlockedResult.push({ ...node, locked: false });
used.add(node.address);
});
return [...lockedResult, ...unlockedResult].map((node, index) => ({
...node,
key: `${node.address}-${index}`,
}));
},
openRouteChangeMapDialog(row, field) {
if (this.isWaybillCompleted(row)) return;
this.routeChangeMapTarget = { row, field };
this.routeChangeMapKeyword = row[`${field}Address`] || row[`${field}Name`] || '';
this.routeChangeMapSelected = {
longitude: row[`${field}Longitude`] || '',
latitude: row[`${field}Latitude`] || '',
detailAddress: this.routeChangeMapKeyword,
regionName: row[`${field}Name`] || '',
regionCode: row[`${field}RegionCode`] || '',
};
this.routeChangeMapSearchResults = [];
this.routeChangeMapStatus = this.routeChangeMapSelected.longitude
? '已加载当前选点,可重新选点'
: '可搜索地址或点击地图选点';
this.routeChangeMapVisible = true;
},
initRouteChangeMap() {
this.loadDetailAmap()
.then(() =>
this.$nextTick(() => {
if (!this.$refs.routeChangeMap) return;
const center = this.routeChangeMapSelected.longitude
? [
Number(this.routeChangeMapSelected.longitude),
Number(this.routeChangeMapSelected.latitude),
]
: [116.40769, 39.89945];
if (!this.routeChangeMapInstance) {
this.routeChangeMapInstance = new window.AMap.Map(this.$refs.routeChangeMap, {
center,
zoom: this.routeChangeMapSelected.longitude ? 14 : 11,
});
window.AMap.plugin(['AMap.Geocoder'], () => {
this.routeChangeMapGeocoder = new window.AMap.Geocoder();
});
this.routeChangeMapInstance.on('click', event =>
this.pickRouteChangeMap(event.lnglat)
);
} else {
this.routeChangeMapInstance.resize();
}
if (this.routeChangeMapSelected.longitude) {
this.renderRouteChangeMapMarker(center);
}
})
)
.catch(() => ElMessage.error('高德地图组件加载失败'));
},
destroyRouteChangeMap() {
this.routeChangeMapMarker?.setMap?.(null);
this.routeChangeMapMarker = null;
this.routeChangeMapInstance?.destroy?.();
this.routeChangeMapInstance = null;
this.routeChangeMapGeocoder = null;
this.routeChangeMapSearchResults = [];
this.routeChangeMapTarget = null;
},
ensureRouteChangeMapGeocoder() {
if (this.routeChangeMapGeocoder) return Promise.resolve(this.routeChangeMapGeocoder);
return new Promise(resolve => {
window.AMap.plugin(['AMap.Geocoder'], () => {
this.routeChangeMapGeocoder = new window.AMap.Geocoder();
resolve(this.routeChangeMapGeocoder);
});
});
},
searchRouteChangeMap() {
const keyword = String(this.routeChangeMapKeyword || '').trim();
if (!keyword) {
ElMessage.warning('请输入地址关键词');
return;
}
if (this.routeChangeMapLoading) return;
this.routeChangeMapSearchResults = [];
this.routeChangeMapLoading = true;
this.loadDetailAmap()
.then(() => searchAmapPlaceText(keyword))
.then(pois => {
this.routeChangeMapSearchResults = normalizeMapSearchResults(pois, keyword);
const first = this.routeChangeMapSearchResults[0];
if (!first?.location) {
this.routeChangeMapStatus = '未找到匹配地址';
ElMessage.warning('地图搜索无匹配地址');
return;
}
return this.$nextTick().then(() => {
this.pickRouteChangeMap(first.location, first.address || first.name || keyword);
});
})
.catch(() => {
this.routeChangeMapStatus = '未找到匹配地址';
ElMessage.warning('地图搜索无匹配地址');
})
.finally(() => {
this.routeChangeMapLoading = false;
});
},
selectRouteChangeMapSearchResult(item) {
if (item?.location) {
this.pickRouteChangeMap(item.location, item.address || item.name || '');
}
},
pickRouteChangeMap(point, keyword) {
const longitude = point.getLng ? point.getLng() : point.lng;
const latitude = point.getLat ? point.getLat() : point.lat;
this.routeChangeMapSelected = {
longitude,
latitude,
detailAddress: keyword || this.routeChangeMapKeyword,
regionName: '',
regionCode: '',
};
this.renderRouteChangeMapMarker([longitude, latitude]);
this.routeChangeMapStatus = '已选点,可确认回填';
this.ensureRouteChangeMapGeocoder().then(() => {
this.routeChangeMapGeocoder.getAddress([longitude, latitude], (status, result) => {
if (status !== 'complete') return;
const component = result.regeocode?.addressComponent || {};
this.routeChangeMapSelected = {
...this.routeChangeMapSelected,
detailAddress:
result.regeocode?.formattedAddress || this.routeChangeMapSelected.detailAddress,
regionName: [component.province, component.city, component.district]
.filter(Boolean)
.join(''),
regionCode: component.adcode || '',
};
this.routeChangeMapKeyword = this.routeChangeMapSelected.detailAddress;
this.routeChangeMapStatus = '已解析地址,可确认回填';
});
});
},
renderRouteChangeMapMarker(position) {
if (!this.routeChangeMapInstance) return;
this.routeChangeMapMarker?.setMap?.(null);
this.routeChangeMapMarker = new window.AMap.Marker({ position });
this.routeChangeMapMarker.setMap(this.routeChangeMapInstance);
this.routeChangeMapInstance.setCenter(position);
},
confirmRouteChangeMap() {
const target = this.routeChangeMapTarget;
if (!target || !this.routeChangeMapSelected.longitude) return;
const { row, field } = target;
row[`${field}Address`] =
this.routeChangeMapSelected.detailAddress || this.routeChangeMapKeyword || '';
if (this.routeChangeMapSelected.regionName) {
row[`${field}Name`] = this.routeChangeMapSelected.regionName;
}
row[`${field}Longitude`] = this.routeChangeMapSelected.longitude;
row[`${field}Latitude`] = this.routeChangeMapSelected.latitude;
if (this.routeChangeMapSelected.regionCode) {
row[`${field}RegionCode`] = this.routeChangeMapSelected.regionCode;
}
this.updateRouteChangeNodes();
this.routeChangeMapVisible = false;
},
async confirmRouteChange() {
const changeRows = this.routeChangeRows.filter(
row =>
row.departureAddress !== row.originalDepartureAddress ||
row.arrivalAddress !== row.originalArrivalAddress
);
const orderChanged = this.isRouteChangeOrderChanged();
if (!changeRows.length && !orderChanged && !this.routeChangeRemark) {
ElMessage.warning('请修改地址、调整路线顺序或填写变更备注');
return;
}
const contentParts = [];
if (changeRows.length) {
contentParts.push(
changeRows
.map(
row =>
`${row.waybillNo || '运单'}:发货地 ${row.originalDepartureAddress} → ${
row.departureAddress
};到货地 ${row.originalArrivalAddress} → ${row.arrivalAddress}`
)
.join(';')
);
}
if (orderChanged) {
contentParts.push(
`路线顺序:${this.formatRouteChangeOrderText(
this.routeChangeOriginalAddresses
)} 调整为 ${this.formatRouteChangeOrderText(
this.routeChangeNodes.map(node => node.address)
)}`
);
}
const content = contentParts.join(';') || '调整运输路线顺序';
this.routeChangeRecords.unshift({
changeTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
changeUser: '当前用户',
content,
remark: this.routeChangeRemark,
});
let taskInfo = {};
try {
taskInfo = JSON.parse(this.dialogForm.taskInfoJson || '{}');
} catch (error) {
taskInfo = {};
}
this.dialogForm.taskInfoJson = JSON.stringify({
...(typeof taskInfo === 'object' && !Array.isArray(taskInfo) ? taskInfo : {}),
routeChangeRecords: this.routeChangeRecords,
});
this.selectedWaybillRows = this.routeChangeRows.map(
({ originalDepartureAddress, originalArrivalAddress, ...row }) => row
);
this.routeNodes = this.routeChangeNodes;
this.syncRouteAddressFields();
try {
await loadingApi.changeRoute({
id: this.dialogForm.id,
routeJson: JSON.stringify(this.routeNodes),
departureAddress: this.dialogForm.departureAddress,
transitAddress: this.dialogForm.transitAddress,
arrivalAddress: this.dialogForm.arrivalAddress,
taskInfoJson: this.dialogForm.taskInfoJson,
});
this.routeChangeVisible = false;
ElMessage.success('路线变更已保存');
} catch (error) {
this.restoreRouteChangeRecords();
throw error;
}
},
async restoreWaybillRows(idsJson, loadingSubNos) {
const ids = parseIdJsonArray(idsJson).filter(Boolean);
if (ids.length) {
const rows = await Promise.all(
ids.map(id =>
getWaybillDetail(id)
.then(res => res.data?.data || res.data)
.catch(() => null)
)
);
this.selectedWaybillRows = rows.filter(Boolean);
this.rebuildSummaryFromWaybills(false);
await this.loadTaskCarrierOptions();
return;
}
this.selectedWaybillRows = this.splitText(loadingSubNos).map(waybillNo => ({ waybillNo }));
this.rebuildSummaryFromWaybills(false);
await this.loadTaskCarrierOptions();
},
async loadCandidateWaybills() {
this.candidateLoading = true;
try {
const selectedProjectId = this.selectedWaybillRows.map(row => row.projectId).find(Boolean);
const query = {
...this.buildQueryParams(this.candidateQuery),
// 公路、待执行、未配载的判定都在后端可配载开关内
// 该开关已把候选限定为公路,再按精确运输方式取值筛会漏掉「公路运输」这类历史写法
loadableOnly: 1,
// allDept=0 表示不指定组织:超管与「数据权限范围=全部组织」的账户后端不过滤组织,
// 其余账户限定本组织(不展开下级)
allDept: 0,
};
// 已选运单后,候选列表优先限定同一项目,便于再按承运商合同乙方交集筛选
if (selectedProjectId && !query.projectId && !query.projectName) {
query.projectId = selectedProjectId;
}
const res = await getWaybillList(
this.candidatePage.currentPage,
this.candidatePage.pageSize,
query
);
const page = unwrapPage(res);
this.candidateRows = page.records;
this.candidatePage.total = page.total;
} finally {
this.candidateLoading = false;
}
},
handleCandidateSearch() {
this.candidatePage.currentPage = 1;
this.loadCandidateWaybills();
},
handleCandidateReset() {
this.candidateQuery = createCandidateSearchForm();
this.candidatePage.currentPage = 1;
this.loadCandidateWaybills();
},
handleCandidateSizeChange(size) {
this.candidatePage.currentPage = 1;
this.candidatePage.pageSize = size;
this.loadCandidateWaybills();
},
handleCandidateCurrentChange(current) {
this.candidatePage.currentPage = current;
this.loadCandidateWaybills();
},
candidateSelectionChange(rows) {
this.candidateSelection = rows || [];
},
async addWaybillRows(rows = []) {
const waybillRows = Array.isArray(rows) ? rows.filter(Boolean) : [];
if (!waybillRows.length) return;
this.candidateAdding = true;
try {
const selectedRows = await Promise.all(
waybillRows.map(async row => {
if (!row.id) return row;
try {
const res = await getWaybillDetail(row.id);
return { ...row, ...(res.data?.data || res.data || {}) };
} catch (error) {
return row;
}
})
);
if (this.isRunningEdit && String(this.dialogForm.carrierType || '').trim() === '承运商') {
const contractId = this.dialogForm.carrierContractId;
if (contractId) {
const projectIds = [
...new Set(
[...this.selectedWaybillRows, ...selectedRows]
.map(row => row.projectId)
.filter(Boolean)
),
];
if (projectIds.length) {
try {
const waybillIds = [...this.selectedWaybillRows, ...selectedRows]
.map(row => row.id)
.filter(Boolean);
const response = await loadingApi.getCarrierContracts(projectIds, waybillIds);
const contracts = response?.data?.data || response?.data || [];
const stillAllowed = (Array.isArray(contracts) ? contracts : []).some(
item => String(item.id) === String(contractId)
);
if (!stillAllowed) {
ElMessage.warning('新增子运单须兼容当前配载单已选定的承运商合同');
return;
}
} catch (error) {
// 合同校验失败时仍允许添加,最终由后端确认拦截
}
}
}
}
const exists = new Set(
this.selectedWaybillRows.map(row => String(row.id || row.waybillNo))
);
// 一张配载单的全部子运单必须同项目:以已选运单(无已选时以本批首张)的项目为准
const anchorRow = [...this.selectedWaybillRows, ...selectedRows].find(row =>
this.waybillProjectKey(row)
);
const anchorKey = anchorRow ? this.waybillProjectKey(anchorRow) : '';
const mismatchedRows = anchorKey
? selectedRows.filter(
row => this.waybillProjectKey(row) && this.waybillProjectKey(row) !== anchorKey
)
: [];
if (mismatchedRows.length) {
const anchorName = anchorRow.projectName || anchorRow.projectId;
const mismatchedText = mismatchedRows
.map(
row =>
`${row.waybillNo || row.id}(项目:${row.projectName || row.projectId || '未知'})`
)
.join('、');
this.$message.warning(
`运单 ${mismatchedText} 与已选运单项目「${anchorName}」不一致,不能加入同一配载单`
);
}
const nextRows = [...this.selectedWaybillRows];
selectedRows.forEach(row => {
if (mismatchedRows.includes(row)) return;
const key = String(row.id || row.waybillNo);
if (!exists.has(key)) {
nextRows.push(row);
exists.add(key);
}
});
// 承运商类型:新增后须仍存在共同的承运商合同乙方(按客户合同乙方=承运商合同甲方匹配后取交集)
if (
String(this.dialogForm.carrierType || '').trim() === '承运商' &&
nextRows.length > this.selectedWaybillRows.length
) {
const compatible = await this.hasCompatibleCarrierContracts(nextRows);
if (!compatible) {
this.$message.warning(
'所选运单在同一项目下没有共同的承运商合同乙方,不能加入同一配载单'
);
return;
}
}
const prevRouteNodes = [...this.routeNodes];
this.selectedWaybillRows = nextRows;
if (this.isRunningEdit) {
await this.refreshWaybillPunchMap();
}
this.rebuildSummaryFromWaybills(true, prevRouteNodes);
await this.loadTaskCarrierOptions();
this.$refs.candidateTableRef?.clearSelection?.();
this.candidateSelection = [];
} finally {
this.candidateAdding = false;
}
},
async hasCompatibleCarrierContracts(rows = []) {
const projectId = (rows || []).map(row => row.projectId).find(Boolean);
const waybillIds = (rows || []).map(row => row.id).filter(Boolean);
if (!projectId || waybillIds.length < 2) return true;
try {
const response = await loadingApi.getCarrierContracts([projectId], waybillIds);
const contracts = response?.data?.data || response?.data || [];
return (Array.isArray(contracts) ? contracts : []).length > 0;
} catch (error) {
return true;
}
},
async addSelectedWaybills() {
await this.addWaybillRows(this.candidateSelection);
},
waybillProjectKey(row = {}) {
return String(row.projectId || row.projectName || '').trim();
},
removeSelectedWaybill(index) {
const row = this.selectedWaybillRows[index];
if (this.isSelectedWaybillRemoveLocked(row)) {
ElMessage.warning('已打卡的子运单不可移除');
return;
}
this.selectedWaybillRows.splice(index, 1);
if (this.isRunningEdit) {
this.refreshWaybillPunchMap().then(() => {
this.rebuildSummaryFromWaybills();
this.loadTaskCarrierOptions();
});
return;
}
this.rebuildSummaryFromWaybills();
this.loadTaskCarrierOptions();
},
rebuildSummaryFromWaybills(rebuildRoute = true, prevRouteNodes = null) {
const rows = this.selectedWaybillRows;
this.dialogForm.loadingSubNos = rows
.map(row => row.waybillNo)
.filter(Boolean)
.join(',');
const rowIds = rows
.map(row => row.id)
.filter(Boolean)
.map(id => String(id));
// 回显失败仅有运单号时,保留原 waybillIdsJson,避免确认提交清空运单绑定
if (rowIds.length) {
this.dialogForm.waybillIdsJson = JSON.stringify(rowIds);
} else if (!String(this.dialogForm.waybillIdsJson || '').trim()) {
this.dialogForm.waybillIdsJson = '[]';
}
this.dialogForm.projectName = uniqueText(rows, 'projectName');
this.dialogForm.customerName = uniqueText(rows, 'customerName');
this.dialogForm.originalNo = uniqueText(rows, 'originalNo');
this.dialogForm.cargoType = uniqueText(rows, 'cargoType');
this.dialogForm.cargoName = uniqueText(rows, 'cargoName');
if (!this.dialogForm.transportType) {
this.dialogForm.transportType = rows.find(row => row.transportType)?.transportType || '';
}
if (rebuildRoute) {
const builtNodes = this.buildRouteNodes(rows);
this.routeNodes = this.isRunningEdit
? this.mergeRouteNodesKeepingPunched(
prevRouteNodes || this.routeNodes,
builtNodes
)
: builtNodes;
}
this.cargoRows = this.buildCargoRows(rows);
// 编辑回填阶段 routeNodes 尚未恢复时跳过,避免清空已有发到货地
if (rebuildRoute || this.routeNodes.length) {
this.syncRouteAddressFields();
}
},
buildRouteNodes(rows) {
const nodeMap = new Map();
const appendNode = (address, displayAddress, tag, locked) => {
if (!address) return;
if (!nodeMap.has(address)) {
nodeMap.set(address, { address, displayAddress, tags: [], locked: false });
}
const node = nodeMap.get(address);
node.tags.push(tag);
if (locked) node.locked = true;
};
rows.forEach((row, rowIndex) => {
const locked = this.isRunningEdit
? this.isWaybillLoadPunched(row)
: this.isWaybillCompleted(row);
const departure = row.departureAddress || row.departureName;
appendNode(
departure,
this.formatCandidateAddress(row, 'departure'),
`装${rowIndex + 1}`,
locked
);
});
rows.forEach((row, rowIndex) => {
const locked = this.isRunningEdit
? this.isWaybillUnloadPunched(row)
: this.isWaybillCompleted(row);
const arrival = row.arrivalAddress || row.arrivalName;
appendNode(
arrival,
this.formatCandidateAddress(row, 'arrival'),
`卸${rowIndex + 1}`,
locked
);
});
return [...nodeMap.values()].map((node, index) => ({
...node,
key: `${node.address}-${index}`,
}));
},
routeTypeText(index) {
if (index === 0) return '起';
if (index === this.routeNodes.length - 1) return '终';
return '经';
},
routeTypeClass(index) {
if (index === 0) return 'start';
if (index === this.routeNodes.length - 1) return 'end';
return 'middle';
},
handleRouteDragStart(index) {
if (this.dialogReadonly || this.isRouteChangeNodeLocked(this.routeNodes[index])) {
this.routeDragIndex = -1;
return;
}
this.routeDragIndex = index;
},
handleRouteDrop(index) {
if (this.dialogReadonly || this.routeDragIndex < 0 || this.routeDragIndex === index) return;
if (
this.isRouteChangeNodeLocked(this.routeNodes[this.routeDragIndex]) ||
this.isRouteChangeNodeLocked(this.routeNodes[index])
) {
this.routeDragIndex = -1;
return;
}
const rows = [...this.routeNodes];
const [target] = rows.splice(this.routeDragIndex, 1);
rows.splice(index, 0, target);
const originalLockedKeys = this.routeNodes.map(item =>
this.isRouteChangeNodeLocked(item) ? item.key : null
);
const nextLockedKeys = rows.map(item =>
this.isRouteChangeNodeLocked(item) ? item.key : null
);
if (
originalLockedKeys.some((key, lockedIndex) => key && key !== nextLockedKeys[lockedIndex])
) {
ElMessage.warning(
this.isRunningEdit ? '已打卡地点不可调整,新地址只能在未打卡节点间拖拽' : '已完成运单对应的路线不可调整'
);
this.routeDragIndex = -1;
return;
}
this.routeDragIndex = -1;
if (!this.validateRouteEndpoints(rows)) return;
this.routeNodes = rows;
this.syncRouteAddressFields();
},
validateRouteEndpoints(nodes = this.routeNodes) {
const startTags = nodes[0]?.tags || [];
if (startTags.some(tag => String(tag).startsWith('卸'))) {
ElMessage.warning('起点不能设置卸货');
return false;
}
const endTags = nodes[nodes.length - 1]?.tags || [];
if (endTags.some(tag => String(tag).startsWith('装'))) {
ElMessage.warning('终点不能设置装货');
return false;
}
return true;
},
syncRouteAddressFields() {
const nodes = this.routeNodes.filter(item => item.address);
this.dialogForm.departureAddress = nodes[0]?.address || '';
this.dialogForm.arrivalAddress = nodes[nodes.length - 1]?.address || '';
this.dialogForm.transitAddress = nodes
.slice(1, -1)
.map(item => item.address)
.join(',');
},
buildCargoRows(rows) {
return rows.flatMap(row => {
const goodsRows = parseJsonArray(row.goodsList || row.goodsRows || row.goodsJson);
if (goodsRows.length) {
return goodsRows.map(item => this.normalizeCargoRow(row, item));
}
return [this.normalizeCargoRow(row, row)];
});
},
formatCargoQuantityDisplay(row = {}) {
const quantity = blankCargoSentinel(row.quantity ?? row.cargoQuantity ?? '');
const unit = String(
blankCargoSentinel(
row.quantityUnit ||
row.goodsQuantityUnit ||
row.cargoUnit ||
row.unit ||
''
) || ''
).trim();
if (quantity === '' || quantity === undefined || quantity === null) {
return unit || '';
}
return unit ? `${quantity} ${unit}` : String(quantity);
},
normalizeCargoRow(waybill, item = {}) {
return sanitizeCargoRow({
projectName: item.projectName || waybill.projectName || '',
cargoName: item.cargoName || item.goodsName || waybill.cargoName || '',
cargoType: item.cargoType || item.goodsType || waybill.cargoType || '',
packageType: item.packageType || item.package || '',
quantity: item.quantity || item.cargoQuantity || waybill.quantity || '',
quantityUnit:
item.quantityUnit ||
item.goodsQuantityUnit ||
item.cargoUnit ||
item.unit ||
waybill.quantityUnit ||
waybill.goodsQuantityUnit ||
waybill.cargoUnit ||
waybill.unit ||
'',
planDate: item.planDate || waybill.startDate || '',
unitPrice: item.unitPrice || waybill.unitPrice || '',
materialCode: item.materialCode || '',
equipmentCode:
item.equipmentCode ||
item.deviceCode ||
waybill.equipmentCode ||
waybill.deviceCode ||
'',
brand: item.brand || item.brandName || waybill.brand || '',
specificationModel:
item.specificationModel ||
item.specModel ||
item.specification ||
waybill.specificationModel ||
'',
remark: item.remark || '',
});
},
applyLoadingMileageFromDetail(form = this.dialogForm) {
let taskMileage = '';
try {
const taskInfo = JSON.parse(form.taskInfoJson || '{}');
taskMileage = taskInfo?.mileage;
} catch (error) {
taskMileage = '';
}
form.mileage = normalizeLoadingMileage(
form.mileage !== undefined && form.mileage !== null && form.mileage !== ''
? form.mileage
: taskMileage
);
},
applyLoadingTaskInfoFromDetail(form = this.dialogForm) {
let taskInfo = {};
try {
taskInfo = JSON.parse(form.taskInfoJson || '{}') || {};
} catch (error) {
taskInfo = {};
}
if (!taskInfo || typeof taskInfo !== 'object' || Array.isArray(taskInfo)) return;
const fields = [
'carrierType',
'carrierName',
'carrierContractId',
'driverName',
'driverPhone',
'vehicleNo',
'trailerVehicleNo',
'escortName',
'escortPhone',
'estimatedStartDate',
'estimatedEndDate',
'taskRemark',
];
fields.forEach(prop => {
if (
(form[prop] === undefined || form[prop] === null || form[prop] === '') &&
taskInfo[prop] !== undefined &&
taskInfo[prop] !== null &&
taskInfo[prop] !== ''
) {
form[prop] = taskInfo[prop];
}
});
if (
(form.mileage === undefined || form.mileage === null || form.mileage === '') &&
taskInfo.mileage !== undefined
) {
form.mileage = normalizeLoadingMileage(taskInfo.mileage);
}
},
detailTaskField(prop) {
const value = String(this.dialogForm?.[prop] ?? '').trim();
return value || '-';
},
detailTaskDate(prop) {
const value = String(this.dialogForm?.[prop] ?? '').trim();
return value ? value.slice(0, 10) : '-';
},
normalizePayload() {
this.syncRouteAddressFields();
this.rebuildSummaryFromWaybills(false);
this.dialogForm.mileage = normalizeLoadingMileage(this.dialogForm.mileage);
const mileage = toSubmitMileage(this.dialogForm.mileage);
const taskInfo = {
carrierType: this.dialogForm.carrierType,
carrierName: this.dialogForm.carrierName,
carrierContractId: this.dialogForm.carrierContractId,
driverName: this.dialogForm.driverName,
driverPhone: this.dialogForm.driverPhone,
vehicleNo: this.dialogForm.vehicleNo,
trailerVehicleNo: this.dialogForm.trailerVehicleNo,
escortName: this.dialogForm.escortName,
escortPhone: this.dialogForm.escortPhone,
estimatedStartDate: this.dialogForm.estimatedStartDate,
estimatedEndDate: this.dialogForm.estimatedEndDate,
taskRemark: this.dialogForm.taskRemark,
};
if (mileage !== null) {
taskInfo.mileage = mileage;
}
return {
...this.dialogForm,
mileage,
routeJson: JSON.stringify(this.routeNodes),
goodsJson: JSON.stringify(this.cargoRows),
taskInfoJson: JSON.stringify(taskInfo),
};
},
validatePayload(draftMode = false) {
if (!this.selectedWaybillRows.length) {
ElMessage.warning('请选择待配载运单');
return false;
}
if (!this.dialogForm.transportType) {
ElMessage.warning('请选择运输方式');
return false;
}
const invalidPhone = [
[this.dialogForm.driverPhone, '手机号'],
[this.dialogForm.escortPhone, '押运人手机号'],
].find(([value]) => String(value || '').trim() && !isMobile(value));
if (invalidPhone) {
ElMessage.warning(`${invalidPhone[1]}格式不正确`);
return false;
}
if (draftMode) {
return true;
}
if (this.selectedWaybillRows.length < 2) {
ElMessage.warning('请至少选择两条待配载运单');
return false;
}
if (!this.validateRouteEndpoints()) {
return false;
}
if (!this.dialogForm.departureAddress || !this.dialogForm.arrivalAddress) {
ElMessage.warning('请确认配载路线');
return false;
}
if (!this.dialogForm.carrierType) {
ElMessage.warning('请选择承运类型');
return false;
}
if (!this.dialogForm.vehicleNo) {
ElMessage.warning('请输入车牌号');
return false;
}
if (this.dialogForm.carrierType === '承运商' && !this.dialogForm.carrierContractId) {
ElMessage.warning('请选择承运商合同');
return false;
}
if (this.dialogForm.carrierType === '自运') {
if (!this.dialogForm.driverName || !this.dialogForm.driverPhone) {
ElMessage.warning('请输入司机和司机手机号');
return false;
}
}
return true;
},
async saveDraft() {
// 暂存不做任何业务校验,直接保存
this.dialogSaving = 'draft';
try {
const res = await loadingApi.saveDraft(this.normalizePayload());
this.dialogForm = {
...this.dialogForm,
...(res.data?.data || res.data || {}),
};
this.applyLoadingMileageFromDetail(this.dialogForm);
ElMessage.success('暂存成功');
sessionStorage.setItem(loadingManageListRefreshKey, '1');
this.closeLoadingDialog();
this.loadTable();
} finally {
this.dialogSaving = '';
}
},
async submitLoading() {
if (!this.validatePayload(false)) return;
this.dialogSaving = 'submit';
try {
const payload = {
...this.normalizePayload(),
// 进行中编辑保持 running;新建/草稿确认由后端按项目是否需接单写入 pending/running
...(this.isRunningEdit ? { businessStatus: 'running' } : {}),
};
if (this.dialogMode === 'reassign') {
await loadingApi.reassign(payload);
ElMessage.success('派单成功');
} else {
await loadingApi.submit(payload);
ElMessage.success(this.isRunningEdit ? '保存成功' : '确认成功');
}
sessionStorage.setItem(loadingManageListRefreshKey, '1');
this.closeLoadingDialog();
this.loadTable();
} finally {
this.dialogSaving = '';
}
},
deleteLoading(row) {
ElMessageBox.confirm(`确定删除配载单 ${row.loadingNo}?`, '提示', { type: 'warning' })
.then(async () => {
const res = await loadingApi.remove(row.id);
const result = res.data?.data || {};
ElMessage.success(`删除成功 ${result.successCount || 0} 条`);
this.loadTable();
})
.catch(() => {});
},
cancelLoading(row) {
ElMessageBox.confirm(`确定取消配载单 ${row.loadingNo}?`, '提示', { type: 'warning' })
.then(async () => {
await loadingApi.cancel(row.id);
ElMessage.success('取消成功');
this.loadTable();
})
.catch(() => {});
},
startLoading(row) {
ElMessageBox.confirm(`确定将配载单 ${row.loadingNo} 改为进行中?`, '提示', { type: 'warning' })
.then(async () => {
await loadingApi.start(row.id);
ElMessage.success('已改为进行中');
this.loadTable();
})
.catch(() => {});
},
completeLoading(row) {
ElMessageBox.confirm(`确定完成配载单 ${row.loadingNo}?`, '提示', { type: 'warning' })
.then(async () => {
await loadingApi.complete(row.id);
ElMessage.success('完成成功');
this.loadTable();
})
.catch(() => {});
},
handleBatchComplete() {
if (!this.selectionRows.length) {
ElMessage.warning('请选择需要批量完成的配载单');
return;
}
ElMessageBox.confirm('确定对选中的配载单执行批量完成?', '提示', { type: 'warning' })
.then(async () => {
const ids = this.selectionRows.map(row => row.id).join(',');
const res = await loadingApi.batchComplete(ids);
const result = res.data?.data || {};
const summary = `批量完成成功 ${result.successCount || 0} 条,跳过 ${
result.skippedCount || 0
} 条`;
if (result.skippedCount && result.skippedReasons?.length) {
ElMessage.warning(`${summary};${result.skippedReasons.join(';')}`);
} else {
ElMessage.success(summary);
}
this.loadTable();
})
.catch(() => {});
},
handleExport() {
const ids = this.selectionRows.map(row => row.id).join(',');
exportBlob(
loadingApi.exportUrl,
{
...this.buildQueryParams(this.searchForm),
ids,
exportColumns: JSON.stringify(loadingManageConfig.exportColumns),
},
{
feedback: true,
}
).then(res => {
downloadXls(res.data, `配载管理${dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`);
});
},
handleCarrierTypeChange() {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierPartyId = '';
this.dialogForm.carrierPartyRole = '';
this.dialogForm.carrierName = '';
this.dialogForm.carrierId = '';
this.clearDialogDriverVehicleFields();
if (this.dialogForm.carrierType === '承运商') {
this.dialogForm.trailerVehicleNo = '';
this.dialogForm.escortName = '';
this.dialogForm.escortPhone = '';
}
if (this.dialogForm.carrierType === '自运') {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierPartyId = '';
this.dialogForm.carrierPartyRole = '';
}
this.loadTaskCarrierOptions();
},
handleTaskCarrierChange(selectedValue) {
const contract = this.taskCarrierOptions.find(
item =>
this.carrierOptionValue(item) === String(selectedValue || '') ||
[item.id, item.carrierName, item.partyB].some(
value => String(value || '') === String(selectedValue || '')
)
);
this.dialogForm.carrierContractId =
this.dialogForm.carrierType === '承运商' && contract?.id ? contract.id : '';
this.dialogForm.carrierPartyId =
this.dialogForm.carrierType === '承运商' ? contract?.carrierPartyId || '' : '';
this.dialogForm.carrierPartyRole =
this.dialogForm.carrierType === '承运商' ? contract?.carrierPartyRole || '' : '';
this.dialogForm.carrierName = contract?.carrierName || '';
this.dialogForm.carrierId = contract?.carrierId || contract?.customerId || '';
this.clearDialogDriverVehicleFields();
},
clearDialogDriverVehicleFields() {
this.dialogForm.driverName = '';
this.dialogForm.driverPhone = '';
this.dialogForm.vehicleNo = '';
this.dialogForm.trailerVehicleNo = '';
this.dialogForm.escortName = '';
this.dialogForm.escortPhone = '';
this.driverOptions = [];
},
handleDriverChange(value) {
const driver = this.driverOptions.find(item => (item.driverName || item.name) === value);
if (driver && !this.dialogForm.driverPhone) {
this.dialogForm.driverPhone = driver.mobile || driver.driverPhone || driver.phone || '';
}
if (String(driver?.drivingVehicle || '').trim()) {
this.dialogForm.vehicleNo = String(driver.drivingVehicle).trim();
}
},
fetchDriverSuggestions(queryString, callback) {
this.loadDialogDriverOptions(String(queryString || '').trim())
.then(() =>
callback(
this.driverOptions.map(item => ({
...item,
value: item.driverName || item.name || '',
}))
)
)
.catch(() => callback([]));
},
handleDriverSuggestionSelect(target, item = {}) {
const value = item.driverName || item.name || '';
if (target === 'dialog') {
this.dialogForm.driverName = value;
const phone = item.mobile || item.driverPhone || item.phone || '';
if (phone) this.dialogForm.driverPhone = phone;
const drivingVehicle = String(item.drivingVehicle || '').trim();
if (drivingVehicle) this.dialogForm.vehicleNo = drivingVehicle;
}
},
fetchEscortSuggestions(queryString, callback) {
const keyword = String(queryString || '').trim();
this.escortLoading = true;
fetchDriverCandidates({
size: 20,
...(keyword ? { driverName: keyword } : {}),
posts: '押运员',
})
.then(records => {
callback(
records.map(item => ({
...item,
value: item.driverName || item.name || '',
}))
);
})
.catch(() => callback([]))
.finally(() => {
this.escortLoading = false;
});
},
handleEscortSuggestionSelect(target, item = {}) {
if (target !== 'dialog') return;
this.dialogForm.escortName = item.driverName || item.name || '';
const phone = item.mobile || item.driverPhone || item.phone || '';
if (phone) this.dialogForm.escortPhone = phone;
},
handleMileageInput(value) {
this.dialogForm.mileage = String(value || '').replace(/[^\d.]/g, '');
},
normalizeLoadingMileage,
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({
url: '/blade-system/dict-biz/dictionary',
method: 'get',
params: { code: 'transport_type' },
});
const records = unwrapRecords(res);
this.transportTypeOptions = records.map(item => ({
label: item.dictValue || item.label || item.name,
value: item.dictKey || item.value || item.dictValue || item.name,
}));
},
async loadProjectOptions(keyword = '') {
this.projectLoading = true;
try {
this.projectOptions = unwrapRecords(await getProjectList(1, 20, { projectName: keyword }));
} finally {
this.projectLoading = false;
}
},
async loadCustomerOptions(keyword = '') {
this.customerLoading = true;
try {
this.customerOptions = unwrapRecords(
await getCustomerList(1, 20, { customerName: keyword, customerType: '承运商' })
);
} finally {
this.customerLoading = false;
}
},
async loadCargoTypeOptions(keyword = '') {
this.cargoTypeLoading = true;
try {
this.cargoTypeOptions = unwrapRecords(await getCargoTypeOptions(keyword));
} finally {
this.cargoTypeLoading = false;
}
},
async loadDriverOptions(keyword = '') {
this.driverLoading = true;
try {
this.driverOptions = unwrapRecords(
await getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '司机' })
);
} finally {
this.driverLoading = false;
}
},
async loadDialogDriverOptions(keyword = '') {
this.driverLoading = true;
try {
this.driverOptions = await fetchDriverCandidates({
size: 20,
...(keyword ? { driverName: keyword } : {}),
posts: '司机',
});
return this.driverOptions;
} finally {
this.driverLoading = false;
}
},
async loadCarrierOptions(keyword = '') {
this.carrierLoading = true;
try {
this.carrierOptions = unwrapRecords(
await getCustomerList(1, 20, {
customerName: keyword,
customerType: '承运商',
status: 1,
})
);
} finally {
this.carrierLoading = false;
}
},
async loadTaskCarrierOptions() {
const requestId = ++this.taskCarrierRequestId;
if (!this.selectedWaybillRows.length) {
this.taskCarrierOptions = [];
this.taskCarrierLoading = false;
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName = '';
return [];
}
this.taskCarrierLoading = true;
try {
// 配载单只有一个项目;传入子运单后按客户合同乙方=承运商合同甲方匹配并取承运商合同乙方交集
const projectId = this.selectedWaybillRows.map(row => row.projectId).find(Boolean);
const waybillIds = this.selectedWaybillRows.map(row => row.id).filter(Boolean);
const response = await loadingApi.getCarrierContracts(
projectId ? [projectId] : [],
waybillIds
);
if (requestId !== this.taskCarrierRequestId) return [];
const contracts = response.data?.data || response.data || [];
if (this.dialogForm.carrierType !== '自运') {
this.taskCarrierOptions = contracts
.map(item => ({
...item,
carrierName:
item.carrierName ||
item.partyB ||
item.partyBName ||
item.contractPartyB ||
item.customerContractPartyB ||
'',
carrierId:
item.customerId ||
item.partyBId ||
item.partyBUserId ||
item.contractPartyBId ||
item.customerContractPartyBId ||
item.carrierId ||
'',
carrierPartyId: item.carrierPartyId || '',
carrierPartyRole: item.carrierPartyRole || '',
}))
.filter(item => item.carrierName);
const current =
this.taskCarrierOptions.find(
item =>
this.carrierOptionValue(item) === this.taskCarrierSelectedValue ||
(String(item.id) === String(this.dialogForm.carrierContractId) &&
(!this.dialogForm.carrierPartyId ||
String(item.carrierPartyId) === String(this.dialogForm.carrierPartyId)) &&
(!this.dialogForm.carrierName ||
item.carrierName === this.dialogForm.carrierName))
) ||
this.taskCarrierOptions.find(item => item.carrierName === this.dialogForm.carrierName);
if (current) {
this.dialogForm.carrierContractId = current.id;
this.dialogForm.carrierPartyId = current.carrierPartyId || '';
this.dialogForm.carrierPartyRole = current.carrierPartyRole || '';
this.dialogForm.carrierName = current.carrierName;
this.dialogForm.carrierId = current.carrierId || '';
} else {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierPartyId = '';
this.dialogForm.carrierPartyRole = '';
this.dialogForm.carrierName = '';
this.dialogForm.carrierId = '';
}
return this.taskCarrierOptions;
}
if (this.dialogForm.carrierType === '自运' && contracts.length) {
this.taskCarrierOptions = contracts
.map(item => ({
...item,
carrierName:
item.carrierName ||
item.partyB ||
item.partyBName ||
item.contractPartyB ||
item.customerContractPartyB ||
'',
carrierId:
item.customerId ||
item.partyBId ||
item.partyBUserId ||
item.contractPartyBId ||
item.customerContractPartyBId ||
item.carrierId ||
'',
carrierPartyId: item.carrierPartyId || '',
carrierPartyRole: item.carrierPartyRole || '',
}))
.filter(item => item.carrierName);
const current = this.taskCarrierOptions.find(
item => item.carrierName === this.dialogForm.carrierName
);
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName =
current?.carrierName || this.taskCarrierOptions[0]?.carrierName || '';
this.dialogForm.carrierId =
current?.carrierId || this.taskCarrierOptions[0]?.carrierId || '';
return this.taskCarrierOptions;
}
if (this.dialogForm.carrierType !== '自运') {
this.taskCarrierOptions = [];
this.dialogForm.carrierContractId = '';
return [];
}
const customerContracts = await Promise.all(
projectIds.map(projectId =>
getContractList(1, 9999, { projectId, contractCategory: '客户合同' })
.then(res => extractRecords(res))
.catch(() => [])
)
);
const partyBNames = [];
const customerContractMap = new Map(
projectIds.map((projectId, index) => [String(projectId), customerContracts[index] || []])
);
this.selectedWaybillRows.forEach(row => {
const direct = row.partyB || row.contractPartyB || row.customerContractPartyB;
const contractsForProject = customerContractMap.get(String(row.projectId)) || [];
const matched = contractsForProject.find(
item => String(item.id) === String(row.contractId)
);
const name = String(direct || matched?.partyB || '').trim();
if (name && !partyBNames.includes(name)) partyBNames.push(name);
});
this.taskCarrierOptions = partyBNames.map(name => ({
id: `customer-contract-${name}`,
carrierName: name,
partyB: name,
}));
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName = partyBNames[0] || '';
this.dialogForm.carrierId = '';
return this.taskCarrierOptions;
} catch (error) {
if (requestId !== this.taskCarrierRequestId) return [];
this.taskCarrierOptions = [];
this.dialogForm.carrierContractId = '';
if (this.dialogForm.carrierType !== '自运') this.dialogForm.carrierName = '';
return [];
} finally {
if (requestId === this.taskCarrierRequestId) this.taskCarrierLoading = false;
}
},
async loadPlanOptions(keyword = '') {
this.planLoading = true;
try {
this.planOptions = unwrapRecords(await getPlanList(1, 20, { planName: keyword }));
} finally {
this.planLoading = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.loading-manage-page {
margin-bottom: 60px;
.loading-manage-page__search {
padding: 12px 12px 4px;
margin-bottom: 20px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.loading-manage-page__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
.loading-manage-page__search-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
min-height: 32px;
}
.loading-manage-page__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin: 12px 0;
}
.loading-manage-page__toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.loading-manage-page__table {
width: 100%;
}
.loading-manage-page__link {
padding: 0;
min-height: auto;
}
.loading-manage-page__sub-nos {
display: block;
line-height: 20px;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.loading-manage-page__sub-no-link {
display: inline;
white-space: normal;
line-height: 20px;
text-align: left;
}
.loading-manage-page__address-cell {
line-height: 20px;
white-space: pre-line;
word-break: break-word;
}
.loading-manage-page__status {
color: #303133;
}
.loading-manage-page__status.is-running {
color: #1dbf00;
}
.loading-manage-page__status.is-cancelled {
color: #909399;
}
.loading-manage-page__actions {
display: flex;
flex-wrap: wrap;
gap: 2px 12px;
}
.loading-manage-page__actions .el-button {
margin-left: 0;
padding: 0;
}
.loading-manage-page__pagination {
display: flex;
justify-content: flex-end;
padding: 12px 0 0;
background: #fff;
}
:deep(.el-form-item) {
margin-bottom: 8px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper),
:deep(.el-date-editor--daterange) {
width: 100%;
}
:deep(.el-table) {
--el-table-border-color: #eff1f7;
--el-table-row-hover-bg-color: #f5f7fa;
}
:deep(.el-table__body tr:nth-child(even) > td) {
background: #fafafa;
}
}
.loading-manage-dialog {
min-height: 12vh;
.loading-manage-dialog__body {
max-height: 78vh;
overflow-y: auto;
padding-right: 4px;
}
.loading-manage-dialog__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0 24px;
}
.loading-manage-dialog__span-2 {
grid-column: 1 / -1;
}
.loading-manage-dialog__title-action {
display: flex;
align-items: center;
justify-content: space-between;
}
.loading-manage-dialog__candidate-search {
padding: 0;
margin-bottom: 0;
}
.loading-manage-dialog__waybill-route-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: start;
//margin-bottom: 12px;
.section-card {
min-height: 257px;
}
}
.loading-manage-dialog__candidate-actions,
.loading-manage-dialog__collapsed-search,
.loading-manage-dialog__table-toolbar,
.loading-manage-dialog__footer {
display: flex;
align-items: center;
justify-content: flex-end;
}
.loading-manage-dialog__table-toolbar {
justify-content: space-between;
margin: 10px 0;
}
.loading-manage-dialog__route-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.loading-manage-dialog__cargo-section {
margin-top: 16px;
}
@media (max-width: 1200px) {
.loading-manage-dialog__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.loading-manage-dialog__waybill-route-layout {
grid-template-columns: 1fr;
}
}
.loading-manage-dialog__route-item {
display: grid;
grid-template-columns: 28px 36px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 8px 12px;
border: 1px solid #eff1f7;
background: #fff;
cursor: move;
&.is-locked {
cursor: not-allowed;
background: #f5f7fa;
color: #909399;
}
}
.loading-manage-dialog__route-handle {
color: #909399;
}
.loading-manage-dialog__route-hint {
color: #909399;
font-size: 12px;
}
.loading-manage-dialog__locked-tip {
color: #909399;
font-size: 12px;
}
.loading-manage-dialog__route-type {
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
border-radius: 50%;
color: #fff;
&--start {
background: #409eff;
}
&--middle {
background: #e6a23c;
}
&--end {
background: #67c23a;
}
}
.loading-manage-dialog__route-address {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-manage-dialog__route-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.loading-manage-dialog__carrier-tabs {
margin-bottom: 12px;
}
.loading-manage-dialog__task-grid {
:deep(.el-form-item__label) {
flex: 0 0 100px;
width: 100px;
}
:deep(.el-form-item__content),
:deep(.el-input),
:deep(.el-select),
:deep(.el-autocomplete),
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper) {
min-width: 0;
width: 100%;
}
}
:deep(.dialog-section-title) {
display: flex;
align-items: center;
margin: 16px 0 12px;
font-weight: 600;
color: #303133;
}
:deep(.dialog-section-title)::before {
content: '';
width: 4px;
height: 16px;
margin-right: 8px;
background: #409eff;
}
:deep(.el-form-item) {
margin-bottom: 24px;
}
:deep(.el-form-item__label) {
text-align: right;
white-space: nowrap;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper) {
width: 100%;
}
:deep(.el-table) {
--el-table-border-color: #eff1f7;
}
}
.loading-detail {
display: flex;
flex-direction: column;
//gap: 8px;
color: #303133;
.loading-detail__summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 4px 0 16px;
:deep(.el-tag) {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 8px;
font-size: 13px;
line-height: 28px;
}
}
.loading-detail__route-title {
display: inline-flex;
align-items: center;
gap: 16px;
}
.loading-detail__label {
color: #909399;
margin-right: 10px;
}
.loading-detail__task-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
padding-bottom: 4px;
align-items: start;
}
.loading-detail__task-main {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.loading-detail__task-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px 24px;
}
.loading-detail__attachment-field {
min-width: 0;
}
.loading-detail__field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.loading-detail__field .loading-detail__label {
margin-right: 0;
}
.loading-detail__value {
display: flex;
flex-wrap: wrap;
gap: 8px;
text-align: left;
color: #303133;
word-break: break-all;
}
.loading-detail__steps {
display: flex;
align-items: flex-start;
overflow-x: auto;
padding: 8px 4px 16px;
}
.loading-detail__step {
position: relative;
min-width: 190px;
flex: 1;
text-align: center;
}
.loading-detail__step:not(:last-child)::after {
position: absolute;
z-index: 0;
top: 16px;
left: calc(50% + 25px);
width: calc(100% - 50px);
height: 6px;
content: '';
background: #e4e7ed;
}
.loading-detail__step-line {
display: flex;
align-items: center;
justify-content: center;
}
.loading-detail__step-dot {
position: relative;
z-index: 1;
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 4px;
font-weight: 600;
}
.loading-detail__step-dot.start {
background: #409eff;
}
.loading-detail__step-dot.middle {
background: #67c23a;
}
.loading-detail__step-dot.end {
background: #e6a23c;
}
.loading-detail__step-address {
margin-top: 8px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.loading-detail__step-address-text {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
cursor: 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: 200px;
}
.loading-detail__punch-record {
display: flex;
flex-direction: column;
gap: 6px;
}
.loading-detail__punch-record-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.loading-detail__punch-record-line {
font-size: 13px;
color: #606266;
span + span {
margin-left: 12px;
}
}
.loading-detail__driver-upload-grid,
.loading-detail__voucher-image-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin-top: 4px;
min-height: 80px;
}
.loading-detail__driver-upload-item,
.loading-detail__voucher-image-item {
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
.el-image {
display: block;
width: 100%;
aspect-ratio: 1;
border: 1px solid #eff1f7;
border-radius: 4px;
overflow: hidden;
background: #f5f7fa;
}
}
.loading-detail__voucher-image-item--folder .el-image {
aspect-ratio: auto;
padding: 12px;
background: transparent;
border: none;
}
.loading-detail__driver-upload-label,
.loading-detail__voucher-folder-name {
font-size: 12px;
color: #606266;
line-height: 18px;
text-align: center;
word-break: break-all;
}
.loading-detail__voucher-folder-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.loading-detail__map {
width: 100%;
min-height: 260px;
border: 1px solid #eff1f7;
}
}
:global(.loading-route-change-dialog .el-dialog__body) {
background: #fff;
}
.loading-route-change-dialog {
.loading-route-change-dialog__original {
color: #a8abb2;
line-height: 20px;
margin-bottom: 4px;
}
.loading-route-change-dialog__address-row {
display: flex;
align-items: center;
gap: 8px;
.el-input {
flex: 1;
min-width: 0;
}
}
.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;
&.is-locked {
cursor: not-allowed;
background: #eef0f3;
color: #909399;
}
}
.loading-route-change-dialog__route-handle-placeholder {
width: 14px;
height: 14px;
}
.loading-route-change-dialog__route-type {
width: 30px;
height: 30px;
border-radius: 50%;
text-align: center;
line-height: 30px;
color: #fff;
&--start {
background: #409eff;
}
&--middle {
background: #e6a23c;
}
&--end {
background: #67c23a;
}
}
.loading-route-change-dialog__tags {
display: flex;
gap: 4px;
}
.loading-route-change-dialog__record-content {
display: flex;
flex-direction: column;
gap: 10px;
line-height: 22px;
white-space: normal;
text-align: left;
}
.loading-route-change-dialog__change-group {
padding-left: 8px;
border-left: 2px solid #409eff;
}
.loading-route-change-dialog__change-group-title {
margin-bottom: 4px;
color: #409eff;
font-weight: 500;
}
.loading-route-change-dialog__change-group-text {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-field {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
}
.loading-route-change-dialog__change-field-label {
color: #909399;
white-space: nowrap;
}
.loading-route-change-dialog__change-before,
.loading-route-change-dialog__change-single {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-arrow {
color: #c0c4cc;
text-align: right;
}
.loading-route-change-dialog__change-after {
color: #409eff;
word-break: break-word;
overflow-wrap: anywhere;
}
.loading-route-change-dialog__change-flag {
margin-left: 6px;
color: #c0c4cc;
font-size: 12px;
}
}
.loading-route-change-map-dialog {
.loading-route-change-map-dialog__toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
.el-input {
flex: 1;
min-width: 0;
}
}
.loading-route-change-map-dialog__map {
width: 100%;
min-width: 0;
height: 440px;
}
.loading-route-change-map-dialog__status {
margin-top: 8px;
color: #606266;
}
}
:global(.loading-manage-form-page) {
width: 100%;
min-height: 100%;
box-sizing: border-box;
background: #f5f6fa;
}
/* 独立页面(新增/编辑/重新派单/详情):内容随 #avue-view 自然滚动,不做内嵌 78vh 滚动盒。
此处权重必须高于 .loading-manage-dialog .loading-manage-dialog__body,故带上 .loading-manage-page 外壳。 */
:global(.loading-manage-page .loading-manage-form-dialog.loading-manage-form-page .loading-manage-dialog__body) {
max-height: none;
overflow: visible;
padding-right: 0;
}
:global(.loading-manage-form-dialog) {
width: 100% !important;
min-height: 100%;
margin: 0 !important;
border-radius: 0;
box-shadow: none;
}
:global(.loading-manage-form-dialog .el-dialog__body) {
max-height: none;
padding: 20px 24px 96px;
background: #f5f6fa;
}
:global(.el-overlay:has(.loading-manage-form-dialog)) {
position: fixed !important;
inset: 92px 0 0 230px !important;
z-index: 100 !important;
overflow: auto;
background: transparent;
}
:global(.el-overlay:has(.loading-manage-form-dialog) .el-overlay-dialog) {
min-height: 100%;
overflow: visible;
}
:global(.loading-manage-form-dialog .el-dialog__header) {
padding: 18px 24px;
margin-right: 0;
background: #fff;
border-bottom: 1px solid #eff1f7;
}
:global(.loading-manage-form-dialog .el-dialog__footer) {
position: fixed;
right: 0;
bottom: 0;
left: 230px;
z-index: 1001;
box-sizing: border-box;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
:global(.loading-manage-form-page .loading-manage-dialog__footer) {
position: fixed;
right: 0;
bottom: 0;
left: 230px;
z-index: 1001;
box-sizing: border-box;
min-height: 64px;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
:global(.avue--collapse .el-overlay:has(.loading-manage-form-dialog)) {
left: 60px !important;
}
:global(.avue-layout--horizontal .el-overlay:has(.loading-manage-form-dialog)) {
top: 92px !important;
left: 0 !important;
}
:global(.avue--collapse .loading-manage-form-dialog .el-dialog__footer) {
left: 60px;
}
:global(.avue-layout--horizontal .loading-manage-form-dialog .el-dialog__footer) {
left: 0;
}
:global(.avue--collapse .loading-manage-form-page .loading-manage-dialog__footer) {
left: 60px;
}
:global(.avue-layout--horizontal .loading-manage-form-page .loading-manage-dialog__footer) {
left: 0;
}
@media (max-width: 900px) {
.loading-manage-dialog__grid {
grid-template-columns: 1fr;
}
.loading-detail__task-row,
.loading-detail__bottom-grid {
grid-template-columns: 1fr;
}
.loading-detail__task-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>