Files
tms-erp-web/src/views/business/loading-manage.vue
T
2026-08-26 23:18:14 +08:00

2913 lines
113 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="!isStandaloneFormPage" 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="!isStandaloneFormPage" 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>
<div class="loading-manage-page__toolbar-right">
<el-tooltip content="刷新" placement="top">
<el-button :icon="Refresh" text @click="loadTable" />
</el-tooltip>
<el-tooltip content="表格密度" placement="top">
<el-button :icon="Rank" text @click="toggleTableSize" />
</el-tooltip>
<el-tooltip content="列设置" placement="top">
<el-button :icon="Setting" text @click="handleColumnSetting" />
</el-tooltip>
</div>
</div>
<el-table
v-if="!isStandaloneFormPage"
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 label="配载单号" prop="loadingNo" min-width="170" fixed="left">
<template #default="{ row }">
<el-link
type="primary"
class="loading-manage-page__link"
@click="openLoadingDialog('view', row)"
>{{ row.loadingNo }}</el-link>
</template>
</el-table-column>
<el-table-column 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
label="车牌号"
prop="vehicleNo"
min-width="170"
show-overflow-tooltip
/>
<el-table-column label="司机" prop="driverName" min-width="120" show-overflow-tooltip />
<el-table-column label="联系电话" prop="driverPhone" min-width="140" show-overflow-tooltip />
<el-table-column label="承运类型" prop="carrierType" min-width="120" show-overflow-tooltip />
<el-table-column
label="发货地"
prop="departureAddress"
min-width="180"
>
<template #default="{ row }">
<el-tooltip :content="formatFullRouteAddress(row, 'departure')" placement="top">
<div class="loading-manage-page__address-cell">{{ formatRouteAddress(row, 'departure') }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="途经地" prop="transitAddress" min-width="190">
<template #default="{ row }">
<el-tooltip :content="formatFullRouteAddress(row, 'transit')" placement="top">
<div class="loading-manage-page__address-cell">{{ formatRouteAddress(row, 'transit') }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="到货地" prop="arrivalAddress" min-width="180">
<template #default="{ row }">
<el-tooltip :content="formatFullRouteAddress(row, 'arrival')" placement="top">
<div class="loading-manage-page__address-cell">{{ formatRouteAddress(row, 'arrival') }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="承运商" prop="carrierName" min-width="160" show-overflow-tooltip />
<el-table-column
label="运输方式"
prop="transportType"
min-width="150"
show-overflow-tooltip
>
<template #default="{ row }">
{{ transportTypeLabel(row.transportType) }}
</template>
</el-table-column>
<el-table-column label="数据来源" prop="dataSource" min-width="130" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createTime" min-width="170" show-overflow-tooltip sortable/>
<el-table-column label="更新时间" prop="updateTime" min-width="170" show-overflow-tooltip sortable/>
<el-table-column
label="过程节点"
prop="currentProcessNode"
min-width="130"
show-overflow-tooltip
/>
<el-table-column 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="action.type === 'delete' ? 'danger' : 'primary'"
@click="handleRowAction(action.type, row)"
>{{ action.label }}</el-link>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="!isStandaloneFormPage" 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>
<component
:is="isStandaloneFormPage ? 'div' : 'el-dialog'"
:model-value="isStandaloneFormPage ? undefined : dialogVisible"
:title="isStandaloneFormPage ? undefined : dialogTitle"
:append-to-body="isStandaloneFormPage ? undefined : true"
:destroy-on-close="isStandaloneFormPage ? undefined : true"
:width="isStandaloneFormPage ? undefined : '96%'"
:top="isStandaloneFormPage ? undefined : '3vh'"
:class="[
'loading-manage-dialog',
{ 'loading-manage-form-dialog': isStandaloneFormPage },
{ 'loading-manage-form-page': isStandaloneFormPage },
]"
:modal="isStandaloneFormPage ? undefined : true"
:show-close="isStandaloneFormPage ? undefined : true"
:close-on-click-modal="isStandaloneFormPage ? undefined : true"
:close-on-press-escape="isStandaloneFormPage ? undefined : true"
@update:model-value="value => !isStandaloneFormPage && (dialogVisible = value)"
@closed="resetLoadingDialog"
>
<div v-if="isStandaloneFormPage" 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 class="loading-detail__heading">配载单详情</div>
<div>{{ dialogForm.loadingNo || '-' }}</div>
<div><el-tag :type="detailStatusType" class="status-text">{{ statusText(dialogForm) }}</el-tag></div>
<div><el-tag type="warning">{{ transportTypeLabel(dialogForm.transportType) }}</el-tag></div>
</div>
<div class="loading-detail__task-row">
<div class="loading-detail__field"><span class="loading-detail__label">任务信息</span><span class="loading-detail__value">{{ taskSummary }}</span></div>
<div class="loading-detail__field"><span class="loading-detail__label">附件</span><span class="loading-detail__value"><el-link v-for="file in detailAttachments" :key="file.url || file.name" type="primary" @click="previewAttachment(file)">{{ file.name }}</el-link><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">{{ node.displayAddress || formatAddressText(node.address) }}</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="weight" label="重量" min-width="110"/><el-table-column prop="volume" label="体积" min-width="110"/><el-table-column prop="quantity" label="数量" min-width="90"/><el-table-column prop="materialCode" label="物料编码" min-width="120"/><el-table-column prop="equipmentCode" label="设备编码" min-width="120"/><el-table-column prop="brand" label="品牌" min-width="120"/><el-table-column prop="specificationModel" label="规格型号" min-width="140"/><el-table-column prop="remark" label="备注" min-width="160"/><el-table-column prop="unitPrice" label="货物单价" min-width="140"/><el-table-column prop="planDate" label="计划日期" min-width="130"/></el-table></section-card>
<section-card title="关联运单信息"><el-table :data="selectedWaybillRows" border><el-table-column type="index" label="序号" width="70"/><el-table-column prop="waybillNo" label="运单号" min-width="150"><template #default="{ row }"><el-link type="primary">{{ row.waybillNo || '-' }}</el-link></template></el-table-column><el-table-column label="配载单号" min-width="150"><template #default>{{ dialogForm.loadingNo || '-' }}</template></el-table-column><el-table-column prop="projectName" label="项目名称" min-width="150"/><el-table-column prop="customerName" label="客户" min-width="140"/><el-table-column prop="departureAddress" label="发货地址" min-width="220" show-overflow-tooltip/><el-table-column prop="arrivalAddress" label="到货地址" min-width="220" show-overflow-tooltip/></el-table></section-card>
<div class="loading-detail__bottom-grid"><section-card title="运输节点"><template #extra><div class="loading-detail__process-actions"><el-select v-model="processProjectId" filterable placeholder="切换项目" @change="handleProcessProjectChange"><el-option v-for="item in processProjectOptions" :key="item.id" :label="item.projectName" :value="item.id" /></el-select><el-button plain @click="handleProcessAction('supplement')">补录执行</el-button><el-button plain @click="handleProcessAction('batch')">批量补录</el-button></div></template><el-timeline v-if="processNodes.length"><el-timeline-item v-for="(item, index) in processNodes" :key="item.id || index" :timestamp="item.time || ''">{{ item.name || item.nodeName || item.label }}</el-timeline-item></el-timeline><el-empty v-else description="暂无运输节点" :image-size="50"/></section-card><section-card title="物流轨迹"><template #extra><div class="loading-detail__track-actions"><el-button plain @click="handleTrackAction('playback')">轨迹回放</el-button><el-button plain @click="handleTrackAction('locate')">实时定位</el-button></div></template><div ref="detailAmap" class="loading-detail__map"></div></section-card></div>
</div>
<el-form
v-else
ref="loadingFormRef"
:model="dialogForm"
:disabled="dialogReadonly"
label-position="right"
label-width="auto"
class="loading-manage-dialog__form archive-form"
>
<section-card title="筛选待配载运单">
<template #extra>
<el-link v-if="!dialogReadonly" type="primary" @click="openCandidateSearch">筛选</el-link>
</template>
<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="item.driverName || item.name"
:value="item.driverName || item.name"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="运输方式">
<el-select
v-model="candidateQuery.transportType"
clearable
filterable
placeholder="请选择"
:disabled="dialogMode === 'add'"
@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>
</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>
</section-card>
<div title="配载信息" class="loading-manage-dialog__load-info-card">
<section-card title="待配载运单">
<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
/>
<el-table-column
prop="projectName"
label="项目名称"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
prop="customerName"
label="客户名称"
min-width="160"
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="cargoName"
label="货物名称"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="cargoType"
label="货物类型"
min-width="140"
show-overflow-tooltip
/>
<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-column
prop="vehicleNo"
label="//航班/班列"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
prop="carrierName"
label="承运商名称"
min-width="160"
show-overflow-tooltip
/>
<el-table-column prop="driverName" label="司机" min-width="120" show-overflow-tooltip />
<el-table-column
prop="originalNo"
label="原始单号"
min-width="130"
show-overflow-tooltip
/>
</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 type="primary">{{ row.waybillNo }}</el-link>
</template>
</el-table-column>
<el-table-column prop="projectName" label="项目名称" min-width="160" />
<el-table-column
prop="departureAddress"
label="发货地"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">
{{ formatWaybillAddress(row, 'departure') }}
</template>
</el-table-column>
<el-table-column
prop="arrivalAddress"
label="到货地"
min-width="220"
show-overflow-tooltip
>
<template #default="{ row }">
{{ formatWaybillAddress(row, 'arrival') }}
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ $index }">
<el-link
v-if="!dialogReadonly"
type="danger"
@click="removeSelectedWaybill($index)"
>删除</el-link>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card title="线路编辑">
<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"
draggable="true"
@dragstart="handleRouteDragStart(index)"
@dragover.prevent
@drop="handleRouteDrop(index)"
>
<el-icon class="loading-manage-dialog__route-handle"><Rank /></el-icon>
<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="货物信息">
<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="100" />
<el-table-column prop="quantityUnit" label="单位" min-width="100" />
<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" />
<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"
@change="handleCarrierTypeChange"
/>
</div>
<div class="loading-manage-dialog__grid loading-manage-dialog__task-grid">
<el-form-item
v-if="dialogForm.carrierType !== '自运'"
label="承运商"
required
>
<el-select
v-model="dialogForm.carrierContractId"
clearable
filterable
:disabled="dialogReadonly || !selectedWaybillRows.length"
:loading="taskCarrierLoading"
:placeholder="taskCarrierPlaceholder"
@change="handleTaskCarrierChange"
@visible-change="visible => visible && loadTaskCarrierOptions()"
>
<el-option
v-for="item in taskCarrierOptions"
:key="item.id"
:label="item.carrierName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="司机" :required="['自运', '网货平台'].includes(dialogForm.carrierType)">
<el-autocomplete
v-model="dialogForm.driverName"
clearable
:debounce="300"
:fetch-suggestions="fetchDriverSuggestions"
:loading="driverLoading"
placeholder="请输入"
@select="item => handleDriverSuggestionSelect('dialog', item)"
/>
</el-form-item>
<el-form-item label="手机号" :required="['自运', '网货平台'].includes(dialogForm.carrierType)">
<el-input v-model="dialogForm.driverPhone" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="车牌号" required>
<el-input v-model="dialogForm.vehicleNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-if="dialogForm.carrierType !== '承运商'" label="挂车车牌号">
<el-input v-model="dialogForm.trailerVehicleNo" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-if="dialogForm.carrierType !== '承运商'" label="押运人">
<el-input v-model="dialogForm.escortName" clearable placeholder="请输入" />
</el-form-item>
<el-form-item v-if="dialogForm.carrierType !== '承运商'" label="押运人手机号">
<el-input v-model="dialogForm.escortPhone" clearable placeholder="请输入" />
</el-form-item>
<el-form-item label="里程km">
<el-input
v-model="dialogForm.mileage"
clearable
placeholder="请输入"
@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="请选择"
/>
</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="请选择"
/>
</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字以内"
/>
</el-form-item>
</div>
</section-card>
</div>
</el-form>
</div>
<div v-if="isStandaloneFormPage" 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 type="primary" plain :loading="dialogSaving === 'draft'" @click="saveDraft">
暂存
</el-button>
<el-button type="primary" :loading="dialogSaving === 'submit'" @click="submitLoading">
{{ dialogMode === 'reassign' ? '确认派单' : '确认' }}
</el-button>
</template>
</div>
<template v-if="!isStandaloneFormPage" #footer>
<div class="loading-manage-dialog__footer">
<template v-if="dialogReadonly">
<el-button @click="closeLoadingDialog">关闭</el-button>
</template>
<template v-else>
<el-button @click="clearLoadingDialog">清空</el-button>
<el-button type="primary" plain :loading="dialogSaving === 'draft'" @click="saveDraft">
暂存
</el-button>
<el-button type="primary" :loading="dialogSaving === 'submit'" @click="submitLoading">
{{ dialogMode === 'reassign' ? '确认派单' : '确认' }}
</el-button>
</template>
</div>
</template>
</component>
<el-dialog v-model="routeChangeVisible" title="变更运输路线" append-to-body destroy-on-close width="78%" class="loading-route-change-dialog">
<el-tabs 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="310"><template #default="{ row }"><div class="loading-route-change-dialog__original">原:{{ row.originalDepartureAddress || '-' }}</div><el-input v-model="row.departureAddress" placeholder="请选择或输入地址" @input="updateRouteChangeNodes"><template #suffix><el-icon class="loading-route-change-dialog__location" @click="openCommonAddressDialog(row, 'departure')"><Location /></el-icon></template></el-input></template></el-table-column>
<el-table-column label="到货地" min-width="310"><template #default="{ row }"><div class="loading-route-change-dialog__original">原:{{ row.originalArrivalAddress || '-' }}</div><el-input v-model="row.arrivalAddress" placeholder="请选择或输入地址" @input="updateRouteChangeNodes"><template #suffix><el-icon class="loading-route-change-dialog__location" @click="openCommonAddressDialog(row, 'arrival')"><Location /></el-icon></template></el-input></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" draggable="true" @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><Rank /></el-icon></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"><template #default="{ row }"><div class="loading-route-change-dialog__record-content">{{ row.content || '-' }}</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="commonAddressVisible" title="选择常用地址" append-to-body width="900px" class="loading-common-address-dialog">
<div class="loading-common-address-dialog__toolbar"><el-input v-model="commonAddressQuery.addressName" clearable placeholder="请输入地址名称" @keyup.enter="loadCommonAddresses"/><el-button type="primary" @click="loadCommonAddresses">查询</el-button></div>
<el-table v-loading="commonAddressLoading" :data="commonAddressRows" border @row-dblclick="selectCommonAddress"><el-table-column prop="addressName" label="地址名称" min-width="180"/><el-table-column prop="regionName" label="区域" min-width="180"/><el-table-column prop="detailAddress" label="详细地址" min-width="260"/><el-table-column label="操作" width="90"><template #default="{ row }"><el-link type="primary" @click="selectCommonAddress(row)">选择</el-link></template></el-table-column></el-table>
<template #footer><el-pagination v-model:current-page="commonAddressPage.currentPage" v-model:page-size="commonAddressPage.pageSize" layout="total, prev, pager, next" :total="commonAddressPage.total" @current-change="loadCommonAddresses"/><el-button @click="commonAddressVisible = false">关闭</el-button></template>
</el-dialog>
</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,
} from '@/api/business/waybill-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 { getList as getProcessConfigList } from '@/api/business/process-config';
import { getList as getCommonAddressList } from '@/api/base/common-address';
import {
carrierTypeOptions,
loadingManageConfig,
loadingStatusMap,
loadingStatusOptions,
} from '@/option/business/loading-manage';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Location, Rank, Refresh, Setting } from '@element-plus/icons-vue';
import dayjs from 'dayjs';
import { isMobile } from '@/utils/validate';
const defaultTransportType = '';
const defaultCandidateTransportType = '';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills';
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: '',
transportType: defaultCandidateTransportType,
});
const createDialogForm = () => ({
id: '',
loadingNo: '',
loadingSubNos: '',
waybillIdsJson: '',
projectName: '',
customerName: '',
transportType: '',
cargoType: '',
cargoName: '',
vehicleNo: '',
trailerVehicleNo: '',
driverName: '',
driverPhone: '',
carrierType: '承运商',
carrierName: '',
carrierContractId: '',
escortName: '',
escortPhone: '',
departureAddress: '',
transitAddress: '',
arrivalAddress: '',
originalNo: '',
dataSource: '手工创建',
startDate: '',
endDate: '',
planName: '',
batchNo: '',
currentProcessNode: '接单',
mileage: '',
estimatedStartDate: '',
estimatedEndDate: '',
taskRemark: '',
goodsJson: '',
routeJson: '',
taskInfoJson: '',
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('、');
export default {
components: {
Rank,
},
data() {
return {
Rank,
Location,
Refresh,
Setting,
loading: false,
tableSize: 'default',
tableData: [],
page: {
currentPage: 1,
pageSize: 10,
total: 0,
pageSizes: [10, 20, 50, 100],
},
searchExpanded: false,
searchForm: createSearchForm(),
selectionRows: [],
dialogVisible: false,
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,
transportTypeOptions: [],
projectOptions: [],
customerOptions: [],
cargoTypeOptions: [],
driverOptions: [],
carrierOptions: [],
taskCarrierOptions: [],
planOptions: [],
projectLoading: false,
customerLoading: false,
cargoTypeLoading: false,
driverLoading: false,
carrierLoading: false,
taskCarrierLoading: false,
planLoading: false,
taskCarrierRequestId: 0,
carrierTypeOptions,
statusOptions: loadingStatusOptions,
dataSourceOptions: loadingManageConfig.dataSourceOptions,
config: loadingManageConfig,
processNodes: [],
processProjectId: '',
processProjectOptions: [],
routeChangeVisible: false,
routeChangeTab: 'change',
routeChangeRows: [],
routeChangeNodes: [],
routeChangeRemark: '',
routeChangeRecords: [],
routeChangeDragIndex: -1,
commonAddressVisible: false,
commonAddressLoading: false,
commonAddressRows: [],
commonAddressQuery: { addressName: '' },
commonAddressPage: { currentPage: 1, pageSize: 10, total: 0 },
commonAddressTarget: null,
detailAmap: null,
};
},
computed: {
dialogReadonly() {
return this.dialogMode === 'view';
},
taskCarrierPlaceholder() {
if (!this.selectedWaybillRows.length) return '请先选择运单';
if (!this.taskCarrierLoading && !this.taskCarrierOptions.length) return '暂无可用承运商合同';
return '请选择';
},
dialogTitle() {
const titleMap = {
add: '新建配载',
edit: '编辑配载单',
view: '配载单详情',
copy: '复制配载单',
reassign: '重新派单',
};
return titleMap[this.dialogMode] || '配载管理';
},
detailStatusType() {
const typeMap = { pending: 'success', running: 'success', completed: 'info', cancelled: 'info', draft: 'warning' };
return typeMap[this.dialogForm.businessStatus] || 'info';
},
taskSummary() {
return [this.dialogForm.carrierName, this.dialogForm.driverName, this.dialogForm.vehicleNo]
.filter(Boolean)
.join(' / ') || '-';
},
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 }
);
},
isStandaloneFormPage() {
return (
this.$route.path === '/business/loading-manage/form' &&
['add', 'edit'].includes(this.$route.query.mode)
);
},
formPageTitle() {
return (
this.$route.query.name ||
`${this.$route.query.mode === 'edit' ? '编辑' : '新增'}配载管理`
);
},
},
mounted() {
this.loadTransportTypeOptions();
this.loadTable();
if (this.isStandaloneFormPage) {
this.syncStandaloneTagTitle();
this.initStandaloneFormPage();
}
const detailId = this.$route.query.detailId;
if (detailId) this.openLoadingDialog('view', { id: detailId });
},
watch: {
$route(to, from) {
if (this.isStandaloneFormPage) {
this.syncStandaloneTagTitle();
if (
to.query.mode !== from?.query?.mode ||
String(to.query.id || '') !== String(from?.query.id || '')
) {
this.initStandaloneFormPage();
}
} else if (from?.query?.mode && this.dialogVisible) {
this.closeLoadingDialog();
}
},
'$route.query.detailId'(detailId) {
if (detailId) this.openLoadingDialog('view', { id: detailId });
},
},
methods: {
syncStandaloneTagTitle() {
if (!this.isStandaloneFormPage) return;
const title = this.$route.query.mode === 'edit' ? '编辑配载管理' : '新增配载管理';
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 || [];
},
toggleTableSize() {
const order = ['default', 'small', 'large'];
const index = order.indexOf(this.tableSize);
this.tableSize = order[(index + 1) % order.length];
},
handleColumnSetting() {
ElMessage.info('列设置按原型保留入口,当前默认展示全部列表字段');
},
statusText(row) {
return row.businessStatusName || loadingStatusMap[row.businessStatus] || '未知';
},
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', query: { detailId: id } });
},
formatRouteAddress(row, type) {
const prefix = type === 'departure' ? 'departure' : type === 'arrival' ? 'arrival' : 'transit';
if (type !== 'transit') {
const address = row[`${prefix}Address`];
const region = this.routeProvinceName(row, prefix);
const district = row[`${prefix}DistrictName`];
const source = this.mergeRouteAddressParts(region, district, address);
return this.formatProvinceCityDistrict(source) || '-';
}
const values = [
row[`${prefix}RegionName`],
row[`${prefix}DistrictName`],
row[`${prefix}Address`],
]
.filter(Boolean)
.flatMap(value => this.splitText(value))
.map(value => this.formatProvinceCityDistrict(value))
.filter(Boolean);
return [...new Set(values)].join(',') || '-';
},
formatFullRouteAddress(row, type) {
const prefix = type === 'departure' ? 'departure' : type === 'arrival' ? 'arrival' : 'transit';
if (type !== 'transit') {
const address = row[`${prefix}Address`];
const region = this.routeProvinceName(row, prefix);
const district = row[`${prefix}DistrictName`];
return this.mergeRouteAddressParts(region, district, address) || '-';
}
const values = [row.transitRegionName, row.transitDistrictName, row.transitAddress]
.filter(Boolean)
.flatMap(value => this.splitText(value));
return [...new Set(values)].join('') || '-';
},
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: 'copy', label: '复制' },
{ type: 'delete', label: '删除' },
];
}
if (status === 'pending') {
return [
{ type: 'view', label: '详情' },
{ type: 'reassign', label: '重新派单' },
{ type: 'edit', label: '编辑' },
{ type: 'copy', label: '复制' },
{ type: 'cancel', label: '取消' },
];
}
if (status === 'running') {
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
{ type: 'complete', label: '完成' },
];
}
if (status === 'completed' || status === 'cancelled') {
return [
{ type: 'view', label: '详情' },
{ type: 'copy', label: '复制' },
];
}
return [{ type: 'view', label: '详情' }];
},
handleRowAction(type, row) {
const actionMap = {
view: () => this.openLoadingDialog('view', row),
edit: () => this.openLoadingDialog('edit', row),
reassign: () => this.openLoadingDialog('reassign', row),
copy: () => this.copyLoading(row),
delete: () => this.deleteLoading(row),
cancel: () => this.cancelLoading(row),
complete: () => this.completeLoading(row),
};
actionMap[type]?.();
},
async initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
await this.openLoadingDialog(mode, {
id: this.$route.query.id,
});
if (mode === 'add') 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'].includes(mode) && !this.isStandaloneFormPage) {
this.$router.push({
path: '/business/loading-manage/form',
query: {
mode,
...(mode === 'edit' && row?.id ? { id: row.id } : {}),
name: mode === 'edit' ? '编辑配载管理' : '新增配载管理',
},
});
return;
}
this.dialogMode = mode;
this.dialogVisible = true;
this.dialogLoading = true;
this.resetDialogData();
if (mode === 'add') this.candidateQuery.transportType = 'road';
try {
if (row?.id) {
const res = await loadingApi.getDetail(row.id);
this.dialogForm = {
...createDialogForm(),
...(res.data?.data || res.data || {}),
};
await this.restoreWaybillRows(
this.dialogForm.waybillIdsJson,
this.dialogForm.loadingSubNos
);
if (this.dialogForm.carrierType === '自运') {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName = '';
}
this.syncProcessProjectOptions();
this.restoreRouteAndCargo();
this.restoreRouteChangeRecords();
await this.loadProcessNodes();
this.$nextTick(() => this.initDetailAmap());
await this.loadCandidateWaybills();
} else {
this.dialogForm = createDialogForm();
this.candidateQuery = createCandidateSearchForm();
this.candidateQuery.transportType = 'road';
await this.loadCandidateWaybills();
}
} finally {
this.dialogLoading = false;
}
},
closeLoadingDialog() {
if (this.isStandaloneFormPage) {
this.$router.$avueRouter?.closeTag?.();
this.$router.push({ path: '/business/loading-manage', query: {} });
return;
}
this.dialogVisible = false;
},
resetLoadingDialog() {
this.resetDialogData();
this.dialogSaving = '';
this.dialogLoading = false;
},
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.candidatePage.currentPage = 1;
this.candidatePage.total = 0;
this.candidateSearchVisible = false;
this.processNodes = [];
this.processProjectId = '';
this.processProjectOptions = [];
},
clearLoadingDialog() {
this.resetDialogData();
if (this.dialogMode === 'add') this.candidateQuery.transportType = 'road';
this.loadCandidateWaybills();
},
restoreRouteAndCargo() {
const routeRows = parseJsonArray(this.dialogForm.routeJson);
const cargoRows = parseJsonArray(this.dialogForm.goodsJson);
this.routeNodes = routeRows.length
? routeRows
: this.buildRouteNodes(this.selectedWaybillRows);
this.cargoRows = cargoRows.length ? cargoRows : this.buildCargoRows(this.selectedWaybillRows);
},
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 = [];
}
},
async loadProcessNodes() {
if (!this.processProjectId) {
this.processNodes = [];
return;
}
try {
const projectId = String(this.processProjectId);
const rows = unwrapRecords(
await getProcessConfigList(1, 100, { projectIds: projectId, status: 1 })
);
const config =
rows.find(item => {
const projectIds = String(item.projectIds || '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
return projectIds.includes(projectId) && item.nodeConfigJson;
}) || {};
this.processNodes = this.parseProcessConfigNodes(config);
} catch (error) {
this.processNodes = [];
}
},
syncProcessProjectOptions() {
const projectMap = new Map();
this.selectedWaybillRows.forEach(row => {
const id = row.projectId;
if (!id || projectMap.has(String(id))) return;
projectMap.set(String(id), {
id,
projectName: row.projectName || row.projectNames || String(id),
});
});
if (!projectMap.size && this.dialogForm.projectId) {
projectMap.set(String(this.dialogForm.projectId), {
id: this.dialogForm.projectId,
projectName: this.dialogForm.projectName || String(this.dialogForm.projectId),
});
}
this.processProjectOptions = [...projectMap.values()];
this.processProjectId = this.processProjectOptions[0]?.id || '';
},
parseProcessConfigNodes(config = {}) {
let nodes = [];
try {
const parsed =
typeof config.nodeConfigJson === 'string'
? JSON.parse(config.nodeConfigJson)
: config.nodeConfigJson;
nodes = Array.isArray(parsed) ? parsed : parsed?.nodes || [];
} catch (error) {
nodes = [];
}
const includedNodes = String(config.includedNodes || '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
return nodes.filter(
node =>
node.enabled !== false && (!includedNodes.length || includedNodes.includes(node.name))
);
},
async handleProcessProjectChange() {
await this.loadProcessNodes();
},
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' ? '暂无可回放的物流轨迹' : '暂无车辆实时定位数据');
},
handleProcessAction(action) {
ElMessage.info(action === 'supplement' ? '补录执行功能暂未接入' : '批量补录功能暂未接入');
},
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 || '',
}));
this.routeChangeNodes = this.buildRouteNodes(this.routeChangeRows);
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) {
this.routeChangeDragIndex = index;
},
handleRouteChangeDrop(index) {
if (this.routeChangeDragIndex < 0 || this.routeChangeDragIndex === index) return;
const nodes = [...this.routeChangeNodes];
const [node] = nodes.splice(this.routeChangeDragIndex, 1);
nodes.splice(index, 0, node);
this.routeChangeNodes = nodes;
this.routeChangeDragIndex = -1;
},
updateRouteChangeNodes() {
this.routeChangeNodes = this.buildRouteNodes(this.routeChangeRows);
},
openCommonAddressDialog(row, field) {
this.commonAddressTarget = { row, field };
this.commonAddressQuery = { addressName: '' };
this.commonAddressPage.currentPage = 1;
this.commonAddressVisible = true;
this.loadCommonAddresses();
},
async loadCommonAddresses() {
this.commonAddressLoading = true;
try {
const page = unwrapPage(await getCommonAddressList(this.commonAddressPage.currentPage, this.commonAddressPage.pageSize, { ...this.commonAddressQuery, allDept: 0 }));
this.commonAddressRows = page.records;
this.commonAddressPage.total = page.total;
} finally {
this.commonAddressLoading = false;
}
},
selectCommonAddress(address) {
const target = this.commonAddressTarget;
if (!target) return;
target.row[`${target.field}Address`] = [address.regionName, address.detailAddress || address.addressName].filter(Boolean).join('') || address.addressName || '';
this.updateRouteChangeNodes();
this.commonAddressVisible = false;
},
async confirmRouteChange() {
const changeRows = this.routeChangeRows.filter(row => row.departureAddress !== row.originalDepartureAddress || row.arrivalAddress !== row.originalArrivalAddress);
if (!changeRows.length && !this.routeChangeRemark) {
ElMessage.warning('请修改地址或填写变更备注');
return;
}
const content = changeRows.map(row => `${row.waybillNo || '运单'}:发货地 ${row.originalDepartureAddress} → ${row.departureAddress};到货地 ${row.originalArrivalAddress} → ${row.arrivalAddress}`).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 res = await getWaybillList(
this.candidatePage.currentPage,
this.candidatePage.pageSize,
{
...this.buildQueryParams(this.candidateQuery),
businessStatus: 'pending',
onlyUnassignedLoading: 1,
...(this.dialogMode === 'add' ? { transportType: 'road' } : {}),
}
);
const page = unwrapPage(res);
this.candidateRows = page.records;
this.candidatePage.total = page.total;
} finally {
this.candidateLoading = false;
}
},
openCandidateSearch() {
this.candidateSearchVisible = true;
this.loadCandidateWaybills();
},
handleCandidateSearch() {
this.candidatePage.currentPage = 1;
this.loadCandidateWaybills();
},
handleCandidateReset() {
this.candidateQuery = createCandidateSearchForm();
if (this.dialogMode === 'add') this.candidateQuery.transportType = 'road';
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;
}
})
);
const exists = new Set(
this.selectedWaybillRows.map(row => String(row.id || row.waybillNo))
);
const nextRows = [...this.selectedWaybillRows];
selectedRows.forEach(row => {
const key = String(row.id || row.waybillNo);
if (!exists.has(key)) {
nextRows.push(row);
exists.add(key);
}
});
this.selectedWaybillRows = nextRows;
this.rebuildSummaryFromWaybills();
await this.loadTaskCarrierOptions();
this.$refs.candidateTableRef?.clearSelection?.();
this.candidateSelection = [];
} finally {
this.candidateAdding = false;
}
},
async addSelectedWaybills() {
await this.addWaybillRows(this.candidateSelection);
},
removeSelectedWaybill(index) {
this.selectedWaybillRows.splice(index, 1);
this.rebuildSummaryFromWaybills();
this.loadTaskCarrierOptions();
},
rebuildSummaryFromWaybills(rebuildRoute = true) {
const rows = this.selectedWaybillRows;
this.dialogForm.loadingSubNos = rows
.map(row => row.waybillNo)
.filter(Boolean)
.join('');
this.dialogForm.waybillIdsJson = JSON.stringify(
rows
.map(row => row.id)
.filter(Boolean)
.map(id => String(id))
);
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) {
this.routeNodes = this.buildRouteNodes(rows);
}
this.cargoRows = this.buildCargoRows(rows);
this.syncRouteAddressFields();
},
buildRouteNodes(rows) {
const nodeMap = new Map();
const appendNode = (address, displayAddress, tag) => {
if (!address) return;
if (!nodeMap.has(address)) nodeMap.set(address, { address, displayAddress, tags: [] });
nodeMap.get(address).tags.push(tag);
};
rows.forEach((row, rowIndex) => {
const departure = row.departureAddress || row.departureName;
appendNode(departure, this.formatWaybillAddress(row, 'departure'), `装${rowIndex + 1}`);
});
rows.forEach((row, rowIndex) => {
const arrival = row.arrivalAddress || row.arrivalName;
appendNode(arrival, this.formatWaybillAddress(row, 'arrival'), `卸${rowIndex + 1}`);
});
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) {
this.routeDragIndex = index;
},
handleRouteDrop(index) {
if (this.routeDragIndex < 0 || this.routeDragIndex === index) return;
const rows = [...this.routeNodes];
const [target] = rows.splice(this.routeDragIndex, 1);
rows.splice(index, 0, target);
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)];
});
},
normalizeCargoRow(waybill, item = {}) {
return {
projectName: item.projectName || waybill.projectName || '',
cargoName: item.cargoName || item.goodsName || waybill.cargoName || '',
cargoType: item.cargoType || item.goodsType || waybill.cargoType || '',
packageType: item.packageType || item.package || '',
weight: item.weight || item.cargoWeight || '',
volume: item.volume || item.cargoVolume || '',
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 || '',
};
},
normalizePayload() {
this.syncRouteAddressFields();
this.rebuildSummaryFromWaybills(false);
if (this.dialogForm.carrierType === '自运') {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName = '';
}
return {
...this.dialogForm,
routeJson: JSON.stringify(this.routeNodes),
goodsJson: JSON.stringify(this.cargoRows),
taskInfoJson: JSON.stringify({
carrierType: this.dialogForm.carrierType,
carrierName: this.dialogForm.carrierName,
driverName: this.dialogForm.driverName,
driverPhone: this.dialogForm.driverPhone,
vehicleNo: this.dialogForm.vehicleNo,
trailerVehicleNo: this.dialogForm.trailerVehicleNo,
escortName: this.dialogForm.escortName,
escortPhone: this.dialogForm.escortPhone,
mileage: this.dialogForm.mileage,
estimatedStartDate: this.dialogForm.estimatedStartDate,
estimatedEndDate: this.dialogForm.estimatedEndDate,
taskRemark: this.dialogForm.taskRemark,
}),
};
},
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 (['自运', '网货平台'].includes(this.dialogForm.carrierType)) {
if (!this.dialogForm.driverName || !this.dialogForm.driverPhone) {
ElMessage.warning('请输入司机和司机手机号');
return false;
}
}
return true;
},
async saveDraft() {
if (!this.validatePayload(true)) return;
this.dialogSaving = 'draft';
try {
const res = await loadingApi.saveDraft(this.normalizePayload());
this.dialogForm = {
...this.dialogForm,
...(res.data?.data || res.data || {}),
};
ElMessage.success('暂存成功');
this.loadTable();
} finally {
this.dialogSaving = '';
}
},
async submitLoading() {
if (!this.validatePayload(false)) return;
this.dialogSaving = 'submit';
try {
const payload = this.normalizePayload();
if (this.dialogMode === 'reassign') {
await loadingApi.reassign(payload);
ElMessage.success('派单成功');
} else {
await loadingApi.submit(payload);
ElMessage.success('确认成功');
}
this.closeLoadingDialog();
this.loadTable();
} finally {
this.dialogSaving = '';
}
},
async copyLoading(row) {
await loadingApi.copy(row.id);
ElMessage.success('复制成功');
this.loadTable();
},
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(() => {});
},
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() {
if (this.dialogForm.carrierType === '承运商') {
this.dialogForm.trailerVehicleNo = '';
this.dialogForm.escortName = '';
this.dialogForm.escortPhone = '';
}
if (this.dialogForm.carrierType === '自运') {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName = '';
}
},
handleTaskCarrierChange(contractId) {
const contract = this.taskCarrierOptions.find(item => String(item.id) === String(contractId));
this.dialogForm.carrierContractId = contract?.id || '';
this.dialogForm.carrierName = contract?.carrierName || '';
},
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.loadDriverOptions(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;
}
},
handleMileageInput(value) {
this.dialogForm.mileage = String(value || '').replace(/[^\d.]/g, '');
},
transportTypeLabel(value) {
if (!value) return '-';
return this.transportTypeOptions.find(item => String(item.value) === String(value))?.label || value;
},
async loadTransportTypeOptions() {
if (this.transportTypeOptions.length) return;
const res = await request({
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, { driverName: keyword }));
} 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 response = await loadingApi.getCarrierContracts();
if (requestId !== this.taskCarrierRequestId) return [];
const contracts = response.data?.data || response.data || [];
this.taskCarrierOptions = contracts;
const current =
contracts.find(
item => String(item.id) === String(this.dialogForm.carrierContractId)
) ||
contracts.find(
item => item.carrierName === this.dialogForm.carrierName
);
if (current) {
this.dialogForm.carrierContractId = current.id;
this.dialogForm.carrierName = current.carrierName;
} else {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName = '';
}
return contracts;
} catch (error) {
if (requestId !== this.taskCarrierRequestId) return [];
this.taskCarrierOptions = [];
this.dialogForm.carrierContractId = '';
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,
.loading-manage-page__toolbar-right {
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: 4px 0;
margin-bottom: 8px;
}
.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;
gap: 8px;
}
.loading-manage-dialog__table-toolbar {
justify-content: space-between;
margin: 10px 0;
}
.loading-manage-dialog__route-list {
display: flex;
flex-direction: column;
gap: 8px;
}
@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;
}
.loading-manage-dialog__route-handle {
color: #909399;
}
.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: 16px;
}
: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: 24px; padding: 4px 0 16px; }
.loading-detail__heading { font-size: 20px; font-weight: 600; margin-right: 8px; }
.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: 1fr 1fr; gap: 24px; padding-bottom: 4px; }
.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; }
.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-tags { display: flex; justify-content: center; gap: 4px; margin-top: 6px; }
.loading-detail__bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.loading-detail__track-actions { display: flex; gap: 8px; }
.loading-detail__process-actions { display: flex; align-items: center; gap: 8px; }
.loading-detail__process-actions .el-select { width: 180px; }
.loading-detail__map { width: 100%; min-height: 260px; border: 1px solid #eff1f7; }
}
.loading-route-change-dialog {
.loading-route-change-dialog__original { color: #a8abb2; line-height: 20px; margin-bottom: 4px; }
.loading-route-change-dialog__location { cursor: pointer; color: #909399; }
.loading-route-change-dialog__hint { color: #909399; }
.loading-route-change-dialog__route-list { display: flex; flex-direction: column; gap: 8px; }
.loading-route-change-dialog__route-item { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto 24px; align-items: center; gap: 12px; padding: 10px 12px; background: #f5f6f7; cursor: move; }
.loading-route-change-dialog__route-type { width: 30px; height: 30px; border-radius: 50%; 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 { white-space: normal; overflow-wrap: anywhere; line-height: 22px; }
}
.loading-common-address-dialog__toolbar { display: flex; gap: 8px; margin-bottom: 12px; }.loading-common-address-dialog__toolbar .el-input { flex: 1; }
:global(.loading-manage-form-page) {
width: 100%;
min-height: 100%;
box-sizing: border-box;
background: #f5f6fa;
}
:global(.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;
}
}
</style>