Files
tms-erp-web/src/views/business/loading-manage.vue
T
gxwebsoft b39501291c refactor(payment): 统一搜索栏“展开/收起”按钮实现
- 取消 el-tooltip 包裹,改用纯 el-button 实现带图标的展开收起按钮
- 按 avue-crud 规范控制按钮显示,新增 searchIndex (默认 4)和 searchItemCount 控制逻辑
- 搜索项数量未超出 searchIndex 时隐藏展开收起按钮
- 相关组件支付模块多处应用此统一方案(bill-ledger-search、bill-payment-search、invoice-application、invoice-receipt、receipt-claim-record、receipt-flow)
- 维护展开状态对应的箭头图标和文案为“收起”与“展开”

refactor(business): 优化路线变更内容渲染与样式

- 替换旧的 formatRouteChangeContent 为更详细的 parseRouteChangeContent,支持多分组与字段显示
- 路线变更内容支持多层次分组、字段标签、改变前后值对比及未变更标识展示
- 取消全局列溢出提示以避免多行内容被截断,针对路线变更内容列显式禁用溢出提示并设置白色换行样式
- 在 business-crud-page、waybill-manage-page、loading-manage 相关页面和组件中统一应用该解析与渲染方案

refactor(option): 调整 measurement-unit 配置以提升表格列展示

- 移除 menuWidth 配置,采用 menuFixed 固定右侧模式
- 为创建人和更新人列添加 minWidth 110,时间列添加 width 180,避免内容被截断
- 表格列数组顺序调整,确保搜索与展示保持一致

refactor(payment & receipt-flow): 统一支付及回执模块工具栏和表格行为

- 按照统一规范和用户反馈,调整多处页面的搜索栏操作区和表格列配置
- 引入 TableHeaderTools 组件和相关状态传递,实现搜索显隐与表格尺寸切换统一
- 优化 avue-crud 表格组件的交互一致性和视觉表现

refactor(utils): 重构路线变更内容解析函数

- 新增 parseRouteChangeContent 以支持复杂路线变更文本的解析与分组
- 识别运单号、字段标签、变更前后值,支持多种箭头符号及调整描述
- 用法推广至相关业务页面,支持更加直观的路线变更展示界面
2026-10-14 16:21:50 +08:00

5353 lines
193 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="90" /><el-table-column
prop="quantityUnit"
label="数量单位"
min-width="100" /><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="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>
<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="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="{ $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',
{ '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="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">
<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"
@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 === '承运商'
? dialogForm.carrierContractId
: dialogForm.carrierName
"
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 || item.carrierName"
:label="
dialogForm.carrierType === '承运商'
? formatTaskCarrierContractLabel(item)
: item.carrierName || item.partyB
"
:value="
dialogForm.carrierType === '承运商'
? item.id
: 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="请输入"
@select="item => handleDriverSuggestionSelect('dialog', item)"
/>
</el-form-item>
<el-form-item
label="手机号"
:required="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-autocomplete
v-model="dialogForm.escortName"
clearable
:debounce="300"
:fetch-suggestions="fetchEscortSuggestions"
:loading="escortLoading"
placeholder="请输入"
@select="item => handleEscortSuggestionSelect('dialog', item)"
/>
</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
: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="请选择"
/>
</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 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>
</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 { fetchDriversByCarrierOrganizations } 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 defaultCandidateTransportType = '';
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: '',
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',
});
// 后端以 -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,
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';
},
// 打卡时间轴:已打卡按时间升序,未打卡保持原节点顺序排在其后
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 '请选择';
},
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,
formatTaskCarrierContractLabel(item = {}) {
const carrierName = item.carrierName || item.partyB || '';
const contractLabel = formatContractOptionLabel(item);
if (carrierName && contractLabel) {
return `${carrierName}(${contractLabel})`;
}
return carrierName || contractLabel;
},
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);
return `${group.typeName}等${group.count}种货物 | ${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: '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' ? '编辑配载管理' : '新增配载管理',
},
});
return;
}
this.dialogMode = mode;
this.dialogLoading = true;
this.resetDialogData();
if (mode === 'add') this.candidateQuery.transportType = 'road';
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();
this.restoreRouteAndCargo();
this.restoreRouteChangeRecords();
this.loadPunchRecords();
this.probeHasRelatedVoucher();
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.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.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('已完成');
},
isRouteChangeNodeLocked(node = {}) {
return Boolean(node?.locked);
},
syncRouteNodeLockedFlags(nodes = [], rows = this.selectedWaybillRows) {
return (nodes || []).map(node => {
const locked = (node.tags || []).some(tag => {
const match = String(tag).match(/^(装|卸)(\d+)$/);
if (!match) return false;
const row = rows[Number(match[2]) - 1];
return row ? this.isWaybillCompleted(row) : false;
});
return { ...node, locked };
});
},
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 query = {
...this.buildQueryParams(this.candidateQuery),
// 待执行(需接单未接)与进行中(无需接单或已接单)均可配载
businessStatus: 'pending,running',
onlyUnassignedLoading: 1,
// 仅查当前账户所属组织及子组织下的运单
allDept: 0,
...(this.dialogMode === 'add' ? { transportType: 'road' } : {}),
};
const transportType = this.getCandidateTransportTypeQuery(query.transportType);
const res = await getWaybillList(
this.candidatePage.currentPage,
this.candidatePage.pageSize,
{ ...query, transportType }
);
const page = unwrapPage(res);
this.candidateRows = page.records;
this.candidatePage.total = page.total;
} finally {
this.candidateLoading = false;
}
},
getCandidateTransportTypeQuery(value) {
const normalized = String(value || '')
.trim()
.toLowerCase();
if (['road', '公路运输'].includes(normalized)) return 'road,公路运输';
return value || '';
},
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(',');
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) {
this.routeNodes = this.buildRouteNodes(rows);
}
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.isWaybillCompleted(row);
const departure = row.departureAddress || row.departureName;
appendNode(
departure,
this.formatCandidateAddress(row, 'departure'),
`装${rowIndex + 1}`,
locked
);
});
rows.forEach((row, rowIndex) => {
const locked = 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.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)];
});
},
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 || '',
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 || '',
});
},
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(),
// 确认生成正式配载单(待执行),覆盖草稿状态
businessStatus: 'pending',
};
if (this.dialogMode === 'reassign') {
await loadingApi.reassign(payload);
ElMessage.success('派单成功');
} else {
await loadingApi.submit(payload);
ElMessage.success('确认成功');
}
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.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.loadTaskCarrierOptions();
},
handleTaskCarrierChange(contractId) {
const contract = this.taskCarrierOptions.find(item =>
[item.id, item.carrierName, item.partyB].some(
value => String(value || '') === String(contractId || '')
)
);
this.dialogForm.carrierContractId =
this.dialogForm.carrierType === '承运商' && contract?.id ? contract.id : '';
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 = [];
},
getDialogCarrierFilter() {
return {
carrierId: this.dialogForm.carrierId || '',
carrierName: this.dialogForm.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.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();
const carrier = this.getDialogCarrierFilter();
if (!carrier.carrierId && !carrier.carrierName) {
callback([]);
return;
}
this.escortLoading = true;
fetchDriversByCarrierOrganizations(
{ size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '押运员' },
carrier
)
.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 = '') {
const carrier = this.getDialogCarrierFilter();
if (!carrier.carrierId && !carrier.carrierName) {
this.driverOptions = [];
return [];
}
this.driverLoading = true;
try {
this.driverOptions = await fetchDriversByCarrierOrganizations(
{ size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '司机' },
carrier
);
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 projectIds = [
...new Set(this.selectedWaybillRows.map(row => row.projectId).filter(Boolean)),
];
const response = await loadingApi.getCarrierContracts(projectIds);
if (requestId !== this.taskCarrierRequestId) return [];
const contracts = response.data?.data || response.data || [];
if (this.dialogForm.carrierType !== '自运') {
this.taskCarrierOptions = contracts
.map(item => ({
...item,
carrierName:
item.partyB ||
item.partyBName ||
item.contractPartyB ||
item.customerContractPartyB ||
item.carrierName ||
'',
carrierId:
item.partyBId ||
item.partyBUserId ||
item.contractPartyBId ||
item.customerContractPartyBId ||
item.carrierId ||
'',
}))
.filter(item => item.carrierName);
const current =
contracts.find(item => String(item.id) === String(this.dialogForm.carrierContractId)) ||
this.taskCarrierOptions.find(item => item.carrierName === this.dialogForm.carrierName);
if (current) {
this.dialogForm.carrierContractId = current.id;
this.dialogForm.carrierName = current.carrierName;
this.dialogForm.carrierId = current.carrierId || '';
} else {
this.dialogForm.carrierContractId = '';
this.dialogForm.carrierName = '';
this.dialogForm.carrierId = '';
}
return this.taskCarrierOptions;
}
if (this.dialogForm.carrierType === '自运' && contracts.length) {
this.taskCarrierOptions = contracts
.map(item => ({
...item,
carrierName:
item.partyB ||
item.partyBName ||
item.contractPartyB ||
item.customerContractPartyB ||
item.carrierName ||
'',
carrierId:
item.partyBId ||
item.partyBUserId ||
item.contractPartyBId ||
item.customerContractPartyBId ||
item.carrierId ||
'',
}))
.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-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>