Files
tms-erp-web/src/views/business/components/waybill-manage-page.vue
T

12429 lines
439 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="[
'waybill-manage-page',
{
'waybill-manage-page--form-page': formPageLocked,
'waybill-manage-page--detail-page': detailPageLocked,
},
]"
>
<component
v-if="!detailPageLocked"
:is="crudContainer"
:option="pageFormOption"
:form-page-title="formPageLocked ? formPageTitle : undefined"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
:status="formPageLocked ? dialogReadonly : undefined"
ref="crud"
:permission="permissionList"
:before-open="beforeOpen"
@row-save="rowSave"
@row-update="rowUpdate"
@row-del="rowDel"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
:icon="actionButtonLikeSearch ? undefined : 'el-icon-plus'"
v-if="canCreate"
@click="openBusinessForm('add')"
>{{ config.createText || '新增' }}
</el-button>
<el-button
type="primary"
icon="el-icon-upload"
plain
v-if="config.importUrl && hasPermission(`${config.permission}_import`) && !readonlyScope"
@click="handleImport"
>{{ config.importText || '批量导入' }}
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="config.templateUrl && hasPermission(`${config.permission}_template`)"
@click="handleTemplate"
>下载模板
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="config.exportUrl && hasPermission(`${config.permission}_export`)"
@click="handleExport"
>批量导出
</el-button>
<el-button
type="success"
icon="el-icon-check"
plain
v-if="canBatchComplete"
@click="handleBatchComplete"
>批量完成
</el-button>
<el-button
type="primary"
:plain="!actionButtonLikeSearch"
v-if="canRoadLoading"
@click="handleRoadLoading"
>{{ config.roadLoadingText || '公路配载' }}
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="
config.batchDelete !== false &&
hasPermission(`${config.permission}_delete`) &&
!readonlyScope
"
@click="handleDelete"
>批量删除
</el-button>
</template>
<template #status="{ row }">
<el-tag :type="statusTagType(row.status)" class="status-text">
{{ displayStatus(row, 'status') }}
</el-tag>
</template>
<template #businessStatus="{ row }">
<span class="waybill-manage-page__status-cell">
<el-tag :type="statusTagType(row.businessStatus)" class="status-text">
{{ displayStatus(row, 'businessStatus') }}
</el-tag>
<el-tooltip
v-if="driverRejectReasonText(row)"
:content="driverRejectReasonText(row)"
placement="top"
>
<el-icon class="waybill-manage-page__reject-tip-icon">
<WarnTriangleFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<template #approvalStatus="{ row }">
<el-tag :type="statusTagType(row.approvalStatus)" class="status-text">
{{ displayStatus(row, 'approvalStatus') }}
</el-tag>
</template>
<template #goodsInfo="{ row }">
<span>{{ formatWaybillGoodsInfo(row) || '-' }}</span>
</template>
<template #waybillNo="{ row }">
<el-link v-if="row.waybillNo" type="primary" @click.stop="openDetail(row)">
{{ row.waybillNo }}
</el-link>
<span v-else>-</span>
</template>
<template #contractName="{ row }">
<div class="waybill-manage-page__contract-name">
<span>{{ row.contractName || '-' }}</span>
</div>
</template>
<template #loadingNo="{ row }">
<el-link v-if="row.loadingNo" type="primary" @click.stop="openLoadingDetail(row)">
{{ row.loadingNo }}
</el-link>
<span v-else>-</span>
</template>
<template #departureAddress="{ row }">
<el-tooltip v-if="row.departureAddress" :content="row.departureAddress" placement="top">
<span>{{ formatWaybillListAddress(row.departureAddress) }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<template #arrivalAddress="{ row }">
<el-tooltip v-if="row.arrivalAddress" :content="row.arrivalAddress" placement="top">
<span>{{ formatWaybillListAddress(row.arrivalAddress) }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<template #basicInfoTitle-form>
<div class="dialog-section-title">基本信息</div>
</template>
<template v-if="config.enableProjectSelect" #projectName-label>项目</template>
<template #relationNo-label>
<span class="waybill-manage-page__label-with-info">
<span>关联单号</span>
<el-tooltip content="仅做关联标记,用于业务中子母单情况" placement="top">
<el-icon class="waybill-manage-page__info-icon"><InfoFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #projectName-form>
<el-select
v-model="selectedProjectId"
class="waybill-manage-page__field"
:placeholder="config.projectPlaceholder || '请选择项目名称'"
filterable
clearable
:loading="projectLoading"
:disabled="dialogReadonly"
@visible-change="visible => visible && loadProjectOptions()"
@change="handleProjectChange"
>
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.projectName"
:value="item.id"
/>
</el-select>
</template>
<template #contractName-form>
<el-select
v-if="contractSelectEnabled"
v-model="form.contractId"
class="waybill-manage-page__field"
placeholder="请选择客户合同"
filterable
clearable
:loading="contractLoading"
:disabled="dialogReadonly || !form.projectId"
@visible-change="visible => visible && loadContractOptionsForProject()"
@change="handleContractChange"
>
<el-option
v-for="item in contractOptions"
:key="item.id"
:label="formatContractOptionLabel(item)"
:value="item.id"
/>
</el-select>
<el-input
v-else
v-model="form.contractName"
class="waybill-manage-page__field"
placeholder="请输入合同名称"
clearable
maxlength="100"
show-word-limit
:disabled="dialogReadonly"
/>
</template>
<template #planName-form>
<el-select
v-model="form.planId"
class="waybill-manage-page__field"
placeholder="请选择计划名称"
filterable
clearable
:loading="shippingPlanLoading"
:disabled="dialogReadonly || isTemplateCreate"
@visible-change="visible => visible && loadShippingPlanOptions()"
@change="handleShippingPlanChange"
>
<el-option
v-for="item in shippingPlanOptions"
:key="item.id || item.planName"
:label="item.planName"
:value="item.id"
/>
</el-select>
</template>
<template #shippingInfoTitle-form>
<div
class="dialog-section-title dialog-section-title--action waybill-manage-page__shipping-title"
>
<span>收发货信息</span>
<el-link
v-if="!dialogReadonly"
class="waybill-manage-page__shipping-route-action"
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportRouteDialog"
>
选择线路
</el-link>
</div>
</template>
<template #departureAddress-form>
<div class="waybill-manage-page__route-address-row">
<el-input
v-model="form.departureName"
readonly
:placeholder="transportStationNamePlaceholder"
:disabled="transportAddressSelectDisabled"
@click="openTransportPrimaryAddressPicker('departure')"
>
<template v-if="transportStationMode" #suffix>
<el-icon
class="waybill-manage-page__map-suffix"
@click.stop="openTransportSecondaryAddressPicker('departure')"
>
<Search />
</el-icon>
</template>
</el-input>
<el-input
v-model="form.departureAddress"
:readonly="transportStationMode || !config.editableRoadAddress"
:placeholder="transportAddressDetailPlaceholder"
:suffix-icon="transportStationMode || config.editableRoadAddress ? undefined : Location"
:disabled="transportAddressSelectDisabled"
@click="handleTransportSecondaryAddressInputClick('departure')"
>
<template v-if="!transportStationMode && config.editableRoadAddress" #suffix>
<el-icon
class="waybill-manage-page__map-suffix"
@click.stop="openTransportMapDialog('departure')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="waybill-manage-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
/>
</el-tooltip>
<span class="waybill-manage-page__route-label">联系人</span>
<el-input
v-model="form.departureContact"
placeholder="请输入"
:disabled="dialogReadonly"
/>
<span class="waybill-manage-page__route-label">联系方式</span>
<el-input v-model="form.departurePhone" placeholder="请输入" :disabled="dialogReadonly" />
</div>
</template>
<template #arrivalAddress-form>
<div class="waybill-manage-page__route-address-row">
<el-input
v-model="form.arrivalName"
readonly
:placeholder="transportStationNamePlaceholder"
:disabled="transportAddressSelectDisabled"
@click="openTransportPrimaryAddressPicker('arrival')"
>
<template v-if="transportStationMode" #suffix>
<el-icon
class="waybill-manage-page__map-suffix"
@click.stop="openTransportSecondaryAddressPicker('arrival')"
>
<Search />
</el-icon>
</template>
</el-input>
<el-input
v-model="form.arrivalAddress"
:readonly="transportStationMode || !config.editableRoadAddress"
:placeholder="transportAddressDetailPlaceholder"
:suffix-icon="transportStationMode || config.editableRoadAddress ? undefined : Location"
:disabled="transportAddressSelectDisabled"
@click="handleTransportSecondaryAddressInputClick('arrival')"
>
<template v-if="!transportStationMode && config.editableRoadAddress" #suffix>
<el-icon
class="waybill-manage-page__map-suffix"
@click.stop="openTransportMapDialog('arrival')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="waybill-manage-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
/>
</el-tooltip>
<span class="waybill-manage-page__route-label">联系人</span>
<el-input v-model="form.arrivalContact" placeholder="请输入" :disabled="dialogReadonly" />
<span class="waybill-manage-page__route-label">联系方式</span>
<el-input v-model="form.arrivalPhone" placeholder="请输入" :disabled="dialogReadonly" />
</div>
</template>
<template #taskInfoTitle-form>
<div class="waybill-manage-page__task-mode-switch">
<el-segmented
v-model="form.taskEntryMode"
:options="taskEntryModeOptions"
:disabled="dialogReadonly"
@change="handleTaskEntryModeChange"
/>
</div>
</template>
<template #taskInfoForm-form>
<div class="waybill-manage-page__task">
<div v-if="isTaskFullMode" class="waybill-manage-page__task-full">
<div class="waybill-manage-page__subsection">
<div
class="dialog-section-title dialog-section-title--action waybill-manage-page__goods-title"
>
<span>货物信息</span>
<div v-if="!dialogReadonly" class="waybill-manage-page__section-actions">
<!-- <el-link type="primary" @click="cargoImportBox = true">导入货物</el-link> -->
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div>
</div>
<div class="waybill-manage-page__cargo-wrap">
<el-table
:data="transportCargoRows"
border
class="waybill-manage-page__cargo-table waybill-manage-page__cargo-table--task"
empty-text="暂无货物信息"
>
<el-table-column
type="index"
label="序号"
width="70"
align="center"
fixed="left"
/>
<el-table-column min-width="220" align="center">
<template #header>
<span class="waybill-manage-page__required-column">货物类型</span>
</template>
<template #default="{ row }">
<el-cascader
v-model="row.cargoTypePath"
:options="transportCargoTypeOptions"
:props="transportCargoTypeCascaderProps"
placeholder="从货物类型获取"
clearable
filterable
:disabled="dialogReadonly"
:filter-method="filterCargoType"
@visible-change="visible => visible && ensureCargoTypeOptions()"
@change="value => handleTransportCargoTypeChange(row, value)"
/>
</template>
</el-table-column>
<el-table-column min-width="240" align="center">
<template #header>
<span class="waybill-manage-page__required-column">货物名称</span>
</template>
<template #default="{ row }">
<el-autocomplete
v-model="row.cargoName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="
(queryString, callback) =>
fetchTaskCargoSuggestions(queryString, callback, row.cargoTypePath)
"
:disabled="dialogReadonly"
:loading="isTaskCargoOptionsLoading(row.cargoTypePath)"
@select="item => handleTaskCargoRowSelect(row, item)"
>
<template #default="{ item }">
<div class="waybill-manage-page__cargo-autocomplete-item">
<span>{{ formatCargoTypeLabel(item) }}</span>
<small v-if="item.cargoCode">{{ item.cargoCode }}</small>
</div>
</template>
</el-autocomplete>
</template>
</el-table-column>
<el-table-column label="包装" min-width="130" align="center">
<template #default="{ row }">
<el-select v-model="row.packageType" clearable :disabled="dialogReadonly">
<el-option
v-for="item in packageOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column min-width="130" align="center">
<template #header>
<span class="waybill-manage-page__required-column">数量</span>
</template>
<template #default="{ row }">
<el-input
v-model="row.quantity"
placeholder="请输入"
:disabled="dialogReadonly"
@input="value => handleTransportCargoQuantityInput(row, value)"
/>
</template>
</el-table-column>
<el-table-column min-width="140" align="center">
<template #header>
<span class="waybill-manage-page__required-column">数量单位</span>
</template>
<template #default="{ row }">
<el-select
v-model="row.quantityUnit"
clearable
:disabled="dialogReadonly"
@change="handleTransportCargoQuantityUnitChange"
@visible-change="visible => visible && loadTaskQuantityUnitOptions()"
>
<el-option
v-for="item in taskQuantityUnitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="130" align="center">
<template #default="{ row }">
<el-input
v-model="row.brand"
placeholder="请输入"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="规格" min-width="130" align="center">
<template #default="{ row }">
<el-input
v-model="row.specification"
placeholder="请输入"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="型号" min-width="130" align="center">
<template #default="{ row }">
<el-input
v-model="row.model"
placeholder="请输入"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="物料编码" min-width="150" align="center">
<template #default="{ row }">
<el-input
v-model="row.materialCode"
placeholder="请输入"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="设备编码" min-width="150" align="center">
<template #default="{ row }">
<el-input
v-model="row.deviceCode"
placeholder="请输入"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180" align="center">
<template #default="{ row }">
<el-input
v-model="row.remark"
placeholder="请输入"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ $index }">
<div class="waybill-manage-page__cargo-actions">
<el-link
v-if="!dialogReadonly && $index === transportCargoRows.length - 1"
type="primary"
@click="addTransportCargoRow($index)"
>
新增
</el-link>
<el-link
v-if="!dialogReadonly"
type="danger"
@click="removeTransportCargoRow($index)"
>
删除
</el-link>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="dialog-section-title waybill-manage-page__task-content-title">任务信息</div>
<el-form
:model="form"
label-position="right"
label-width="auto"
class="waybill-manage-page__task-form waybill-manage-page__task-form--full"
>
<div class="waybill-manage-page__task-row">
<el-form-item
label="承运类型"
required
class="waybill-manage-page__task-carrier-type waybill-manage-page__task-span-4"
>
<el-radio-group
v-model="form.carrierType"
:disabled="dialogReadonly"
@change="handleTaskCarrierTypeChange"
>
<el-radio-button
v-for="item in waybillTaskCarrierTypes"
:key="item"
:label="item"
:value="item"
/>
</el-radio-group>
</el-form-item>
</div>
<div
v-if="hasTransportType"
class="waybill-manage-page__subsection waybill-manage-page__carrier-section"
>
<div class="waybill-manage-page__subsection-head">
<span>承运信息</span>
</div>
<div class="waybill-manage-page__task-row">
<el-form-item
:label="taskCarrierLabel"
:required="taskCarrierRequired"
class="waybill-manage-page__task-span-1"
>
<el-select
v-model="taskCarrierValue"
:placeholder="`请选择${taskCarrierLabel}`"
filterable
clearable
:suffix-icon="Search"
:loading="taskCarrierLoading"
:disabled="dialogReadonly || !form.projectId"
@visible-change="visible => visible && loadTaskCarrierOptions()"
>
<el-option
v-for="item in taskCarrierOptions"
:key="
item.id ||
item.customerName ||
item.carrierName ||
item.fullName ||
item.name ||
item.customerCode
"
:label="
item.label ||
item.customerName ||
item.carrierName ||
item.fullName ||
item.name
"
:value="taskCarrierOptionValue(item)"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="!isNonRoadTransport"
label="司机"
class="waybill-manage-page__task-span-1"
>
<el-autocomplete
v-model="form.driverName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskDriverSuggestions"
:loading="taskDriverLoading"
:disabled="dialogReadonly"
@select="handleTaskDriverSelect"
/>
</el-form-item>
<el-form-item
v-if="!isNonRoadTransport"
label="手机号"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.driverPhone"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
:label="transportVehicleNoLabel"
required
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.vehicleNo"
:placeholder="`请输入${transportVehicleNoLabel}`"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
v-if="isNonRoadTransport"
label="船长"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.captainName"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
v-if="isNonRoadTransport"
label="联系电话"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.driverPhone"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
v-if="isNonRoadTransport"
label="箱号"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.containerNo"
placeholder="请输入"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
v-if="isNonRoadTransport"
label="舱位"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.cabinNo"
placeholder="请输入"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
</div>
<div v-if="!isNonRoadTransport" class="waybill-manage-page__task-row">
<el-form-item
v-if="showRoadEscortFields"
label="挂车车牌号"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.trailerVehicleNo"
placeholder="请输入"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
v-if="showRoadEscortFields"
label="押运人"
class="waybill-manage-page__task-span-1"
>
<el-autocomplete
v-model="form.escortName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskEscortSuggestions"
:loading="taskEscortLoading"
:disabled="dialogReadonly"
@select="handleTaskEscortSelect"
/>
</el-form-item>
<el-form-item
v-if="showRoadEscortFields"
label="押运人手机号"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.escortPhone"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
v-if="!isNonRoadTransport"
label="里程(km)"
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.mileage"
placeholder="请输入"
clearable
maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly"
@input="handleTaskMileageInput"
/>
</el-form-item>
<el-form-item
:label="isRoadTransport ? '计划发货日期' : '预计发货日期'"
class="waybill-manage-page__task-span-1"
>
<el-date-picker
v-model="form.estimatedStartTime"
type="date"
placeholder="默认填充当天"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
:label="isRoadTransport ? '计划完成日期' : '预计完成日期'"
class="waybill-manage-page__task-span-1"
>
<el-date-picker
v-model="form.estimatedEndTime"
type="date"
placeholder="请输入"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled="dialogReadonly"
/>
</el-form-item>
</div>
<div class="waybill-manage-page__task-row">
<el-form-item
label="备注"
class="waybill-manage-page__task-note waybill-manage-page__task-span-4"
>
<el-input
v-model="form.taskRemark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
:disabled="dialogReadonly"
placeholder="请输入,200字以内"
/>
</el-form-item>
</div>
</div>
<template v-else-if="hasTransportType">
<div v-if="isRoadTransport" class="waybill-manage-page__task-row">
<el-form-item
label="司机"
:required="isRoadTransport"
class="waybill-manage-page__task-span-1"
>
<el-autocomplete
v-model="form.driverName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskDriverSuggestions"
:loading="taskDriverLoading"
:disabled="dialogReadonly"
@select="handleTaskDriverSelect"
/>
</el-form-item>
<el-form-item label="手机号" required class="waybill-manage-page__task-span-1">
<el-input
v-model="form.driverPhone"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="车牌号" required class="waybill-manage-page__task-span-1">
<el-input
v-model="form.vehicleNo"
placeholder="请输入"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="挂车车牌号" class="waybill-manage-page__task-span-1">
<el-input
v-model="form.trailerVehicleNo"
placeholder="请输入"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
</div>
<div v-if="isRoadTransport" class="waybill-manage-page__task-row">
<el-form-item label="押运人" class="waybill-manage-page__task-span-1">
<el-autocomplete
v-model="form.escortName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskEscortSuggestions"
:loading="taskEscortLoading"
:disabled="dialogReadonly"
@select="handleTaskEscortSelect"
/>
</el-form-item>
<el-form-item label="押运人手机号" class="waybill-manage-page__task-span-1">
<el-input
v-model="form.escortPhone"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="里程(km)" class="waybill-manage-page__task-span-1">
<el-input
v-model="form.mileage"
placeholder="请输入"
clearable
maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly"
@input="handleTaskMileageInput"
/>
</el-form-item>
</div>
<div v-if="isRoadTransport" class="waybill-manage-page__task-row">
<el-form-item
:label="isRoadTransport ? '计划发货日期' : '预计发货日期'"
class="waybill-manage-page__task-span-1"
>
<el-date-picker
v-model="form.estimatedStartTime"
type="date"
placeholder="默认填充当天"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item
:label="isRoadTransport ? '计划完成日期' : '预计完成日期'"
class="waybill-manage-page__task-span-1"
>
<el-date-picker
v-model="form.estimatedEndTime"
type="date"
placeholder="请输入"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled="dialogReadonly"
/>
</el-form-item>
</div>
<div v-if="isRoadTransport" class="waybill-manage-page__task-row">
<el-form-item
label="备注"
class="waybill-manage-page__task-note waybill-manage-page__task-span-4"
>
<el-input
v-model="form.taskRemark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
:disabled="dialogReadonly"
placeholder="请输入,200字以内"
/>
</el-form-item>
</div>
<template v-if="isNonRoadTransport">
<div class="waybill-manage-page__task-row">
<el-form-item
:label="transportVehicleNoLabel"
required
class="waybill-manage-page__task-span-1"
>
<el-input
v-model="form.vehicleNo"
:placeholder="`请输入${transportVehicleNoLabel}`"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="船长" class="waybill-manage-page__task-span-1">
<el-input
v-model="form.captainName"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="联系电话" class="waybill-manage-page__task-span-1">
<el-input
v-model="form.driverPhone"
placeholder="请输入"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="箱号" class="waybill-manage-page__task-span-1">
<el-input
v-model="form.containerNo"
placeholder="请输入"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
</div>
<div class="waybill-manage-page__task-row">
<el-form-item label="舱位" class="waybill-manage-page__task-span-1">
<el-input
v-model="form.cabinNo"
placeholder="请输入"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
</div>
<div class="waybill-manage-page__task-row">
<el-form-item
label="备注"
class="waybill-manage-page__task-note waybill-manage-page__task-span-4"
>
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
:disabled="dialogReadonly"
placeholder="请输入,200字以内"
/>
</el-form-item>
</div>
</template>
</template>
</el-form>
<div class="waybill-manage-page__subsection">
<div class="waybill-manage-page__subsection-head">
<span class="waybill-manage-page__label-with-info">
<span>运费信息</span>
<el-tooltip content="可选填写,仅做记录,不影响应付结算" placement="top">
<el-icon class="waybill-manage-page__info-icon"><InfoFilled /></el-icon>
</el-tooltip>
</span>
</div>
<el-form
:model="form"
label-position="right"
label-width="auto"
class="waybill-manage-page__freight-form waybill-manage-page__freight-form--road"
>
<template v-for="group in taskFreightGroups" :key="group.key">
<el-form-item label="单价">
<el-input
:model-value="taskFullFreightGroupUnitPrice(group)"
placeholder="请输入"
clearable
:disabled="dialogReadonly"
@input="
value => handleTaskFullFreightGroupNumberInput(group, 'unitPrice', value)
"
@clear="() => handleTaskFullFreightGroupNumberInput(group, 'unitPrice', '')"
>
<template #append>
<el-select
:model-value="taskFullFreightGroupPriceUnit(group)"
class="waybill-manage-page__append-select waybill-manage-page__append-select--wide"
placeholder="单位"
:disabled="dialogReadonly"
@change="value => handleTaskFullFreightGroupPriceUnitChange(group, value)"
@visible-change="visible => visible && loadTaskFeeUnitOptions()"
>
<el-option
v-for="item in taskFeeUnitSelectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="运费">
<el-input
:model-value="taskFullFreightGroupAmount(group)"
placeholder="请输入运费"
:disabled="dialogReadonly"
@input="value => handleTaskFullFreightGroupAmountInput(group, value)"
@clear="() => handleTaskFullFreightGroupAmountInput(group, '')"
>
<template #suffix>{{ taskFreightCurrencyLabel }}</template>
</el-input>
</el-form-item>
</template>
<el-form-item label="其他运费合计">
<el-input
v-model="form.otherFeeTotal"
placeholder="请输入"
clearable
:disabled="dialogReadonly"
@input="value => handleTaskNumberInput('otherFeeTotal', value)"
>
<template #suffix>{{ taskFreightCurrencyLabel }}</template>
</el-input>
</el-form-item>
<el-form-item label="运费合计">
<el-input :model-value="taskFullFreightTotal" disabled>
<template #suffix>{{ taskFreightCurrencyLabel }}</template>
</el-input>
</el-form-item>
</el-form>
</div>
</div>
<div
v-if="!isTaskFullMode"
class="dialog-section-title waybill-manage-page__task-content-title"
>
任务信息
</div>
<el-form
v-if="!isTaskFullMode"
:model="form"
label-position="right"
label-width="auto"
class="waybill-manage-page__task-form"
>
<el-form-item
label="承运类型"
required
class="waybill-manage-page__task-carrier-type waybill-manage-page__task-span-4"
>
<el-radio-group
v-model="form.carrierType"
:disabled="dialogReadonly"
@change="handleTaskCarrierTypeChange"
>
<el-radio-button
v-for="item in waybillTaskCarrierTypes"
:key="item"
:label="item"
:value="item"
/>
</el-radio-group>
</el-form-item>
<template v-if="hasTransportType">
<el-form-item :label="taskCarrierLabel" :required="taskCarrierRequired">
<el-select
v-model="taskCarrierValue"
:placeholder="`请选择${taskCarrierLabel}`"
filterable
clearable
:loading="taskCarrierLoading"
:disabled="dialogReadonly || !form.projectId"
@visible-change="visible => visible && loadTaskCarrierOptions()"
>
<el-option
v-for="item in taskCarrierOptions"
:key="
item.id ||
item.customerName ||
item.carrierName ||
item.fullName ||
item.name ||
item.customerCode
"
:label="
item.label ||
item.customerName ||
item.carrierName ||
item.fullName ||
item.name
"
:value="taskCarrierOptionValue(item)"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="isRoadTransport"
label="司机"
:required="form.carrierType === '自运'"
>
<el-autocomplete
v-model="form.driverName"
placeholder="请输入司机"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskDriverSuggestions"
:loading="taskDriverLoading"
:disabled="dialogReadonly"
@select="handleTaskDriverSelect"
>
<template #default="{ item }">
<span>{{ driverOptionText(item) }}</span>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item
v-if="isRoadTransport"
label="手机号"
:required="form.carrierType === '自运'"
>
<el-input
v-model="form.driverPhone"
placeholder="请输入手机号"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item :label="transportVehicleNoLabel" required>
<el-input
v-model="form.vehicleNo"
:placeholder="`请输入${transportVehicleNoLabel}`"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item v-if="isNonRoadTransport" label="船长">
<el-input
v-model="form.captainName"
placeholder="请输入船长"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item v-if="isNonRoadTransport && isCarrierMode" label="联系电话">
<el-input
v-model="form.driverPhone"
placeholder="请输入联系电话"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item v-if="isNonRoadTransport" label="箱号">
<el-input
v-model="form.containerNo"
placeholder="请输入箱号"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item v-if="isNonRoadTransport" label="舱位">
<el-input
v-model="form.cabinNo"
placeholder="请输入舱位"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item v-if="showRoadEscortFields" label="挂车车牌号">
<el-input
v-model="form.trailerVehicleNo"
placeholder="请输入挂车车牌号"
clearable
maxlength="30"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item v-if="showRoadEscortFields" label="押运人">
<el-autocomplete
v-model="form.escortName"
placeholder="请输入押运人"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskEscortSuggestions"
:loading="taskEscortLoading"
:disabled="dialogReadonly"
@select="handleTaskEscortSelect"
>
<template #default="{ item }">
<span>{{ driverOptionText(item) }}</span>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item v-if="showRoadEscortFields" label="押运人手机号">
<el-input
v-model="form.escortPhone"
placeholder="请输入押运人手机号"
clearable
maxlength="20"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="货物类型" required>
<el-cascader
v-model="form.cargoTypePath"
:options="transportCargoTypeOptions"
:props="transportCargoTypeCascaderProps"
placeholder="请选择到二级"
clearable
filterable
:disabled="dialogReadonly"
:filter-method="filterCargoType"
@visible-change="visible => visible && ensureCargoTypeOptions()"
@change="handleTaskCargoTypeChange"
/>
</el-form-item>
<el-form-item label="货物名称" required>
<el-autocomplete
v-model="form.cargoName"
placeholder="请输入货物名称"
clearable
:debounce="300"
:fetch-suggestions="
(queryString, callback) =>
fetchTaskCargoSuggestions(queryString, callback, form.cargoTypePath)
"
:disabled="dialogReadonly"
:loading="taskCargoLoading"
@select="handleTaskCargoSelect"
>
<template #default="{ item }">
<div class="waybill-manage-page__cargo-autocomplete-item">
<span>{{ formatCargoTypeLabel(item) }}</span>
<small v-if="item.cargoCode">{{ item.cargoCode }}</small>
</div>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item label="规格">
<el-input
v-model="form.specification"
placeholder="请填写规格"
clearable
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="型号">
<el-input
v-model="form.model"
placeholder="请填写型号"
clearable
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="数量" required>
<el-input
v-model="form.quantity"
placeholder="请输入数量"
clearable
:disabled="dialogReadonly"
@input="value => handleTaskNumberInput('quantity', value)"
>
<template #append>
<el-select
v-model="form.quantityUnit"
class="waybill-manage-page__append-select waybill-manage-page__append-select--wide"
placeholder="单位"
:disabled="dialogReadonly"
@visible-change="visible => visible && loadTaskQuantityUnitOptions()"
>
<el-option
v-for="item in taskQuantityUnitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="里程(km)">
<el-input
v-model="form.mileage"
placeholder="请输入里程"
clearable
maxlength="10"
inputmode="numeric"
:disabled="dialogReadonly"
@input="handleTaskMileageInput"
/>
</el-form-item>
<el-form-item :label="isRoadTransport ? '计划发货日期' : '预计发货日期'">
<el-date-picker
v-model="form.estimatedStartTime"
type="date"
placeholder="请选择预计发货日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item :label="isRoadTransport ? '计划完成日期' : '预计完成日期'">
<el-date-picker
v-model="form.estimatedEndTime"
type="date"
placeholder="请选择预计完成日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled="dialogReadonly"
/>
</el-form-item>
<el-form-item label="单价">
<el-input
v-model="form.unitPrice"
placeholder="请输入单价"
clearable
:disabled="dialogReadonly"
@input="value => handleTaskNumberInput('unitPrice', value)"
>
<template #append>
<el-select
v-model="form.priceUnit"
class="waybill-manage-page__append-select waybill-manage-page__append-select--wide"
placeholder="单位"
:disabled="dialogReadonly"
@visible-change="visible => visible && loadTaskFeeUnitOptions()"
>
<el-option
v-for="item in taskFeeUnitSelectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="其他费用合计">
<el-input
v-model="form.otherFeeTotal"
placeholder="请输入其他费用合计"
clearable
:disabled="dialogReadonly"
@input="value => handleTaskNumberInput('otherFeeTotal', value)"
/>
</el-form-item>
<el-form-item label="备注" class="waybill-manage-page__task-remark">
<el-input
v-model="form.taskRemark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入备注,200字以内"
:disabled="dialogReadonly"
/>
</el-form-item>
</template>
</el-form>
</div>
</template>
<template #attachmentTitle-form>
<div class="dialog-section-title">{{ config.attachmentTitle || '其它附件' }}</div>
</template>
<template #attachmentsJson-form>
<div class="waybill-manage-page__attachment">
<div class="waybill-manage-page__attachment-head">
<el-button
type="primary"
:disabled="!attachmentRows.length"
@click="handleBatchDownload"
>
批量下载
</el-button>
</div>
<el-table
:data="attachmentRows"
border
class="waybill-manage-page__attachment-table"
@selection-change="handleAttachmentSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
label="文件名"
min-width="200"
align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(row)"
@keydown.enter="previewAttachment(row)"
@keydown.space.prevent="previewAttachment(row)"
>{{ row.originalName || row.name }}</span>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220" align="center">
<template #default="{ row }">
<el-input
v-model="row.description"
placeholder="请输入附件描述"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="文件大小" width="110" align="center">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column
prop="uploadTime"
label="上传时间"
width="160"
align="center"
sortable
/>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row, $index }">
<el-link v-if="!dialogReadonly" type="danger" @click="removeAttachment($index)">
删除
</el-link>
<el-link v-else type="primary" @click="downloadAttachment(row)"> 下载 </el-link>
</template>
</el-table-column>
</el-table>
<div class="waybill-manage-page__attachment-upload">
<vehicle-attachment-upload
v-model="attachmentRows"
:readonly="dialogReadonly"
:file-types="attachmentFileTypes"
:max-size="50"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
/>
</div>
</div>
</template>
<template #menu-form>
<div v-if="!dialogReadonly" class="waybill-manage-page__freight-footer-summary">
<span>
运费合计:<strong>¥{{ formatFooterFreightTotal }}</strong>
</span>
<el-link v-if="hasProjectProcessConfig" type="primary" @click="openWaybillProcessConfig">
查看过程配置
</el-link>
</div>
<div
v-if="formPageLocked"
class="waybill-manage-page__standalone-menu-actions"
>
<el-button class="waybill-manage-page__form-close" @click="closeCrudDialog">
关闭
</el-button>
<el-button
v-if="config.enableDraftSave && !dialogReadonly"
class="waybill-manage-page__form-draft"
type="primary"
plain
:loading="draftSaveLoading"
@click="handleDraftSave"
>
{{ config.draftSaveText || '保存' }}
</el-button>
</div>
</template>
<template #menu="{ row, index }">
<div class="waybill-manage-page__row-actions">
<el-link type="primary" v-if="showDetailButton(row)" @click="openDetail(row)"
>详情</el-link
>
<el-link
type="primary"
v-if="hasPermission(`${config.permission}_view`) && !showDetailButton(row)"
@click="$refs.crud.rowView(row, index)"
>
查看
</el-link>
<el-link type="primary" v-if="canEdit(row)" @click="openBusinessForm('edit', row)">
编辑
</el-link>
<el-link type="primary" v-if="canReassign(row)" @click="openReassignDrawer(row)">
重新派单
</el-link>
<el-link type="primary" v-if="canCopy(row)" @click="handleCopy(row)"> 复制 </el-link>
<el-link type="primary" v-if="canEnable(row)" @click="handleAction('enable', row)">
启用
</el-link>
<el-link type="primary" v-if="canDisable(row)" @click="handleAction('disable', row)">
停用
</el-link>
<el-link type="primary" v-if="canCancel(row)" @click="handleAction('cancel', row)">
取消
</el-link>
<el-link type="primary" v-if="canComplete(row)" @click="handleAction('complete', row)">
{{ completeActionLabel }}
</el-link>
<el-link
type="primary"
v-if="canConfirmReturn(row)"
@click="handleAction('confirmReturn', row)"
>
{{ completeActionLabel }}
</el-link>
<el-link type="primary" v-if="canMaintainMileage(row)" @click="openMileageDialog(row)">
维护里程
</el-link>
<el-link
v-for="operation in customOperations(row)"
:key="operation.action"
:type="operation.type || 'primary'"
@click="handleCustomOperation(operation, row)"
>
{{ operation.label }}
</el-link>
<el-link type="danger" v-if="canDelete(row)" @click="rowDel(row)"> 删除 </el-link>
</div>
</template>
</component>
<el-drawer
v-model="reassignDrawer.visible"
title="重新派单"
direction="btt"
size="280px"
append-to-body
destroy-on-close
class="waybill-manage-page__reassign-drawer"
@closed="resetReassignDrawer"
>
<el-form
ref="reassignFormRef"
:model="reassignForm"
:rules="reassignRules"
label-width="72px"
class="waybill-manage-page__reassign-form"
>
<div class="waybill-manage-page__reassign-row">
<div class="waybill-manage-page__reassign-meta">
<span class="waybill-manage-page__reassign-meta-label">运单号</span>
<span class="waybill-manage-page__reassign-meta-value">{{
reassignDrawer.row?.waybillNo || '-'
}}</span>
</div>
<div class="waybill-manage-page__reassign-meta waybill-manage-page__reassign-meta--wide">
<span class="waybill-manage-page__reassign-meta-label">拒绝原因</span>
<span class="waybill-manage-page__reassign-meta-value">{{
driverRejectReasonText(reassignDrawer.row) || '-'
}}</span>
</div>
</div>
<div class="waybill-manage-page__reassign-row">
<el-form-item label="司机" prop="driverName" class="waybill-manage-page__reassign-span-1">
<el-autocomplete
v-model="reassignForm.driverName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchReassignDriverSuggestions"
:loading="reassignDrawer.driverLoading"
style="width: 100%"
@select="handleReassignDriverSelect"
@change="handleReassignDriverChange"
>
<template #default="{ item }">
<span>{{ driverOptionText(item) }}</span>
</template>
</el-autocomplete>
</el-form-item>
<el-form-item
label="手机号"
prop="driverPhone"
class="waybill-manage-page__reassign-span-1"
>
<el-input
v-model="reassignForm.driverPhone"
placeholder="请输入"
clearable
maxlength="20"
/>
</el-form-item>
<el-form-item label="车牌号" prop="vehicleNo" class="waybill-manage-page__reassign-span-1">
<el-input
v-model="reassignForm.vehicleNo"
placeholder="请输入车牌号"
clearable
maxlength="30"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="waybill-manage-page__reassign-footer">
<el-button @click="reassignDrawer.visible = false">取消</el-button>
<el-button
type="primary"
:loading="reassignDrawer.submitting"
@click="submitReassign"
>
确认派单
</el-button>
</div>
</template>
</el-drawer>
<el-dialog
v-model="mileageDialog.visible"
width="520px"
append-to-body
:close-on-click-modal="false"
class="waybill-manage-page__mileage-dialog"
@closed="resetMileageDialog"
>
<template #header>
<div class="dialog-section-title">维护里程</div>
</template>
<el-form
ref="mileageFormRef"
:model="mileageForm"
:rules="mileageRules"
label-position="right"
label-width="auto"
class="waybill-manage-page__mileage-form"
>
<el-form-item label="里程(公里)" prop="mileage">
<el-input
:model-value="mileageForm.mileage"
inputmode="numeric"
maxlength="10"
placeholder="请输入里程"
@input="handleMaintainMileageInput"
>
<template #append>公里</template>
</el-input>
</el-form-item>
<el-form-item label="备注" prop="mileageRemark">
<el-input
v-model="mileageForm.mileageRemark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入里程维护备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="mileageDialog.visible = false">取消</el-button>
<el-button
type="primary"
:loading="mileageDialog.submitting"
@click="submitMileageMaintenance"
>
提交
</el-button>
</template>
</el-dialog>
<empty-pagination
v-show="!formPageLocked && !detailPageLocked"
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
<component
:is="detailContainer"
v-model="detailBox"
:title="`${config.title}详情`"
:append-to-body="!detailPageLocked"
top="10px"
width="96%"
show-close
:class="[
'waybill-manage-page__detail-dialog',
{ 'waybill-manage-page__detail-page': detailPageLocked },
$attrs.option?.dialogCustomClass,
]"
>
<div v-loading="detailLoading" class="waybill-manage-page__detail-content">
<template v-if="detailBox || detailPageLocked">
<section-card class="waybill-manage-page__waybill-detail-summary">
<div class="waybill-manage-page__waybill-heading">
<strong>运单详情</strong>
<span>{{ detailRow.waybillNo || '-' }}</span>
<el-tag :type="statusTagType(detailRow.businessStatus)">
{{ displayStatus(detailRow, 'businessStatus') }}
</el-tag>
<el-tag type="primary">
{{
detailRow.transportTypeName ||
getTransportTypeLabel(detailRow.transportType) ||
waybillTransportMode(detailRow) ||
'-'
}}
</el-tag>
<el-link
v-if="detailRow.id && canChangeWaybillRoute(detailRow)"
class="waybill-manage-page__waybill-route-change-link"
type="primary"
@click="openWaybillRouteChangeDialog"
>
变更运输路线
</el-link>
</div>
<div class="waybill-manage-page__waybill-summary-grid">
<div
v-for="item in waybillDetailSummaryFields"
:key="item[0]"
class="waybill-manage-page__waybill-summary-item"
:class="{ 'is-plan': item[0] === 'planName' }"
>
<span class="waybill-manage-page__waybill-label">{{ item[1] }}</span>
<span
v-if="canOpenWaybillDetailSummaryLink(item[0])"
class="waybill-manage-page__waybill-value is-link"
role="link"
@click="handleWaybillDetailSummaryClick(item[0])"
>
{{ formatDetailValue(detailRow, item[0]) }}
</span>
<span
v-else
:class="['waybill-manage-page__waybill-value', { 'is-link': item[2] }]"
>
{{ formatDetailValue(detailRow, item[0]) }}
</span>
</div>
<div class="waybill-manage-page__waybill-route">
<div class="waybill-manage-page__waybill-route-item is-start">
<div class="waybill-manage-page__waybill-route-marker">起</div>
<div class="waybill-manage-page__waybill-route-detail">
<strong class="waybill-manage-page__waybill-route-name">
{{
formatWaybillRouteCityDistrict(
detailRow.departureAddress || detailRow.departureName
)
}}
</strong>
<el-tooltip
:content="detailRow.departureAddress || detailRow.departureName || '-'"
placement="top"
>
<span class="waybill-manage-page__waybill-route-addr">
{{ detailRow.departureAddress || detailRow.departureName || '-' }}
</span>
</el-tooltip>
<div class="waybill-manage-page__waybill-route-contact">
<span>联系人:{{ detailRow.departureContact || '-' }}</span>
<span>联系方式:{{ detailRow.departurePhone || '-' }}</span>
</div>
</div>
</div>
<span class="waybill-manage-page__waybill-route-line" aria-hidden="true" />
<div class="waybill-manage-page__waybill-route-item is-end">
<div class="waybill-manage-page__waybill-route-marker">终</div>
<div class="waybill-manage-page__waybill-route-detail">
<strong class="waybill-manage-page__waybill-route-name">
{{
formatWaybillRouteCityDistrict(
detailRow.arrivalAddress || detailRow.arrivalName
)
}}
</strong>
<el-tooltip
:content="detailRow.arrivalAddress || detailRow.arrivalName || '-'"
placement="top"
>
<span class="waybill-manage-page__waybill-route-addr">
{{ detailRow.arrivalAddress || detailRow.arrivalName || '-' }}
</span>
</el-tooltip>
<div class="waybill-manage-page__waybill-route-contact">
<span>联系人:{{ detailRow.arrivalContact || '-' }}</span>
<span>联系方式:{{ detailRow.arrivalPhone || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="waybill-manage-page__waybill-attachments">
<span class="waybill-manage-page__waybill-label">附件</span>
<div>
<span
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
class="attachment-file-name"
role="button"
tabindex="0"
@click="previewAttachment(file)"
@keydown.enter="previewAttachment(file)"
@keydown.space.prevent="previewAttachment(file)"
>{{ file.originalName || file.name || '-' }}</span>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
</div>
</section-card>
<section-card title="货物信息">
<el-table
:data="
transportCargoRows.length ? transportCargoRows : parseJsonArray(detailRow.goodsJson)
"
border
>
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="cargoName" label="货物名称" min-width="150" />
<el-table-column prop="cargoType" label="货物类型" min-width="130" />
<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="110" />
<el-table-column prop="materialCode" label="物料编码" min-width="130" />
<el-table-column prop="deviceCode" label="设备编码" min-width="130" />
<el-table-column prop="brand" label="品牌" min-width="110" />
<el-table-column label="规格型号" min-width="140"
><template #default="{ row }">{{
[row.specification, row.model].filter(Boolean).join('/') || '-'
}}</template></el-table-column
>
<el-table-column prop="remark" label="备注" min-width="180" />
</el-table>
</section-card>
<section-card title="承运信息">
<div class="waybill-manage-page__waybill-carrier-grid">
<div class="waybill-manage-page__waybill-detail-field">
<span>承运类型</span><strong>{{ detailRow.carrierType || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>承运商</span><strong>{{ detailRow.carrierName || '-' }}</strong>
</div>
<template v-if="waybillIsRoadTransport(detailRow)">
<div class="waybill-manage-page__waybill-detail-field">
<span>司机</span
><strong
>{{
[detailRow.driverName, detailRow.driverPhone].filter(Boolean).join(' ') || '-'
}}</strong
>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>车牌号</span><strong>{{ detailRow.vehicleNo || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>挂车车牌</span><strong>{{ detailRow.trailerVehicleNo || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>押运人</span
><strong
>{{
[detailRow.escortName, detailRow.escortPhone].filter(Boolean).join(' ') || '-'
}}</strong
>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>里程(km)</span
><strong>{{ waybillDetailMileage(detailRow) || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>计划发货日期</span
><strong>{{ waybillDetailEstimatedStartTime(detailRow) || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>计划完成日期</span
><strong>{{ waybillDetailEstimatedEndTime(detailRow) || '-' }}</strong>
</div>
</template>
<template v-else-if="waybillIsNonRoadTransport(detailRow)">
<div class="waybill-manage-page__waybill-detail-field">
<span>船长</span
><strong
>{{
[detailRow.captainName, detailRow.driverPhone].filter(Boolean).join(' ') || '-'
}}</strong
>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>船班号/船次号/班次号</span>
<strong>{{ detailRow.vehicleNo || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>箱号</span><strong>{{ detailRow.containerNo || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>仓位</span><strong>{{ detailRow.cabinNo || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>预计发货日期</span
><strong>{{ waybillDetailEstimatedStartTime(detailRow) || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>预计完成日期</span
><strong>{{ waybillDetailEstimatedEndTime(detailRow) || '-' }}</strong>
</div>
</template>
<template v-else>
<div class="waybill-manage-page__waybill-detail-field">
<span>{{ waybillVehicleNoLabel(detailRow) }}</span>
<strong>{{ detailRow.vehicleNo || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>里程(km)</span
><strong>{{ waybillDetailMileage(detailRow) || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>计划发货日期</span
><strong>{{ waybillDetailEstimatedStartTime(detailRow) || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>计划完成日期</span
><strong>{{ waybillDetailEstimatedEndTime(detailRow) || '-' }}</strong>
</div>
</template>
<div
class="waybill-manage-page__waybill-detail-field"
:class="{
'waybill-manage-page__waybill-detail-field--remark': waybillIsRoadTransport(detailRow),
'waybill-manage-page__waybill-detail-field--remark-full': waybillIsNonRoadTransport(detailRow),
}"
>
<span>备注</span
><strong>{{ detailRow.taskRemark || detailRow.remark || '-' }}</strong>
</div>
</div>
</section-card>
<section-card title="运费信息">
<div
class="waybill-manage-page__waybill-carrier-grid waybill-manage-page__waybill-fee-grid"
>
<div class="waybill-manage-page__waybill-detail-field">
<span>单价</span>
<strong>
<template v-if="waybillUnitPriceWithCurrency(detailRow).value !== '-'">
<span class="waybill-manage-page__waybill-fee-value">
{{ waybillUnitPriceWithCurrency(detailRow).value }}
</span>
<span v-if="waybillUnitPriceWithCurrency(detailRow).unit">
{{ ' ' + waybillUnitPriceWithCurrency(detailRow).unit }}
</span>
</template>
<template v-else>-</template>
</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>运费</span>
<strong>
<template v-if="waybillAmountWithCurrency(detailRow, 'freight').value !== '-'">
<span class="waybill-manage-page__waybill-fee-value">{{
waybillAmountWithCurrency(detailRow, 'freight').value
}}</span
>元
</template>
<template v-else>-</template>
</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>其他费用合计</span>
<strong>
<template
v-if="waybillAmountWithCurrency(detailRow, 'otherFeeTotal').value !== '-'"
>
<span class="waybill-manage-page__waybill-fee-value">{{
waybillAmountWithCurrency(detailRow, 'otherFeeTotal').value
}}</span
>元
</template>
<template v-else>-</template>
</strong>
</div>
<div class="waybill-manage-page__waybill-detail-field">
<span>运费合计</span>
<strong>
<template
v-if="waybillAmountWithCurrency(detailRow, 'freightTotal').value !== '-'"
>
<span class="waybill-manage-page__waybill-fee-value">{{
waybillAmountWithCurrency(detailRow, 'freightTotal').value
}}</span
>元
</template>
<template v-else>-</template>
</strong>
</div>
</div>
</section-card>
<section-card title="异常上报">
<el-table v-loading="waybillExceptionLoading" :data="waybillExceptionRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="reporterName" label="上报人" min-width="120" align="center" />
<el-table-column label="异常节点" min-width="120" align="center">
<template #default="{ row }">
{{ row.nodeName || '-' }}
</template>
</el-table-column>
<el-table-column
prop="exceptionTypeName"
label="异常类型"
min-width="160"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="reportDescription"
label="上报说明"
min-width="180"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="carrierName"
label="承运商"
min-width="160"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="latestFollowContent"
label="最新跟进说明"
min-width="200"
align="center"
show-overflow-tooltip
/>
<el-table-column label="状态" min-width="120" align="center">
<template #default="{ row }">
<el-link
:type="row.disposalStatus === 'completed' ? 'info' : 'primary'"
@click="
openWaybillException(
row,
row.disposalStatus === 'completed' ? 'detail' : 'follow'
)
"
>
{{ row.disposalStatusName || '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<div class="waybill-manage-page__exception-actions">
<template v-if="row.disposalStatus !== 'completed'">
<el-link
v-if="permission.exception_disposal_follow"
type="primary"
@click="openWaybillException(row, 'follow')"
>
跟进
</el-link>
<el-link
v-if="permission.exception_disposal_edit"
type="primary"
@click="openWaybillException(row, 'edit')"
>
编辑
</el-link>
<el-link
v-if="permission.exception_disposal_complete"
type="primary"
@click="completeWaybillExceptionRow(row)"
>
完成
</el-link>
</template>
<template v-else>
<el-link
v-if="permission.exception_disposal_view"
type="primary"
@click="openWaybillException(row, 'detail')"
>
详情
</el-link>
<el-link
v-if="permission.exception_disposal_edit"
type="primary"
@click="openWaybillException(row, 'edit')"
>
编辑
</el-link>
</template>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
<div class="waybill-manage-page__waybill-detail-bottom">
<section-card title="执行详情">
<el-tabs v-model="waybillProcessDetailTab" type="border-card">
<el-tab-pane label="打卡详情" name="punch">
<div v-loading="waybillPunchRecordsLoading">
<el-timeline v-if="waybillPunchRecords.length">
<el-timeline-item
v-for="(record, index) in orderedWaybillPunchRecords"
:key="waybillPunchRecordKey(record, index)"
:timestamp="record.punchTime || record.statusName || '未打卡'"
:type="record.punched ? 'primary' : 'info'"
:color="record.punched ? '#2b6ce8' : '#c0c4cc'"
placement="top"
>
<div class="waybill-manage-page__punch-record">
<div class="waybill-manage-page__punch-record-title">
{{ record.nodeName || record.nodeCode || '打卡' }}
<el-tag
:type="record.punched ? 'success' : 'info'"
size="small"
effect="plain"
>{{ record.statusName || (record.punched ? '已打卡' : '未打卡') }}</el-tag
>
<template
v-if="waybillPunchRecordExceptions[waybillPunchRecordKey(record, index)]"
>
<el-tag
size="small"
:type="
waybillPunchRecordExceptions[waybillPunchRecordKey(record, index)]
.unfinished
? 'danger'
: 'success'
"
effect="plain"
>{{
waybillPunchRecordExceptions[waybillPunchRecordKey(record, index)]
.unfinished
? '异常'
: '已处理异常'
}}</el-tag
>
<el-link
class="waybill-manage-page__punch-record-exception-link"
type="primary"
:underline="false"
@click="
openWaybillPunchRecordException(
waybillPunchRecordExceptions[waybillPunchRecordKey(record, index)]
)
"
>{{
waybillPunchRecordExceptions[waybillPunchRecordKey(record, index)]
.unfinished
? '跟进'
: '详情'
}}</el-link
>
</template>
<el-link
v-if="canEditWaybillPunchRecord && !isAcceptPunchRecord(record)"
class="waybill-manage-page__punch-record-edit-link"
type="primary"
:underline="false"
@click="openWaybillPunchRecordEdit(record)"
>{{ record.punched ? '编辑打卡' : '补卡' }}</el-link
>
</div>
<template v-if="record.punched">
<div
v-if="record.address"
class="waybill-manage-page__punch-record-line"
>
地点:{{ record.address }}
</div>
<div
v-if="record.weight || record.volume || record.quantity"
class="waybill-manage-page__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>
<!-- 里程只挂在末位节点上(needMileage 由后端下发),显示的是运单里程 -->
<div
v-if="record.needMileage && Number(record.mileage) > 0"
class="waybill-manage-page__punch-record-line"
>
里程 {{ record.mileage }} 公里
</div>
<div
v-if="record.remark"
class="waybill-manage-page__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="waybill-manage-page__punch-record-line">暂未打卡</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty
v-else-if="!waybillPunchRecordsLoading"
description="暂无打卡记录"
:image-size="50"
/>
</div>
</el-tab-pane>
<el-tab-pane
v-if="waybillHasRelatedVoucher"
label="批量补录"
name="batchSupplement"
>
<voucher-execution-detail
v-if="waybillIsCarrier(detailRow)"
:waybill-id="detailRow.id"
/>
<div
v-else-if="waybillVoucherFolderView"
class="waybill-manage-page__voucher-folder-toolbar"
>
<el-button text type="primary" @click="backToWaybillVoucherFolders">
返回文件夹
</el-button>
<span>{{ waybillVoucherFolderName(waybillVoucherFolder) }}</span>
</div>
<div
v-if="!waybillIsCarrier(detailRow)"
v-loading="waybillVoucherImagesLoading"
class="waybill-manage-page__voucher-image-grid"
>
<div
v-for="(image, index) in waybillVoucherImages"
:key="
image.id ||
image.objectKey ||
(isWaybillVoucherFolder(image)
? `${image.voucherId || 'folder'}-${image.folderName || image.name || index}`
: index)
"
:class="[
'waybill-manage-page__voucher-image-item',
{
'waybill-manage-page__voucher-image-item--folder':
isWaybillVoucherFolder(image),
},
]"
:title="
isWaybillVoucherFolder(image)
? waybillVoucherFolderName(image)
: image.imageName || image.name
"
@click="
isWaybillVoucherFolder(image)
? openWaybillVoucherFolder(image)
: previewWaybillVoucherImage(index)
"
>
<el-image
:src="
isWaybillVoucherFolder(image)
? image.icon || '/img/文件夹.png'
: image.url
"
:fit="isWaybillVoucherFolder(image) ? 'contain' : 'cover'"
lazy
/>
<div
v-if="isWaybillVoucherFolder(image)"
class="waybill-manage-page__voucher-folder-name"
>
{{ waybillVoucherFolderName(image) }}
</div>
</div>
<el-empty
v-if="!waybillVoucherImagesLoading && !waybillVoucherImages.length"
description="暂无凭证图片"
:image-size="50"
/>
</div>
</el-tab-pane>
<el-tab-pane
v-if="waybillDriverUploads.length"
label="司机上传"
name="driverUpload"
>
<div
v-loading="waybillPunchRecordsLoading"
class="waybill-manage-page__driver-upload-grid"
>
<div
v-for="(photo, index) in waybillDriverUploads"
:key="`${photo.url}-${index}`"
class="waybill-manage-page__driver-upload-item"
:title="photo.label"
@click="previewDriverUploadPhoto(waybillDriverUploads, index)"
>
<el-image :src="photo.url" fit="cover" lazy />
<div class="waybill-manage-page__driver-upload-label">
{{ photo.label || '凭证' }}
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</section-card>
<section-card title="物流轨迹">
<template #extra>
<div class="waybill-manage-page__waybill-track-actions">
<el-button
plain
:type="waybillTrackMode === 'playback' ? 'primary' : undefined"
:loading="waybillTrackLoading"
@click="handleWaybillTrackAction('playback')"
>轨迹回放</el-button
>
<el-button
plain
:type="waybillTrackMode === 'locate' ? 'primary' : undefined"
:loading="waybillLocateLoading"
@click="handleWaybillTrackAction('locate')"
>实时定位</el-button
>
</div>
</template>
<div
v-if="waybillTrackMode === 'playback'"
class="waybill-manage-page__waybill-track-toolbar"
>
<span>时间段</span>
<el-date-picker
v-model="waybillTrackDateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:clearable="false"
:disabled="waybillTrackLoading"
/>
<el-button
type="primary"
:loading="waybillTrackLoading"
@click="loadWaybillTrack"
>查询</el-button
>
<span class="waybill-manage-page__waybill-track-total"
>轨迹点 {{ waybillTrackInfo?.total || 0 }} 个</span
>
</div>
<div
v-if="waybillTrackMode === 'locate'"
class="waybill-manage-page__waybill-locate"
>
<div v-if="waybillLocateInfo" class="waybill-manage-page__waybill-locate-meta">
<div>
<span>车牌号</span><strong>{{ waybillLocateInfo.vehicleNo || '-' }}</strong>
</div>
<div>
<span>定位时间</span><strong>{{ waybillLocateInfo.locateTime || '-' }}</strong>
</div>
<div>
<span>速度</span><strong>{{ waybillLocateInfo.speed || '-' }}</strong>
</div>
<div>
<span>方向</span><strong>{{ waybillLocateInfo.direction || '-' }}</strong>
</div>
<div class="waybill-manage-page__waybill-locate-address">
<span>地址</span><strong>{{ waybillLocateInfo.address || '-' }}</strong>
</div>
<div>
<span>坐标</span
><strong
>{{
waybillLocateInfo.longitude != null && waybillLocateInfo.latitude != null
? `${waybillLocateInfo.longitude}, ${waybillLocateInfo.latitude}`
: '-'
}}
</strong>
</div>
</div>
<div
ref="waybillLocateMap"
class="waybill-manage-page__waybill-locate-map"
></div>
<div
v-if="!waybillLocateInfo"
class="waybill-manage-page__waybill-map-tip"
>
{{ waybillLocateHint || '暂无数据' }}
</div>
</div>
<div
v-else-if="waybillTrackMode === 'playback'"
class="waybill-manage-page__waybill-locate"
>
<div
ref="waybillTrackMap"
class="waybill-manage-page__waybill-locate-map waybill-manage-page__waybill-locate-map--track"
></div>
<div
v-if="!(waybillTrackInfo?.points || []).length"
class="waybill-manage-page__waybill-map-tip"
>
{{ waybillTrackHint || '暂无数据' }}
</div>
</div>
<div v-else class="waybill-manage-page__waybill-map-empty">暂无数据</div>
</section-card>
</div>
</template>
</div>
<div v-if="detailPageLocked" class="waybill-manage-page__detail-footer">
<el-button type="primary" @click="closeDetail">关闭</el-button>
</div>
<template v-if="!detailPageLocked" #footer>
<el-button type="primary" @click="closeDetail">关闭</el-button>
</template>
</component>
<el-dialog
v-model="waybillExceptionDialog.visible"
:title="waybillExceptionDialogTitle"
:width="waybillExceptionDialog.mode === 'edit' ? '1080px' : '960px'"
top="6vh"
append-to-body
destroy-on-close
>
<div
v-loading="waybillExceptionDialog.loading"
class="waybill-manage-page__exception-dialog"
>
<el-form
v-if="waybillExceptionDialog.mode === 'edit'"
ref="waybillExceptionEditFormRef"
:model="waybillExceptionEditForm"
:rules="waybillExceptionEditRules"
label-position="right"
label-width="96px"
>
<section-card title="运单信息">
<el-descriptions :column="3" border size="small">
<el-descriptions-item
v-for="field in waybillExceptionWaybillFields"
:key="field.prop"
:label="field.label"
>
{{ waybillExceptionValue(field.prop) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card title="异常信息">
<el-row :gutter="18">
<el-col :span="12">
<el-form-item label="上报人">
<span class="waybill-manage-page__exception-readonly">{{
waybillExceptionValue('reporterName')
}}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上报时间">
<span class="waybill-manage-page__exception-readonly">{{
waybillExceptionValue('reportTime')
}}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="异常节点">
<span class="waybill-manage-page__exception-readonly">{{
waybillExceptionValue('nodeName')
}}</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="异常类型" prop="exceptionType">
<el-select
v-model="waybillExceptionEditForm.exceptionType"
placeholder="请选择"
@change="handleWaybillExceptionTypeChange"
>
<el-option
v-for="item in waybillExceptionTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="异常原因" prop="exceptionReason">
<el-select
v-model="waybillExceptionEditForm.exceptionReason"
clearable
placeholder="请选择"
:disabled="!waybillExceptionEditForm.exceptionType"
>
<el-option
v-for="item in waybillExceptionReasonOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="上报说明" prop="reportDescription">
<el-input
v-model="waybillExceptionEditForm.reportDescription"
type="textarea"
maxlength="500"
show-word-limit
:rows="3"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="现场照片">
<div class="waybill-manage-page__exception-edit-photos">
<div
v-for="(url, index) in waybillExceptionEditForm.scenePhotoList"
:key="url"
class="waybill-manage-page__exception-edit-photo"
>
<el-image
:src="url"
:preview-src-list="waybillExceptionEditForm.scenePhotoList"
:initial-index="index"
fit="cover"
/>
<el-link
type="danger"
:underline="false"
@click="removeWaybillExceptionPhoto(index)"
>删除</el-link
>
</div>
<el-upload
action="/api/blade-resource/oss/endpoint/put-file"
name="file"
:headers="waybillExceptionUploadHeaders"
:show-file-list="false"
:before-upload="beforeWaybillExceptionPhotoUpload"
:on-success="handleWaybillExceptionPhotoSuccess"
:on-error="handleWaybillExceptionPhotoError"
>
<div class="waybill-manage-page__exception-edit-photo-add">+ 添加</div>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
</section-card>
</el-form>
<template v-else>
<section-card title="运单信息">
<el-descriptions :column="3" border size="small">
<el-descriptions-item
v-for="field in waybillExceptionWaybillFields"
:key="field.prop"
:label="field.label"
>
{{ waybillExceptionValue(field.prop) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card title="异常信息">
<el-descriptions :column="2" border size="small">
<el-descriptions-item
v-for="field in waybillExceptionDetailFields"
:key="field.prop"
:label="field.label"
:span="field.span === 2 ? 2 : 1"
>
{{ waybillExceptionValue(field.prop) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card title="现场照片">
<div class="waybill-manage-page__exception-photos">
<el-image
v-for="(url, index) in waybillExceptionPhotoUrls"
:key="url"
:src="url"
:preview-src-list="waybillExceptionPhotoUrls"
:initial-index="index"
fit="cover"
/>
<el-empty
v-if="!waybillExceptionPhotoUrls.length"
description="暂无现场照片"
:image-size="56"
/>
</div>
</section-card>
<section-card v-if="waybillExceptionDialog.mode === 'follow'" title="跟进">
<el-form
ref="waybillExceptionFollowFormRef"
:model="waybillExceptionFollowForm"
:rules="waybillExceptionFollowRules"
label-position="right"
label-width="96px"
>
<el-row :gutter="18">
<el-col :span="24">
<el-form-item label="跟进说明" prop="followContent">
<el-input
v-model="waybillExceptionFollowForm.followContent"
type="textarea"
maxlength="500"
show-word-limit
:rows="3"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="跟进附件">
<vehicle-attachment-upload
v-model="waybillExceptionFollowForm.attachments"
:limit="20"
:max-size="500"
button-text="上传附件"
:show-tip="false"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</section-card>
<section-card title="历史跟进">
<el-table :data="waybillExceptionDetail.followRecords || []" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
v-for="column in waybillExceptionFollowColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:min-width="column.minWidth"
align="center"
show-overflow-tooltip
/>
<el-table-column label="附件" min-width="120" align="center">
<template #default="{ row }">
<template v-if="parseWaybillExceptionAttachments(row.attachmentsJson).length">
<el-link
v-for="(file, index) in parseWaybillExceptionAttachments(
row.attachmentsJson
)"
:key="index"
type="primary"
class="waybill-manage-page__exception-attachment-link"
@click="previewWaybillExceptionAttachment(file, row)"
>
{{ file.name || '附件' + (index + 1) }}
</el-link>
</template>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</section-card>
</template>
</div>
<template #footer>
<el-button
v-if="waybillExceptionDialog.mode === 'edit' && permission.exception_disposal_edit"
type="primary"
:loading="waybillExceptionDialog.submitting"
@click="submitWaybillExceptionEdit"
>
提交
</el-button>
<el-button
v-if="waybillExceptionDialog.mode === 'follow' && permission.exception_disposal_follow"
type="primary"
:loading="waybillExceptionDialog.submitting"
@click="submitWaybillExceptionFollow"
>
提交
</el-button>
<el-button
v-if="
waybillExceptionDialog.mode === 'follow' && permission.exception_disposal_complete
"
type="primary"
plain
@click="completeWaybillExceptionFromDialog"
>
完成
</el-button>
<el-button @click="waybillExceptionDialog.visible = false">取消</el-button>
</template>
</el-dialog>
<el-dialog
v-model="waybillPunchRecordEditDialog.visible"
title="编辑打卡记录"
append-to-body
destroy-on-close
width="720px"
top="8vh"
class="waybill-manage-page__punch-edit-dialog"
>
<el-form
ref="waybillPunchRecordEditFormRef"
:model="waybillPunchEditForm"
:rules="waybillPunchEditRules"
label-position="right"
label-width="96px"
>
<section-card title="打卡信息">
<el-row :gutter="18">
<el-col :span="24">
<el-form-item label="过程节点">
<span class="waybill-manage-page__punch-edit-readonly">{{
waybillPunchEditRecord.nodeName || waybillPunchEditRecord.nodeCode || '-'
}}</span>
</el-form-item>
</el-col>
<el-col v-if="waybillPunchEditPunchDisabled" :span="24">
<el-alert
type="info"
:closable="false"
show-icon
title="该节点过程配置为不打卡,保存仅作数据补录"
/>
</el-col>
<el-col :span="12">
<el-form-item label="打卡时间" prop="punchTime">
<el-date-picker
v-model="waybillPunchEditForm.punchTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择打卡时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col v-if="waybillPunchEditFields.location" :span="24">
<el-form-item label="打卡地址">
<el-input
v-model="waybillPunchEditForm.address"
readonly
maxlength="500"
placeholder="点击选择地图地址"
:suffix-icon="Location"
@click="waybillPunchAddressMapVisible = true"
/>
</el-form-item>
</el-col>
<el-col v-if="waybillPunchEditFields.location" :span="12">
<el-form-item label="经度">
<el-input v-model="waybillPunchEditForm.longitude" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col v-if="waybillPunchEditFields.location" :span="12">
<el-form-item label="纬度">
<el-input v-model="waybillPunchEditForm.latitude" placeholder="请输入" />
</el-form-item>
</el-col>
<el-col v-for="item in waybillPunchEditFields.cargoFields" :key="item.prop" :span="8">
<el-form-item :label="item.label">
<el-input
v-model="waybillPunchEditForm[item.prop]"
maxlength="32"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<!-- 里程只在末位打卡节点出现:后端按「末位 + 计费规则含里程」下发 needMileage,前端不自己推 -->
<el-col v-if="waybillPunchEditMileageVisible" :span="8">
<el-form-item label="里程">
<el-input
v-model="waybillPunchEditForm.mileage"
maxlength="10"
:disabled="waybillPunchEditMileageLocked"
placeholder="请输入"
@input="handleWaybillPunchMileageInput"
>
<template #append>公里</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input
v-model="waybillPunchEditForm.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="打卡照片">
<punch-record-photos
:photos="waybillPunchEditForm.photos"
:group-types="waybillPunchEditPhotoTypes"
:max-per-type="waybillPunchEditPhotoLimit"
editable
native-preview
upload-action="/api/blade-resource/oss/endpoint/put-file"
:upload-headers="waybillExceptionUploadHeaders"
:before-upload="beforeWaybillPunchPhotoUpload"
@remove="removeWaybillPunchEditPhoto"
@upload-success="handleWaybillPunchPhotoSuccess"
@upload-error="handleWaybillPunchPhotoError"
/>
<div
v-if="waybillPunchEditRecord.type === 'enroute'"
class="waybill-manage-page__punch-edit-photo-tip"
>
在途打卡只保留 1 张照片
</div>
</el-form-item>
</el-col>
</el-row>
</section-card>
</el-form>
<template #footer>
<el-button @click="waybillPunchRecordEditDialog.visible = false">取消</el-button>
<el-button
type="primary"
:loading="waybillPunchRecordEditDialog.submitting"
@click="submitWaybillPunchRecordEdit"
>
保存
</el-button>
</template>
</el-dialog>
<address-map-picker
v-model="waybillPunchAddressMapVisible"
:address="waybillPunchEditForm.address"
:longitude="waybillPunchEditForm.longitude"
:latitude="waybillPunchEditForm.latitude"
@confirm="handleWaybillPunchAddressMapConfirm"
/>
<el-dialog
v-model="attachmentDocumentPreviewVisible"
:title="attachmentPreviewFile.name || '附件预览'"
append-to-body
destroy-on-close
width="90%"
top="4vh"
>
<open-file-viewer
v-if="attachmentDocumentPreviewVisible && attachmentPreviewFile.url"
:file="attachmentPreviewFile.url"
:file-name="attachmentPreviewFile.name"
:mime-type="attachmentPreviewFile.mimeType"
width="100%"
height="72vh"
fit="contain"
theme="auto"
locale="zh-CN"
:toolbar="attachmentViewerToolbar"
:plugins="attachmentViewerPlugins"
@unsupported="handleAttachmentPreviewUnsupported"
@error="handleAttachmentPreviewError"
/>
</el-dialog>
<el-image-viewer
v-if="attachmentImagePreviewVisible"
:url-list="attachmentImagePreviewUrls"
:initial-index="attachmentImagePreviewIndex"
@close="attachmentImagePreviewVisible = false"
/>
<el-dialog
v-model="waybillRouteChangeBox"
title="变更运输路线"
append-to-body
destroy-on-close
width="78%"
class="waybill-manage-page__route-change-dialog"
>
<el-tabs type="border-card" v-model="waybillRouteChangeTab">
<el-tab-pane label="变更路线" name="change">
<section-card title="运单地址">
<el-table :data="waybillRouteChangeRows" border>
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="waybillNo" label="运单号" min-width="150">
<template #default="{ row }">
<el-link type="primary">{{ row.waybillNo || '-' }}</el-link>
</template>
</el-table-column>
<el-table-column prop="projectName" label="项目名称" min-width="160" />
<el-table-column label="发货地" min-width="310">
<template #default="{ row }">
<div class="waybill-manage-page__route-change-original">
原:{{ row.originalDepartureAddress || '-' }}
</div>
<el-input
:model-value="row.departureAddress"
readonly
placeholder="请点击地图选点"
@click="openWaybillRouteChangeMapDialog(row, 'departure')"
>
<template #suffix>
<el-icon
class="waybill-manage-page__route-change-location"
@click.stop="openWaybillRouteChangeMapDialog(row, 'departure')"
>
<Location />
</el-icon>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="到货地" min-width="310">
<template #default="{ row }">
<div class="waybill-manage-page__route-change-original">
原:{{ row.originalArrivalAddress || '-' }}
</div>
<el-input
:model-value="row.arrivalAddress"
readonly
placeholder="请点击地图选点"
@click="openWaybillRouteChangeMapDialog(row, 'arrival')"
>
<template #suffix>
<el-icon
class="waybill-manage-page__route-change-location"
@click.stop="openWaybillRouteChangeMapDialog(row, 'arrival')"
>
<Location />
</el-icon>
</template>
</el-input>
</template>
</el-table-column>
</el-table>
</section-card>
<el-form label-width="auto">
<el-form-item label="变更备注" style="margin-top: 8px">
<el-input
v-model="waybillRouteChangeRemark"
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="waybillRouteChangeRecords" 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="waybill-manage-page__route-change-record-content">
<div
v-for="(group, groupIndex) in parseRouteChangeContent(row.content)"
:key="groupIndex"
class="waybill-manage-page__route-change-group"
>
<div
v-if="group.title"
class="waybill-manage-page__route-change-group-title"
>
{{ group.title }}
</div>
<div v-if="group.text" class="waybill-manage-page__route-change-group-text">
{{ group.text }}
</div>
<div
v-for="(field, fieldIndex) in group.fields"
:key="fieldIndex"
class="waybill-manage-page__route-change-field"
>
<span class="waybill-manage-page__route-change-field-label">
{{ field.label }}
</span>
<template v-if="field.changed">
<span class="waybill-manage-page__route-change-before">
{{ field.before }}
</span>
<span class="waybill-manage-page__route-change-arrow">→</span>
<span class="waybill-manage-page__route-change-after">
{{ field.after }}
</span>
</template>
<span v-else class="waybill-manage-page__route-change-single">
{{ field.single }}
<span
v-if="field.unchanged"
class="waybill-manage-page__route-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="!waybillRouteChangeRecords.length"
description="暂无变更记录"
:image-size="60"
/>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="waybillRouteChangeBox = false">关闭</el-button>
<el-button
v-if="waybillRouteChangeTab === 'change'"
type="primary"
:loading="waybillRouteChangeSaving"
@click="confirmWaybillRouteChange"
>
确认变更
</el-button>
</template>
</el-dialog>
<flow-design
v-if="website.design.designMode"
is-dialog
v-model:is-display="flowBox"
:process-instance-id="processInstanceId"
/>
<el-dialog v-else title="流程图" append-to-body v-model="flowBox" :fullscreen="true">
<iframe
:src="flowUrl"
width="100%"
height="700"
title="流程图"
frameBorder="no"
border="0"
marginWidth="0"
marginHeight="0"
scrolling="no"
allowTransparency="yes"
/>
<template #footer>
<el-button @click="flowBox = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
title="选择线路"
append-to-body
v-model="transportRouteBox"
width="1280px"
@opened="loadTransportRouteList"
>
<div class="waybill-manage-page__dialog-search">
<el-form :model="transportRouteQuery" label-position="right" label-width="86px">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="线路名称">
<el-input v-model="transportRouteQuery.routeName" clearable placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="发货地址">
<el-input
v-model="transportRouteQuery.departureAddress"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="收货地址">
<el-input
v-model="transportRouteQuery.arrivalAddress"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6" class="waybill-manage-page__dialog-search-actions">
<el-button type="primary" @click="handleTransportRouteSearch">查询</el-button>
<el-button @click="handleTransportRouteReset">重置</el-button>
</el-col>
</el-row>
</el-form>
</div>
<el-table border height="320" :data="transportRouteRows" v-loading="transportRouteLoading">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="routeName" label="线路名称" min-width="150" />
<el-table-column prop="routeCode" label="线路编号" min-width="140" />
<el-table-column prop="departureName" label="发货地" min-width="140" />
<el-table-column
prop="departureAddress"
label="发货地址"
min-width="220"
show-overflow-tooltip
/>
<el-table-column prop="departureContact" label="发货联系人" min-width="120" />
<el-table-column prop="departurePhone" label="发货联系方式" min-width="140" />
<el-table-column prop="arrivalName" label="收货地" min-width="140" />
<el-table-column
prop="arrivalAddress"
label="收货地址"
min-width="220"
show-overflow-tooltip
/>
<el-table-column prop="arrivalContact" label="收货联系人" min-width="120" />
<el-table-column prop="arrivalPhone" label="收货联系方式" min-width="140" />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column prop="deptName" label="组织" min-width="150" show-overflow-tooltip />
<el-table-column
prop="updateUserName"
label="更新人"
min-width="120"
:formatter="formatTransportRouteUpdateUserName"
/>
<el-table-column prop="updateTime" label="更新时间" min-width="160" sortable />
<el-table-column prop="createTime" label="创建时间" min-width="160" sortable />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click.stop="selectTransportRouteRow(row)"> 选择 </el-link>
</template>
</el-table-column>
</el-table>
<div class="waybill-manage-page__dialog-pagination">
<el-pagination
v-model:current-page="transportRoutePage.currentPage"
v-model:page-size="transportRoutePage.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
:total="transportRoutePage.total"
@size-change="handleTransportRouteSizeChange"
@current-change="handleTransportRouteCurrentChange"
/>
</div>
<template #footer>
<el-button @click="transportRouteBox = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
title="选择常用地址"
append-to-body
v-model="transportAddressBox"
width="1280px"
@opened="loadTransportAddressList"
>
<div class="waybill-manage-page__dialog-search">
<el-form :model="transportAddressQuery" label-position="right" label-width="86px">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="地址名称">
<el-input
v-model="transportAddressQuery.addressName"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="地址类型">
<el-select
v-model="transportAddressQuery.addressType"
clearable
filterable
placeholder="请选择"
:disabled="!!getTransportFixedAddressType()"
>
<el-option
v-for="item in addressTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="详细地址">
<el-input
v-model="transportAddressQuery.detailAddress"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6" class="waybill-manage-page__dialog-search-actions">
<el-button type="primary" @click="handleTransportAddressSearch">查询</el-button>
<el-button @click="handleTransportAddressReset">重置</el-button>
</el-col>
</el-row>
</el-form>
</div>
<el-table
border
height="320"
:data="transportAddressRows"
v-loading="transportAddressLoading"
>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="addressName" label="地址名称" min-width="150" />
<el-table-column prop="addressCode" label="地址编号" min-width="130" />
<el-table-column prop="addressType" label="类型" min-width="120" />
<el-table-column
prop="siteCodeDisplay"
label="站点编码"
min-width="120"
:formatter="formatTransportAddressSiteCode"
/>
<el-table-column
prop="detailAddress"
label="详细地址"
min-width="260"
show-overflow-tooltip
/>
<el-table-column prop="longitude" label="经度" min-width="120" />
<el-table-column prop="latitude" label="纬度" min-width="120" />
<el-table-column prop="regionName" label="行政区划" min-width="150" show-overflow-tooltip />
<el-table-column prop="contactName" label="联系人" min-width="120" />
<el-table-column prop="contactPhone" label="联系方式" min-width="140" />
<el-table-column prop="deptName" label="组织" min-width="150" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" min-width="160" sortable />
<el-table-column prop="createTime" label="创建时间" min-width="160" sortable />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click.stop="selectTransportAddressRow(row)"> 选择 </el-link>
</template>
</el-table-column>
</el-table>
<div class="waybill-manage-page__dialog-pagination">
<el-pagination
v-model:current-page="transportAddressPage.currentPage"
v-model:page-size="transportAddressPage.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
:total="transportAddressPage.total"
@size-change="handleTransportAddressSizeChange"
@current-change="handleTransportAddressCurrentChange"
/>
</div>
<template #footer>
<el-button @click="transportAddressBox = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
:title="`选择${transportStationTypeLabel || '站点'}`"
append-to-body
v-model="transportStationBox"
width="1180px"
@opened="loadTransportStationList"
>
<div class="waybill-manage-page__dialog-search">
<el-form :model="transportStationQuery" label-position="right" label-width="86px">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="站点名称">
<el-input v-model="transportStationQuery.name" clearable placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="地址类型">
<el-input :model-value="transportStationTypeLabel" disabled />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="详细地址">
<el-input
v-model="transportStationQuery.detailAddress"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6" class="waybill-manage-page__dialog-search-actions">
<el-button type="primary" @click="handleTransportStationSearch">查询</el-button>
<el-button @click="handleTransportStationReset">重置</el-button>
</el-col>
</el-row>
</el-form>
</div>
<el-table
border
height="320"
:data="transportStationRows"
v-loading="transportStationLoading"
>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="stationName" label="站点名" min-width="160" />
<el-table-column prop="stationType" label="类型" min-width="120" />
<el-table-column prop="stationCode" label="站点编码" min-width="130" />
<el-table-column
prop="detailAddress"
label="详细地址"
min-width="260"
show-overflow-tooltip
/>
<el-table-column prop="longitude" label="经度" min-width="120" />
<el-table-column prop="latitude" label="纬度" min-width="120" />
<el-table-column prop="regionName" label="行政区划" min-width="160" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" min-width="160" sortable />
<el-table-column prop="createTime" label="创建时间" min-width="160" sortable />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click.stop="selectTransportStationRow(row)"> 选择 </el-link>
</template>
</el-table-column>
</el-table>
<div class="waybill-manage-page__dialog-pagination">
<el-pagination
v-model:current-page="transportStationPage.currentPage"
v-model:page-size="transportStationPage.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
:total="transportStationPage.total"
@size-change="handleTransportStationSizeChange"
@current-change="handleTransportStationCurrentChange"
/>
</div>
<template #footer>
<el-button @click="transportStationBox = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
title="地图选择地址"
append-to-body
v-model="transportMapBox"
width="920px"
@opened="initTransportAmap"
@closed="waybillRouteChangeMapTarget = null"
>
<div class="waybill-manage-page__map-toolbar">
<el-input
v-model="transportMapKeyword"
clearable
placeholder="输入地址关键词"
@keyup.enter="searchTransportMapKeyword"
/>
<el-button
type="primary"
:icon="Search"
:loading="transportMapLoading"
@click="searchTransportMapKeyword"
>
搜索
</el-button>
</div>
<div class="map-picker-content">
<div ref="transportAmap" class="waybill-manage-page__map"></div>
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
</div>
<div class="waybill-manage-page__map-info">
<span>{{ transportMapStatus }}</span>
<span v-if="transportMapSelected.regionName"
>行政区划:{{ transportMapSelected.regionName }}</span
>
</div>
<template #footer>
<el-button @click="closeTransportMapDialog">取消</el-button>
<el-button
type="primary"
:disabled="!transportMapSelected.longitude"
@click="confirmTransportMapPick"
>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
title="选择常用货物"
append-to-body
v-model="commonCargoBox"
width="1280px"
@opened="loadCommonCargoList"
>
<div class="waybill-manage-page__dialog-search">
<el-form :model="commonCargoQuery" label-position="right" label-width="100px">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="货物名称">
<el-input v-model="commonCargoQuery.cargoName" clearable placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="货物类型">
<el-cascader
v-model="commonCargoQuery.cargoTypePath"
:options="cargoTypeOptions"
:props="cargoTypeCascaderProps"
placeholder="请选择"
clearable
filterable
:filter-method="filterCargoType"
@visible-change="visible => visible && ensureCargoTypeOptions()"
@change="handleCommonCargoTypeChange"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="规格型号">
<el-input
v-model="commonCargoQuery.specificationModel"
clearable
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6" class="waybill-manage-page__dialog-search-actions">
<el-button type="primary" @click="handleCommonCargoSearch">查询</el-button>
<el-button @click="handleCommonCargoReset">重置</el-button>
</el-col>
</el-row>
</el-form>
</div>
<el-table
border
height="320"
:data="commonCargoRows"
v-loading="commonCargoLoading"
@selection-change="handleCommonCargoSelectionChange"
>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="150" />
<el-table-column prop="cargoCode" label="货物编号" min-width="130" />
<el-table-column prop="firstCargoTypeName" label="一级货物类型" min-width="150" />
<el-table-column prop="secondCargoTypeName" label="二级货物类型" min-width="150" />
<el-table-column prop="secondCargoTypeCode" label="二级货物编码" min-width="140" />
<el-table-column prop="packageType" label="包装" min-width="100" />
<el-table-column prop="brand" label="品牌" min-width="120" />
<el-table-column prop="specification" label="规格" min-width="120" />
<el-table-column prop="model" label="型号" min-width="120" />
<el-table-column prop="cargoValue" label="单价" min-width="120" />
<el-table-column prop="priceUnit" label="计价单位" min-width="120" />
<el-table-column
prop="cargoSize"
label="尺寸"
min-width="120"
:formatter="formatCommonCargoSize"
/>
<el-table-column
prop="descriptionOne"
label="其他说明1"
min-width="160"
show-overflow-tooltip
/>
<el-table-column
prop="descriptionTwo"
label="其他说明2"
min-width="160"
show-overflow-tooltip
/>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column prop="deptName" label="组织" min-width="150" show-overflow-tooltip />
<el-table-column prop="updateTime" label="更新时间" min-width="160" sortable />
<el-table-column prop="createTime" label="创建时间" min-width="160" sortable />
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click.stop="selectCommonCargoRow(row)"> 选择 </el-link>
</template>
</el-table-column>
</el-table>
<div class="waybill-manage-page__dialog-pagination">
<el-pagination
v-model:current-page="commonCargoPage.currentPage"
v-model:page-size="commonCargoPage.pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
:total="commonCargoPage.total"
@size-change="handleCommonCargoSizeChange"
@current-change="handleCommonCargoCurrentChange"
/>
</div>
<template #footer>
<el-button @click="commonCargoBox = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog
title="导入货物"
append-to-body
v-model="cargoImportBox"
width="560px"
class="waybill-manage-page__cargo-import-dialog"
@closed="cargoImportRows = []"
>
<div class="waybill-manage-page__cargo-import">
<div class="waybill-manage-page__cargo-import-card">
<el-upload
action="#"
accept=".xls,.xlsx"
:auto-upload="false"
:show-file-list="false"
:disabled="cargoImportLoading"
:on-change="handleCargoImportFileChange"
>
<el-button type="primary" :loading="cargoImportLoading">上传</el-button>
<template #tip>
<div class="el-upload__tip">支持.xlsx、.xls的文件格式,单个文件大小限制50M</div>
</template>
</el-upload>
<el-button type="primary" @click="handleCargoImportTemplate">点击下载模板</el-button>
</div>
</div>
<template #footer>
<el-button :disabled="cargoImportLoading" @click="closeCargoImportDialog">关闭</el-button>
<el-button
type="primary"
:loading="cargoImportLoading"
:disabled="!cargoImportRows.length"
@click="confirmCargoImport"
>
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="waybillProcessConfigBox"
title="过程配置"
append-to-body
width="980px"
class="waybill-manage-page__process-config-dialog"
>
<div v-loading="waybillProcessConfigLoading">
<el-empty
v-if="!waybillProcessConfigRows.length"
description="当前项目暂无过程配置"
:image-size="72"
/>
<section
v-for="row in waybillProcessConfigRows"
:key="row.id || row.configCode"
class="waybill-manage-page__process-config-section"
>
<div class="waybill-manage-page__process-config-meta">
<span>{{ row.configName || '过程配置' }}</span>
<span v-if="row.defaultFinishDays">默认{{ row.defaultFinishDays }}天后完成运输</span>
</div>
<div
v-if="getProcessConfigEnabledNodes(row).length"
class="waybill-manage-page__process-timeline"
>
<div class="waybill-manage-page__process-timeline-line"></div>
<div
v-for="node in getProcessConfigEnabledNodes(row)"
:key="node.key"
class="waybill-manage-page__process-timeline-item"
>
<span class="waybill-manage-page__process-timeline-dot"></span>
<div class="waybill-manage-page__process-timeline-name">{{ node.name }}</div>
<div class="waybill-manage-page__process-timeline-desc">
<div v-for="text in processConfigTimelineDescriptions(node)" :key="text">
{{ text }}
</div>
</div>
</div>
</div>
<el-empty v-else description="未配置现行过程节点" :image-size="56" />
</section>
</div>
<template #footer>
<el-button type="primary" @click="waybillProcessConfigBox = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog :title="`${config.title}导入`" append-to-body v-model="excelBox" width="555px">
<avue-form :option="excelOptionConfig" v-model="excelForm">
<template #excelTemplate>
<el-button type="primary" @click="handleTemplate">
点击下载<i class="el-icon-download el-icon--right"></i>
</el-button>
</template>
</avue-form>
</el-dialog>
</basic-container>
</template>
<script>
import { exportBlob, importBlob } from '@/api/common';
import { getList as getCommonAddressList } from '@/api/base/common-address';
import { getList as getAirportMasterList } from '@/api/base/airport-master';
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getList as getPortTerminalList } from '@/api/base/port-terminal';
import { getList as getRailwayStationList } from '@/api/base/railway-station';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
import {
getDetail as getContractDetail,
getList as getContractList,
} from '@/api/business/contract-manage';
import {
getDetail as getProjectDetail,
getList as getProjectList,
} from '@/api/business/project-apply';
import { getList as getLoadingList } from '@/api/business/loading-manage';
import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archive';
import { getList as getCommonRouteList } from '@/api/business/common-route';
import {
getDetail as getShippingPlanDetail,
getList as getShippingPlanList,
} from '@/api/business/transport-plan';
import {
getDetail as getProcessConfigDetail,
getList as getProcessConfigList,
getVoucherImages as getProcessConfigVoucherImages,
} from '@/api/business/process-config';
import {
getList as getWaybillList,
getPunchRecords as getWaybillPunchRecords,
locateVehicle,
savePunchRecord,
trackVehicle,
} from '@/api/business/waybill-manage';
import VoucherExecutionDetail from './voucher-execution-detail.vue';
import PunchRecordPhotos from './punch-record-photos.vue';
import AddressMapPicker from '@/components/address-map-picker/main.vue';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
import { fetchDriverCandidates, driverOptionText } from '@/utils/carrier-org-resource';
import { getDictionary } from '@/api/system/dictbiz';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
import { addressTypeOptions } from '@/option/base/common-address';
import { packageOptions } from '@/option/business/common';
import { formatUpdateUserName } from '@/utils/audit';
import { getToken, isAdministrator } from '@/utils/auth';
import {
currencyUnit,
mapFeeUnitOptions,
remapPriceUnitCurrency,
resolvePriceUnitByQuantityUnit,
} from '@/utils/currency';
import { formatContractOptionLabel } from '@/utils/contract';
import { wgs84ToGcj02 } from '@/utils/coordinate';
import { openImportDialog } from '@/utils/import-excel';
import { normalizeMapSearchResults, searchAmapPlaceText } from '@/utils/map-search';
import { loadMeasurementUnitOptions } from '@/utils/measurement-unit';
import { parseRouteChangeContent } from '@/utils/route-change';
import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
import { InfoFilled, Location, OfficeBuilding, Search, WarnTriangleFilled } from '@element-plus/icons-vue';
import { ElAutocomplete, ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import {
fallbackPlugin,
imagePlugin,
officePlugin,
pdfPlugin,
textPlugin,
} from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { h, resolveComponent } from 'vue';
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import {
disposalStatusOptions,
exceptionCategoryOptions,
} from '@/option/transit/exception-disposal';
import { getUploadHeaders } from '@/utils/upload';
import * as exceptionDisposalApi from '@/api/transit/exception-disposal';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
let amapLoader;
const standaloneWaybillFormRoute = '/business/waybill-manage/form';
const standaloneWaybillListRoute = '/business/waybill-manage';
const standaloneWaybillFormPaths = [standaloneWaybillListRoute, standaloneWaybillFormRoute];
const standaloneWaybillDetailRoute = '/business/waybill-manage/detail';
const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills';
const waybillManageListRefreshKey = 'waybill-manage-list-refresh';
// 视为「已经处理完」的异常处置状态,与后端打卡门禁同一口径(已完成、已撤销都放行),
// 所以「处理中」仍算未完成,节点上继续显示异常。
const settledExceptionStatuses = ['completed', 'revoked'];
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const PageAvueForm = {
inheritAttrs: false,
props: {
formPageTitle: { type: String, default: '' },
},
render() {
const attrs = { ...this.$attrs };
const rowSave = attrs.onRowSave;
const rowUpdate = attrs.onRowUpdate;
const boxType = attrs.option?.boxType;
delete attrs.onRowSave;
delete attrs.onRowUpdate;
attrs.onSubmit = (row, hide) => {
if (boxType === 'edit') {
// Avue form passes its state-reset callback as the second argument. Keep it
// available as the failure callback as well, otherwise a rejected request
// leaves the standalone form permanently disabled.
rowUpdate?.(row, undefined, hide, hide);
} else {
rowSave?.(row, hide, hide);
}
};
const slots = Object.fromEntries(
Object.entries(this.$slots).flatMap(([name, slot]) => {
if (name === 'menu-form' || name === 'menu-form-before') return [[name, slot]];
const prop = name.replace(/-form$/, '');
return [
[prop, slot],
[`${prop}-header`, slot],
];
})
);
const children = [];
if (this.formPageTitle) {
children.push(h('div', { class: 'archive-page-form__title' }, this.formPageTitle));
}
children.push(h(getPlainAvueForm(resolveComponent('avue-form')), attrs, slots));
return h(
'div',
{ class: attrs.option?.dialogCustomClass || 'waybill-manage-form-page-dialog' },
children
);
},
};
const PageDetail = {
inheritAttrs: false,
render() {
return h('div', { class: this.$attrs.class }, [
...(this.$slots.default?.() || []),
...(this.$slots.footer?.() || []),
]);
},
};
const estimateMenuButtonCount = config => {
const rowActions = (config.actions || []).filter(action => action !== 'batchComplete');
return 3 + rowActions.length + (config.operations || []).length;
};
const extractRecords = res => {
const data = res?.data?.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 WAYBILL_QUANTITY_UNIT = '吨';
const defaultTransportCargo = () => ({
cargoName: '',
cargoType: '',
cargoTypeCode: '',
cargoTypePath: [],
quantity: '',
quantityUnit: '',
unitPrice: '',
priceUnit: '元/吨',
freightAmount: '',
packageType: '',
brand: '',
specification: '',
model: '',
materialCode: '',
deviceCode: '',
remark: '',
});
const taskCarrierTypes = ['承运商', '自运'];
// Avue 默认分组使用折叠容器。独立运单表单需要始终展示各业务分区,
// 因此仅替换本页面的分组渲染器,保留 avue-form 的字段渲染和校验能力。
const PlainAvueGroup = {
name: 'PlainAvueGroup',
inheritAttrs: false,
props: {
display: { type: Boolean, default: true },
header: { type: Boolean, default: true },
label: { type: String, default: '' },
icon: { type: String, default: '' },
},
render() {
if (!this.display) return null;
const children = [];
if (this.$slots.tabs) children.push(...this.$slots.tabs());
if (this.header && (this.$slots.header || this.label || this.icon)) {
children.push(
h('div', { class: 'avue-group__header' }, [
this.$slots.header
? this.$slots.header()
: h('h1', { class: 'avue-group__title' }, this.label),
])
);
}
if (this.$slots.default) children.push(...this.$slots.default());
return h('div', { class: 'avue-group' }, children);
},
};
const plainAvueFormCache = new WeakMap();
const getPlainAvueForm = avueForm => {
if (!avueForm || typeof avueForm !== 'object') return avueForm;
if (!plainAvueFormCache.has(avueForm)) {
plainAvueFormCache.set(avueForm, {
...avueForm,
components: {
...(avueForm.components || {}),
avueGroup: PlainAvueGroup,
'avue-group': PlainAvueGroup,
},
});
}
return plainAvueFormCache.get(avueForm);
};
export default {
components: {
PageAvueForm,
PageDetail,
InfoFilled,
WarnTriangleFilled,
ElImageViewer,
OpenFileViewer,
VoucherExecutionDetail,
PunchRecordPhotos,
AddressMapPicker,
},
name: 'WaybillManagePage',
props: {
api: {
type: Object,
required: true,
},
config: {
type: Object,
required: true,
},
crudOption: {
type: Object,
required: true,
},
crudExcelOption: {
type: Object,
default: () => ({ column: [] }),
},
// 可选:固定操作列宽度;不传则按按钮数量自动估算(沿用既有逻辑)
menuWidth: {
type: Number,
default: null,
},
detailId: {
type: [String, Number],
default: null,
},
standaloneFormPage: {
type: Boolean,
default: false,
},
standaloneDetailPage: {
type: Boolean,
default: false,
},
},
data() {
return {
Location,
OfficeBuilding,
Search,
form: {},
suppressTransportTypeClear: false,
query: {},
loading: true,
data: [],
allDept: 0,
dialogReadonly: false,
detailBox: false,
detailLoading: false,
detailRow: {},
waybillDetailProcessNodes: [],
waybillPunchRecords: [],
waybillDriverUploads: [],
waybillPunchRecordsLoading: false,
waybillProcessDetailTab: 'punch',
waybillHasRelatedVoucher: false,
waybillVoucherImages: [],
waybillVoucherFolders: [],
waybillVoucherFolder: null,
waybillVoucherFolderView: false,
waybillVoucherImagesLoading: false,
waybillVoucherImagesLoaded: false,
waybillExceptionRows: [],
waybillExceptionLoading: false,
waybillExceptionDialog: {
visible: false,
mode: 'follow',
loading: false,
submitting: false,
},
waybillExceptionDetail: {},
waybillExceptionFollowForm: { followContent: '', attachments: [] },
waybillExceptionFollowRules: {
followContent: [{ max: 500, message: '跟进说明不能超过500个字符', trigger: 'blur' }],
},
waybillExceptionEditForm: {
exceptionType: '',
exceptionReason: '',
reportDescription: '',
scenePhotoList: [],
},
waybillExceptionEditRules: {
exceptionType: [{ required: true, message: '请选择异常类型', trigger: 'change' }],
reportDescription: [
{ required: true, message: '请输入上报说明', trigger: 'blur' },
{ max: 500, message: '上报说明不能超过500个字符', trigger: 'blur' },
],
},
waybillPunchRecordEditDialog: {
visible: false,
submitting: false,
},
waybillPunchEditRecord: {},
waybillPunchEditForm: {
punchTime: '',
address: '',
longitude: '',
latitude: '',
weight: '',
volume: '',
quantity: '',
mileage: '',
remark: '',
photos: [],
},
waybillPunchAddressMapVisible: false,
waybillRouteChangeBox: false,
waybillRouteChangeTab: 'change',
waybillRouteChangeRows: [],
waybillRouteChangeNodes: [],
waybillRouteChangeRemark: '',
waybillRouteChangeRecords: [],
waybillRouteChangeSaving: false,
waybillLocateLoading: false,
waybillLocateInfo: null,
waybillLocateHint: '',
waybillLocateMapInstance: null,
waybillLocateMarker: null,
waybillLocateInfoWindow: null,
waybillTrackMode: '',
waybillTrackLoading: false,
waybillTrackInfo: null,
waybillTrackHint: '',
waybillTrackDateRange: [],
waybillTrackMapInstance: null,
waybillTrackPolyline: null,
waybillTrackStartMarker: null,
waybillTrackEndMarker: null,
mileageDialog: {
visible: false,
submitting: false,
row: null,
},
reassignDrawer: {
visible: false,
submitting: false,
driverLoading: false,
row: null,
},
reassignForm: {
id: '',
driverId: '',
driverName: '',
driverPhone: '',
vehicleNo: '',
},
reassignDriverOptions: [],
reassignRules: {
driverName: [{ required: true, message: '请选择司机', trigger: 'change' }],
driverPhone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (!value) {
callback();
return;
}
if (!isMobile(value)) {
callback(new Error('请输入正确的手机号'));
return;
}
callback();
},
trigger: 'blur',
},
],
vehicleNo: [{ required: true, message: '请输入车牌号', trigger: 'blur' }],
},
mileageForm: {
id: '',
mileage: '',
mileageRemark: '',
},
mileageRules: {
mileage: [
{ required: true, message: '请输入里程', trigger: 'blur' },
{
pattern: /^[1-9]\d{0,9}$/,
message: '里程必须为不超过10位的正整数',
trigger: 'blur',
},
],
},
waybillRouteChangeMapTarget: null,
draftSaveLoading: false,
crudDialogType: '',
defaultDialogButtons: null,
option: (() => {
const opt = this.cloneOption(this.crudOption, estimateMenuButtonCount(this.config));
if (this.menuWidth != null) opt.menuWidth = this.menuWidth;
if (this.standaloneFormPage && standaloneWaybillFormPaths.includes(this.$route.path)) {
opt.dialogAppendToBody = false;
opt.dialogModal = false;
opt.dialogCustomClass = 'waybill-manage-form-page-dialog';
opt.dialogTop = '0';
opt.dialogWidth = '100%';
} else if (!opt.dialogCustomClass) {
// 普通弹窗模式:灰底 body + 白卡表单(与独立表单页风格一致)
opt.dialogCustomClass = 'waybill-manage-form-dialog';
}
return opt;
})(),
selectedProjectId: '',
projectOptions: [],
projectLoading: false,
contractOptions: [],
contractLoading: false,
shippingPlanOptions: [],
shippingPlanLoading: false,
addressTypeOptions,
flowBox: false,
flowUrl: '',
processInstanceId: '',
attachmentRows: [],
selectedAttachmentRows: [],
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
transportCargoRows: [],
taskFreightCurrency: 'RMB',
freightCurrencyOptions: [],
freightCurrencyLoading: false,
waybillProcessConfigBox: false,
waybillProcessConfigLoading: false,
waybillProcessConfigRows: [],
hasProjectProcessConfig: false,
transportRouteBox: false,
transportRouteLoading: false,
transportRouteQuery: {},
transportRouteRows: [],
transportRouteSelected: null,
transportRoutePage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
transportAddressBox: false,
transportAddressTarget: '',
transportAddressLoading: false,
transportAddressQuery: {},
transportTypeOptions: [],
transportTypeOptionsLoading: false,
transportAddressRows: [],
transportAddressSelected: null,
transportAddressPage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
transportStationBox: false,
transportStationTarget: '',
transportStationLoading: false,
transportStationQuery: {},
transportStationRows: [],
transportStationPage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
transportMapBox: false,
transportMapTarget: '',
transportMapLoading: false,
transportMapKeyword: '',
transportMapStatus: '可搜索地址或点击地图选点',
transportMapSelected: {},
transportMapSearchResults: [],
transportAmap: null,
transportAmapMarker: null,
transportAmapGeocoder: null,
commonCargoBox: false,
commonCargoLoading: false,
commonCargoQuery: {},
commonCargoRows: [],
commonCargoSelected: [],
commonCargoPage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
taskCarrierTypes,
taskCarrierOptions: [],
taskCarrierLoading: false,
taskCarrierRequestId: 0,
waybillCarrierContractsLoaded: false,
waybillHasCarrierContracts: null,
waybillCarrierContractRequestId: 0,
taskDriverOptions: [],
taskDriverLoading: false,
taskEscortOptions: [],
taskEscortLoading: false,
taskCargoOptionsMap: {},
taskCargoLoadingMap: {},
taskQuantityUnitOptions: [],
taskQuantityUnitLoading: false,
taskFeeUnitOptions: [],
taskFeeUnitLoading: false,
cargoImportBox: false,
cargoImportLoading: false,
cargoImportRows: [],
cargoTypeOptions: [],
cargoTypeFlatOptions: [],
cargoTypeLoading: false,
cargoTypeRequest: null,
packageOptions,
quantityUnitOptions: [],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
excelBox: false,
excelForm: {},
excelOptionConfig: this.cloneOption(this.crudExcelOption),
page: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
currentPage: 1,
total: 0,
},
selectionList: [],
attachmentUploadMode: false,
/** 复制来源运单业务状态(表单字段 addDisplay:false,提交时需显式带回) */
copySourceBusinessStatus: '',
standaloneFormKey: '',
// 实例所属路由的路径快照:用于判断当前 $route 是否还是「本实例自己的路由」。
// 必须在 data 里初始化,不能放 created —— Vue 的 Options API 执行顺序是
// data → computed → watch(immediate) → created,放 created 会被首个
// immediate watcher 读到空值,把本该执行的首次加载也挡掉。
routePathLocked: this.$route.path,
// 页面形态(列表 / 独立表单页 / 独立详情页)在实例创建时锁定一次,之后不再随 $route 变化。
// 原因:标签页 keep-alive 只会让实例 deactivate,实例仍会随 $route(全局响应式)重新渲染;
// 若形态跟着路由翻回列表/弹窗态,缓存实例会渲染出 append-to-body 的 el-dialog —— 弹窗被
// Teleport 到 body 后,KeepAlive.deactivate 的 move 搬不动它,DOM 会永久残留在页面上,
// 表现为“从运单详情/独立表单离开后点其它菜单,详情弹窗又冒出来”。
formPageLocked: false,
detailPageLocked: false,
// 同上:表单模式(add / edit)也一次性锁定,避免缓存实例随 $route 变 query 导致 option 变形
formModeLocked: '',
};
},
created() {
this.formPageLocked = this.isStandaloneWaybillFormPage;
this.detailPageLocked = this.isStandaloneWaybillDetailPage;
this.formModeLocked = this.$route.query.mode || '';
this.setupSearchAutocompletes();
if (this.config.enableAllDept && this.isAdmin) {
this.allDept = 1;
}
if (this.config.enableProjectSelect) {
this.loadProjectOptions();
}
if (this.config.enableTaskInfoForm) {
this.loadTaskFeeUnitOptions();
this.ensureCargoTypeOptions();
}
if (this.config.enableWaybillFooterFreightSummary) this.loadFreightCurrencyOptions();
if (this.shippingInfoFormEnabled) this.loadTransportTypeOptions();
if (this.isStandaloneWaybillFormPage) {
this.$nextTick(() => {
this.initStandaloneFormPage();
// 先完成独立表单初始化,再回填模板数据,避免被新增表单默认值覆盖。
this.$nextTick(() => this.consumeTemplateCreatePayload());
});
} else {
this.consumeTemplateCreatePayload();
}
this.loadTaskQuantityUnitOptions();
},
// 标签切走(实例被 keep-alive 缓存)或销毁时,收起本页所有 append-to-body 的弹窗。
// 否则 Teleport 出去的弹窗 DOM 会一直留在 body 上,盖住后续打开的页面。
deactivated() {
this.closeInnerDialogs();
},
activated() {
// 列表实例被 keep-alive 重新激活时,按标记拉取最新数据
if (this.formPageLocked || this.detailPageLocked) return;
if (sessionStorage.getItem(waybillManageListRefreshKey)) {
sessionStorage.removeItem(waybillManageListRefreshKey);
this.onLoad(this.page, this.query);
}
},
beforeUnmount() {
this.closeInnerDialogs();
},
computed: {
...mapGetters(['permission', 'userInfo']),
waybillExceptionDialogTitle() {
return (
{ detail: '异常详情', follow: '异常跟进', edit: '编辑异常' }[
this.waybillExceptionDialog.mode
] || '异常详情'
);
},
waybillExceptionWaybillFields() {
return [
{ label: '运单号', prop: 'waybillNo' },
{ label: '配载单号/总单号', prop: 'loadingOrMasterNo' },
{ label: '车牌号', prop: 'vehicleNo' },
{ label: '项目', prop: 'projectName' },
{ label: '承运商', prop: 'carrierName' },
];
},
waybillExceptionDetailFields() {
return [
{ label: '上报人', prop: 'reporterName' },
{ label: '上报时间', prop: 'reportTime' },
{ label: '异常节点', prop: 'nodeName' },
{ label: '异常类型', prop: 'exceptionTypeName' },
{ label: '异常原因', prop: 'exceptionReason' },
{ label: '上报说明', prop: 'reportDescription', span: 2 },
];
},
waybillExceptionFollowColumns() {
return [
{ label: '跟进说明', prop: 'followContent', minWidth: 260 },
{ label: '跟进人', prop: 'followUserName', minWidth: 130 },
{ label: '跟进时间', prop: 'followTime', minWidth: 170 },
];
},
waybillExceptionTypeOptions() {
return exceptionCategoryOptions.map(item => ({ label: item.label, value: item.value }));
},
waybillExceptionReasonOptions() {
return (
exceptionCategoryOptions.find(
item => item.value === this.waybillExceptionEditForm.exceptionType
)?.children || []
);
},
waybillExceptionPhotoUrls() {
return this.parseWaybillExceptionPhotos(this.waybillExceptionDetail.scenePhotos);
},
waybillExceptionUploadHeaders() {
return getUploadHeaders();
},
permissionList() {
return {
addBtn: this.canCreate,
};
},
// 打卡记录编辑入口与运单编辑同权限:能改运单的人才能订正它的打卡流水。
// 时间轴的「编辑打卡 / 补卡」链接是本页唯一写入入口,提交固定 punched=true。
// 取消打卡不在本页;待回单确认的运单不显示该链接。
canEditWaybillPunchRecord() {
return (
this.hasPermission(`${this.config.permission}_edit`) &&
!this.readonlyScope &&
this.statusValue(this.detailRow) !== 'pending_return_confirm'
);
},
waybillPunchEditNode() {
return this.findWaybillPunchEditNodeConfig(this.waybillPunchEditRecord);
},
// 编辑弹窗的字段按过程配置取舍:配置里没采的项不展示;
// 记录里已有值的项即使配置后来改小也保留——否则历史数据里的货量/定位再也改不掉。
// 打卡照片不按配置取舍:过程配置的「上传凭证」是司机端的要求,管理端补录不看它,
// 否则配置没勾凭证的节点,记录里又还没有照片时,这个节点就再也传不了附件。
waybillPunchEditFields() {
const record = this.waybillPunchEditRecord || {};
const node = this.waybillPunchEditNode;
const hasValue = value => value !== undefined && value !== null && String(value).trim() !== '';
// 接单/回单是确认节点,过程配置里没有「打卡定位」开关,其 location 是模板默认值,不采信
const isConfirmNode = ['accept', 'return'].includes(String(node?.key || ''));
const cargoTypes = Array.isArray(node?.cargoTypes) ? node.cargoTypes : [];
const cargoFields = [
{ prop: 'weight', label: '重量(吨)', cargoType: '重量' },
{ prop: 'volume', label: '体积(方)', cargoType: '体积' },
{ prop: 'quantity', label: '数量(件)', cargoType: '数量' },
].filter(item => cargoTypes.includes(item.cargoType) || hasValue(record[item.prop]));
return {
location:
!isConfirmNode &&
(Boolean(node?.location) ||
hasValue(record.address) ||
hasValue(record.longitude) ||
hasValue(record.latitude)),
cargoFields,
};
},
// 过程配置为「不打卡」的节点,补录只是数据订正,司机端不会因此出现该节点的打卡
waybillPunchEditPunchDisabled() {
return this.waybillPunchEditNode?.punch === false;
},
// 里程:后端下发 needMileage 才出现(末位打卡节点 + 计费规则含里程);在途打卡不采集里程
waybillPunchEditMileageVisible() {
const record = this.waybillPunchEditRecord || {};
return record.needMileage === true && record.type !== 'enroute';
},
// 已生成结算单的运单里程冻结:输入框置灰,改动由后端拒绝
waybillPunchEditMileageLocked() {
return this.waybillPunchEditRecord?.mileageLocked === true;
},
waybillPunchEditRules() {
return { punchTime: [{ required: true, message: '请选择打卡时间', trigger: 'change' }] };
},
// 照片类型只有两个来源:固定的「打卡照片」,和该节点过程配置勾选的凭证类型;
// 不接受配置外的类型。在途打卡表只有一个照片字段、也没有类型列,不按凭证类型分组
waybillPunchEditPhotoTypes() {
if (this.waybillPunchEditRecord.type === 'enroute') return [];
const voucherTypes = Array.isArray(this.waybillPunchEditNode?.voucherTypes)
? this.waybillPunchEditNode.voucherTypes
: [];
return ['打卡照片', ...voucherTypes];
},
// 打卡照片与各凭证类型各自最多 9 张,不另设节点总张数上限;
// 在途打卡用 0 表示不限制——它保存时只保留第一张,张数由那一步约束
waybillPunchEditPhotoLimit() {
return this.waybillPunchEditRecord.type === 'enroute' ? 0 : 9;
},
// 打卡时间轴按打卡时间先后展示:已打卡记录按打卡时间升序在前,
// 未打卡记录保持流程节点原顺序排在其后。
orderedWaybillPunchRecords() {
const records = Array.isArray(this.waybillPunchRecords) ? this.waybillPunchRecords : [];
return records
.map((record, index) => ({
record,
index,
punchedAt: this.waybillPunchRecordTimestamp(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);
},
// 打卡记录 → 该节点的异常处置。异常跟节点走,不跟打卡走:节点未打卡也可能有异常。
// 数据源是异常处置表(waybillExceptionRows),不是打卡行上的 exception_flag
// ——后者由打卡接口写入且未随异常上报维护,实测有异常的节点它的值仍是 0。
// 匹配优先用 nodeCode,回退 nodeName:两者各有一批历史数据只填了一边。
waybillPunchRecordExceptions() {
const rows = Array.isArray(this.waybillExceptionRows) ? this.waybillExceptionRows : [];
const index = new Map();
rows.forEach(row => {
const nodeCode = String(row.nodeCode || '').trim().toLowerCase();
const nodeName = String(row.nodeName || '').trim();
if (nodeCode) {
if (!index.has(`code:${nodeCode}`)) index.set(`code:${nodeCode}`, []);
index.get(`code:${nodeCode}`).push(row);
}
if (nodeName) {
if (!index.has(`name:${nodeName}`)) index.set(`name:${nodeName}`, []);
index.get(`name:${nodeName}`).push(row);
}
});
const result = {};
(Array.isArray(this.waybillPunchRecords) ? this.waybillPunchRecords : []).forEach(
(record, position) => {
const nodeCode = String(record.nodeCode || '').trim().toLowerCase();
const nodeName = String(record.nodeName || '').trim();
const matched =
(nodeCode && index.get(`code:${nodeCode}`)) ||
(nodeName && index.get(`name:${nodeName}`)) ||
[];
if (!matched.length) return;
const isSettled = item => settledExceptionStatuses.includes(item.disposalStatus);
result[this.waybillPunchRecordKey(record, position)] = {
total: matched.length,
// 只要还有一条没处理完,这个节点就算「有事没完」,链接送去跟进
unfinished: matched.filter(item => !isSettled(item)).length,
// 取未完成的那条(没有则取第一条)作为跟进/详情的打开对象
target: matched.find(item => !isSettled(item)) || matched[0],
};
}
);
return result;
},
isStandaloneWaybillPage() {
return this.standaloneFormPage && standaloneWaybillFormPaths.includes(this.$route.path);
},
isStandaloneWaybillFormPage() {
return this.isStandaloneWaybillPage && ['add', 'edit'].includes(this.$route.query.mode);
},
isStandaloneWaybillDetailPage() {
return this.standaloneDetailPage && this.$route.path === standaloneWaybillDetailRoute;
},
// 容器形态读实例创建时锁定的标志,不随 $route 翻转(详见 data 中 formPageLocked 的说明)
// 当前路由是否仍属于本实例(tab.js 按 fullPath 建实例,一个实例只对应一个 path)。
// 被 keep-alive 缓存后如果 $route 已经跑到别的页面上,就不该再用「当前页面的 id」
// 去触发本实例的请求或跳转,否则会拿别的单据 id 查自己的接口。
isOwnedRouteActive() {
return this.$route.path === this.routePathLocked;
},
detailContainer() {
return this.detailPageLocked ? 'PageDetail' : 'el-dialog';
},
formPageTitle() {
if (this.isStandaloneWaybillFormPage) {
return (
this.$route.query.name ||
`${this.$route.query.mode === 'edit' ? '编辑' : '新增'}${this.config.title || ''}`
);
}
return '';
},
crudContainer() {
return this.formPageLocked ? 'PageAvueForm' : 'avue-crud';
},
pageFormOption() {
if (!this.formPageLocked) return this.option;
const base = {
...this.option,
boxType: this.formModeLocked === 'edit' ? 'edit' : 'add',
menuBtn: true,
submitBtn: true,
emptyBtn: false,
};
return this.buildFormGroupOption(base);
},
waybillDetailSummaryFields() {
return [
['customerName', '客户', true],
['contractNo', '合同编号', true],
['projectName', '项目', true],
['originalNo', '原始单号'],
['planName', '计划名称'],
['planNo', '计划单号', true],
['masterNo', '总单号', true],
['loadingNo', '配载单号', true],
];
},
isTemplateCreate() {
return this.config.permission === 'waybill_manage' && Boolean(this.$route.query.fromTemplate);
},
completeActionLabel() {
return '完成';
},
isRoadTransport() {
return this.transportMode === 'road';
},
isNonRoadTransport() {
return !this.isRoadTransport;
},
transportVehicleNoLabel() {
return this.isRoadTransport ? '车牌号' : '船班号/船次号/班次号';
},
actionButtonLikeSearch() {
return this.config.actionButtonLikeSearch === true;
},
contractSelectEnabled() {
return this.config.enableContractSelect === true;
},
shippingPlanSelectEnabled() {
return this.config.enableShippingPlanSelect === true;
},
shippingInfoFormEnabled() {
return this.config.enableShippingInfoForm === true;
},
taskInfoFormEnabled() {
return this.config.enableTaskInfoForm === true;
},
taskEntryModeOptions() {
const locked = this.hasMultipleConfiguredGoods(this.form, this.transportCargoRows);
return [
{ label: '精简录入', value: 'simple', disabled: locked },
{ label: '完整录入', value: 'full' },
];
},
isTaskFullMode() {
return this.form.taskEntryMode === 'full';
},
isCarrierMode() {
return this.form.carrierType === '承运商';
},
taskCarrierRequired() {
return this.isTaskCarrierRequired(this.form.carrierType);
},
waybillTaskCarrierTypes() {
if (this.waybillCarrierContractsLoaded && this.waybillHasCarrierContracts === false) {
return ['自运'];
}
return this.taskCarrierTypes;
},
taskCarrierLabel() {
return '承运商';
},
taskCarrierValue: {
get() {
if (this.form.carrierType === '自运') {
return this.form.carrierName || '';
}
return this.taskCarrierOptionValue({
carrierContractId: this.form.carrierContractId,
carrierPartyId: this.form.carrierPartyId,
carrierName: this.form.carrierName,
});
},
set(value) {
this.handleTaskCarrierChange(value);
},
},
showRoadEscortFields() {
return this.isRoadTransport && !this.isCarrierMode;
},
taskCargoOptions() {
return this.getTaskCargoOptionsByPath(this.form.cargoTypePath);
},
taskCargoLoading() {
return this.isTaskCargoOptionsLoading(this.form.cargoTypePath);
},
taskCargoQuantityTotal() {
const total = this.transportCargoRows.reduce((sum, row) => {
const value = Number(row.quantity);
return Number.isFinite(value) ? sum + value : sum;
}, 0);
if (!total) return '';
return Number.isInteger(total) ? String(total) : String(Number(total.toFixed(3)));
},
taskFreightAmount() {
const quantity = Number(this.taskCargoQuantityTotal || this.form.quantity || 0);
const unitPrice = Number(this.form.unitPrice || 0);
if (!quantity || !unitPrice) return '';
const amount = quantity * unitPrice;
return Number.isInteger(amount) ? String(amount) : String(Number(amount.toFixed(2)));
},
taskFreightGroups() {
// 运单统一按吨计价,运费录入只保留一行汇总所有货物。
return [
{
key: '__all__',
quantityUnit: WAYBILL_QUANTITY_UNIT,
rows: this.transportCargoRows || [],
},
];
},
taskFullFreightTotal() {
const total = this.taskFreightGroups.reduce(
(sum, group) => sum + Number(this.taskFullFreightGroupAmount(group) || 0),
0
);
return this.formatTaskFullFreightNumber(total);
},
taskFreightCurrencyLabel() {
const currency = this.taskFreightCurrency;
if (!currency) return '';
return this.currencyRemark(currency);
},
taskFeeUnitSelectOptions() {
return mapFeeUnitOptions(this.taskFeeUnitOptions, this.taskFreightCurrency || 'RMB');
},
defaultPriceUnit() {
return `${currencyUnit(this.taskFreightCurrency || 'RMB')}/吨`;
},
isAdmin() {
return isAdministrator(this.userInfo);
},
canViewAllDept() {
return this.isAdmin;
},
readonlyScope() {
return (
this.config.enableAllDept && this.config.allDeptReadonly !== false && this.allDept === 1
);
},
canCreate() {
return this.hasPermission(`${this.config.permission}_add`) && !this.readonlyScope;
},
cargoTypeCascaderProps() {
return {
label: 'cargoName',
value: 'id',
children: 'children',
disabled: (data, node) => node.level === 1,
leaf: 'leaf',
checkStrictly: true,
emitPath: true,
};
},
transportCargoTypeOptions() {
return this.cargoTypeOptions.filter(item => item.children?.length);
},
transportCargoTypeCascaderProps() {
return {
label: 'cargoName',
value: 'id',
children: 'children',
emitPath: true,
};
},
canBatchComplete() {
return (
this.hasAction('batchComplete') &&
this.hasPermission(`${this.config.permission}_batch_complete`) &&
!this.readonlyScope
);
},
canRoadLoading() {
const permission =
this.config.roadLoadingPermission || `${this.config.permission}_road_loading`;
return this.config.roadLoading && this.hasPermission(permission) && !this.readonlyScope;
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
waybillFooterFreightTotal() {
const freightAmount = this.isTaskFullMode
? Number(this.taskFullFreightTotal || 0)
: Number(this.taskFreightAmount || 0);
return freightAmount + Number(this.form.otherFeeTotal || 0);
},
footerFreightTotal() {
return this.waybillFooterFreightTotal;
},
formatFooterFreightTotal() {
return this.footerFreightTotal.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
},
transportAddressSelectDisabled() {
return this.dialogReadonly || this.isBillingFieldEmpty(this.form.transportType);
},
transportMode() {
return this.resolveTransportMode(this.form.transportType);
},
hasTransportType() {
return !this.isBillingFieldEmpty(this.form.transportType);
},
transportStationMode() {
return ['water', 'rail', 'air'].includes(this.transportMode);
},
transportStationTypeLabel() {
const labelMap = {
water: '港口/码头',
rail: '铁路车站',
air: '空港机场',
};
return labelMap[this.transportMode] || '';
},
transportStationNamePlaceholder() {
if (!this.transportStationMode) return '行政区划自动带出';
const labelMap = {
water: '请选择港口/码头',
rail: '请选择车站',
air: '请选择空港',
};
return labelMap[this.transportMode] || '请选择站点';
},
transportAddressDetailPlaceholder() {
return this.transportStationMode ? '选择站点后带出详细地址' : '请输入';
},
},
watch: {
$route(to, from) {
// 列表实例:从表单/详情返回时刷新,避免 keep-alive 仍显示旧数据
if (!this.formPageLocked && !this.detailPageLocked) {
if (
to.path === standaloneWaybillListRoute &&
(from?.path?.includes('/waybill-manage/form') ||
from?.path?.includes('/waybill-manage/detail'))
) {
sessionStorage.removeItem(waybillManageListRefreshKey);
this.onLoad(this.page, this.query);
}
return;
}
if (!this.formPageLocked || !this.isStandaloneWaybillFormPage) return;
if (
to.query.mode !== from?.query?.mode ||
String(to.query.id || '') !== String(from?.query?.id || '')
) {
this.$nextTick(() => this.initStandaloneFormPage());
}
},
detailId: {
immediate: true,
handler(id) {
// 宿主 view 传的是全局 $route.query.id / detailId,实例被缓存后仍会被重渲染,
// 必须确认当前路由还是自己的:否则跳到别的详情页时会把「别的单据 id」灌进来,
// 触发 openDetail 用错 id 查运单 → 报「运单管理不存在」。
if (!this.isOwnedRouteActive) return;
if (id !== undefined && id !== null && id !== '') {
this.$nextTick(() => this.openDetail({ id }));
}
},
},
waybillProcessDetailTab(tab) {
if (tab === 'batchSupplement') this.loadWaybillVoucherImages();
if (tab === 'punch' || tab === 'driverUpload') this.loadWaybillPunchRecords();
},
'form.transportType'(value, oldValue) {
if (!this.shippingInfoFormEnabled || this.suppressTransportTypeClear) return;
if (String(value ?? '') === String(oldValue ?? '')) return;
this.handleTransportTypeChange(value);
},
},
methods: {
formatContractOptionLabel,
driverOptionText,
parseRouteChangeContent,
// 页面被 keep-alive 缓存/卸载时调用,关闭本页所有 append-to-body 的弹窗,
// 避免 Teleport 出去的 DOM 残留在 body 上盖住后续页面(详见 data 里的说明)。
closeInnerDialogs() {
this.detailBox = false;
this.mileageDialog.visible = false;
this.attachmentDocumentPreviewVisible = false;
this.attachmentImagePreviewVisible = false;
this.waybillRouteChangeBox = false;
this.waybillRouteChangeMapTarget = null;
this.flowBox = false;
this.transportRouteBox = false;
this.transportAddressBox = false;
this.transportStationBox = false;
this.transportMapBox = false;
this.commonCargoBox = false;
this.cargoImportBox = false;
this.waybillProcessConfigBox = false;
this.excelBox = false;
this.waybillExceptionDialog.visible = false;
},
initStandaloneFormPage() {
if (!this.isStandaloneWaybillFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
const id = this.$route.query.id || '';
const copyId = this.$route.query.copyId || '';
const key = `${mode}:${id}:${copyId}`;
if (this.standaloneFormKey === key) return;
this.standaloneFormKey = key;
if (mode === 'edit') {
this.form = { id };
} else if (mode === 'add' && copyId) {
// 复制模式:加载原数据
this.form = { id: copyId };
}
this.beforeOpen(() => {}, mode);
},
buildFormGroupOption(option) {
const sectionTitleProps = [
'basicInfoTitle',
'shippingInfoTitle',
'taskInfoTitle',
'attachmentTitle',
];
const columns = [...(option.column || [])].sort((a, b) => (b.order || 0) - (a.order || 0));
const groups = [];
let current = [];
columns.forEach(col => {
if (sectionTitleProps.includes(col.prop)) {
if (current.length) groups.push(current);
current = [col];
} else {
current.push(col);
}
});
if (current.length) groups.push(current);
if (groups.length <= 1) return option;
const [mainColumn, ...groupColumns] = groups;
return {
...option,
column: mainColumn,
group: groupColumns.map((columns, index) => {
const title = columns.find(column => sectionTitleProps.includes(column.prop));
return {
prop: title?.prop || `waybillManageGroup${index + 1}`,
label: '',
arrow: false,
column: title ? columns.filter(column => column.prop !== title.prop) : columns,
};
}),
};
},
openBusinessForm(mode, row = {}) {
if (this.isStandaloneWaybillPage) {
this.$router.push({
path: standaloneWaybillFormRoute,
query: {
mode,
...(mode === 'edit' && row.id ? { id: row.id } : {}),
name: `${mode === 'edit' ? '编辑' : '新增'}${this.config.title || ''}`,
},
});
return;
}
this.$refs.crud?.[mode === 'edit' ? 'rowEdit' : 'rowAdd'](row);
},
cloneOption(option, menuButtonCount) {
const nextOption = {
...option,
column: (option.column || []).map(column => ({ ...column })),
};
const tableOrder = this.config.tableColumnOrder || [];
if (tableOrder.length) {
const orderMap = new Map(tableOrder.map((prop, index) => [prop, index]));
nextOption.column.sort((left, right) => {
const leftOrder = orderMap.has(left.prop) ? orderMap.get(left.prop) : tableOrder.length;
const rightOrder = orderMap.has(right.prop)
? orderMap.get(right.prop)
: tableOrder.length;
return leftOrder - rightOrder;
});
nextOption.column.forEach(column => {
if (column.prop) column.hide = !orderMap.has(column.prop);
});
}
return applyTableMenuWidth(nextOption, menuButtonCount);
},
formatWaybillGoodsInfo(row = {}) {
// 列表接口的货物明细字段存在多种兼容命名,优先使用完整的 goodsJson。
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 this.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.parseQuantity(
item.quantity ?? item.cargoQuantity ?? item.goodsQuantity
);
return result;
}, {});
return Object.values(groups)
.map(group => {
const quantity = this.formatQuantity(group.quantity);
const typeName =
group.count > 1 ? `${group.typeName}等${group.count}种货物` : group.typeName;
return `${typeName} | ${quantity}${group.unit ? ` ${group.unit}` : ''}`;
})
.join('; ');
},
formatWaybillProvinceCityDistrict(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;
},
formatWaybillRouteCityDistrict(value) {
const text = String(value || '').trim();
if (!text) return '-';
const provinceMatch = text.match(/^.+?(?:省|自治区|特别行政区)/);
const cityRegion = provinceMatch ? text.slice(provinceMatch[0].length) : text;
const cityMatch = cityRegion.match(/^(.+?市)/);
if (!cityMatch) return this.formatWaybillProvinceCityDistrict(text);
const city = cityMatch[1];
const remainder = cityRegion.slice(city.length);
const districtMatch = remainder.match(
/^(.+?(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗){1,2})/
);
const district = districtMatch ? districtMatch[1] : '';
return [city, district].filter(Boolean).join(' ');
},
waybillDetailRouteName(row = {}, field) {
const address = String(row[`${field}Address`] || '').trim();
const name = String(row[`${field}Name`] || '').trim();
if (!address) return name || '-';
if (!name || address.includes(name)) {
return name || this.formatWaybillProvinceCityDistrict(address);
}
return this.formatWaybillProvinceCityDistrict(address);
},
formatWaybillListAddress(value) {
const text = String(value || '')
.trim()
.replace(/[\\/||、,,\s]+/g, '');
if (!text) return '-';
const districtPattern = '(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗)';
const parts = [];
let remainder = text;
const province = text.match(/^(.+?(?:省|自治区|特别行政区))/);
if (province) {
parts.push(province[1]);
remainder = text.slice(province[1].length);
}
const city = remainder.match(/^(.+?市)/);
if (city) {
parts.push(city[1]);
remainder = remainder.slice(city[1].length);
}
const district = remainder.match(new RegExp(`^(.+?${districtPattern})`));
if (district) parts.push(district[1]);
return parts.length ? parts.join('/') : text;
},
hasPermission(code) {
return this.isAdmin || this.validData(this.permission && this.permission[code], false);
},
hasAction(action) {
return (this.config.actions || []).includes(action);
},
statusValue(row) {
return row[this.config.statusProp || 'status'];
},
inStatus(row, statusList) {
if (!statusList || !statusList.length) {
return true;
}
const status = this.statusValue(row);
const statusText = this.config.statusTextProp ? row[this.config.statusTextProp] : '';
return statusList.includes(status) || statusList.includes(statusText);
},
isReadonlyEditAllowed(row) {
return this.inStatus(row, this.config.ignoreReadonlyEditStatus);
},
isReadonlyDeleteAllowed(row) {
return this.inStatus(row, this.config.ignoreReadonlyDeleteStatus);
},
canEdit(row) {
return (
this.hasPermission(`${this.config.permission}_edit`) &&
!this.readonlyScope &&
(!row.readonly || this.isReadonlyEditAllowed(row)) &&
this.inStatus(row, this.config.editStatus) &&
(typeof this.config.canEdit !== 'function' || this.config.canEdit(row))
);
},
canDelete(row) {
return (
this.hasPermission(`${this.config.permission}_delete`) &&
!this.readonlyScope &&
(!row.readonly || this.isReadonlyDeleteAllowed(row)) &&
this.inStatus(row, this.config.deleteStatus)
);
},
canCopy(row) {
if (!row) return false;
// 归属本账号可见范围的运单允许复制。跨组织但项目归属本账号的记录会被标成只读,
// 只读仅拦截编辑/删除,不拦截复制。具备查看、新增、编辑或复制权限即可使用。
return (
this.hasAction('copy') &&
!this.readonlyScope &&
(this.hasPermission(`${this.config.permission}_copy`) ||
this.hasPermission(`${this.config.permission}_add`) ||
this.hasPermission(`${this.config.permission}_edit`) ||
this.hasPermission(`${this.config.permission}_view`))
);
},
canEnable(row) {
return (
this.hasAction('enable') &&
this.hasPermission(`${this.config.permission}_enable`) &&
!this.readonlyScope &&
!row.readonly &&
[0, 2].includes(row.status)
);
},
canDisable(row) {
return (
this.hasAction('disable') &&
this.hasPermission(`${this.config.permission}_disable`) &&
!this.readonlyScope &&
!row.readonly &&
row.status === 1
);
},
canCancel(row) {
if (
!this.hasAction('cancel') ||
!this.hasPermission(`${this.config.permission}_cancel`) ||
this.readonlyScope ||
row.readonly
) {
return false;
}
// 进行中的运单隐藏取消(与 config.canCancel / isInProgressStatus 对齐)
if (typeof this.config.canCancel === 'function' && !this.config.canCancel(row)) {
return false;
}
const status = this.statusValue(row);
// 待回单确认可取消,取消不生成应收应付
return status === 'pending' || status === 'pending_return_confirm';
},
canComplete(row) {
if (
!this.hasAction('complete') ||
!this.hasPermission(`${this.config.permission}_complete`) ||
this.readonlyScope ||
row.readonly
) {
return false;
}
const status = this.statusValue(row);
// 待回单确认的「完成」由确认回单入口承担(走打卡节点与未了结异常校验);
// 只有确认回单不可用时才退回这里的普通完成
if (status === 'pending_return_confirm') {
return !this.canConfirmReturn(row);
}
return status === 'processing' || status === 'running';
},
canConfirmReturn(row) {
return (
this.hasAction('confirmReturn') &&
this.hasPermission(`${this.config.permission}_complete`) &&
!this.readonlyScope &&
!row.readonly &&
this.statusValue(row) === 'pending_return_confirm'
);
},
canMaintainMileage(row) {
return (
this.config.permission === 'waybill_manage' &&
this.hasPermission(`${this.config.permission}_mileage`) &&
typeof this.api.maintainMileage === 'function' &&
this.statusValue(row) === 'completed' &&
row.mileageMaintainable === true
);
},
canReassign(row) {
return (
this.hasAction('reassign') &&
this.hasPermission(`${this.config.permission}_reassign`) &&
!this.readonlyScope &&
!row.readonly &&
this.statusValue(row) === 'pending' &&
(typeof this.config.canReassign !== 'function' || this.config.canReassign(row))
);
},
customOperations(row) {
return (this.config.operations || []).filter(operation => this.canOperation(operation, row));
},
consumeTemplateCreatePayload() {
const target = this.config.templateCreateTarget;
if (!target) return;
const storageKey = 'business-template-create';
const raw = sessionStorage.getItem(storageKey);
if (!raw) return;
let payload;
try {
payload = JSON.parse(raw);
} catch (error) {
sessionStorage.removeItem(storageKey);
return;
}
if (payload.target !== target) return;
sessionStorage.removeItem(storageKey);
this.$nextTick(() => {
// 独立表单页已经由路由控制新增状态,不能再调用 rowAdd 打开弹窗。
if (!this.isStandaloneWaybillFormPage) this.$refs.crud?.rowAdd?.();
this.$nextTick(() => this.applyTemplateCreatePayload(payload.data));
});
},
applyTemplateCreatePayload(data) {
// 模板主键不能带入目标业务单据,避免提交时被后端误判为更新。
const row = { ...(data || {}) };
[
'id',
'createUser',
'createUserName',
'createDept',
'createTime',
'updateUser',
'updateUserName',
'updateTime',
].forEach(key => delete row[key]);
this.suppressTransportTypeClear = true;
Object.assign(this.form, row);
this.form.dataSource = '手工创建';
this.applyWaybillTemplateTaskData(row);
this.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
this.form.planName = this.form.planName || this.form.templateName || '';
this.selectedProjectId = this.form.projectId || '';
this.fillCustomerNameFromProject();
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
this.selectedAttachmentRows = [];
if (this.contractSelectEnabled) {
this.syncCurrentContractOption();
this.loadContractOptionsForProject();
this.syncTaskFreightCurrencyFromContract();
}
if (this.config.enableProjectSelect) this.syncCurrentProjectOption();
this.loadProjectProcessConfigState();
this.initWaybillCargoRows();
if (this.taskInfoFormEnabled) this.initTaskInfoForm();
},
fillCustomerNameFromProject(projectId = this.form.projectId) {
if (this.form.customerName || !projectId) return Promise.resolve(this.form.customerName);
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
const customerName =
project?.customerNames || project?.customerName || project?.customer || '';
if (customerName) {
this.form.customerName = customerName;
return Promise.resolve(customerName);
}
return getProjectDetail(projectId)
.then(res => {
const detail = res?.data?.data || res?.data || {};
const name = detail.customerNames || detail.customerName || detail.customer || '';
if (!this.form.customerName && name) this.form.customerName = name;
return name;
})
.catch(() => '');
},
applyWaybillTemplateTaskData(template = {}) {
if (this.config.permission !== 'waybill_manage') return;
const goodsRows = this.parseJsonArray(template.goodsJson);
if (!goodsRows.length) return;
const freight = this.parseJsonObject(template.freightJson);
const freightItems = Array.isArray(freight.freightItems) ? freight.freightItems : [];
const enrichedGoodsRows = goodsRows.map((goods, index) => {
const freightItem =
freightItems[index] ||
freightItems.find(
item => String(item.quantityUnit || '') === String(goods.quantityUnit || '')
) ||
{};
return {
...goods,
unitPrice: freightItem.unitPrice ?? goods.unitPrice ?? '',
priceUnit: freightItem.priceUnit || goods.priceUnit || '',
};
});
this.form.goodsJson = JSON.stringify(enrichedGoodsRows);
this.taskFreightCurrency = freight.currency || this.taskFreightCurrency || 'RMB';
this.syncTaskFreightCurrencyFromContract();
this.form.otherFeeTotal =
freight.otherFreightAmount ?? freight.otherFeeTotal ?? this.form.otherFeeTotal ?? '';
if (enrichedGoodsRows.length > 1) {
this.form.taskEntryMode = 'full';
return;
}
const [goods] = enrichedGoodsRows;
this.form.taskEntryMode = 'simple';
Object.assign(this.form, {
cargoType: goods.cargoType || goods.goodsType || '',
cargoTypeCode: goods.cargoTypeCode || goods.goodsTypeCode || '',
cargoName: goods.cargoName || goods.goodsName || '',
specification: goods.specification || goods.spec || '',
model: goods.model || goods.modelName || '',
quantity: goods.quantity ?? goods.cargoQuantity ?? goods.goodsQuantity ?? '',
quantityUnit: goods.quantityUnit || goods.goodsQuantityUnit || goods.unit || '吨',
unitPrice: goods.unitPrice,
priceUnit: goods.priceUnit || this.defaultPriceUnit,
});
},
canOperation(operation, row) {
const permission = operation.permission || `${this.config.permission}_${operation.action}`;
if (!this.hasPermission(permission) || this.readonlyScope || row.readonly) {
return false;
}
const prop = operation.statusProp || this.config.statusProp || 'status';
const statusList = operation.status || [];
if (statusList.length && !statusList.includes(row[prop])) {
return false;
}
return true;
},
showDetailButton(row) {
// 草稿与其它状态统一走「详情」(独立详情页 / 详情弹窗),
// 不再对草稿单独降级为 avue 的「查看」表单弹窗,避免同一列出现两种弹窗形态。
if (!this.config.detailButton || !this.hasPermission(`${this.config.permission}_view`))
return false;
return true;
},
formatDetailValue(row, prop) {
if (!row) return '-';
if (prop === 'businessStatus') return this.displayStatus(row, prop);
if (prop === 'feeGenerationMode') {
return row[prop] === 'manual' ? '账单导入生成' : '系统生成';
}
// 计划未选时接口可能回传 planId=-1 / planName=-1,详情统一显示为空
if (prop === 'planName' || prop === 'planId') {
const planName = row.planName;
if (
planName === undefined ||
planName === null ||
planName === '' ||
String(planName) === '-1' ||
Number(planName) === -1
) {
return '-';
}
return planName;
}
const column = this.findColumn(this.option.column, prop);
if (column?.formatter) return column.formatter(row, {}, row[prop]);
if (column?.dicData) {
const item = column.dicData.find(dic => String(dic.value) === String(row[prop]));
if (item) return item.label;
}
const value = row[prop];
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
return '-';
}
if (value && typeof value === 'object') return JSON.stringify(value);
if (typeof value === 'string' && /^[\[{]/.test(value.trim())) {
try {
const parsed = JSON.parse(value);
return typeof parsed === 'object' ? JSON.stringify(parsed) : parsed;
} catch (error) {
// 非 JSON 文本按原值展示。
}
}
return value;
},
waybillTransportMode(row = {}) {
return (
row.transportModeName ||
row.transportMode ||
row.transportTypeName ||
row.transportType ||
''
);
},
waybillIsRoadTransport(row = {}) {
return this.waybillTransportModeCode(row) === 'road';
},
waybillTransportModeCode(row = {}) {
const text = [
row.transportType,
row.transportTypeName,
row.transportMode,
row.transportModeName,
this.getTransportTypeLabel(row.transportType),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (/公路|道路|汽车|陆运|road|highway/.test(text) || text === 'gl') return 'road';
if (/水路|水运|海运|港|water|ship|sea/.test(text) || text === 'sl') return 'water';
if (/航空|空运|空港|air|airport/.test(text) || text === 'hk') return 'air';
if (/铁路|rail|train/.test(text) || text === 'tl') return 'rail';
return '';
},
waybillIsNonRoadTransport(row = {}) {
return ['water', 'rail', 'air'].includes(this.waybillTransportModeCode(row));
},
waybillIsCarrier(row = {}) {
return String(row.carrierTypeName || row.carrierType || '').trim() === '承运商';
},
waybillDetailTaskInfo(row = {}) {
return this.parseJsonObject(row.taskInfoJson || row.taskInfo || {});
},
waybillDetailMileage(row = {}) {
const taskInfo = this.waybillDetailTaskInfo(row);
const goodsRows = this.parseJsonArray(row.goodsJson);
const firstGoods = goodsRows[0] || {};
return this.normalizeMileageValue(
row.mileage || taskInfo.mileage || firstGoods.mileage || ''
);
},
waybillDetailEstimatedStartTime(row = {}) {
const taskInfo = this.waybillDetailTaskInfo(row);
const value =
row.estimatedStartTime ||
row.planStartDate ||
taskInfo.estimatedStartTime ||
taskInfo.planStartDate ||
'';
return this.formatWaybillDetailDate(value);
},
waybillDetailEstimatedEndTime(row = {}) {
const taskInfo = this.waybillDetailTaskInfo(row);
const value =
row.estimatedEndTime ||
row.planEndDate ||
taskInfo.estimatedEndTime ||
taskInfo.planEndDate ||
'';
return this.formatWaybillDetailDate(value);
},
formatWaybillDetailDate(value) {
const text = String(value || '').trim();
if (!text) return '';
return text.slice(0, 10);
},
waybillVehicleNoLabel(row = {}) {
return this.waybillIsRoadTransport(row) ? '车牌号' : '船班号/船次号/班次号';
},
waybillUnitPrice(row = {}) {
const price = row.unitPrice || row.price || '';
const unit = row.priceUnit || row.billingUnit || '';
if (price === '' || Number(price) === -1) return '';
return unit ? `${price}(${unit})` : price;
},
waybillCurrencyRemark(row = {}) {
const freight = this.parseJsonObject(row.freightJson);
const value = row.currency || row.freightCurrency || freight.currency || '';
if (!value) return '';
const normalized = String(value).toUpperCase();
const option = this.freightCurrencyOptions.find(item => {
const itemValue = String(item.value || '').toUpperCase();
return itemValue === normalized || (normalized === 'CNY' && itemValue === 'RMB');
});
const remark = String(option?.remark || '').trim();
if ((normalized === 'CNY' || normalized === 'RMB') && remark === '元') return '¥';
return remark;
},
waybillUnitPriceWithCurrency(row = {}) {
const text = this.waybillUnitPrice(row);
if (!text) return { value: '-', unit: '' };
const match = String(text).match(/^(.*)\((.*)\)$/);
const price = match ? match[1] : text;
const unit = match ? match[2] : '';
return {
value: `${this.waybillCurrencyRemark(row)}${price}`,
unit,
};
},
waybillAmountWithCurrency(row = {}, prop) {
const value = this.formatDetailValue(row, prop);
if (value === '-') return { value: '-' };
const number = Number(value);
const display = Number.isFinite(number) ? number.toFixed(2) : value;
return { value: `${this.waybillCurrencyRemark(row)}${display}` };
},
openDetail(row) {
if (!this.isStandaloneWaybillDetailPage) {
// 已不在自己的路由上(被缓存后路由切走):此时 row.id 极可能是别的单据的 id,
// 再 push 会生成一堆错误的「运单管理详情」标签
if (!this.isOwnedRouteActive) return;
this.$router.push({ path: standaloneWaybillDetailRoute, query: { id: row.id } });
return;
}
this.detailBox = true;
this.detailLoading = true;
this.detailRow = { ...row };
this.waybillProcessDetailTab = 'punch';
this.waybillHasRelatedVoucher = false;
this.waybillDetailProcessNodes = [];
this.waybillPunchRecords = [];
this.waybillDriverUploads = [];
this.waybillVoucherImages = [];
this.waybillVoucherFolders = [];
this.waybillVoucherFolder = null;
this.waybillVoucherFolderView = false;
this.waybillVoucherImagesLoaded = false;
this.waybillExceptionRows = [];
const request =
typeof this.api.getDetail === 'function'
? this.api.getDetail(row.id)
: Promise.resolve({ data: { data: row } });
request
.then(res => {
const detail = res?.data?.data || res?.data || row;
this.detailRow = detail;
this.loadWaybillPunchRecords();
this.loadWaybillVoucherImages();
this.loadWaybillExceptions(detail);
if (detail.contractId) {
return getContractDetail(detail.contractId)
.then(contractRes => {
const contract = contractRes?.data?.data || contractRes?.data || {};
this.detailRow = {
...detail,
contractNo: contract.contractNo || detail.contractNo || detail.contractName || '',
customerName:
contract.partyA || contract.customerName || detail.customerName || '',
};
this.applyFormDetail(this.detailRow);
return this.loadWaybillDetailProcessNodes(this.detailRow.projectId);
})
.catch(() => {
this.detailRow = {
...detail,
contractNo: detail.contractNo || detail.contractName || '',
};
this.applyFormDetail(this.detailRow);
return this.loadWaybillDetailProcessNodes(this.detailRow.projectId);
});
}
this.applyFormDetail(detail);
return this.loadWaybillDetailProcessNodes(detail.projectId);
})
.finally(() => {
this.detailLoading = false;
});
},
// 异常处置列表接口不支持按 waybillId 精确过滤,只有 waybillNo 模糊匹配,
// 而运单号存在前缀包含(YD001 会命中 YD0011),故取回后必须再按 waybillId 精确筛一次。
// 多取一页降低「同前缀运单占满首页」导致漏数据的概率。
// 注意 String() 比较只是避免类型不一致,挡不住 int64 精度:运单 id 超出 JS 安全整数范围,
// 两个相差很小的 id 在响应 JSON 解析时就已被解析成同一个浮点值,此处无从分辨。
async loadWaybillExceptions(detail) {
const waybillId = detail?.id;
const waybillNo = detail?.waybillNo;
if (!waybillId || !waybillNo) {
this.waybillExceptionRows = [];
return;
}
this.waybillExceptionLoading = true;
try {
const res = await exceptionDisposalApi.getList(1, 100, { waybillNo });
const data = res?.data?.data || res?.data || res || {};
this.waybillExceptionRows = (data.records || [])
.filter(item => String(item.waybillId) === String(waybillId))
.map(this.decorateWaybillExceptionRow);
} catch (error) {
this.waybillExceptionRows = [];
} finally {
this.waybillExceptionLoading = false;
}
},
decorateWaybillExceptionRow(row) {
return {
...row,
exceptionTypeName: this.waybillExceptionName(row.exceptionType),
disposalStatusName:
disposalStatusOptions.find(item => item.value === row.disposalStatus)?.label ||
row.disposalStatus ||
'-',
};
},
waybillExceptionName(value) {
if (!value) return '-';
// 一级存中文大类名(如「交通异常」),二级存子类名(如「货损」),两级都查
const group = exceptionCategoryOptions.find(item => item.value === value);
if (group) return group.label;
for (const item of exceptionCategoryOptions) {
const match = item.children.find(child => child.value === value);
if (match) return match.label;
}
return value;
},
async openWaybillException(row, mode) {
this.waybillExceptionDialog = {
visible: true,
mode,
loading: true,
submitting: false,
};
this.waybillExceptionFollowForm = { followContent: '', attachments: [] };
try {
const res = await exceptionDisposalApi.getDetail(row.id);
const detail = res?.data?.data || res?.data || res || {};
this.waybillExceptionDetail = this.decorateWaybillExceptionRow(detail);
if (mode === 'edit') {
this.waybillExceptionEditForm = {
exceptionType: this.waybillExceptionDetail.exceptionType || '',
exceptionReason: this.waybillExceptionDetail.exceptionReason || '',
reportDescription: this.waybillExceptionDetail.reportDescription || '',
scenePhotoList: this.parseWaybillExceptionPhotos(
this.waybillExceptionDetail.scenePhotos
),
};
}
} finally {
this.waybillExceptionDialog.loading = false;
}
},
waybillExceptionValue(prop) {
const value = this.waybillExceptionDetail[prop];
return value === null || value === undefined || value === '' ? '-' : value;
},
handleWaybillExceptionTypeChange() {
// 一级类型变了,旧二级可能不属于新类型
const stillValid = this.waybillExceptionReasonOptions.some(
item => item.value === this.waybillExceptionEditForm.exceptionReason
);
if (!stillValid) {
this.waybillExceptionEditForm.exceptionReason = '';
}
},
beforeWaybillExceptionPhotoUpload(file) {
const validType = ['image/jpeg', 'image/png', 'image/jpg', 'image/bmp'].includes(file.type);
if (!validType) {
this.$message.error('仅支持 JPG、PNG、BMP 图片');
return false;
}
if (file.size / 1024 / 1024 >= 5) {
this.$message.error('图片大小不能超过 5MB');
return false;
}
return true;
},
handleWaybillExceptionPhotoSuccess(res) {
if (res.code === 200 && res.data) {
const url = res.data.link || res.data.url || res.data.domain || '';
if (url) {
this.waybillExceptionEditForm.scenePhotoList = [
...this.waybillExceptionEditForm.scenePhotoList,
url,
];
}
} else {
this.$message.error(res.msg || '上传失败');
}
},
handleWaybillExceptionPhotoError() {
this.$message.error('上传失败');
},
removeWaybillExceptionPhoto(index) {
const list = [...this.waybillExceptionEditForm.scenePhotoList];
list.splice(index, 1);
this.waybillExceptionEditForm.scenePhotoList = list;
},
parseWaybillExceptionPhotos(value) {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed.filter(Boolean);
} catch (error) {
return String(value)
.split(',')
.map(item => item.trim())
.filter(Boolean);
}
return [];
},
parseWaybillExceptionAttachments(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter(item => item && item.url) : [];
} catch (error) {
return [];
}
},
previewWaybillExceptionAttachment(file, row) {
this.previewAttachment(file, this.parseWaybillExceptionAttachments(row.attachmentsJson));
},
async submitWaybillExceptionEdit() {
await this.$refs.waybillExceptionEditFormRef.validate();
this.waybillExceptionDialog.submitting = true;
try {
await exceptionDisposalApi.update({
id: this.waybillExceptionDetail.id,
exceptionType: this.waybillExceptionEditForm.exceptionType,
exceptionReason: this.waybillExceptionEditForm.exceptionReason,
reportDescription: this.waybillExceptionEditForm.reportDescription,
scenePhotos: this.waybillExceptionEditForm.scenePhotoList.join(','),
});
this.$message.success('编辑成功');
this.waybillExceptionDialog.visible = false;
this.refreshWaybillExceptions();
} finally {
this.waybillExceptionDialog.submitting = false;
}
},
async submitWaybillExceptionFollow() {
await this.$refs.waybillExceptionFollowFormRef.validate();
const followContent = (this.waybillExceptionFollowForm.followContent || '').trim();
const attachmentsJson = this.waybillExceptionFollowForm.attachments.length
? JSON.stringify(this.waybillExceptionFollowForm.attachments)
: '';
// 后端要求二者至少有其一
if (!followContent && !attachmentsJson) {
this.$message.warning('请填写跟进说明或上传附件');
return;
}
this.waybillExceptionDialog.submitting = true;
try {
await exceptionDisposalApi.follow({
id: this.waybillExceptionDetail.id,
followContent,
attachmentsJson,
});
this.$message.success('跟进成功');
this.waybillExceptionDialog.visible = false;
this.refreshWaybillExceptions();
} finally {
this.waybillExceptionDialog.submitting = false;
}
},
async completeWaybillExceptionRow(row) {
await this.$confirm('确认完成该异常处置?', '提示', { type: 'warning' });
await exceptionDisposalApi.complete({ id: row.id });
this.$message.success('完成成功');
this.refreshWaybillExceptions();
},
async completeWaybillExceptionFromDialog() {
await this.$confirm('确认完成该异常处置?', '提示', { type: 'warning' });
await exceptionDisposalApi.complete({ id: this.waybillExceptionDetail.id });
this.$message.success('完成成功');
this.waybillExceptionDialog.visible = false;
this.refreshWaybillExceptions();
},
refreshWaybillExceptions() {
this.loadWaybillExceptions(this.detailRow);
},
closeDetail() {
this.destroyWaybillLocateMap();
this.destroyWaybillTrackMap();
this.waybillTrackMode = '';
this.waybillLocateInfo = null;
this.waybillLocateHint = '';
this.waybillTrackInfo = null;
this.waybillTrackHint = '';
if (this.isStandaloneWaybillDetailPage) {
this.markWaybillListNeedRefresh();
this.$router.$avueRouter?.closeTag?.();
this.$router.push({ path: standaloneWaybillListRoute, query: {} });
return;
}
this.detailBox = false;
},
async openLoadingDetail(row = {}) {
let id = row.loadingId || row.loadingManageId || '';
if (!id && row.loadingNo) {
try {
const res = await getLoadingList(1, 1, { loadingNo: row.loadingNo });
const data = res?.data?.data || res?.data || res || {};
const records = data.records || data.rows || (Array.isArray(data) ? data : []);
id = records[0]?.id || '';
} catch (error) {
id = '';
}
}
if (!id) {
this.$message.info('当前配载单暂无详情数据');
return;
}
this.$router.push({
path: '/business/loading-manage/detail',
query: { id, name: '配载单详情' },
});
},
waybillDetailCustomerLabel(row = {}) {
return row.customer || row.fullName || row.shortName || row.customerName || row.customerCode || '';
},
waybillDetailCustomerId(row = {}) {
return row.id || row.customerId || '';
},
canOpenWaybillDetailSummaryLink(prop) {
const row = this.detailRow || {};
const value = this.formatDetailValue(row, prop);
if (!value || value === '-') return false;
if (prop === 'customerName') return Boolean(row.customerName);
if (prop === 'contractNo') return Boolean(row.contractId);
if (prop === 'projectName') return Boolean(row.projectId);
return false;
},
handleWaybillDetailSummaryClick(prop) {
if (prop === 'customerName') return this.openWaybillDetailCustomer();
if (prop === 'contractNo') return this.openWaybillDetailContract();
if (prop === 'projectName') return this.openWaybillDetailProject();
},
async resolveWaybillDetailCustomerId() {
const row = this.detailRow || {};
if (row.customerId) return row.customerId;
const name = String(row.customerName || '').trim();
if (!name) return '';
if (row.projectId) {
try {
const res = await getProjectDetail(row.projectId);
const customers = this.parseJsonArray(res.data?.data?.customerJson);
const matched =
customers.find(item => this.waybillDetailCustomerLabel(item) === name) ||
customers.find(item => {
const label = this.waybillDetailCustomerLabel(item);
return label && (name.includes(label) || label.includes(name));
});
const id = this.waybillDetailCustomerId(matched);
if (id) return id;
} catch (error) {
// 项目客户解析失败时回退到客商档案检索
}
}
try {
const res = await getCustomerArchiveList(1, 20, { fullName: name });
const records = res.data?.data?.records || [];
const exact =
records.find(item => item.fullName === name || item.shortName === name) || records[0];
return exact?.id || '';
} catch (error) {
return '';
}
},
async openWaybillDetailCustomer() {
if (!this.detailRow?.customerName) return;
const customerId = await this.resolveWaybillDetailCustomerId();
if (!customerId) {
this.$message.warning('未找到对应客商档案,无法打开');
return;
}
this.$router.push({
path: '/vehicle/customer-archive/form',
query: { id: String(customerId), name: '查看客商档案', view: '1' },
});
},
openWaybillDetailContract() {
if (!this.detailRow?.contractId) {
this.$message.warning('合同信息缺失,无法打开');
return;
}
this.$router.push({
path: '/business/contract-manage/detail',
query: { id: this.detailRow.contractId, name: '合同详情' },
});
},
openWaybillDetailProject() {
if (!this.detailRow?.projectId) {
this.$message.warning('项目信息缺失,无法打开');
return;
}
this.$router.push({
path: '/business/project-apply/form',
query: { mode: 'view', id: this.detailRow.projectId, name: '查看项目管理' },
});
},
loadWaybillDetailProcessNodes(projectId) {
this.waybillDetailProcessNodes = [];
if (!projectId) return Promise.resolve([]);
return getProcessConfigList(1, 100, { projectIds: projectId, status: 1 })
.then(res => {
const config = extractRecords(res).find(row => {
const projectIds = String(row.projectIds || '')
.split(',')
.map(item => item.trim());
return projectIds.includes(String(projectId));
});
if (!config?.id) return [];
return getProcessConfigDetail(config.id, this.detailRow.id).then(detailRes => {
const detail = detailRes?.data?.data || detailRes?.data || config;
this.waybillHasRelatedVoucher =
this.waybillHasRelatedVoucher || Boolean(detail.hasRelatedVoucher);
return this.getProcessConfigEnabledNodes(detail);
});
})
.then(nodes => {
if (String(this.detailRow.projectId) === String(projectId)) {
this.waybillDetailProcessNodes = nodes;
}
return nodes;
})
.catch(() => []);
},
// 解析打卡时间(支持常见日期字符串格式),未打卡或无有效时间返回 null
waybillPunchRecordTimestamp(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 = this.$dayjs ? this.$dayjs(text) : null;
if (parsed && typeof parsed.isValid === 'function' && parsed.isValid()) {
return parsed.valueOf();
}
const fallback = new Date(text.replace(/-/g, '/')).getTime();
return Number.isNaN(fallback) ? null : fallback;
},
// 时间轴 key:只认节点标识,不带序号。异常索引与模板 v-for 共用它,
// 带上序号的话,编辑打卡时间会重排时间轴,序号一变异常标签就挂错节点或整个消失。
waybillPunchRecordKey(record, index) {
return String(record?.nodeCode || record?.id || `punch-${index}`);
},
// 未处理完 → 跟进;全处理完 → 只读详情(与异常处置列表的操作口径一致)
openWaybillPunchRecordException(exception) {
if (!exception?.target) return;
this.openWaybillException(exception.target, exception.unfinished ? 'follow' : 'detail');
},
loadWaybillPunchRecords() {
const waybillId = this.detailRow?.id;
if (!waybillId || this.waybillPunchRecordsLoading) return;
this.waybillPunchRecordsLoading = true;
const request =
typeof this.api.getPunchRecords === 'function'
? this.api.getPunchRecords(waybillId)
: getWaybillPunchRecords(waybillId);
request
.then(res => {
const data = res?.data?.data || res?.data || {};
this.waybillPunchRecords = Array.isArray(data.records) ? data.records : [];
this.waybillDriverUploads = Array.isArray(data.driverUploads)
? data.driverUploads
: [];
})
.catch(() => {
this.waybillPunchRecords = [];
this.waybillDriverUploads = [];
})
.finally(() => {
this.waybillPunchRecordsLoading = false;
});
},
// 打卡记录 → 过程配置节点:nodeCode 命中优先,回退 nodeName(与后端 listPunchRecords 同口径)
findWaybillPunchEditNodeConfig(record = {}) {
const nodes = Array.isArray(this.waybillDetailProcessNodes)
? this.waybillDetailProcessNodes
: [];
const nodeCode = String(record?.nodeCode || '')
.trim()
.toLowerCase();
const nodeName = String(record?.nodeName || '').trim();
return (
nodes.find(node => nodeCode && String(node.key || '').trim().toLowerCase() === nodeCode) ||
nodes.find(node => nodeName && String(node.name || '').trim() === nodeName) ||
null
);
},
// 接单节点没有打卡流水,它的完成由运单的接单事实决定:补录或取消打卡都改不动它,
// 放出去只会造出一条与运单事实打架的流水。判据与后端一致(key=accept / name=接单)。
isAcceptPunchRecord(record = {}) {
const node = this.findWaybillPunchEditNodeConfig(record);
const key = String(node?.key || record?.nodeCode || '')
.trim()
.toLowerCase();
const name = String(node?.name || record?.nodeName || '').trim();
return key === 'accept' || name === '接单';
},
openWaybillPunchRecordEdit(record = {}) {
if (!this.detailRow?.id || !record) return;
this.waybillPunchEditRecord = { ...record };
// 弹窗不提供打卡状态开关:未打卡的记录点「补卡」进来,保存即补出一条打卡流水
this.waybillPunchEditForm = {
punchTime: record.punchTime || this.$dayjs().format('YYYY-MM-DD HH:mm:ss'),
address: record.address || '',
longitude: record.longitude || '',
latitude: record.latitude || '',
weight: record.weight || '',
volume: record.volume || '',
quantity: record.quantity || '',
// 里程只有正整数口径,后端若仍下发 "200.00" 会过不了提交时的正整数校验
mileage: Number(record.mileage) > 0 ? String(Number(record.mileage)) : '',
remark: record.remark || '',
photos: (record.photos || [])
.filter(photo => photo?.url)
.map(photo => ({ url: photo.url, type: photo.voucherType || photo.type || '' })),
};
this.waybillPunchAddressMapVisible = false;
this.waybillPunchRecordEditDialog.visible = true;
},
// 地图选点回填:地址与经纬度必须成套,只回填地址会让坐标停留在旧点上
handleWaybillPunchAddressMapConfirm(payload) {
const selection = typeof payload === 'string' ? { address: payload } : payload || {};
if (selection.address) {
this.waybillPunchEditForm.address = selection.address;
}
if (
selection.longitude !== undefined &&
selection.longitude !== null &&
selection.longitude !== ''
) {
this.waybillPunchEditForm.longitude = Number(selection.longitude).toFixed(6);
}
if (
selection.latitude !== undefined &&
selection.latitude !== null &&
selection.latitude !== ''
) {
this.waybillPunchEditForm.latitude = Number(selection.latitude).toFixed(6);
}
},
beforeWaybillPunchPhotoUpload(file, type = '') {
const validType = ['image/jpeg', 'image/png', 'image/jpg', 'image/bmp'].includes(file.type);
if (!validType) {
this.$message.error('仅支持 JPG、PNG、BMP 图片');
return false;
}
// 单张 20MB 与司机端一致:凭证要原图,5MB 会把手机拍的正常凭证挡在门外
if (file.size / 1024 / 1024 >= 20) {
this.$message.error('图片大小不能超过 20MB');
return false;
}
// 每个照片类型各自最多 9 张。界面已经不给满员的类型「添加」入口,这里再兜一层。
// 张数按界面上的分组口径统计:没带类型的照片算进兜底类型「凭证」
const limit = this.waybillPunchEditPhotoLimit;
if (limit > 0 && type) {
const used = (this.waybillPunchEditForm.photos || []).filter(
photo => (String(photo?.type || '').trim() || '凭证') === type
).length;
if (used >= limit) {
this.$message.error(`${type}最多上传 ${limit} 张`);
return false;
}
}
return true;
},
handleWaybillPunchPhotoSuccess(res, type = '') {
if (res.code !== 200 || !res.data) {
this.$message.error(res.msg || '上传失败');
return;
}
const url = res.data.link || res.data.url || res.data.domain || '';
if (!url) return;
// 照片落进点开的那一组并带上该类型的名字:类型写空,保存时会被按下标回推成别的类型
const photos = [...this.waybillPunchEditForm.photos, { url, type }];
// 在途打卡表只有一个照片字段(单张),多传的保存时会被丢掉,这里先只留一张
this.waybillPunchEditForm.photos =
this.waybillPunchEditRecord.type === 'enroute' ? photos.slice(0, 1) : photos;
},
handleWaybillPunchPhotoError() {
this.$message.error('上传失败');
},
removeWaybillPunchEditPhoto(index) {
const photos = [...this.waybillPunchEditForm.photos];
photos.splice(index, 1);
this.waybillPunchEditForm.photos = photos;
},
// 里程只收整数:非数字直接丢掉,长度与「维护里程」一致(≤10 位)
handleWaybillPunchMileageInput(value) {
this.waybillPunchEditForm.mileage = String(value || '')
.replace(/\D/g, '')
.slice(0, 10);
},
async submitWaybillPunchRecordEdit() {
await this.$refs.waybillPunchRecordEditFormRef.validate();
const record = this.waybillPunchEditRecord;
const form = this.waybillPunchEditForm;
// 管理端里程选填:留空表示不改动运单里程,所以只校验填了值的情况
const mileageText = String(form.mileage || '').trim();
if (this.waybillPunchEditMileageVisible && mileageText && !/^[1-9]\d{0,9}$/.test(mileageText)) {
this.$message.warning('里程必须为不超过10位的正整数');
return;
}
const submit =
typeof this.api?.savePunchRecord === 'function' ? this.api.savePunchRecord : savePunchRecord;
this.waybillPunchRecordEditDialog.submitting = true;
try {
await submit({
waybillId: this.detailRow.id,
type: record.type === 'enroute' ? 'enroute' : 'node',
nodeCode: record.nodeCode || '',
nodeName: record.nodeName || '',
// 弹窗没有打卡状态开关:无论「补卡」还是「编辑打卡」,保存都落一条已打卡流水
punched: true,
punchTime: form.punchTime,
address: form.address,
longitude: form.longitude,
latitude: form.latitude,
weight: form.weight,
volume: form.volume,
quantity: form.quantity,
// 不传代表不改;后端按 null 处理
mileage: mileageText ? Number(mileageText) : undefined,
remark: form.remark,
photos: form.photos.map(photo => ({ url: photo.url, type: photo.type })),
});
this.$message.success('保存成功');
this.waybillPunchRecordEditDialog.visible = false;
this.loadWaybillPunchRecords();
} finally {
this.waybillPunchRecordEditDialog.submitting = 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;
},
loadWaybillVoucherImages() {
if (
!this.detailRow.id ||
this.waybillVoucherImagesLoading ||
this.waybillVoucherImagesLoaded
) {
return;
}
this.waybillVoucherImagesLoading = true;
getProcessConfigVoucherImages(this.detailRow.id)
.then(res => {
this.waybillVoucherImages = extractRecords(res);
this.waybillVoucherFolders = this.waybillVoucherImages.filter(item =>
this.isWaybillVoucherFolder(item)
);
this.waybillVoucherFolder = null;
this.waybillVoucherFolderView = false;
if (this.waybillVoucherImages.length) this.waybillHasRelatedVoucher = true;
this.waybillVoucherImagesLoaded = true;
})
.finally(() => {
this.waybillVoucherImagesLoading = false;
});
},
isWaybillVoucherFolder(item) {
return Boolean(item && (item.isFolder === true || item.type === 'folder'));
},
waybillVoucherFolderName(folder) {
return folder?.folderName || folder?.name || folder?.plateNo || '凭证文件夹';
},
openWaybillVoucherFolder(folder) {
if (!this.detailRow.id || !this.isWaybillVoucherFolder(folder)) return;
if (!folder.voucherId || !folder.folderName) {
this.$message.warning('凭证文件夹信息不完整');
return;
}
this.waybillVoucherImagesLoading = true;
getProcessConfigVoucherImages(this.detailRow.id, {
voucherId: folder.voucherId,
folderName: folder.folderName,
})
.then(res => {
this.waybillVoucherFolder = folder;
this.waybillVoucherImages = extractRecords(res).filter(
item => !this.isWaybillVoucherFolder(item)
);
this.waybillVoucherFolderView = true;
})
.finally(() => {
this.waybillVoucherImagesLoading = false;
});
},
backToWaybillVoucherFolders() {
this.waybillVoucherImages = [...this.waybillVoucherFolders];
this.waybillVoucherFolder = null;
this.waybillVoucherFolderView = false;
},
previewWaybillVoucherImage(index) {
const imageRows = this.waybillVoucherImages.filter(
image => !this.isWaybillVoucherFolder(image)
);
const currentImage = this.waybillVoucherImages[index];
const imageIndex = imageRows.findIndex(image => image === currentImage);
this.attachmentImagePreviewUrls = imageRows
.map(image => image.url)
.filter(Boolean);
this.attachmentImagePreviewIndex = imageIndex >= 0 ? imageIndex : 0;
this.attachmentImagePreviewVisible = this.attachmentImagePreviewUrls.length > 0;
},
buildWaybillRouteNodes(rows = []) {
const nodes = [];
rows.forEach((row, rowIndex) => {
[
['departure', '发'],
['arrival', '收'],
].forEach(([field, tag]) => {
const address = row[`${field}Address`] || row[`${field}Name`];
if (!address) return;
nodes.push({
key: `${row.id || row.waybillNo || rowIndex}-${field}`,
address,
tags: [tag, row.waybillNo].filter(Boolean),
waybillId: row.id,
waybillNo: row.waybillNo,
field,
});
});
});
return nodes;
},
restoreWaybillRouteChangeRecords() {
const taskInfo = this.parseJsonObject(this.detailRow.taskInfoJson);
this.waybillRouteChangeRecords = Array.isArray(taskInfo.routeChangeRecords)
? [...taskInfo.routeChangeRecords].sort((a, b) =>
String(b.changeTime).localeCompare(String(a.changeTime))
)
: [];
},
openWaybillRouteChangeDialog() {
if (!this.detailRow.id || !this.canChangeWaybillRoute(this.detailRow)) return;
this.restoreWaybillRouteChangeRecords();
this.waybillRouteChangeTab = 'change';
this.waybillRouteChangeRemark = '';
this.waybillRouteChangeRows = [
{
...this.detailRow,
originalDepartureAddress:
this.detailRow.departureAddress || this.detailRow.departureName || '',
originalArrivalAddress: this.detailRow.arrivalAddress || this.detailRow.arrivalName || '',
departureAddress: this.detailRow.departureAddress || this.detailRow.departureName || '',
arrivalAddress: this.detailRow.arrivalAddress || this.detailRow.arrivalName || '',
},
];
this.waybillRouteChangeNodes = this.buildWaybillRouteNodes(this.waybillRouteChangeRows);
this.waybillRouteChangeBox = true;
},
canChangeWaybillRoute(row = {}) {
const status = String(this.statusValue(row) || row.businessStatus || row.status || '');
if (status === 'completed') return false;
const statusName = String(row.businessStatusName || row.statusName || '');
return !statusName.includes('已完成');
},
updateWaybillRouteChangeNodes() {
this.waybillRouteChangeNodes = this.buildWaybillRouteNodes(this.waybillRouteChangeRows);
},
openWaybillRouteChangeMapDialog(row, field) {
if (!row || !field) return;
this.waybillRouteChangeMapTarget = { row, field };
this.transportMapTarget = field;
this.transportMapKeyword = row[`${field}Address`] || row[`${field}Name`] || '';
this.transportMapSelected = this.buildTransportMapSelection({
lng: row[`${field}Longitude`],
lat: row[`${field}Latitude`],
address: row[`${field}Address`],
regionName: row[`${field}Name`],
regionCode: row[`${field}RegionCode`],
});
this.transportMapStatus = this.transportMapSelected.longitude
? '已加载当前选点,可重新选点'
: '可搜索地址或点击地图选点';
this.transportMapBox = true;
},
async confirmWaybillRouteChange() {
const row = this.waybillRouteChangeRows[0] || {};
const changed =
row.departureAddress !== row.originalDepartureAddress ||
row.arrivalAddress !== row.originalArrivalAddress;
const routeJson = JSON.stringify(this.waybillRouteChangeNodes);
if (!changed && !this.waybillRouteChangeRemark) {
this.$message.warning('请修改地址或填写变更备注');
return;
}
if (typeof this.api.changeRoute !== 'function') {
this.$message.warning('当前接口未接入路线变更功能');
return;
}
const content = `${row.waybillNo || '运单'}:发货地 ${row.originalDepartureAddress || '-'} → ${
row.departureAddress || '-'
};到货地 ${row.originalArrivalAddress || '-'} → ${row.arrivalAddress || '-'}`;
const records = [
{
changeTime: this.$dayjs().format('YYYY-MM-DD HH:mm:ss'),
changeUser: this.userInfo?.realName || this.userInfo?.account || '当前用户',
content,
remark: this.waybillRouteChangeRemark,
},
...this.waybillRouteChangeRecords,
];
const taskInfo = this.parseJsonObject(this.detailRow.taskInfoJson);
const taskInfoJson = JSON.stringify({
...taskInfo,
routeChangeRecords: records,
});
this.waybillRouteChangeSaving = true;
try {
await this.api.changeRoute({
id: this.detailRow.id,
routeJson,
departureAddress: row.departureAddress,
arrivalAddress: row.arrivalAddress,
taskInfoJson,
});
let refreshedDetail = null;
if (typeof this.api.getDetail === 'function') {
try {
const detailRes = await this.api.getDetail(this.detailRow.id);
refreshedDetail = detailRes?.data?.data || detailRes?.data || null;
} catch (error) {
refreshedDetail = null;
}
}
this.detailRow = {
...this.detailRow,
...(refreshedDetail || {}),
departureAddress: row.departureAddress,
arrivalAddress: row.arrivalAddress,
routeJson,
taskInfoJson,
};
this.waybillRouteChangeRecords = records;
this.waybillRouteChangeBox = false;
this.$message.success('路线变更已保存');
this.onLoad(this.page, this.query);
} finally {
this.waybillRouteChangeSaving = false;
}
},
handleWaybillTrackAction(action) {
if (action === 'playback') {
this.destroyWaybillLocateMap();
this.waybillTrackMode = 'playback';
this.waybillLocateInfo = null;
this.waybillLocateHint = '';
if (!Array.isArray(this.waybillTrackDateRange) || this.waybillTrackDateRange.length !== 2) {
this.waybillTrackDateRange = this.buildDefaultTrackDateRange();
}
this.loadWaybillTrack();
return;
}
this.destroyWaybillTrackMap();
this.waybillTrackMode = 'locate';
this.waybillTrackInfo = null;
this.waybillTrackHint = '';
this.loadWaybillLocate();
},
buildDefaultTrackDateRange() {
const pad = value => String(value).padStart(2, '0');
const formatDate = date =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const today = new Date();
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
return [formatDate(yesterday), formatDate(today)];
},
destroyWaybillLocateMap() {
if (this.waybillLocateMarker) {
this.waybillLocateMarker.setMap(null);
this.waybillLocateMarker = null;
}
if (this.waybillLocateInfoWindow) {
this.waybillLocateInfoWindow.close();
this.waybillLocateInfoWindow = null;
}
if (this.waybillLocateMapInstance) {
this.waybillLocateMapInstance.destroy();
this.waybillLocateMapInstance = null;
}
},
destroyWaybillTrackMap() {
if (this.waybillTrackPolyline) {
this.waybillTrackPolyline.setMap(null);
this.waybillTrackPolyline = null;
}
if (this.waybillTrackStartMarker) {
this.waybillTrackStartMarker.setMap(null);
this.waybillTrackStartMarker = null;
}
if (this.waybillTrackEndMarker) {
this.waybillTrackEndMarker.setMap(null);
this.waybillTrackEndMarker = null;
}
if (this.waybillTrackMapInstance) {
this.waybillTrackMapInstance.destroy();
this.waybillTrackMapInstance = null;
}
},
async loadWaybillLocate() {
const waybillId = this.detailRow?.id;
if (!waybillId) {
this.$message.warning('运单信息不完整');
return;
}
if (!this.detailRow.vehicleNo) {
this.$message.warning('运单未绑定车牌号,无法实时定位');
return;
}
this.waybillLocateLoading = true;
this.waybillLocateHint = '正在获取车辆实时定位...';
this.waybillLocateInfo = null;
try {
const locateApi =
typeof this.api.locateVehicle === 'function' ? this.api.locateVehicle : locateVehicle;
const res = await locateApi(waybillId);
const data = res?.data?.data || null;
if (!data || data.longitude == null || data.latitude == null) {
this.waybillLocateInfo = null;
this.waybillLocateHint = '暂无车辆实时定位数据';
this.$message.warning('暂无车辆实时定位数据');
return;
}
this.waybillLocateInfo = data;
this.waybillLocateHint = '';
await this.$nextTick();
await this.renderWaybillLocateMap(data);
} catch (error) {
this.waybillLocateInfo = null;
this.waybillLocateHint = '实时定位获取失败';
this.$message.error(error?.message || '实时定位获取失败');
} finally {
this.waybillLocateLoading = false;
}
},
async loadWaybillTrack() {
const waybillId = this.detailRow?.id;
if (!waybillId) {
this.$message.warning('运单信息不完整');
return;
}
if (!this.detailRow.vehicleNo) {
this.$message.warning('运单未绑定车牌号,无法查询历史轨迹');
return;
}
if (!Array.isArray(this.waybillTrackDateRange) || this.waybillTrackDateRange.length !== 2) {
this.waybillTrackDateRange = this.buildDefaultTrackDateRange();
}
const [startDate, endDate] = this.waybillTrackDateRange;
this.waybillTrackLoading = true;
this.waybillTrackHint = '正在获取历史轨迹...';
this.destroyWaybillTrackMap();
try {
const trackApi =
typeof this.api.trackVehicle === 'function' ? this.api.trackVehicle : trackVehicle;
const res = await trackApi(waybillId, startDate, endDate);
const data = res?.data?.data || null;
const points = Array.isArray(data?.points) ? data.points : [];
if (!data || points.length === 0) {
this.waybillTrackInfo = data || { total: 0, points: [] };
this.waybillTrackHint = '暂无可回放的物流轨迹';
this.$message.warning('暂无可回放的物流轨迹');
return;
}
this.waybillTrackInfo = data;
this.waybillTrackHint = '';
await this.$nextTick();
await this.renderWaybillTrackMap(points);
} catch (error) {
this.waybillTrackInfo = null;
this.waybillTrackHint = '历史轨迹获取失败';
this.$message.error(error?.message || '历史轨迹获取失败');
} finally {
this.waybillTrackLoading = false;
}
},
async renderWaybillLocateMap(locateInfo) {
const rawLongitude = Number(locateInfo?.longitude);
const rawLatitude = Number(locateInfo?.latitude);
if (!Number.isFinite(rawLongitude) || !Number.isFinite(rawLatitude)) {
this.$message.warning('定位坐标无效,无法在地图上展示');
return;
}
// LBS 返回 WGS-84,高德地图使用 GCJ-02,绘制前需转换
const [longitude, latitude] = wgs84ToGcj02(rawLongitude, rawLatitude);
try {
await this.loadAmap();
await this.$nextTick();
const container = this.$refs.waybillLocateMap;
if (!container || !window.AMap) {
this.$message.warning('高德地图容器未就绪');
return;
}
this.destroyWaybillLocateMap();
this.waybillLocateMapInstance = new window.AMap.Map(container, {
zoom: 15,
center: [longitude, latitude],
viewMode: '2D',
resizeEnable: true,
});
const content = [
`<div style="padding:4px 2px;line-height:1.6;font-size:12px;">`,
`<div><b>${locateInfo.vehicleNo || '车辆位置'}</b></div>`,
locateInfo.locateTime ? `<div>时间:${locateInfo.locateTime}</div>` : '',
locateInfo.address ? `<div>地址:${locateInfo.address}</div>` : '',
`<div>坐标:${rawLongitude}, ${rawLatitude}</div>`,
`</div>`,
].join('');
this.waybillLocateInfoWindow = new window.AMap.InfoWindow({
content,
offset: new window.AMap.Pixel(0, -30),
});
this.waybillLocateMarker = new window.AMap.Marker({
position: [longitude, latitude],
title: locateInfo.vehicleNo || '车辆位置',
anchor: 'bottom-center',
});
this.waybillLocateMarker.on('click', () => {
this.waybillLocateInfoWindow.open(this.waybillLocateMapInstance, [longitude, latitude]);
});
this.waybillLocateMapInstance.add(this.waybillLocateMarker);
this.waybillLocateInfoWindow.open(this.waybillLocateMapInstance, [longitude, latitude]);
this.waybillLocateMapInstance.setFitView([this.waybillLocateMarker], false, [40, 40, 40, 40]);
setTimeout(() => {
this.waybillLocateMapInstance?.resize?.();
}, 80);
} catch (error) {
window.console.warn('实时定位地图渲染失败', error);
this.$message.error('高德地图渲染失败,请稍后重试');
}
},
async renderWaybillTrackMap(points) {
// LBS 轨迹为 WGS-84,绘制到高德前转为 GCJ-02
const path = (points || [])
.map(item => {
const longitude = Number(item.longitude);
const latitude = Number(item.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
return null;
}
return wgs84ToGcj02(longitude, latitude);
})
.filter(Boolean);
if (!path.length) {
this.$message.warning('轨迹坐标无效,无法在地图上展示');
return;
}
try {
await this.loadAmap();
await this.$nextTick();
const container = this.$refs.waybillTrackMap;
if (!container || !window.AMap) {
this.$message.warning('高德地图容器未就绪');
return;
}
this.destroyWaybillTrackMap();
this.waybillTrackMapInstance = new window.AMap.Map(container, {
zoom: 12,
center: path[0],
viewMode: '2D',
resizeEnable: true,
});
this.waybillTrackPolyline = new window.AMap.Polyline({
path,
strokeColor: '#409eff',
strokeWeight: 6,
strokeOpacity: 0.9,
lineJoin: 'round',
lineCap: 'round',
showDir: path.length > 1,
});
this.waybillTrackStartMarker = new window.AMap.Marker({
position: path[0],
title: '起点',
anchor: 'bottom-center',
label: {
content: '起',
direction: 'top',
offset: new window.AMap.Pixel(0, -6),
},
});
this.waybillTrackEndMarker = new window.AMap.Marker({
position: path[path.length - 1],
title: '终点',
anchor: 'bottom-center',
label: {
content: '终',
direction: 'top',
offset: new window.AMap.Pixel(0, -6),
},
});
this.waybillTrackMapInstance.add([
this.waybillTrackPolyline,
this.waybillTrackStartMarker,
this.waybillTrackEndMarker,
]);
this.waybillTrackMapInstance.setFitView(
[this.waybillTrackPolyline, this.waybillTrackStartMarker, this.waybillTrackEndMarker],
false,
[48, 48, 48, 48]
);
setTimeout(() => {
this.waybillTrackMapInstance?.resize?.();
}, 80);
} catch (error) {
window.console.warn('历史轨迹地图渲染失败', error);
this.$message.error('高德地图渲染失败,请稍后重试');
}
},
displayStatus(row, prop) {
const formatStatus = this.config.formatStatus;
const textProp = prop === this.config.statusProp ? this.config.statusTextProp : '';
const column = this.findColumn(this.option.column, prop);
const item = column && (column.dicData || []).find(dic => dic.value === row[prop]);
const defaultText =
textProp && row[textProp] ? row[textProp] : item ? item.label : row[prop] || '未知';
return typeof formatStatus === 'function'
? formatStatus(row, prop, defaultText)
: defaultText;
},
statusTagType(status) {
if (
[1, 'pending', 'processing', 'running', 'approved', 'change_approved'].includes(status)
) {
return 'success';
}
if ([2, 'cancelled', 'withdrawn', 'draft'].includes(status)) {
return 'info';
}
if (['rejected', 'change_rejected'].includes(status)) {
return 'danger';
}
if (status === 'completed') {
return 'primary';
}
if (status === 'pending_return_confirm') {
return 'warning';
}
return 'warning';
},
normalizeRow(row, saveAsDraft = false) {
const submitRow = { ...row };
const isAddMode =
this.crudDialogType === 'add' ||
(this.isStandaloneWaybillFormPage && this.$route.query.mode === 'add');
if (isAddMode) {
delete submitRow.id;
delete submitRow.waybillNo;
// 复制新建:业务状态固定为草稿,不沿用原运单状态
const copyId = this.$route.query.copyId || '';
if (copyId) {
submitRow.businessStatus = this.config.draftStatus || 'draft';
}
}
const skipValidation = saveAsDraft && this.config.skipDraftValidation === true;
if (
!skipValidation &&
!this.validateMobileFields([
[submitRow.departurePhone, '发货联系方式'],
[submitRow.arrivalPhone, '收货联系方式'],
[submitRow.driverPhone, this.isNonRoadTransport ? '联系电话' : '手机号'],
[submitRow.escortPhone, '押运人手机号'],
])
) {
return false;
}
if (this.taskInfoFormEnabled) {
this.syncTaskFreightCurrencyFromContract();
if (!skipValidation && !this.validateTaskInfoForm(submitRow)) return false;
this.syncTaskInfoToRow(submitRow);
}
if (this.config.enableAttachmentTable) {
submitRow.attachmentsJson = JSON.stringify(this.attachmentRows);
}
const isCopyAdd = Boolean(this.$route.query.copyId) && isAddMode;
if (this.config.saveAsDraft && saveAsDraft) {
submitRow[this.config.draftStatusProp || 'approvalStatus'] =
this.config.draftStatus || 'draft';
} else if (
!isCopyAdd &&
this.config.draftStatusProp === 'businessStatus' &&
submitRow.businessStatus === (this.config.draftStatus || 'draft')
) {
// 正式提交:清空草稿态,由后端按过程配置校正为待执行/进行中(复制新建除外,保持草稿)
submitRow.businessStatus = '';
}
delete submitRow.basicInfoTitle;
delete submitRow.templateInfoTitle;
delete submitRow.shippingInfoTitle;
delete submitRow.taskInfoTitle;
delete submitRow.taskInfoForm;
delete submitRow.goodsInfoTitle;
delete submitRow.contractFileTitle;
delete submitRow.paymentRatioTitle;
delete submitRow.attachmentTitle;
delete submitRow.changeRecordTitle;
delete submitRow.contractPeriod;
Object.keys(submitRow).forEach(key => {
if (typeof submitRow[key] === 'string') {
submitRow[key] = submitRow[key].trim();
}
});
// 未选计划时清空 -1 占位,避免落库后编辑/查看回显 -1
if (
submitRow.planId === undefined ||
submitRow.planId === null ||
submitRow.planId === '' ||
Number(submitRow.planId) === -1 ||
Number(submitRow.planId) <= 0
) {
submitRow.planId = null;
if (
!submitRow.planName ||
String(submitRow.planName) === '-1' ||
Number(submitRow.planName) === -1
) {
submitRow.planName = null;
}
}
if (String(submitRow.planName) === '-1' || Number(submitRow.planName) === -1) {
submitRow.planName = null;
}
return submitRow;
},
validateMobileFields(fields = []) {
const invalidField = fields.find(([value]) => String(value || '').trim() && !isMobile(value));
if (!invalidField) return true;
this.$message.warning(`${invalidField[1]}格式不正确`);
return false;
},
currencyName(value) {
const text = String(value || '');
if (text.includes(' - ')) return text.slice(text.indexOf(' - ') + 3);
return (
{
CNY: '人民币',
RMB: '人民币',
USD: '美元',
EUR: '欧元',
JPY: '日元',
GBP: '英镑',
}[text.toUpperCase()] || text
);
},
currencyRemark(value) {
const option = this.freightCurrencyOptions.find(
item => String(item.value).toUpperCase() === String(value || '').toUpperCase()
);
return option?.remark || this.currencyName(option?.label || value);
},
normalizeFreightCurrency(value) {
const raw =
typeof value === 'object'
? value.dictKey || value.value || value.code || value.dictValue || ''
: value;
const normalized = String(raw || 'RMB')
.trim()
.toUpperCase();
const code = normalized.includes(' - ') ? normalized.split(' - ')[0].trim() : normalized;
const option = this.freightCurrencyOptions.find(
item => String(item.value || '').toUpperCase() === code
);
return option?.value || code || 'RMB';
},
syncTaskFreightCurrencyFromContract(contract = null) {
const selected =
contract ||
this.contractOptions.find(item => String(item.id) === String(this.form.contractId));
const value =
selected?.settlementCurrency ||
selected?.settlementCurrencyCode ||
selected?.currency ||
this.form.settlementCurrency ||
'RMB';
const nextCurrency = this.normalizeFreightCurrency(value);
const previousCurrency = this.taskFreightCurrency;
this.taskFreightCurrency = nextCurrency;
if (previousCurrency && previousCurrency !== nextCurrency) {
this.remapWaybillPriceUnits(nextCurrency);
}
},
remapWaybillPriceUnits(currency) {
if (this.form.priceUnit) {
this.form.priceUnit = remapPriceUnitCurrency(this.form.priceUnit, currency);
}
(this.transportCargoRows || []).forEach(cargo => {
if (cargo.priceUnit) {
cargo.priceUnit = remapPriceUnitCurrency(cargo.priceUnit, currency);
}
});
(this.taskFreightGroups || []).forEach(group => {
(group.rows || []).forEach(row => {
if (row.priceUnit) {
row.priceUnit = remapPriceUnitCurrency(row.priceUnit, currency);
}
});
});
},
loadFreightCurrencyOptions() {
if (this.freightCurrencyOptions.length || this.freightCurrencyLoading) {
return Promise.resolve(this.freightCurrencyOptions);
}
this.freightCurrencyLoading = true;
return getSystemDictionary({ code: 'currency_type' })
.then(res => {
const currencyNameMap = {
CNY: '人民币',
RMB: '人民币',
USD: '美元',
EUR: '欧元',
JPY: '日元',
GBP: '英镑',
};
this.freightCurrencyOptions = this.normalizeDictOptions(res.data?.data || []).map(
item => {
const code = String(item.value || '').toUpperCase();
return {
...item,
label: `${code} - ${currencyNameMap[code] || item.label || item.value}`,
};
}
);
const resolveCurrencyValue = value => {
const normalized = String(value || '').toUpperCase();
const exact = this.freightCurrencyOptions.find(
item => String(item.value).toUpperCase() === normalized
);
if (exact) return exact.value;
if (normalized === 'CNY') {
const rmb = this.freightCurrencyOptions.find(
item => String(item.value).toUpperCase() === 'RMB'
);
return rmb?.value || value;
}
return value;
};
this.taskFreightCurrency = resolveCurrencyValue(this.taskFreightCurrency);
return this.freightCurrencyOptions;
})
.finally(() => {
this.freightCurrencyLoading = false;
});
},
openWaybillProcessConfig() {
if (!this.form.projectId || this.waybillProcessConfigLoading) return;
this.waybillProcessConfigBox = true;
this.waybillProcessConfigLoading = true;
this.getProjectProcessConfigRows(this.form.projectId)
.then(rows =>
Promise.all(
rows.map(row =>
getProcessConfigDetail(row.id)
.then(res => ({ ...row, ...(res.data?.data || res.data || {}) }))
.catch(() => row)
)
)
)
.then(rows => {
this.waybillProcessConfigRows = rows;
})
.finally(() => {
this.waybillProcessConfigLoading = false;
});
},
getProjectProcessConfigRows(projectId) {
if (!projectId) return Promise.resolve([]);
return getProcessConfigList(1, 100, { projectIds: projectId, status: 1 }).then(res =>
extractRecords(res).filter(row => {
const projectIds = String(row.projectIds || '')
.split(',')
.map(item => item.trim());
return Number(row.status) === 1 && projectIds.includes(String(projectId));
})
);
},
loadProjectProcessConfigState() {
const projectId = this.form.projectId;
this.hasProjectProcessConfig = false;
this.waybillProcessConfigRows = [];
if (!projectId) return Promise.resolve([]);
return this.getProjectProcessConfigRows(projectId)
.then(rows => {
if (String(this.form.projectId) === String(projectId)) {
this.hasProjectProcessConfig = rows.length > 0;
}
return rows;
})
.catch(() => []);
},
getProcessConfigEnabledNodes(row = {}) {
let storedNodes = [];
try {
const parsed = JSON.parse(row.nodeConfigJson || '[]');
storedNodes = Array.isArray(parsed) ? parsed : parsed.nodes || [];
} catch (error) {
storedNodes = [];
}
const includedNodes = String(row.includedNodes || '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
return storedNodes.filter(
node =>
node.enabled !== false && (!includedNodes.length || includedNodes.includes(node.name))
);
},
processConfigTimelineDescriptions(node = {}) {
const descriptions = [];
if (node.key === 'accept') {
descriptions.push(
`是否接单:${node.confirmMode === 'no_confirm_accept' ? '无需确认接单' : '是'}`
);
if (node.confirmMode === 'yes' && node.confirmDriver) {
descriptions.push('确认接单人:司机');
}
return descriptions;
}
if (node.key === 'return') {
descriptions.push(
`是否确认:${node.confirmMode === 'no_confirm_complete' ? '无需确认完成' : '是'}`
);
if (node.confirmMode === 'yes' && node.confirmInternal) {
descriptions.push('确认完成:内部人员');
}
} else {
descriptions.push(`打卡操作:${node.punch ? '是' : '否'}`);
descriptions.push(`打卡定位:${node.location ? '是' : '否'}`);
if (node.key === 'transit' && node.punch) {
descriptions.push(`打卡频次:每${node.frequencyDays || 1}天打卡1次`);
descriptions.push(`打卡时段:${node.timeStart || '00:00'}-${node.timeEnd || '23:59'}`);
}
if (node.supportCargo && node.uploadCargo && node.cargoTypes?.length) {
descriptions.push(`上传货量:${node.cargoTypes.join('、')}`);
}
}
if (node.supportVoucher && node.uploadVoucher && node.voucherTypes?.length) {
descriptions.push(`上传凭证:${node.voucherTypes.join('、')}`);
}
return descriptions;
},
stopSubmitLoading(loading) {
if (typeof loading === 'function') {
loading();
}
},
rowSave(row, done, loading) {
// 复制页提交走草稿保存,避免 submit 接口把 draft 校正为待执行/进行中
const isCopySubmit = Boolean(this.$route.query.copyId);
const submitRow = this.normalizeRow(row, isCopySubmit);
if (!submitRow) {
this.stopSubmitLoading(loading);
return;
}
const request =
isCopySubmit && typeof this.api.saveDraft === 'function'
? this.api.saveDraft
: this.getSaveRequest();
request(submitRow).then(
res => {
this.markWaybillListNeedRefresh();
this.onLoad(this.page);
this.$message({ type: 'success', message: '提交成功!' });
done();
if (this.isStandaloneWaybillPage) this.closeCrudDialog();
},
error => {
window.console.log(error);
this.stopSubmitLoading(loading);
}
);
},
rowUpdate(row, index, done, loading) {
const submitRow = this.normalizeRow(row);
if (!submitRow) {
this.stopSubmitLoading(loading);
return;
}
const request =
this.attachmentUploadMode && typeof this.api.updateAttachments === 'function'
? this.api.updateAttachments
: this.getSaveRequest();
request(submitRow).then(
() => {
const skipMk = this.attachmentUploadMode;
this.attachmentUploadMode = false;
this.markWaybillListNeedRefresh();
this.onLoad(this.page);
this.$message({ type: 'success', message: skipMk ? '操作成功!' : '提交成功!' });
done();
if (this.isStandaloneWaybillPage) this.closeCrudDialog();
},
error => {
this.attachmentUploadMode = false;
window.console.log(error);
this.stopSubmitLoading(loading);
}
);
},
getSaveRequest() {
return this.api.submit;
},
markWaybillListNeedRefresh() {
sessionStorage.setItem(waybillManageListRefreshKey, '1');
},
handleDraftSave() {
const request =
typeof this.api.saveDraft === 'function' ? this.api.saveDraft : this.api.submit;
if (this.draftSaveLoading) return;
const submitRow = this.normalizeRow(this.form, true);
if (!submitRow) return;
this.draftSaveLoading = true;
request(submitRow)
.then(() => {
this.$message.success(`${this.config.draftSaveText || '保存'}成功`);
this.markWaybillListNeedRefresh();
this.onLoad(this.page, this.query);
this.closeCrudDialog();
})
.finally(() => {
this.draftSaveLoading = false;
});
},
closeCrudDialog() {
if (this.isStandaloneWaybillPage) {
// 关闭独立表单/详情页时标记列表需刷新(含直接点关闭未保存的场景)
this.markWaybillListNeedRefresh();
this.$router.$avueRouter?.closeTag?.();
this.$router.push({ path: standaloneWaybillListRoute, query: {} });
return;
}
const crud = this.$refs.crud;
if (crud && typeof crud.closeDialog === 'function') {
crud.closeDialog();
}
},
rowDel(row) {
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => this.api.remove(row.id))
.then(res => {
this.afterRemove(res.data.data);
});
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
const selection = this.selectionList.filter(item => this.canDelete(item));
if (!selection.length) {
this.$message.warning('所选数据当前状态不可删除');
return;
}
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => this.api.remove(selection.map(item => item.id).join(',')))
.then(res => {
this.afterRemove(res.data.data);
});
},
afterRemove(result = {}) {
this.onLoad(this.page);
const successCount = result.successCount || 0;
const skippedCount = result.skippedCount || 0;
if (skippedCount) {
this.$message.warning(`成功删除${successCount}条,因业务引用跳过${skippedCount}条`);
} else {
this.$message({ type: 'success', message: '删除成功!' });
}
this.selectionClear();
},
beforeOpen(done, type) {
this.crudDialogType = type;
this.dialogReadonly = type === 'view';
const copyId = this.$route.query.copyId || '';
// 复制模式:先加载原数据,然后清除 ID 相关字段
if (type === 'add' && copyId) {
this.api.getDetail(copyId).then(res => {
const sourceData = res.data.data || {};
this.copySourceBusinessStatus = '';
// 清除不应该复制的字段;复制新建固定为草稿
delete sourceData.id;
delete sourceData.code;
delete sourceData.waybillNo;
delete sourceData.waybillStatus;
delete sourceData.businessStatus;
delete sourceData.businessStatusName;
delete sourceData.driverAcceptStatus;
delete sourceData.driverAcceptTime;
delete sourceData.driverAcceptDriverId;
delete sourceData.driverRejectTime;
delete sourceData.driverRejectReason;
delete sourceData.loadingNo;
delete sourceData.masterNo;
delete sourceData.createTime;
delete sourceData.updateTime;
delete sourceData.createUser;
delete sourceData.updateUser;
delete sourceData.createDept;
delete sourceData.createUserName;
delete sourceData.updateUserName;
this.applyFormDetail(sourceData);
this.form.dataSource = '手工创建';
this.form.businessStatus = this.config.draftStatus || 'draft';
done();
});
return;
}
if (type === 'add') {
this.copySourceBusinessStatus = '';
this.form = {
...(this.config.defaultForm || {}),
};
this.form.dataSource = '手工创建';
this.form.taskEntryMode = 'simple';
this.selectedProjectId = '';
this.attachmentRows = [];
this.selectedAttachmentRows = [];
this.shippingPlanOptions = [];
if (this.contractSelectEnabled) {
this.contractOptions = [];
this.form.contractId = '';
this.form.contractName = '';
}
if (this.shippingPlanSelectEnabled) {
this.clearShippingPlan();
}
this.transportCargoRows = [];
this.taskFreightCurrency = 'RMB';
this.hasProjectProcessConfig = false;
this.waybillProcessConfigRows = [];
this.taskCarrierRequestId += 1;
this.taskCarrierOptions = [];
this.taskCarrierLoading = false;
this.resetWaybillCarrierContractState();
this.initTaskInfoForm();
this.initWaybillCargoRows();
if (this.config.enableTemplateCodePreview && typeof this.api.nextCode === 'function') {
this.api
.nextCode()
.then(res => {
this.form.templateCode = res.data?.data || '';
})
.finally(done);
return;
}
done();
return;
}
if (['edit', 'view'].includes(type)) {
this.api.getDetail(this.form.id).then(res => {
this.applyFormDetail(res.data.data || {});
done();
});
return;
}
done();
},
applyFormDetail(row) {
this.suppressTransportTypeClear = true;
this.form = { ...(row || {}) };
['quantity', 'mileage', 'unitPrice', 'otherFeeTotal', 'freight', 'freightTotal'].forEach(
prop => {
if (this.isSentinelMinusOne(this.form[prop])) this.form[prop] = '';
}
);
if ([this.form.planId, this.form.planName].some(value => Number(value) === -1)) {
this.form.planId = '';
this.form.planName = '';
}
this.form.mileage = this.normalizeMileageValue(this.form.mileage);
this.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
this.selectedProjectId = this.form.projectId || '';
this.resetWaybillCarrierContractState();
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
this.selectedAttachmentRows = [];
this.initWaybillCargoRows();
this.initTaskInfoForm();
if (this.config.enableProjectSelect) {
this.syncCurrentProjectOption();
}
this.loadProjectProcessConfigState();
if (this.contractSelectEnabled) {
this.syncCurrentContractOption();
this.syncTaskFreightCurrencyFromContract();
this.loadContractOptionsForProject();
}
if (this.shippingPlanSelectEnabled) {
this.syncCurrentShippingPlanOption();
}
},
parseJsonArray(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const list = JSON.parse(value);
return Array.isArray(list) ? list : [];
} catch (error) {
return [];
}
},
parseJsonObject(value) {
if (!value) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
try {
const data = JSON.parse(value);
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
} catch (error) {
return {};
}
},
parseQuantity(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;
},
formatQuantity(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+$/, '');
},
loadProjectOptions() {
if (!this.config.enableProjectSelect || this.projectLoading) return;
this.projectLoading = true;
getProjectList(1, 9999, this.config.projectQueryParams || {})
.then(res => {
this.projectOptions = extractRecords(res).filter(
item => !this.config.excludeVoidedProjects || item.approvalStatus !== 'voided'
);
this.syncCurrentProjectOption();
})
.finally(() => {
this.projectLoading = false;
});
},
syncCurrentProjectOption() {
if (!this.form.projectId || !this.form.projectName) return;
if (this.config.excludeVoidedProjects) return;
const exists = this.projectOptions.some(
item => String(item.id) === String(this.form.projectId)
);
if (!exists) {
this.projectOptions.unshift({
id: this.form.projectId,
projectName: this.form.projectName,
projectCode: this.form.projectCode,
undertakeDeptName: this.form.undertakeDeptName,
fundLimit: this.form.projectFundLimit,
});
}
},
handleProjectChange(projectId) {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
if (!project) {
this.form.projectId = '';
this.form.projectName = '';
this.form.projectCode = '';
this.form.undertakeDeptName = '';
if (this.contractSelectEnabled) {
this.form.contractId = '';
this.form.contractName = '';
this.contractOptions = [];
this.syncTaskFreightCurrencyFromContract();
}
if (this.shippingPlanSelectEnabled) {
this.clearShippingPlan();
this.shippingPlanOptions = [];
}
this.taskCarrierRequestId += 1;
this.taskCarrierOptions = [];
this.taskCarrierLoading = false;
this.resetWaybillCarrierContractState();
this.form.carrierName = '';
this.form.carrierId = '';
this.form.carrierContractId = '';
this.form.carrierPartyId = '';
this.form.carrierPartyRole = '';
this.hasProjectProcessConfig = false;
this.waybillProcessConfigRows = [];
return;
}
this.form.projectId = project.id;
this.form.projectName = project.projectName || '';
this.form.projectCode = project.projectCode || '';
this.form.undertakeDeptName = project.undertakeDeptName || '';
this.taskCarrierRequestId += 1;
this.taskCarrierLoading = false;
this.resetWaybillCarrierContractState();
this.form.carrierName = '';
this.form.carrierId = '';
this.form.carrierContractId = '';
this.form.carrierPartyId = '';
this.form.carrierPartyRole = '';
this.loadProjectProcessConfigState();
this.loadTaskCarrierOptions(project);
if (this.contractSelectEnabled) {
this.form.customerName =
project.customerName ||
project.customerNames ||
project.customer ||
this.form.customerName ||
'';
}
if (this.contractSelectEnabled) {
this.form.contractId = '';
this.form.contractName = '';
this.syncTaskFreightCurrencyFromContract();
this.loadContractOptionsForProject(true);
}
if (this.shippingPlanSelectEnabled) {
this.clearShippingPlan();
this.loadShippingPlanOptions();
}
this.$refs.crud?.validateField?.('projectName');
},
loadContractOptionsForProject(autoPick = false) {
if (!this.contractSelectEnabled || !this.form.projectId || this.contractLoading) {
return Promise.resolve([]);
}
this.contractLoading = true;
return getContractList(1, 9999, {
projectId: this.form.projectId,
projectName: this.form.projectName,
...(this.config.contractQueryParams || {}),
})
.then(res => {
this.contractOptions = extractRecords(res);
this.syncCurrentContractOption();
this.syncTaskFreightCurrencyFromContract();
if (autoPick && this.contractOptions.length) {
this.applyContract(this.contractOptions[0]);
}
return this.contractOptions;
})
.finally(() => {
this.contractLoading = false;
});
},
syncCurrentContractOption() {
if (!this.form.contractId || !this.form.contractName) return;
const exists = this.contractOptions.some(
item => String(item.id) === String(this.form.contractId)
);
if (!exists) {
this.contractOptions.unshift({
id: this.form.contractId,
contractName: this.form.contractName,
settlementCurrency: this.form.settlementCurrency || '',
});
}
},
handleContractChange(contractId) {
const contract = this.contractOptions.find(item => String(item.id) === String(contractId));
if (!contract) {
this.form.contractId = '';
this.form.contractName = '';
this.syncTaskFreightCurrencyFromContract();
return;
}
this.applyContract(contract);
},
applyContract(contract = {}) {
this.form.contractId = contract.id || '';
this.form.contractName = contract.contractName || '';
this.form.settlementCurrency =
contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || '';
this.form.customerName =
contract.partyA ||
contract.customerName ||
contract.customerNames ||
this.form.customerName ||
'';
this.syncTaskFreightCurrencyFromContract(contract);
this.fillSelfOperatedCarrierFromContract(contract);
this.$refs.crud?.validateField?.('contractName');
},
loadShippingPlanOptions() {
if (!this.shippingPlanSelectEnabled || this.shippingPlanLoading) {
return Promise.resolve([]);
}
this.shippingPlanLoading = true;
return getShippingPlanList(1, 9999, {
projectId: this.form.projectId,
projectName: this.form.projectName,
})
.then(res => {
this.shippingPlanOptions = extractRecords(res);
this.syncCurrentShippingPlanOption();
return this.shippingPlanOptions;
})
.finally(() => {
this.shippingPlanLoading = false;
});
},
syncCurrentShippingPlanOption() {
if (!this.form.planName) return;
const exists = this.shippingPlanOptions.some(
item =>
String(item.id) === String(this.form.planId) ||
String(item.planName) === String(this.form.planName)
);
if (!exists) {
this.shippingPlanOptions.unshift({
id: this.form.planId || this.form.planName,
planName: this.form.planName,
});
}
},
handleShippingPlanChange(planId) {
const plan = this.shippingPlanOptions.find(item => String(item.id) === String(planId));
if (!plan) {
this.clearShippingPlan();
return;
}
this.shippingPlanLoading = true;
getShippingPlanDetail(plan.id)
.then(res => this.applyShippingPlan(res.data?.data || res.data || plan))
.finally(() => {
this.shippingPlanLoading = false;
});
},
applyShippingPlan(plan = {}) {
this.suppressTransportTypeClear = true;
this.form.planId = plan.id || '';
this.form.planName = plan.planName || '';
[
'contractId',
'contractName',
'customerName',
'transportType',
'transportMode',
'transportTypeName',
'transportModeName',
'departureAddressId',
'departureName',
'departureAddress',
'departureContact',
'departurePhone',
'departureProvince',
'departureCity',
'departureDistrict',
'arrivalAddressId',
'arrivalName',
'arrivalAddress',
'arrivalContact',
'arrivalPhone',
'arrivalProvince',
'arrivalCity',
'arrivalDistrict',
].forEach(prop => {
if (plan[prop] !== undefined) this.form[prop] = plan[prop];
});
this.form.estimatedStartTime = plan.planStartDate || plan.estimatedStartTime || '';
this.form.estimatedEndTime = plan.planEndDate || plan.estimatedEndTime || '';
this.form.goodsJson =
typeof plan.goodsJson === 'string' ? plan.goodsJson : JSON.stringify(plan.goodsJson || []);
this.attachmentRows = this.parseJsonArray(plan.attachmentsJson);
this.form.attachmentsJson = JSON.stringify(this.attachmentRows);
const goodsRows = this.parseJsonArray(this.form.goodsJson);
this.form.taskEntryMode = goodsRows.length > 1 ? 'full' : 'simple';
const firstGoods = goodsRows[0] || {};
[
'cargoType',
'cargoTypeCode',
'cargoName',
'specification',
'model',
'quantity',
'quantityUnit',
].forEach(prop => {
if (firstGoods[prop] !== undefined) this.form[prop] = firstGoods[prop];
});
this.form.cargoTypePath = this.normalizeCargoTypePath(firstGoods.cargoTypePath);
this.initTaskInfoForm();
this.ensureCargoTypeOptions().then(() => {
if (this.form.cargoTypePath.length) return;
const cargoType = this.findCargoTypeByCodeOrName({
cargoTypeCode: this.form.cargoTypeCode,
cargoType: this.form.cargoType,
});
if (cargoType?.path) this.form.cargoTypePath = cargoType.path;
});
if (this.isTaskFullMode) {
this.initTaskFullCargoRows(goodsRows);
}
if (this.contractSelectEnabled) this.syncCurrentContractOption();
this.$refs.crud?.validateField?.('planName');
this.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
},
clearShippingPlan() {
this.form.planId = '';
this.form.planName = '';
},
initTaskInfoForm() {
if (!this.taskInfoFormEnabled) return;
const taskInfo = this.parseJsonObject(this.form.taskInfoJson);
const freightInfo = this.parseJsonObject(this.form.freightJson);
const carrierRows = this.parseJsonArray(this.form.carrierJson);
const carrierInfo = carrierRows[0] || this.parseJsonObject(this.form.carrierJson);
const goodsRows = this.parseJsonArray(this.form.goodsJson);
const goodsInfo = goodsRows[0] || {};
this.form.taskEntryMode =
this.form.taskEntryMode || taskInfo.taskEntryMode || carrierInfo.taskEntryMode || 'simple';
if (this.hasMultipleConfiguredGoods(this.form, goodsRows)) {
this.form.taskEntryMode = 'full';
}
this.form.carrierType =
this.form.carrierType ||
taskInfo.carrierType ||
carrierInfo.carrierType ||
carrierInfo.type ||
'承运商';
this.form.carrierName =
this.form.carrierName ||
taskInfo.carrierName ||
carrierInfo.carrierName ||
carrierInfo.customerName ||
carrierInfo.name ||
'';
this.form.carrierContractId =
this.form.carrierContractId ||
taskInfo.carrierContractId ||
carrierInfo.carrierContractId ||
'';
this.form.driverName =
this.form.driverName || taskInfo.driverName || carrierInfo.driverName || '';
this.form.driverPhone =
this.form.driverPhone ||
taskInfo.driverPhone ||
carrierInfo.driverPhone ||
carrierInfo.mobile ||
'';
this.form.vehicleNo =
this.form.vehicleNo || taskInfo.vehicleNo || carrierInfo.vehicleNo || '';
this.form.captainName = this.form.captainName || taskInfo.captainName || '';
this.form.cabinNo = this.form.cabinNo || taskInfo.cabinNo || '';
this.form.containerNo = this.form.containerNo || taskInfo.containerNo || '';
this.form.trailerVehicleNo =
this.form.trailerVehicleNo ||
taskInfo.trailerVehicleNo ||
carrierInfo.trailerVehicleNo ||
'';
this.form.escortName =
this.form.escortName || taskInfo.escortName || carrierInfo.escortName || '';
this.form.escortPhone =
this.form.escortPhone || taskInfo.escortPhone || carrierInfo.escortPhone || '';
this.form.cargoType =
this.form.cargoType ||
taskInfo.cargoType ||
goodsInfo.cargoType ||
goodsInfo.goodsType ||
'';
this.form.cargoTypeCode =
this.form.cargoTypeCode ||
taskInfo.cargoTypeCode ||
goodsInfo.cargoTypeCode ||
goodsInfo.goodsTypeCode ||
'';
this.form.cargoName =
this.form.cargoName ||
taskInfo.cargoName ||
goodsInfo.cargoName ||
goodsInfo.goodsName ||
'';
this.form.specification =
this.form.specification || taskInfo.specification || goodsInfo.specification || '';
this.form.model = this.form.model || taskInfo.model || goodsInfo.model || '';
this.form.quantity = this.form.quantity || taskInfo.quantity || goodsInfo.quantity || '';
this.form.quantityUnit =
this.form.quantityUnit || taskInfo.quantityUnit || goodsInfo.quantityUnit || '吨';
const mileage = this.form.mileage || taskInfo.mileage || goodsInfo.mileage || '';
this.form.mileage = this.normalizeMileageValue(mileage);
this.form.unitPrice = this.normalizeOptionalAmount(
this.form.unitPrice || taskInfo.unitPrice || goodsInfo.unitPrice || ''
);
['quantity', 'mileage', 'unitPrice'].forEach(prop => {
if (this.isSentinelMinusOne(this.form[prop])) this.form[prop] = '';
});
this.form.priceUnit =
this.form.priceUnit || taskInfo.priceUnit || goodsInfo.priceUnit || this.defaultPriceUnit;
this.form.otherFeeTotal = this.normalizeOptionalAmount(
this.form.otherFeeTotal || taskInfo.otherFeeTotal || goodsInfo.otherFeeTotal || ''
);
this.form.estimatedStartTime =
this.form.estimatedStartTime ||
taskInfo.estimatedStartTime ||
goodsInfo.estimatedStartTime ||
'';
this.form.estimatedEndTime =
this.form.estimatedEndTime || taskInfo.estimatedEndTime || goodsInfo.estimatedEndTime || '';
this.form.taskRemark = this.form.taskRemark || taskInfo.taskRemark || goodsInfo.remark || '';
this.taskFreightCurrency = freightInfo.currency || this.taskFreightCurrency || 'RMB';
this.syncTaskFreightCurrencyFromContract();
this.loadFreightCurrencyOptions();
this.form.cargoTypePath = this.normalizeCargoTypePath(this.form.cargoTypePath);
if (!this.form.cargoTypePath.length) {
this.ensureCargoTypeOptions().then(() => {
const cargoType = this.findCargoTypeByCodeOrName({
cargoTypeCode: this.form.cargoTypeCode,
cargoType: this.form.cargoType,
});
if (cargoType?.path) {
this.form.cargoTypePath = cargoType.path;
}
});
}
if (this.isTaskFullMode) {
this.initTaskFullCargoRows(goodsRows);
}
if (this.form.projectId) {
this.loadTaskCarrierOptions();
}
},
handleTaskEntryModeChange(value) {
if (
value === 'simple' &&
this.hasMultipleConfiguredGoods(this.form, this.transportCargoRows)
) {
this.form.taskEntryMode = 'full';
this.$message.warning('当前配置了多条货物信息,不能切换为精简录入');
return;
}
this.form.taskEntryMode = value || 'simple';
if (this.form.taskEntryMode === 'full') {
this.initTaskFullCargoRows(this.parseJsonArray(this.form.goodsJson));
}
},
handleTaskFullFreightNumberInput(cargo, prop, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
cargo[prop] =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
this.syncTransportCargoJson();
},
handleTaskFullFreightAmountInput(cargo, value) {
this.handleTaskFullFreightNumberInput(cargo, 'freightAmount', value);
},
taskFullFreightAutoCalculable() {
const rows = this.transportCargoRows || [];
if (!rows.length) return false;
const quantityUnits = rows.map(row => String(row.quantityUnit || '').trim());
if (quantityUnits.some(unit => !unit) || new Set(quantityUnits).size !== 1) return false;
const expectedPriceUnit = this.resolvePriceUnitByQuantityUnit(quantityUnits[0]);
if (!expectedPriceUnit) return false;
return rows.every(
row => !this.isBillingFieldEmpty(row.unitPrice) && String(row.priceUnit || '').trim() === expectedPriceUnit
);
},
taskFullFreightQuantity(index) {
return this.formatTaskFullFreightNumber(this.transportCargoRows[index]?.quantity || 0);
},
taskFullFreightAmount(cargo = {}) {
if (!this.taskFullFreightAutoCalculable()) {
return this.formatTaskFullFreightNumber(cargo.freightAmount);
}
if (!this.isBillingFieldEmpty(cargo.freightAmount)) {
return this.formatTaskFullFreightNumber(cargo.freightAmount);
}
return this.formatTaskFullFreightNumber(
Number(cargo.unitPrice || 0) * Number(cargo.quantity || 0)
);
},
taskFullFreightGroupUnitPrice(group = {}) {
const prices = (group.rows || [])
.map(cargo => this.normalizeOptionalAmount(cargo.unitPrice))
.filter(Boolean);
if (!prices.length || prices.some(price => price !== prices[0])) return '';
return prices[0];
},
taskFullFreightGroupPriceUnit(group = {}) {
return (
group.rows?.[0]?.priceUnit ||
this.resolvePriceUnitByQuantityUnit(group.rows?.[0]?.quantityUnit) ||
''
);
},
taskFullFreightGroupAmount(group = {}) {
const total = (group.rows || []).reduce(
(sum, cargo) => sum + Number(this.taskFullFreightAmount(cargo) || 0),
0
);
return this.formatTaskFullFreightNumber(total);
},
handleTaskFullFreightGroupNumberInput(group, prop, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
const normalized =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
(group.rows || []).forEach(cargo => {
cargo[prop] = normalized;
});
this.syncTransportCargoJson();
},
handleTaskFullFreightGroupPriceUnitChange(group, value) {
(group.rows || []).forEach(cargo => {
cargo.priceUnit = value || '';
});
this.syncTransportCargoJson();
},
handleTaskFullFreightGroupAmountInput(group, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
const normalized =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
const rows = group.rows || [];
const total = Number(normalized || 0);
const quantities = rows.map(cargo => Math.max(0, Number(cargo.quantity || 0)));
const quantityTotal = quantities.reduce((sum, quantity) => sum + quantity, 0);
let allocated = 0;
rows.forEach((cargo, index) => {
if (index === rows.length - 1) {
cargo.freightAmount = this.formatTaskFullFreightNumber(total - allocated);
return;
}
let amount = 0;
if (quantityTotal) {
amount = Number((total * (quantities[index] / quantityTotal)).toFixed(2));
} else if (index === 0) {
amount = total;
}
allocated += amount;
cargo.freightAmount = this.formatTaskFullFreightNumber(amount);
});
this.syncTransportCargoJson();
},
formatTaskFullFreightNumber(value) {
if (value === undefined || value === null || String(value).trim() === '') return '';
if (this.isSentinelMinusOne(value)) return '';
const number = Number(value);
if (!Number.isFinite(number)) return '';
return Number(number.toFixed(2)).toString();
},
isSentinelMinusOne(value) {
if (value === undefined || value === null || value === '') return false;
return Number(value) === -1 || String(value).trim() === '-1';
},
normalizeOptionalAmount(value) {
if (value === undefined || value === null || value === '' || this.isSentinelMinusOne(value)) {
return '';
}
return value;
},
handleTaskCarrierTypeChange(value) {
const nextValue = value || '承运商';
this.form.carrierName = '';
this.form.carrierId = '';
this.form.carrierContractId = '';
this.form.carrierPartyId = '';
this.form.carrierPartyRole = '';
this.form.driverId = '';
this.form.driverName = '';
this.form.driverPhone = '';
this.form.vehicleNo = '';
this.form.trailerVehicleNo = '';
this.form.escortName = '';
this.form.escortPhone = '';
this.taskDriverOptions = [];
this.taskEscortOptions = [];
if (nextValue === '承运商') {
this.form.trailerVehicleNo = '';
this.form.escortName = '';
this.form.escortPhone = '';
}
this.form.carrierType = nextValue;
this.taskCarrierRequestId += 1;
this.taskCarrierOptions = [];
this.taskCarrierLoading = false;
this.resetWaybillCarrierContractState();
// 承运类型切换后必须基于当前项目重新获取承运商合同;
// 非自运场景的承运商选项仅允许使用合同乙方。
if (this.form.projectId) {
this.loadTaskCarrierOptions();
}
},
fillSelfOperatedCarrierFromContract(contract = {}) {
if (this.form.carrierType !== '自运') return;
const carrierName = String(
contract.partyB ||
contract.partyBName ||
contract.contractPartyB ||
contract.customerContractPartyB ||
''
).trim();
if (!carrierName) return;
const exists = this.taskCarrierOptions.some(
item => String(item.value || item.carrierName || '') === carrierName
);
if (!exists) {
this.taskCarrierOptions.push({
carrierName,
fullName: carrierName,
value: carrierName,
label: carrierName,
});
}
this.form.carrierName = carrierName;
this.form.carrierId = '';
},
isTaskCarrierRequired(carrierType) {
return String(carrierType || '').trim() === '承运商';
},
taskCarrierOptionValue(item = {}) {
if (this.form.carrierType !== '自运') {
if (item.value) return item.value;
return [item.carrierContractId || item.id || '', item.carrierPartyId || '', item.carrierName || '']
.join('__');
}
return (
item.carrierName ||
item.customerName ||
item.fullName ||
item.name ||
item.value ||
''
);
},
getProjectCarrierOptions(project = {}) {
const rows = this.parseJsonArray(project.carrierJson);
const names = String(project.carrierNames || '')
.split(/[、,,;;\n]/)
.map(item => item.trim())
.filter(Boolean);
const options = [];
const append = item => {
if (!item) return;
const id = item.id || item.carrierId || item.customerId || '';
const label =
(typeof item === 'string' ? item : '') ||
item.carrier ||
item.carrierName ||
item.fullName ||
item.shortName ||
item.customerName ||
item.name ||
'';
const value = String(label).trim();
if (!value || options.some(option => option.value === value)) return;
options.push({ id, carrierName: value, fullName: value, value });
};
rows.forEach(append);
names.forEach(append);
return options;
},
parseContractPartyRows(partyJson, fallbackName = '', partyRole = 'B') {
const rows = this.parseJsonArray(partyJson)
.map(item => ({
id: item.id || '',
customerId: item.customerId || '',
customerName: String(item.customerName || '').trim(),
creditCode: item.creditCode || '',
partyRole,
}))
.filter(item => item.customerName);
if (rows.length) return rows;
return String(fallbackName || '')
.split(/[、,,]/)
.map(name => name.trim())
.filter(Boolean)
.map(name => ({
id: '',
customerId: '',
customerName: name,
creditCode: '',
partyRole,
}));
},
getWaybillCarrierContractOptions(contracts = []) {
const options = [];
contracts.forEach(contract => {
if (!contract?.id) return;
const parties = [
...this.parseContractPartyRows(contract.partyBJson, contract.partyB, 'B'),
...this.parseContractPartyRows(contract.partyCJson, contract.partyC, 'C'),
];
if (!parties.length) {
const fallbackName = String(
contract.partyB ||
contract.partyBName ||
contract.contractPartyB ||
contract.customerContractPartyB ||
contract.secondParty ||
contract.secondPartyName ||
''
).trim();
if (fallbackName) {
parties.push({
id: '',
customerId: '',
customerName: fallbackName,
creditCode: '',
partyRole: 'B',
});
}
}
const contractIdentifier = contract.contractName || contract.contractNo || contract.id;
parties.forEach(party => {
const roleLabel = party.partyRole === 'C' ? '丙方' : '乙方';
const optionValue = [contract.id, party.id || '', party.customerName].join('__');
options.push({
id: contract.id,
carrierId: party.customerId || '',
value: optionValue,
label: `${party.customerName}(${roleLabel}) - ${contractIdentifier}`,
carrierName: party.customerName,
fullName: party.customerName,
carrierContractId: contract.id,
carrierPartyId: party.id || '',
carrierPartyRole: party.partyRole,
creditCode: party.creditCode || '',
});
});
});
return options;
},
resetWaybillCarrierContractState() {
this.waybillCarrierContractRequestId += 1;
this.waybillCarrierContractsLoaded = false;
this.waybillHasCarrierContracts = null;
},
loadWaybillCarrierContractState(project = {}) {
const projectId = project.id || project.projectId || this.form.projectId;
const requestId = ++this.waybillCarrierContractRequestId;
if (!projectId) {
this.waybillCarrierContractsLoaded = false;
this.waybillHasCarrierContracts = null;
return Promise.resolve([]);
}
return getContractList(1, 9999, {
projectId,
projectName: project.projectName || this.form.projectName,
contractCategory: '承运商合同',
})
.then(res => {
if (requestId !== this.waybillCarrierContractRequestId) return null;
const options = this.getWaybillCarrierContractOptions(extractRecords(res));
this.waybillCarrierContractsLoaded = true;
this.waybillHasCarrierContracts = options.length > 0;
if (!options.length && this.form.carrierType !== '自运') {
this.form.carrierType = '自运';
this.form.carrierName = '';
this.form.carrierId = '';
this.form.carrierContractId = '';
this.form.carrierPartyId = '';
this.form.carrierPartyRole = '';
}
return options;
})
.catch(() => {
if (requestId === this.waybillCarrierContractRequestId) {
this.waybillCarrierContractsLoaded = false;
this.waybillHasCarrierContracts = null;
}
return null;
});
},
loadTaskCarrierOptions(project = {}) {
if (!this.taskInfoFormEnabled || this.taskCarrierLoading) return Promise.resolve([]);
const projectId = project.id || project.projectId || this.form.projectId;
const requestId = ++this.taskCarrierRequestId;
const carrierTypeAtRequest = this.form.carrierType;
const contractPromise = this.loadWaybillCarrierContractState({
...project,
id: projectId,
});
if (carrierTypeAtRequest !== '自运') {
if (!projectId) {
this.taskCarrierOptions = [];
return Promise.resolve([]);
}
this.taskCarrierLoading = true;
return contractPromise
.then(options => {
if (requestId !== this.taskCarrierRequestId) return this.taskCarrierOptions;
if (options === null) return this.taskCarrierOptions;
this.taskCarrierOptions = options || [];
return this.taskCarrierOptions;
})
.catch(() => this.taskCarrierOptions)
.finally(() => {
if (requestId === this.taskCarrierRequestId) this.taskCarrierLoading = false;
});
}
const currentProject = this.projectOptions.find(
item => String(item.id) === String(projectId)
);
this.taskCarrierOptions = this.getProjectCarrierOptions({
...currentProject,
...project,
});
if (!projectId) return Promise.resolve(this.taskCarrierOptions);
this.taskCarrierLoading = true;
const detailPromise = getProjectDetail(projectId)
.then(res => {
if (requestId !== this.taskCarrierRequestId) return this.taskCarrierOptions;
const detail = res?.data?.data || {};
this.taskCarrierOptions = this.getProjectCarrierOptions({
...currentProject,
...project,
...detail,
});
return this.taskCarrierOptions;
})
.catch(() => this.taskCarrierOptions);
return Promise.all([contractPromise, detailPromise])
.then(([carrierContractOptions, options]) => {
if (requestId !== this.taskCarrierRequestId) return this.taskCarrierOptions;
if (carrierTypeAtRequest === '自运') {
this.taskCarrierOptions = [];
const customerContract = this.contractOptions.find(
item => String(item.id) === String(this.form.contractId)
);
this.fillSelfOperatedCarrierFromContract(customerContract);
return this.taskCarrierOptions;
}
this.taskCarrierOptions = options || [];
return this.taskCarrierOptions;
})
.finally(() => {
if (requestId === this.taskCarrierRequestId) this.taskCarrierLoading = false;
});
},
handleTaskCarrierChange(value) {
const carrier = this.taskCarrierOptions.find(item =>
[
this.taskCarrierOptionValue(item),
item.value,
item.customerName,
item.carrierName,
item.fullName,
item.name,
].some(name => String(name || '') === String(value || ''))
);
if (this.form.carrierType !== '自运') {
this.form.carrierContractId = carrier?.carrierContractId || '';
this.form.carrierPartyId = carrier?.carrierPartyId || '';
this.form.carrierPartyRole = carrier?.carrierPartyRole || '';
this.form.carrierId = carrier?.carrierId || '';
this.form.carrierName = carrier?.carrierName || '';
} else {
this.form.carrierId = carrier?.carrierContractId ? '' : carrier?.id || '';
this.form.carrierPartyId = '';
this.form.carrierPartyRole = '';
this.form.carrierName = value || '';
}
// 承运商变更后清空司机/车辆,避免跨组织脏数据
this.form.driverId = '';
this.form.driverName = '';
this.form.driverPhone = '';
this.form.vehicleNo = '';
this.form.trailerVehicleNo = '';
this.form.escortName = '';
this.form.escortPhone = '';
this.taskDriverOptions = [];
this.taskEscortOptions = [];
},
loadTaskDriverOptions() {
if (!this.taskInfoFormEnabled || this.taskDriverLoading) return Promise.resolve([]);
this.taskDriverLoading = true;
return fetchDriverCandidates({ size: 9999, posts: '司机' })
.then(records => {
this.taskDriverOptions = records;
return this.taskDriverOptions;
})
.finally(() => {
this.taskDriverLoading = false;
});
},
fetchTaskDriverSuggestions(queryString, callback) {
const keyword = String(queryString || '').trim();
this.taskDriverLoading = true;
fetchDriverCandidates({ size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '司机' })
.then(records => {
this.taskDriverOptions = records;
callback(
records.map(item => ({
...item,
value: item.driverName || item.name || '',
}))
);
})
.catch(error => {
window.console.log(error);
callback([]);
})
.finally(() => {
this.taskDriverLoading = false;
});
},
fetchTaskEscortSuggestions(queryString, callback) {
const keyword = String(queryString || '').trim();
this.taskEscortLoading = true;
fetchDriverCandidates({
size: 20,
...(keyword ? { driverName: keyword } : {}),
posts: '押运员',
})
.then(records => {
this.taskEscortOptions = records;
callback(records.map(item => ({ ...item, value: item.driverName || item.name || '' })));
})
.catch(error => {
window.console.log(error);
callback([]);
})
.finally(() => {
this.taskEscortLoading = false;
});
},
handleTaskDriverSelect(item = {}) {
const value = item.driverName || item.name || '';
const driverPhone = item.mobile || item.phone || item.driverPhone || '';
const drivingVehicle = String(item.drivingVehicle || '').trim();
this.form.driverId = item.id || '';
this.form.driverName = value;
if (driverPhone) this.form.driverPhone = driverPhone;
if (drivingVehicle) this.form.vehicleNo = drivingVehicle;
},
handleTaskEscortSelect(item = {}) {
const value = item.driverName || item.name || '';
const escortPhone = item.mobile || item.phone || item.driverPhone || '';
this.form.escortName = value;
if (escortPhone) this.form.escortPhone = escortPhone;
},
handleTaskDriverChange(value) {
const driver = this.taskDriverOptions.find(item =>
[item.driverName, item.name].some(name => String(name || '') === String(value || ''))
);
this.form.driverId = driver?.id || '';
this.form.driverName = value || '';
if (driver) {
this.form.driverPhone =
driver.mobile || driver.phone || driver.driverPhone || this.form.driverPhone || '';
if (String(driver.drivingVehicle || '').trim()) {
this.form.vehicleNo = String(driver.drivingVehicle).trim();
}
}
},
getTaskCargoTypeOptionsKey(path = []) {
const normalizedPath = this.normalizeCargoTypePath(path);
if (!normalizedPath.length) return '';
const cargoType = this.findCargoTypeByPath(normalizedPath);
return String(cargoType?.cargoCode || cargoType?.code || normalizedPath.join('/'));
},
getTaskCargoOptionsByPath(path = []) {
const key = this.getTaskCargoTypeOptionsKey(path);
return key ? this.taskCargoOptionsMap[key] || [] : [];
},
isTaskCargoOptionsLoading(path = []) {
const key = this.getTaskCargoTypeOptionsKey(path);
return key ? !!this.taskCargoLoadingMap[key] : false;
},
setTaskCargoOptionsByKey(key, options = []) {
if (!key) return;
this.taskCargoOptionsMap = {
...this.taskCargoOptionsMap,
[key]: options,
};
},
setTaskCargoLoadingByKey(key, loading) {
if (!key) return;
this.taskCargoLoadingMap = {
...this.taskCargoLoadingMap,
[key]: loading,
};
},
buildTaskCargoListQueryParams(path = []) {
const normalizedPath = this.normalizeCargoTypePath(path);
const labels = this.getCargoTypePathLabels(normalizedPath);
const secondCargoType = this.findCargoTypeByPath(normalizedPath);
return {
secondCargoTypeName: labels[1] || '',
secondCargoTypeCode: secondCargoType?.cargoCode || secondCargoType?.code || '',
};
},
loadTaskCargoOptionsByPath(path = [], cargoName = '') {
const normalizedPath = this.normalizeCargoTypePath(path);
if (!normalizedPath.length) return Promise.resolve([]);
return this.ensureCargoTypeOptions().then(() => {
const key = this.getTaskCargoTypeOptionsKey(normalizedPath);
const params = this.buildTaskCargoListQueryParams(normalizedPath);
const keyword = String(cargoName || '').trim();
if (!params.secondCargoTypeName && !params.secondCargoTypeCode && !keyword) {
this.setTaskCargoOptionsByKey(key, []);
return [];
}
this.setTaskCargoLoadingByKey(key, true);
return getCommonCargoList(1, 500, {
...params,
...(keyword ? { cargoName: keyword } : {}),
allDept: 0,
})
.then(res => {
const records = extractRecords(res);
this.setTaskCargoOptionsByKey(key, records);
return records;
})
.catch(error => {
window.console.log(error);
this.setTaskCargoOptionsByKey(key, []);
return [];
})
.finally(() => {
this.setTaskCargoLoadingByKey(key, false);
});
});
},
fetchTaskCargoSuggestions(queryString, callback, path = []) {
const keyword = String(queryString || '').trim();
const normalizedPath = this.normalizeCargoTypePath(path);
if (!keyword && !normalizedPath.length) {
callback([]);
return;
}
let loadingKey = '';
this.ensureCargoTypeOptions()
.then(() => {
const key = this.getTaskCargoTypeOptionsKey(normalizedPath);
loadingKey = key;
const params = this.buildTaskCargoListQueryParams(normalizedPath);
if (key) this.setTaskCargoLoadingByKey(key, true);
return getCommonCargoList(1, 50, {
...params,
...(keyword ? { cargoName: keyword } : {}),
allDept: 0,
}).then(res => {
const records = extractRecords(res);
if (key) this.setTaskCargoOptionsByKey(key, records);
callback(
records.map(item => ({
...item,
value: this.formatCargoTypeLabel(item),
}))
);
return key;
});
})
.catch(error => {
window.console.log(error);
callback([]);
})
.finally(() => {
if (loadingKey) this.setTaskCargoLoadingByKey(loadingKey, false);
});
},
handleTaskCargoRowSelect(row, item = {}) {
const value = this.formatCargoTypeLabel(item);
row.cargoName = value;
row.specification = item.specification || item.spec || row.specification || '';
row.model = item.model || item.modelName || row.model || '';
this.syncTransportCargoJson();
},
loadTaskCargoOptionsForForm() {
return this.loadTaskCargoOptionsByPath(this.form.cargoTypePath);
},
handleTaskCargoTypeChange(value) {
const path = this.normalizeCargoTypePath(value);
const cargoType = this.findCargoTypeByPath(path);
const labels = this.getCargoTypePathLabels(path);
this.form.cargoTypePath = path;
this.form.cargoType = labels.length ? labels[labels.length - 1] : '';
this.form.cargoTypeCode = cargoType?.cargoCode || cargoType?.code || cargoType?.id || '';
this.form.cargoName = '';
this.form.specification = '';
this.form.model = '';
this.loadTaskCargoOptionsForForm();
},
handleTaskCargoChange(value) {
const cargo = this.taskCargoOptions.find(item => this.formatCargoTypeLabel(item) === value);
this.form.cargoName = value || '';
if (cargo) {
this.form.specification =
cargo.specification || cargo.spec || this.form.specification || '';
this.form.model = cargo.model || cargo.modelName || this.form.model || '';
}
},
handleTaskCargoSelect(item = {}) {
this.form.cargoName = this.formatCargoTypeLabel(item);
this.form.specification = item.specification || item.spec || this.form.specification || '';
this.form.model = item.model || item.modelName || this.form.model || '';
},
getTaskCargoRowOptions(row = {}) {
return this.getTaskCargoOptionsByPath(row.cargoTypePath);
},
handleTaskCargoRowNameChange(row, value) {
const cargo = this.getTaskCargoRowOptions(row).find(
item => this.formatCargoTypeLabel(item) === value
);
row.cargoName = value || '';
if (cargo) {
row.specification = cargo.specification || cargo.spec || row.specification || '';
row.model = cargo.model || cargo.modelName || row.model || '';
}
this.syncTransportCargoJson();
},
initTaskFullCargoRows(goodsRows = []) {
if (!this.taskInfoFormEnabled) return;
const freightItems = this.parseJsonObject(this.form.freightJson).freightItems || [];
const rows = (goodsRows.length ? goodsRows : this.transportCargoRows).map((row, index) => {
const cargo = this.normalizeTransportCargoRow(row);
if (index === 0 && !cargo.unitPrice && this.form.unitPrice) {
cargo.unitPrice = this.form.unitPrice;
}
cargo.priceUnit = cargo.priceUnit || this.form.priceUnit || this.defaultPriceUnit;
if (this.isBillingFieldEmpty(cargo.freightAmount)) {
cargo.freightAmount = this.normalizeOptionalAmount(
freightItems[index]?.freightAmount ?? ''
);
}
return cargo;
});
while (rows.length < 1) {
rows.push(this.normalizeTransportCargoRow());
}
this.transportCargoRows = rows;
this.syncTransportCargoJson();
},
loadTaskQuantityUnitOptions() {
if (this.taskQuantityUnitOptions.length || this.taskQuantityUnitLoading) {
return Promise.resolve(this.taskQuantityUnitOptions);
}
this.taskQuantityUnitLoading = true;
return loadMeasurementUnitOptions()
.then(options => {
this.taskQuantityUnitOptions = options;
this.quantityUnitOptions = options.map(item => item.value);
return this.taskQuantityUnitOptions;
})
.finally(() => {
this.taskQuantityUnitLoading = false;
});
},
loadTaskFeeUnitOptions() {
if (this.taskFeeUnitOptions.length || this.taskFeeUnitLoading) {
return Promise.resolve(this.taskFeeUnitOptions);
}
this.taskFeeUnitLoading = true;
return getDictionary({ code: 'unit_fee' })
.then(res => {
this.taskFeeUnitOptions = this.normalizeDictOptions(res.data.data || []);
return this.taskFeeUnitOptions;
})
.finally(() => {
this.taskFeeUnitLoading = false;
});
},
normalizeDictOptions(list = []) {
return (list || []).map(item => ({
label: item.dictValue || item.label || item.value || '',
value: item.dictKey || item.value || item.dictValue || '',
remark: item.remark || '',
}));
},
handleTaskNumberInput(prop, value) {
this.form[prop] = this.normalizeTaskNumber(value);
},
handleTaskMileageInput(value) {
this.form.mileage = String(value || '')
.replace(/\D/g, '')
.slice(0, 10);
},
normalizeMileageValue(value) {
if (value === undefined || value === null || value === '' || Number(value) === -1) return '';
const mileage = Number(value);
return Number.isFinite(mileage) && mileage >= 0 ? String(Math.trunc(mileage)) : '';
},
normalizeTaskNumber(value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
return parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('')}` : parts[0];
},
validateTaskCargoRows() {
if (!this.transportCargoRows.length) {
this.$message.warning('请至少添加一条货物信息');
return false;
}
for (const [index, cargo] of this.transportCargoRows.entries()) {
const requiredFields = [
['cargoName', '货物名称'],
['cargoType', '货物类型'],
['quantity', '数量'],
['quantityUnit', '数量单位'],
];
const emptyField = requiredFields.find(([prop]) => this.isBillingFieldEmpty(cargo[prop]));
if (emptyField) {
this.$message.warning(`第${index + 1}行${emptyField[1]}不能为空`);
return false;
}
const invalidField = [['quantity', '数量']].find(([prop]) => {
const value = cargo[prop];
return value !== undefined && value !== null && value !== '' && Number(value) < 0;
});
if (invalidField) {
this.$message.warning(`第${index + 1}行${invalidField[1]}不能小于0`);
return false;
}
const invalidPrice = Number(cargo.unitPrice || 0) < 0;
if (invalidPrice) {
this.$message.warning(`第${index + 1}行单价不能小于0`);
return false;
}
if (!this.isBillingFieldEmpty(cargo.freightAmount) && Number(cargo.freightAmount) < 0) {
this.$message.warning(`第${index + 1}行运费不能小于0`);
return false;
}
}
return true;
},
validateTaskInfoForm(row = {}) {
if (this.isTaskFullMode && !this.validateTaskCargoRows()) {
return false;
}
if (this.isTaskFullMode && !this.taskFreightCurrency) {
this.$message.warning('请选择币种');
return false;
}
const requiredFields = this.config.transportFormRequiredFields || [
['carrierType', '承运类型'],
['vehicleNo', this.transportVehicleNoLabel],
];
if (this.isTaskCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', '承运商']);
}
if (row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']);
}
if (this.isRoadTransport && row.carrierType === '自运') {
requiredFields.push(['driverName', '司机']);
requiredFields.push(['driverPhone', '手机号']);
}
if (!this.isTaskFullMode) {
requiredFields.push(['cargoName', '货物名称']);
requiredFields.push(['quantity', '数量']);
}
const emptyField = requiredFields.find(([prop]) => this.isBillingFieldEmpty(row[prop]));
if (emptyField) {
this.$message.warning(`请选择或输入${emptyField[1]}`);
return false;
}
const numberFields = [
['quantity', '数量'],
['mileage', '里程'],
['otherFeeTotal', '其他费用合计'],
];
if (!this.isTaskFullMode) numberFields.push(['unitPrice', '单价']);
const invalidField = numberFields.find(([prop]) => {
const value = row[prop];
return value !== undefined && value !== null && value !== '' && Number(value) < 0;
});
if (invalidField) {
this.$message.warning(`${invalidField[1]}不能小于0`);
return false;
}
if (this.isRoadTransport) {
const invalidLengthField = [
['driverName', '司机', 20],
['driverPhone', '手机号', 20],
['vehicleNo', '车牌号', 30],
['trailerVehicleNo', '挂车车牌号', 30],
['escortName', '押运人', 20],
['escortPhone', '押运人手机号', 20],
].find(([prop, , max]) => String(row[prop] || '').length > max);
if (invalidLengthField) {
this.$message.warning(`${invalidLengthField[1]}不能超过${invalidLengthField[2]}个字符`);
return false;
}
} else {
const invalidLengthField = [
['vehicleNo', '船班号/船次号/班次号', 30],
['captainName', '船长', 20],
['cabinNo', '舱位', 30],
['containerNo', '箱号', 30],
].find(([prop, , max]) => String(row[prop] || '').length > max);
if (invalidLengthField) {
this.$message.warning(`${invalidLengthField[1]}不能超过${invalidLengthField[2]}个字符`);
return false;
}
}
if (row.mileage && (!/^\d{1,10}$/.test(String(row.mileage)) || Number(row.mileage) <= 0)) {
this.$message.warning('里程必须为不超过10位的正整数');
return false;
}
if (
row.estimatedStartTime &&
row.estimatedEndTime &&
row.estimatedEndTime < row.estimatedStartTime
) {
this.$message.warning('预计完成日期不能早于预计发货日期');
return false;
}
if (row.taskRemark && row.taskRemark.length > 200) {
this.$message.warning('任务备注不能超过200个字符');
return false;
}
return true;
},
syncTaskInfoToRow(row) {
row.taskEntryMode = row.taskEntryMode || 'simple';
['quantity', 'mileage', 'unitPrice', 'otherFeeTotal'].forEach(prop => {
if (this.isBillingFieldEmpty(row[prop]) || this.isSentinelMinusOne(row[prop])) {
row[prop] = '';
}
});
const taskInfo = {
taskEntryMode: row.taskEntryMode,
carrierType: row.carrierType,
carrierId: row.carrierId,
carrierName: row.carrierName,
carrierContractId: row.carrierContractId,
driverId: row.driverId,
driverName: row.driverName,
driverPhone: row.driverPhone,
vehicleNo: row.vehicleNo,
captainName: row.captainName,
cabinNo: row.cabinNo,
containerNo: row.containerNo,
trailerVehicleNo: row.trailerVehicleNo,
escortName: row.escortName,
escortPhone: row.escortPhone,
mileage: row.mileage,
estimatedStartTime: row.estimatedStartTime,
estimatedEndTime: row.estimatedEndTime,
unitPrice: row.unitPrice,
priceUnit: row.priceUnit,
taskRemark: row.taskRemark,
};
if (!this.isBillingFieldEmpty(row.otherFeeTotal) && !this.isSentinelMinusOne(row.otherFeeTotal)) {
taskInfo.otherFeeTotal = row.otherFeeTotal;
} else {
delete row.otherFeeTotal;
}
row.carrierJson = JSON.stringify([
{
carrierType: row.carrierType,
carrierId: row.carrierId,
carrierName: row.carrierName,
carrierContractId: row.carrierContractId,
driverId: row.driverId,
driverName: row.driverName,
driverPhone: row.driverPhone,
vehicleNo: row.vehicleNo,
captainName: row.captainName,
cabinNo: row.cabinNo,
containerNo: row.containerNo,
trailerVehicleNo: row.trailerVehicleNo,
escortName: row.escortName,
escortPhone: row.escortPhone,
mileage: row.mileage,
taskEntryMode: row.taskEntryMode,
},
]);
const goodsRows = this.isTaskFullMode
? this.transportCargoRows.map(item => this.normalizeTransportCargoRow(item))
: [
{
cargoType: row.cargoType,
cargoTypeCode: row.cargoTypeCode,
cargoTypePath: row.cargoTypePath,
cargoName: row.cargoName,
specification: row.specification,
model: row.model,
quantity: row.quantity,
quantityUnit: row.quantityUnit,
mileage: row.mileage,
unitPrice: row.unitPrice,
priceUnit: row.priceUnit,
remark: row.taskRemark,
},
];
if (this.isTaskFullMode) {
const firstCargo = goodsRows[0] || {};
taskInfo.unitPrice = firstCargo.unitPrice || '';
taskInfo.priceUnit = firstCargo.priceUnit || '';
row.freightJson = this.serializeTaskFullFreight();
}
row.taskInfoJson = JSON.stringify(taskInfo);
row.goodsJson = JSON.stringify(goodsRows);
const firstGoodsRow =
goodsRows.find(
item =>
!this.isBillingFieldEmpty(item.cargoName) || !this.isBillingFieldEmpty(item.cargoType)
) ||
goodsRows[0] ||
{};
row.cargoType = firstGoodsRow.cargoType || row.cargoType || '';
row.cargoTypeCode = firstGoodsRow.cargoTypeCode || row.cargoTypeCode || '';
row.cargoTypePath = firstGoodsRow.cargoTypePath || row.cargoTypePath || [];
row.cargoName = firstGoodsRow.cargoName || row.cargoName || '';
row.specification = firstGoodsRow.specification || row.specification || '';
row.model = firstGoodsRow.model || row.model || '';
row.quantity = firstGoodsRow.quantity || row.quantity || '';
row.quantityUnit = firstGoodsRow.quantityUnit || row.quantityUnit || '';
row.mileage = firstGoodsRow.mileage || row.mileage || '';
row.unitPrice = firstGoodsRow.unitPrice || (this.isTaskFullMode ? '' : row.unitPrice) || '';
row.priceUnit = firstGoodsRow.priceUnit || (this.isTaskFullMode ? '' : row.priceUnit) || '';
row.taskRemark = firstGoodsRow.remark || row.taskRemark || '';
},
serializeTaskFullFreight() {
return JSON.stringify({
currency: this.taskFreightCurrency || 'RMB',
totalFreightAmount: this.taskFullFreightTotal,
otherFreightAmount: this.form.otherFeeTotal || '',
freightItems: this.transportCargoRows.map((cargo, index) => ({
cargoIndex: index,
cargoName: cargo.cargoName || '',
cargoType: cargo.cargoType || '',
unitPrice: cargo.unitPrice || '',
priceUnit: cargo.priceUnit || '',
quantity: this.taskFullFreightQuantity(index),
quantityUnit: cargo.quantityUnit || '',
freightAmount: this.taskFullFreightAmount(cargo),
})),
});
},
initWaybillCargoRows() {
const goodsRows = this.parseJsonArray(this.form.goodsJson);
this.transportCargoRows = goodsRows.map(row => this.normalizeTransportCargoRow(row));
if (!this.transportCargoRows.length) {
this.transportCargoRows.push(this.normalizeTransportCargoRow());
}
if (this.hasMultipleConfiguredGoods(this.form, this.transportCargoRows)) {
this.form.taskEntryMode = 'full';
}
this.syncTransportCargoJson();
this.syncCurrentContractOption();
this.restoreTransportCargoTypePaths();
},
restoreTransportCargoTypePaths() {
if (!this.transportCargoRows.length) return Promise.resolve();
return this.ensureCargoTypeOptions().then(() => {
this.transportCargoRows.forEach(row => {
if (this.normalizeCargoTypePath(row.cargoTypePath).length) return;
const cargoType = this.findCargoTypeByCodeOrName({
cargoTypeCode: row.cargoTypeCode,
cargoType: row.cargoType,
});
if (!cargoType?.path) return;
row.cargoTypePath = cargoType.path;
row.cargoType = this.getCargoTypePathLabels(cargoType.path).at(-1) || row.cargoType;
row.cargoTypeCode = cargoType.cargoCode || cargoType.code || row.cargoTypeCode;
});
this.syncTransportCargoJson();
});
},
normalizeTransportCargoRow(row = {}) {
const cargo = {
...defaultTransportCargo(),
...row,
cargoTypePath: this.normalizeCargoTypePath(row.cargoTypePath),
cargoType:
row.cargoType || row.goodsType || row.secondCargoTypeName || row.firstCargoTypeName || '',
cargoTypeCode:
row.cargoTypeCode ||
row.goodsTypeCode ||
row.secondCargoTypeCode ||
row.firstCargoTypeCode ||
'',
cargoName: row.cargoName || row.goodsName || '',
packageType: row.packageType || row.package || '',
specification: row.specification || row.spec || '',
freightAmount: this.normalizeOptionalAmount(
row.freightAmount ?? row.amount ?? row.totalAmount ?? ''
),
};
['quantity', 'mileage', 'unitPrice', 'freightAmount'].forEach(prop => {
if (this.isSentinelMinusOne(cargo[prop])) cargo[prop] = '';
});
return cargo;
},
hasMultipleConfiguredGoods(source = {}, rows = []) {
const configuredRows = this.parseJsonArray(
source?.goodsJson || source?.goodsRows || source?.goodsList
);
const list = configuredRows.length ? configuredRows : Array.isArray(rows) ? rows : [];
return list.length > 1;
},
syncTransportCargoJson() {
if (!this.isTaskFullMode) return;
this.form.goodsJson = JSON.stringify(
this.transportCargoRows.map(row => this.normalizeTransportCargoRow(row))
);
},
addTransportCargoRow(index = -1, row = {}) {
const nextRow = this.normalizeTransportCargoRow({
priceUnit: this.form.priceUnit || this.defaultPriceUnit,
...row,
});
if (
index < 0 &&
this.transportCargoRows.length === 1 &&
this.isEmptyTransportCargoRow(this.transportCargoRows[0])
) {
this.transportCargoRows.splice(0, 1, nextRow);
} else if (index > -1) {
this.transportCargoRows.splice(index + 1, 0, nextRow);
} else {
this.transportCargoRows.push(nextRow);
}
this.syncTransportCargoJson();
},
isEmptyTransportCargoRow(row = {}) {
return ![
row.cargoName,
row.cargoType,
row.cargoTypeCode,
row.quantity,
row.quantityUnit,
row.packageType,
row.brand,
row.specification,
row.model,
row.materialCode,
row.deviceCode,
row.remark,
].some(value => !this.isBillingFieldEmpty(value));
},
removeTransportCargoRow(index) {
this.transportCargoRows.splice(index, 1);
if (!this.transportCargoRows.length && this.isTaskFullMode) {
this.transportCargoRows.push(this.normalizeTransportCargoRow());
}
this.syncTransportCargoJson();
},
handleTransportCargoQuantityInput(row, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
row.quantity =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 3)}` : parts[0];
this.syncTransportCargoJson();
},
resolvePriceUnitByQuantityUnit(quantityUnit) {
return resolvePriceUnitByQuantityUnit(
quantityUnit,
this.taskFreightCurrency || this.form.settlementCurrency || 'RMB'
);
},
handleTransportCargoQuantityUnitChange(quantityUnit) {
const priceUnit = this.resolvePriceUnitByQuantityUnit(quantityUnit);
if (!priceUnit) return;
(this.transportCargoRows || []).forEach(cargo => {
cargo.priceUnit = priceUnit;
});
this.form.priceUnit = priceUnit;
this.syncTransportCargoJson();
},
handleTransportCargoTypeChange(row, value) {
const path = this.normalizeCargoTypePath(value);
const cargoType = this.findCargoTypeByPath(path);
const labels = this.getCargoTypePathLabels(path);
row.cargoTypePath = path;
row.cargoType = labels.length ? labels[labels.length - 1] : '';
row.cargoTypeCode = cargoType?.cargoCode || cargoType?.code || cargoType?.id || '';
row.cargoName = '';
row.specification = '';
row.model = '';
this.syncTransportCargoJson();
this.loadTaskCargoOptionsByPath(path);
},
handleTransportTypeChange(value) {
this.form.transportTypeName = this.getTransportTypeLabel(value);
this.clearTransportShippingInfo();
},
clearTransportShippingInfo() {
[
'departureName',
'departureAddress',
'departureAddressCode',
'departureSiteCode',
'departureLongitude',
'departureLatitude',
'departureContact',
'departurePhone',
'arrivalName',
'arrivalAddress',
'arrivalAddressCode',
'arrivalSiteCode',
'arrivalLongitude',
'arrivalLatitude',
'arrivalContact',
'arrivalPhone',
].forEach(prop => {
this.form[prop] = '';
});
this.transportRouteSelected = null;
this.transportAddressSelected = null;
this.transportMapSelected = {};
this.transportRouteBox = false;
this.transportAddressBox = false;
this.transportStationBox = false;
this.transportMapBox = false;
this.transportAddressTarget = '';
this.transportStationTarget = '';
this.transportMapTarget = '';
this.transportMapKeyword = '';
this.transportMapStatus = '可搜索地址或点击地图选点';
if (this.transportAmapMarker) {
this.transportAmapMarker.setMap(null);
this.transportAmapMarker = null;
}
this.$refs.crud?.clearValidate?.([
'departureAddress',
'arrivalAddress',
'departureName',
'arrivalName',
'departureContact',
'departurePhone',
'arrivalContact',
'arrivalPhone',
]);
},
getTransportTypeLabel(value) {
if (value && typeof value === 'object') {
return value.label || value.dictValue || value.name || value.value || value.dictKey || '';
}
const column = (this.option.column || []).find(item => item.prop === 'transportType');
const dictionary = [...(column?.dicData || []), ...this.transportTypeOptions];
const item = dictionary.find(
dic =>
String(dic.value ?? dic.dictKey ?? '') === String(value ?? '') ||
String(dic.dictKey ?? '') === String(value ?? '')
);
return item?.label || item?.dictValue || '';
},
loadTransportTypeOptions() {
if (this.transportTypeOptions.length || this.transportTypeOptionsLoading) {
return Promise.resolve(this.transportTypeOptions);
}
this.transportTypeOptionsLoading = true;
return getDictionary({ code: 'transport_type' })
.then(res => {
this.transportTypeOptions = this.normalizeDictOptions(res.data?.data || []);
return this.transportTypeOptions;
})
.finally(() => {
this.transportTypeOptionsLoading = false;
});
},
resolveTransportMode(value) {
const values = [value, this.form.transportTypeName, this.getTransportTypeLabel(value)]
.flatMap(item => {
if (!item || typeof item !== 'object') return [item];
return [item.label, item.dictValue, item.name, item.value, item.dictKey];
})
.filter(Boolean);
const text = values.join(' ').toLowerCase();
if (!text) return '';
if (
text.includes('公路') ||
text.includes('道路') ||
text.includes('road') ||
text.includes('highway') ||
text === 'gl'
) {
return 'road';
}
if (
text.includes('铁路') ||
text.includes('rail') ||
text.includes('train') ||
text === 'tl'
) {
return 'rail';
}
if (
text.includes('水路') ||
text.includes('水运') ||
text.includes('海运') ||
text.includes('港') ||
text.includes('water') ||
text.includes('ship') ||
text.includes('sea') ||
text === 'sl'
) {
return 'water';
}
if (
text.includes('航空') ||
text.includes('空运') ||
text.includes('空港') ||
text.includes('air') ||
text.includes('airport') ||
text === 'hk'
) {
return 'air';
}
return '';
},
openTransportPrimaryAddressPicker(target) {
if (this.transportStationMode) {
this.openTransportStationDialog(target);
}
},
openTransportSecondaryAddressPicker(target) {
if (this.transportStationMode) {
this.openTransportStationDialog(target);
return;
}
this.openTransportMapDialog(target);
},
handleTransportSecondaryAddressInputClick(target) {
if (this.transportStationMode || !this.config.editableRoadAddress) {
this.openTransportSecondaryAddressPicker(target);
}
},
isBillingFieldEmpty(value) {
return (
value === undefined ||
value === null ||
String(value).trim() === '' ||
this.isSentinelMinusOne(value)
);
},
ensureTransportTypeBeforeAddress() {
if (!this.shippingInfoFormEnabled || !this.isBillingFieldEmpty(this.form.transportType)) {
return true;
}
this.$message.warning('请先选择运输方式');
return false;
},
openTransportRouteDialog() {
if (!this.ensureTransportTypeBeforeAddress()) return;
this.transportRouteSelected = null;
this.transportRoutePage.currentPage = 1;
this.transportRouteQuery = {};
this.transportRouteBox = true;
},
loadTransportRouteList() {
this.transportRouteLoading = true;
getCommonRouteList(this.transportRoutePage.currentPage, this.transportRoutePage.pageSize, {
...this.transportRouteQuery,
allDept: 0,
})
.then(res => {
const data = res.data.data || {};
this.transportRouteRows = data.records || [];
this.transportRoutePage.total = data.total || 0;
})
.finally(() => {
this.transportRouteLoading = false;
});
},
handleTransportRouteSearch() {
this.transportRoutePage.currentPage = 1;
this.loadTransportRouteList();
},
handleTransportRouteReset() {
this.transportRouteQuery = {};
this.transportRoutePage.currentPage = 1;
this.loadTransportRouteList();
},
handleTransportRouteSizeChange(pageSize) {
this.transportRoutePage.pageSize = pageSize;
this.transportRoutePage.currentPage = 1;
this.loadTransportRouteList();
},
handleTransportRouteCurrentChange(currentPage) {
this.transportRoutePage.currentPage = currentPage;
this.loadTransportRouteList();
},
formatTransportRouteUpdateUserName(row) {
return formatUpdateUserName(row);
},
selectTransportRouteRow(row) {
this.transportRouteSelected = row;
this.applyTransportRoute(row);
this.transportRouteBox = false;
},
applyTransportRoute(route = {}) {
this.applyTransportAddress('departure', {
addressName: route.departureName,
detailAddress: route.departureAddress,
longitude: route.departureLongitude,
latitude: route.departureLatitude,
contactName: route.departureContact,
contactPhone: route.departurePhone,
addressCode: route.departureAddressCode,
siteCode: route.departureSiteCode,
});
this.applyTransportAddress('arrival', {
addressName: route.arrivalName,
detailAddress: route.arrivalAddress,
longitude: route.arrivalLongitude,
latitude: route.arrivalLatitude,
contactName: route.arrivalContact,
contactPhone: route.arrivalPhone,
addressCode: route.arrivalAddressCode,
siteCode: route.arrivalSiteCode,
});
},
openTransportStationDialog(target) {
if (!this.ensureTransportTypeBeforeAddress()) return;
if (!this.transportStationMode) return;
this.transportStationTarget = target;
this.transportStationQuery = {};
this.transportStationPage.currentPage = 1;
this.transportStationBox = true;
},
getTransportStationListRequest() {
const requestMap = {
water: getPortTerminalList,
rail: getRailwayStationList,
air: getAirportMasterList,
};
return requestMap[this.transportMode];
},
loadTransportStationList() {
const request = this.getTransportStationListRequest();
if (!request) return;
this.transportStationLoading = true;
request(
this.transportStationPage.currentPage,
this.transportStationPage.pageSize,
this.buildTransportStationQueryParams()
)
.then(res => {
const data = res.data.data || {};
this.transportStationRows = (data.records || []).map(row =>
this.normalizeTransportStationRow(row)
);
this.transportStationPage.total = data.total || 0;
})
.finally(() => {
this.transportStationLoading = false;
});
},
buildTransportStationQueryParams() {
const query = this.transportStationQuery || {};
return Object.keys(query).reduce((params, key) => {
const value = query[key];
if (value !== undefined && value !== null && value !== '') {
params[key] = value;
}
return params;
}, {});
},
handleTransportStationSearch() {
this.transportStationPage.currentPage = 1;
this.loadTransportStationList();
},
handleTransportStationReset() {
this.transportStationQuery = {};
this.transportStationPage.currentPage = 1;
this.loadTransportStationList();
},
handleTransportStationSizeChange(pageSize) {
this.transportStationPage.pageSize = pageSize;
this.transportStationPage.currentPage = 1;
this.loadTransportStationList();
},
handleTransportStationCurrentChange(currentPage) {
this.transportStationPage.currentPage = currentPage;
this.loadTransportStationList();
},
normalizeTransportStationRow(row = {}) {
const regionName =
row.regionName ||
[row.provinceName, row.cityName, row.districtName].filter(Boolean).join('');
return {
...row,
stationName: row.name || row.stationName || row.addressName || '',
stationType: row.category || this.transportStationTypeLabel,
stationCode: row.code || row.tmisCode || row.iataCode || row.icaoCode || row.siteCode || '',
detailAddress: row.detailAddress || row.address || '',
regionName,
};
},
selectTransportStationRow(row) {
const station = this.normalizeTransportStationRow(row);
this.applyTransportAddress(this.transportStationTarget, {
addressName: station.stationName,
addressType: this.transportStationTypeLabel,
detailAddress: station.detailAddress,
longitude: station.longitude,
latitude: station.latitude,
regionName: station.regionName,
regionCode: station.regionCode,
siteCode: station.stationCode,
siteCodeDisplay: station.stationCode,
remark: station.remark,
});
this.transportStationBox = false;
},
openTransportAddressDialog(target) {
if (!this.ensureTransportTypeBeforeAddress()) return;
const open = () => {
this.transportAddressTarget = target;
this.transportAddressSelected = null;
this.resetTransportAddressQuery();
this.transportAddressPage.currentPage = 1;
this.transportAddressBox = true;
};
open();
},
openTransportAddressPicker(target) {
if (this.transportStationMode) {
this.openTransportStationDialog(target);
return;
}
this.openTransportAddressDialog(target);
},
resetTransportAddressQuery() {
const addressType = this.getTransportFixedAddressType();
this.transportAddressQuery = addressType ? { addressType } : {};
},
getTransportFixedAddressType() {
if (!this.config.fixedTransportAddressType) return '';
const typeMap = {
road: '常规地址',
rail: '铁路车站',
water: '港口/码头',
air: '空港机场',
};
return typeMap[this.transportMode] || '';
},
loadTransportAddressList() {
this.transportAddressLoading = true;
getCommonAddressList(
this.transportAddressPage.currentPage,
this.transportAddressPage.pageSize,
{
...this.transportAddressQuery,
allDept: 0,
}
)
.then(res => {
const data = res.data.data || {};
this.transportAddressRows = data.records || [];
this.transportAddressPage.total = data.total || 0;
})
.finally(() => {
this.transportAddressLoading = false;
});
},
handleTransportAddressSearch() {
this.transportAddressPage.currentPage = 1;
this.loadTransportAddressList();
},
handleTransportAddressReset() {
this.resetTransportAddressQuery();
this.transportAddressPage.currentPage = 1;
this.loadTransportAddressList();
},
handleTransportAddressSizeChange(pageSize) {
this.transportAddressPage.pageSize = pageSize;
this.transportAddressPage.currentPage = 1;
this.loadTransportAddressList();
},
handleTransportAddressCurrentChange(currentPage) {
this.transportAddressPage.currentPage = currentPage;
this.loadTransportAddressList();
},
formatTransportAddressSiteCode(row) {
return row.addressType === '常规地址' ? '/' : row.siteCodeDisplay || row.siteCode || '';
},
selectTransportAddressRow(row) {
this.transportAddressSelected = row;
this.applyTransportAddress(this.transportAddressTarget, {
...row,
preferRegionName: true,
});
this.transportAddressBox = false;
},
applyTransportAddress(target, address = {}) {
const prefix = target === 'departure' ? 'departure' : 'arrival';
this.form[`${prefix}Name`] = address.preferRegionName
? address.regionName || address.addressName || ''
: address.addressName || address.regionName || '';
this.form[`${prefix}Address`] = address.detailAddress || address.address || '';
this.form[`${prefix}AddressCode`] = address.addressCode || '';
this.form[`${prefix}SiteCode`] =
address.addressType === '常规地址'
? '/'
: address.siteCodeDisplay || address.siteCode || '';
this.form[`${prefix}Longitude`] = address.longitude || '';
this.form[`${prefix}Latitude`] = address.latitude || '';
this.form[`${prefix}Contact`] = address.contactName || this.form[`${prefix}Contact`] || '';
this.form[`${prefix}Phone`] = address.contactPhone || this.form[`${prefix}Phone`] || '';
// 地址值在本轮响应式更新后再清理校验状态,避免 Avue 以旧空值重新触发必填提示。
this.$nextTick(() => {
this.$refs.crud?.clearValidate?.([`${prefix}Address`]);
});
},
closeTransportMapDialog() {
this.transportMapBox = false;
this.waybillRouteChangeMapTarget = null;
},
openTransportMapDialog(target) {
if (this.dialogReadonly) return;
if (!this.ensureTransportTypeBeforeAddress()) return;
const prefix = target === 'departure' ? 'departure' : 'arrival';
this.waybillRouteChangeMapTarget = null;
this.transportMapTarget = prefix;
this.transportMapKeyword = this.form[`${prefix}Address`] || this.form[`${prefix}Name`] || '';
this.transportMapSelected = this.buildTransportMapSelection({
lng: this.form[`${prefix}Longitude`],
lat: this.form[`${prefix}Latitude`],
address: this.form[`${prefix}Address`],
regionName: this.form[`${prefix}Name`],
});
this.transportMapStatus = this.transportMapSelected.longitude
? '已加载当前选点,可重新选点'
: '可搜索地址或点击地图选点';
this.transportMapBox = true;
},
loadAmap() {
if (window.AMap && window.AMap.Map) {
return Promise.resolve();
}
if (!amapLoader) {
amapLoader = 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 amapLoader;
},
initTransportAmap() {
this.loadAmap()
.then(() => {
this.$nextTick(() => {
if (!this.transportAmap) {
const center = this.toLngLat(
this.transportMapSelected.longitude || 116.40769,
this.transportMapSelected.latitude || 39.89945
);
this.transportAmap = new window.AMap.Map(this.$refs.transportAmap, {
center,
zoom: this.transportMapSelected.longitude ? 14 : 11,
});
window.AMap.plugin(['AMap.ToolBar', 'AMap.Geocoder'], () => {
this.transportAmap.addControl(new window.AMap.ToolBar());
if (!this.transportAmapGeocoder) {
this.transportAmapGeocoder = new window.AMap.Geocoder();
}
});
this.transportAmap.on('click', event => this.pickTransportMapPoint(event.lnglat));
} else if (typeof this.transportAmap.resize === 'function') {
this.transportAmap.resize();
}
if (this.transportMapSelected.longitude) {
this.renderTransportMapMarker(
this.toLngLat(
this.transportMapSelected.longitude,
this.transportMapSelected.latitude
)
);
}
});
})
.catch(() => {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
this.transportMapBox = false;
});
},
searchTransportMapKeyword() {
const keyword = String(this.transportMapKeyword || '').trim();
if (!keyword) {
this.$message.warning('请输入地址关键词');
return;
}
if (this.transportMapLoading) return;
this.transportMapSearchResults = [];
this.transportMapLoading = true;
this.loadAmap()
.then(() => searchAmapPlaceText(keyword))
.then(pois => {
this.transportMapSearchResults = normalizeMapSearchResults(pois, keyword);
const first = this.transportMapSearchResults[0];
if (!first?.location) {
this.transportMapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
return;
}
return this.$nextTick().then(() => {
if (typeof this.transportAmap?.resize === 'function') {
this.transportAmap.resize();
}
this.pickTransportMapPoint(first.location, first.address || first.name || keyword);
});
})
.catch(() => {
this.transportMapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
})
.finally(() => {
this.transportMapLoading = false;
});
},
selectTransportMapSearchResult(item) {
if (item && item.location) {
this.pickTransportMapPoint(item.location, item.address || item.name || '');
}
},
pickTransportMapPoint(lnglat, keyword) {
const longitude = this.getPointLng(lnglat);
const latitude = this.getPointLat(lnglat);
if (longitude === undefined || latitude === undefined) {
this.$message.warning('选点坐标无效');
return;
}
const point = this.toLngLat(longitude, latitude);
this.renderTransportMapMarker(point);
this.transportMapSelected = this.buildTransportMapSelection({
lng: longitude,
lat: latitude,
address: keyword,
});
this.transportMapStatus = '正在反查地址...';
this.ensureTransportAmapGeocoder()
.then(() => this.runAmapGeocode('address', point))
.then(result => {
const address = this.resolveMapAddress(result);
this.transportMapSelected = {
...this.transportMapSelected,
detailAddress: address.detailAddress || this.transportMapSelected.detailAddress,
regionName: address.regionName || this.transportMapSelected.regionName,
regionCode: address.regionCode || this.transportMapSelected.regionCode,
};
this.transportMapKeyword =
this.transportMapSelected.detailAddress || this.transportMapKeyword;
this.transportMapStatus = this.transportMapSelected.detailAddress || '已选点,可确认回填';
})
.catch(() => {
this.transportMapStatus = '反查地址失败';
this.$message.error('反查地址失败,请重新选点');
});
},
renderTransportMapMarker(point) {
if (!this.transportAmap || !window.AMap) return;
if (this.transportAmapMarker) {
this.transportAmapMarker.setMap(null);
}
this.transportAmapMarker = new window.AMap.Marker({
position: point,
});
this.transportAmapMarker.setMap(this.transportAmap);
this.transportAmap.setCenter(point);
},
confirmTransportMapPick() {
if (!this.transportMapSelected.longitude) {
this.$message.warning('请先搜索或点击地图完成选点');
return;
}
if (this.waybillRouteChangeMapTarget) {
const { row, field } = this.waybillRouteChangeMapTarget;
row[`${field}Address`] =
this.transportMapSelected.detailAddress || row[`${field}Address`] || '';
if (this.transportMapSelected.regionName) {
row[`${field}Name`] = this.transportMapSelected.regionName;
}
row[`${field}Longitude`] = this.transportMapSelected.longitude;
row[`${field}Latitude`] = this.transportMapSelected.latitude;
if (this.transportMapSelected.regionCode) {
row[`${field}RegionCode`] = this.transportMapSelected.regionCode;
}
this.updateWaybillRouteChangeNodes();
this.waybillRouteChangeMapTarget = null;
this.transportMapBox = false;
return;
}
const prefix = this.transportMapTarget;
this.form[`${prefix}Address`] =
this.transportMapSelected.detailAddress || this.form[`${prefix}Address`];
this.form[`${prefix}Name`] =
this.transportMapSelected.regionName || this.form[`${prefix}Name`];
this.form[`${prefix}Longitude`] = this.transportMapSelected.longitude;
this.form[`${prefix}Latitude`] = this.transportMapSelected.latitude;
this.transportMapBox = false;
this.$nextTick(() => {
this.$refs.crud?.clearValidate?.([`${prefix}Address`]);
});
},
buildTransportMapSelection({ lng, lat, address, regionName, regionCode }) {
const longitude = this.formatCoordinate(lng);
const latitude = this.formatCoordinate(lat);
return {
longitude,
latitude,
detailAddress: address || '',
regionName: regionName || '',
regionCode: regionCode || '',
};
},
formatCoordinate(value) {
if (value === undefined || value === null || value === '') return '';
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue.toFixed(6) : '';
},
toLngLat(lng, lat) {
return new window.AMap.LngLat(Number(lng), Number(lat));
},
getPointLng(point) {
if (!point) return undefined;
if (typeof point.getLng === 'function') return point.getLng();
return point.lng ?? point.lon;
},
getPointLat(point) {
if (!point) return undefined;
if (typeof point.getLat === 'function') return point.getLat();
return point.lat;
},
ensureTransportAmapGeocoder() {
if (this.transportAmapGeocoder) {
return Promise.resolve(this.transportAmapGeocoder);
}
return new Promise((resolve, reject) => {
if (!window.AMap || !window.AMap.plugin) {
reject(new Error('高德地图组件未就绪'));
return;
}
window.AMap.plugin(['AMap.Geocoder'], () => {
try {
this.transportAmapGeocoder = new window.AMap.Geocoder();
resolve(this.transportAmapGeocoder);
} catch (error) {
reject(error);
}
});
});
},
runAmapGeocode(action, input) {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
reject(new Error('高德地图请求超时'));
}, 10000);
const done = (status, result) => {
window.clearTimeout(timer);
if (status === 'complete' && result) {
resolve(result);
return;
}
reject(new Error('高德地图请求失败'));
};
try {
if (action === 'location') {
this.transportAmapGeocoder.getLocation(input, done);
} else {
this.transportAmapGeocoder.getAddress(input, done);
}
} catch (error) {
window.clearTimeout(timer);
reject(error);
}
});
},
resolveMapPoint(result) {
if (!result) return null;
const geocode = Array.isArray(result.geocodes) ? result.geocodes[0] : null;
if (geocode && geocode.location) return geocode.location;
if (result.location) return result.location;
if (result.lnglat) return result.lnglat;
if (result.getLng || result.lng || result.lon) return result;
return null;
},
resolveMapAddress(result = {}) {
const regeocode = result.regeocode || {};
const component = regeocode.addressComponent || {};
const city = Array.isArray(component.city) ? '' : component.city;
const regionName = [component.province, city, component.district].filter(Boolean).join('');
return {
detailAddress: regeocode.formattedAddress || '',
regionName,
regionCode: component.adcode || '',
};
},
openCommonCargoDialog() {
this.commonCargoSelected = [];
this.commonCargoQuery = {};
this.commonCargoPage.currentPage = 1;
this.commonCargoBox = true;
},
loadCommonCargoList() {
this.commonCargoSelected = [];
this.commonCargoLoading = true;
getCommonCargoList(this.commonCargoPage.currentPage, this.commonCargoPage.pageSize, {
...this.buildCommonCargoQueryParams(),
allDept: 0,
})
.then(res => {
const data = res.data.data || {};
this.commonCargoRows = data.records || [];
this.commonCargoPage.total = data.total || 0;
})
.finally(() => {
this.commonCargoLoading = false;
});
},
buildCommonCargoQueryParams() {
const { cargoTypePath, ...query } = this.commonCargoQuery || {};
return Object.keys(query).reduce((params, key) => {
const value = query[key];
if (value !== undefined && value !== null && value !== '') {
params[key] = value;
}
return params;
}, {});
},
handleCommonCargoSearch() {
this.commonCargoPage.currentPage = 1;
this.loadCommonCargoList();
},
handleCommonCargoReset() {
this.commonCargoQuery = {};
this.commonCargoPage.currentPage = 1;
this.loadCommonCargoList();
},
handleCommonCargoSizeChange(pageSize) {
this.commonCargoPage.pageSize = pageSize;
this.commonCargoPage.currentPage = 1;
this.loadCommonCargoList();
},
handleCommonCargoCurrentChange(currentPage) {
this.commonCargoPage.currentPage = currentPage;
this.loadCommonCargoList();
},
handleCommonCargoSelectionChange(rows) {
this.commonCargoSelected = rows || [];
},
handleCommonCargoTypeChange(value) {
const path = this.normalizeCargoTypePath(value);
const labels = this.getCargoTypePathLabels(path);
const firstCargoType = this.cargoTypeFlatOptions.find(
item => String(item.id) === String(path[0] || '') && item.path?.length === 1
);
const secondCargoType = this.findCargoTypeByPath(path);
this.commonCargoQuery = {
...this.commonCargoQuery,
cargoTypePath: path,
firstCargoTypeName: labels[0] || '',
firstCargoTypeCode: firstCargoType?.cargoCode || firstCargoType?.code || '',
secondCargoTypeName: labels[1] || '',
secondCargoTypeCode: secondCargoType?.cargoCode || secondCargoType?.code || '',
};
if (!path.length) {
delete this.commonCargoQuery.firstCargoTypeName;
delete this.commonCargoQuery.firstCargoTypeCode;
delete this.commonCargoQuery.secondCargoTypeName;
delete this.commonCargoQuery.secondCargoTypeCode;
}
},
formatCommonCargoSize(row) {
return row.cargoSize || row.size || row.dimension || row.dimensions || '';
},
async selectCommonCargoRow(row) {
this.commonCargoSelected = [row];
await this.ensureCargoTypeOptions();
const cargo = this.mapCommonCargoRow(row);
this.addTransportCargoRow(-1, cargo);
this.commonCargoBox = false;
},
async confirmCommonCargoSelection() {
if (!this.commonCargoSelected.length) return;
await this.ensureCargoTypeOptions();
this.commonCargoSelected.forEach(row => {
this.addTransportCargoRow(-1, this.mapCommonCargoRow(row));
});
this.$message.success(`成功选择${this.commonCargoSelected.length}条常用货物`);
this.commonCargoBox = false;
},
mapCommonCargoRow(row = {}) {
const cargoTypePath = this.resolveCommonCargoTypePath(row);
const cargoType = this.findCargoTypeByPath(cargoTypePath);
const labels = this.getCargoTypePathLabels(cargoTypePath);
return {
cargoName: row.cargoName,
cargoTypePath,
cargoType: labels[1] || row.secondCargoTypeName || row.firstCargoTypeName,
cargoTypeCode:
cargoType?.cargoCode ||
cargoType?.code ||
row.secondCargoTypeCode ||
row.firstCargoTypeCode,
packageType: row.packageType,
brand: row.brand,
specification: row.specification,
model: row.model,
remark: row.remark || row.descriptionOne || '',
};
},
async handleCargoImportFileChange(file) {
const rawFile = file.raw;
if (!rawFile) return;
if (!/\.(xls|xlsx)$/i.test(rawFile.name || '')) {
this.$message.warning('请上传 .xlsx、.xls 格式文件');
return;
}
if (rawFile.size > 50 * 1024 * 1024) {
this.$message.warning('单个文件大小不能超过50M');
return;
}
this.cargoImportLoading = true;
try {
const res = await importBlob('/blade-transport/shipping-template/import-goods', rawFile);
const contentType = res.headers['content-type'] || res.data.type || '';
if (contentType.includes('application/vnd.ms-excel')) {
downloadXls(
res.data,
`运单货物导入失败明细${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
);
this.$message.warning('部分数据导入失败,已下载失败明细');
return;
}
const result = JSON.parse(await res.data.text());
if (result.code !== 200) throw new Error(result.msg || '货物导入失败');
const rows = result.data || [];
if (!rows.length) {
this.$message.warning('未读取到货物数据');
return;
}
this.cargoImportRows = rows;
this.$message.success(`成功读取${rows.length}条货物,请点击确定回填`);
} catch (error) {
window.console.log(error);
this.$message.error('货物导入失败,请检查文件格式');
} finally {
this.cargoImportLoading = false;
}
},
confirmCargoImport() {
if (!this.cargoImportRows.length) {
this.$message.warning('请先上传货物文件');
return;
}
this.cargoImportRows.forEach(row => {
this.addTransportCargoRow(-1, row);
});
this.$message.success(`成功导入${this.cargoImportRows.length}条货物`);
this.closeCargoImportDialog();
},
closeCargoImportDialog() {
if (this.cargoImportLoading) return;
this.cargoImportBox = false;
this.cargoImportRows = [];
},
handleCargoImportTemplate() {
exportBlob('/blade-transport/shipping-template/export-goods-template').then(res => {
downloadXls(res.data, '运单货物导入模板.xlsx');
});
},
handleAmountInput(prop, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
this.form[prop] =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
},
handleIntegerInput(prop, value) {
this.form[prop] = String(value || '').replace(/\D/g, '');
},
handleAttachmentChange(list) {
const userName = this.userInfo?.realName || this.userInfo?.userName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.attachmentRows = (list || []).map(item => ({
...item,
description: item.description || '',
uploadUserName: item.uploadUserName || userName,
uploadTime: item.uploadTime || uploadTime,
}));
this.selectedAttachmentRows = [];
this.form.attachmentsJson = JSON.stringify(this.attachmentRows);
},
handleAttachmentSelectionChange(rows) {
this.selectedAttachmentRows = rows || [];
},
removeAttachment(index) {
this.attachmentRows.splice(index, 1);
this.selectedAttachmentRows = this.selectedAttachmentRows.filter(item =>
this.attachmentRows.includes(item)
);
this.form.attachmentsJson = JSON.stringify(this.attachmentRows);
},
formatFileSize(size) {
const number = Number(size);
if (!number) return '';
if (number < 1024) return `${number}B`;
if (number < 1024 * 1024) return `${(number / 1024).toFixed(1)}KB`;
return `${(number / 1024 / 1024).toFixed(1)}MB`;
},
attachmentUrl(row = {}) {
return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || '';
},
attachmentName(row = {}) {
return row.originalName || row.name || row.fileName || '附件';
},
attachmentExtension(row = {}) {
const source = String(this.attachmentName(row) || this.attachmentUrl(row)).split('?')[0];
const index = source.lastIndexOf('.');
return index > -1 ? source.slice(index + 1).toLowerCase() : '';
},
isAttachmentImage(row) {
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(this.attachmentExtension(row));
},
previewAttachment(row, rows = this.attachmentRows) {
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法预览');
return;
}
if (this.isAttachmentImage(row)) {
this.attachmentImagePreviewUrls = (rows || [])
.filter(item => this.isAttachmentImage(item) && this.attachmentUrl(item))
.map(item => this.attachmentUrl(item));
this.attachmentImagePreviewIndex = Math.max(
this.attachmentImagePreviewUrls.indexOf(url),
0
);
this.attachmentImagePreviewVisible = true;
return;
}
this.attachmentPreviewFile = {
name: this.attachmentName(row),
url,
mimeType: row.mimeType || row.contentType || '',
};
this.attachmentDocumentPreviewVisible = true;
},
handleAttachmentPreviewUnsupported() {
this.$message.warning('当前文件暂不支持在线预览');
},
handleAttachmentPreviewError() {
this.$message.error('附件预览失败');
},
downloadAttachment(row) {
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空');
return;
}
downloadFileByUrl(url, this.attachmentName(row));
},
handleBatchDownload() {
const rows = this.selectedAttachmentRows.length
? this.selectedAttachmentRows
: this.attachmentRows;
rows.forEach((row, index) => {
window.setTimeout(() => this.downloadAttachment(row), index * 200);
});
},
ensureCargoTypeOptions() {
if (this.cargoTypeOptions.length) {
return Promise.resolve(this.cargoTypeOptions);
}
if (this.cargoTypeRequest) {
return this.cargoTypeRequest;
}
this.cargoTypeLoading = true;
this.cargoTypeRequest = getCargoTypeList(1, 9999)
.then(res => {
this.cargoTypeOptions = this.buildCargoTypeTree(extractRecords(res));
this.cargoTypeFlatOptions = this.flattenCargoTypeOptions(this.cargoTypeOptions);
return this.cargoTypeOptions;
})
.finally(() => {
this.cargoTypeLoading = false;
this.cargoTypeRequest = null;
});
return this.cargoTypeRequest;
},
buildCargoTypeTree(cargoTypes = []) {
const flatCargoTypes = [];
const collectCargoTypes = list => {
(list || []).forEach(item => {
flatCargoTypes.push({
...item,
children: undefined,
});
if (item.children?.length) collectCargoTypes(item.children);
});
};
collectCargoTypes(cargoTypes);
const nodeMap = new Map();
const codeMap = new Map();
flatCargoTypes.forEach(item => {
const id = item.id ?? item.cargoCode ?? item.code;
if (id === undefined || id === null) return;
const node = {
...item,
id: String(id),
cargoName: this.formatCargoTypeLabel(item),
cargoCode: item.cargoCode || item.code || '',
parentId:
item.parentId === undefined || item.parentId === null ? '' : String(item.parentId),
parentCargoCode: item.parentCargoCode || item.parentCode || '',
children: [],
};
nodeMap.set(node.id, node);
if (node.cargoCode) {
codeMap.set(String(node.cargoCode), node);
}
});
const rootNodes = [];
nodeMap.forEach(node => {
const parent =
nodeMap.get(node.parentId) ||
codeMap.get(String(node.parentCargoCode || '')) ||
codeMap.get(String(node.parentId || ''));
if (parent && parent !== node && Number(node.typeLevel) !== 1) {
parent.children.push(node);
} else {
rootNodes.push(node);
}
});
return rootNodes.map(item => this.normalizeCargoTypeNode(item, 1)).filter(Boolean);
},
normalizeCargoTypeNode(cargoType, level = 1, parentPath = []) {
if (level > 2) return null;
const id = String(cargoType.id);
const path = [...parentPath, id];
const children =
level === 1
? (cargoType.children || [])
.map(item => this.normalizeCargoTypeNode(item, level + 1, path))
.filter(Boolean)
: [];
return {
...cargoType,
id,
cargoName: this.formatCargoTypeLabel(cargoType),
cargoCode: cargoType.cargoCode || cargoType.code || '',
path,
leaf: level === 2,
children: children.length ? children : undefined,
};
},
flattenCargoTypeOptions(options = []) {
const result = [];
const collectOptions = list => {
(list || []).forEach(item => {
result.push(item);
if (item.children?.length) collectOptions(item.children);
});
};
collectOptions(options);
return result;
},
formatCargoTypeLabel(item = {}) {
return item.cargoName || item.name || item.typeName || item.label || '';
},
filterCargoType(node, keyword) {
const text = String(keyword || '').trim();
if (!text) return true;
const labels = node.pathLabels?.length ? node.pathLabels : [node.label];
return (
labels.some(label => String(label || '').includes(text)) ||
String(node.text || '').includes(text)
);
},
getCargoTypePathLabels(path = []) {
let options = this.cargoTypeOptions;
const labels = [];
(path || []).forEach(value => {
const option = (options || []).find(item => String(item.id) === String(value));
if (!option) return;
labels.push(option.cargoName || '');
options = option.children || [];
});
return labels.filter(Boolean);
},
findCargoTypeByPath(path = []) {
const value = Array.isArray(path) && path.length >= 2 ? String(path[1]) : '';
if (!value) return null;
return (
this.cargoTypeFlatOptions.find(
item => String(item.id) === value && item.path?.length >= 2
) || null
);
},
findCargoTypeByCodeOrName(condition = {}) {
const code = String(condition.cargoTypeCode || '').trim();
const name = String(condition.cargoType || '').trim();
return (
this.cargoTypeFlatOptions.find(item => {
const cargoCode = String(item.cargoCode || item.code || item.id || '');
return item.path?.length >= 2 && code && cargoCode === code;
}) ||
this.cargoTypeFlatOptions.find(item => {
const cargoName = this.formatCargoTypeLabel(item);
return item.path?.length >= 2 && name && (cargoName === name || name.endsWith(cargoName));
}) ||
null
);
},
resolveCommonCargoTypePath(row = {}) {
const path = this.normalizeCargoTypePath(row.cargoTypePath);
if (path.length) return path;
const cargoType =
this.findCargoTypeByCodeOrName({
cargoTypeCode: row.secondCargoTypeCode || row.firstCargoTypeCode || row.cargoTypeCode,
cargoType: row.secondCargoTypeName || row.firstCargoTypeName || row.cargoType,
}) ||
this.findCargoTypeByCodeOrName({
cargoTypeCode: row.firstCargoTypeCode || '',
cargoType: row.firstCargoTypeName || '',
});
return cargoType?.path || [];
},
normalizeCargoTypePath(path) {
return Array.isArray(path) && path.length >= 2
? path.slice(0, 2).map(value => String(value))
: [];
},
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page);
},
setupSearchAutocompletes() {
const fields = [
{
prop: 'projectName',
placeholder: '请选择或输入项目名称',
fetch: (queryString, callback) =>
this.fetchSearchNameSuggestions(queryString, callback, {
request: keyword =>
getProjectList(1, 20, keyword ? { projectName: keyword } : {}),
getValue: item => item.projectName || item.name || '',
}),
},
{
prop: 'customerName',
placeholder: '请选择或输入客户名称',
fetch: (queryString, callback) =>
this.fetchSearchNameSuggestions(queryString, callback, {
request: keyword =>
getCustomerArchiveList(1, 20, {
...(keyword ? { fullName: keyword } : {}),
customerType: '客户',
}),
getValue: item => item.fullName || item.customerName || item.name || '',
}),
},
{
prop: 'cargoType',
placeholder: '请选择或输入货物类型',
fetch: (queryString, callback) =>
this.fetchSearchNameSuggestions(queryString, callback, {
request: keyword =>
getCargoTypeList(1, 20, keyword ? { cargoName: keyword } : {}),
getValue: item => item.cargoName || item.typeName || item.name || item.label || '',
}),
},
{
prop: 'carrierName',
placeholder: '请选择或输入承运商名称',
fetch: (queryString, callback) =>
this.fetchSearchNameSuggestions(queryString, callback, {
request: keyword =>
getCustomerArchiveList(1, 20, {
...(keyword ? { fullName: keyword } : {}),
customerType: '承运商',
}),
getValue: item =>
item.fullName || item.carrierName || item.customerName || item.name || '',
}),
},
{
prop: 'driverName',
placeholder: '请选择或输入司机名称',
fetch: (queryString, callback) =>
this.fetchSearchNameSuggestions(queryString, callback, {
request: keyword =>
getDriverList(1, 20, {
...(keyword ? { driverName: keyword } : {}),
posts: '司机',
}),
getValue: item => item.driverName || item.name || '',
}),
},
{
prop: 'planName',
placeholder: '请选择或输入计划名称',
fetch: (queryString, callback) =>
this.fetchSearchNameSuggestions(queryString, callback, {
request: keyword =>
getShippingPlanList(1, 20, keyword ? { planName: keyword } : {}),
getValue: item => item.planName || item.name || '',
}),
},
];
fields.forEach(field => {
const column = this.findColumn?.(this.option.column, field.prop);
if (!column) return;
column.searchPlaceholder = field.placeholder;
column.renderSearch = scope =>
this.renderSearchAutocomplete(scope, field.prop, field.placeholder, field.fetch);
});
},
renderSearchAutocomplete(scope, prop, placeholder, fetchSuggestions) {
return h(ElAutocomplete, {
modelValue: scope.row?.[prop] ?? '',
'onUpdate:modelValue': value => {
if (scope.row) scope.row[prop] = value ?? '';
},
fetchSuggestions,
debounce: 300,
clearable: true,
triggerOnFocus: true,
placeholder,
style: 'width: 100%',
valueKey: 'value',
fitInputWidth: true,
});
},
fetchSearchNameSuggestions(queryString, callback, { request, getValue }) {
const keyword = String(queryString || '').trim();
Promise.resolve(request(keyword))
.then(res => {
const records = extractRecords(res);
const seen = new Set();
const options = [];
records.forEach(item => {
const value = String(getValue(item) || '').trim();
if (!value || seen.has(value)) return;
seen.add(value);
options.push({ ...item, value });
});
callback(options);
})
.catch(error => {
window.console.log(error);
callback([]);
});
},
searchChange(params, done) {
this.query = {
...params,
};
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
selectionChange(list) {
this.selectionList = list;
},
selectionClear() {
this.selectionList = [];
if (this.$refs.crud) {
this.$refs.crud.toggleSelection();
}
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
handleScopeChange() {
if (!this.canViewAllDept) {
this.allDept = 0;
}
this.page.currentPage = 1;
this.onLoad(this.page);
},
normalizeSearchRangeValue(value, suffix) {
if (!value) return undefined;
return suffix ? `${value} ${suffix}` : value;
},
normalizeSearchRangeParams(params = {}) {
const query = { ...params };
const rangeMap = this.config.searchRangeMap || {};
Object.entries(rangeMap).forEach(([rangeProp, mapping]) => {
const [startProp, endProp, startSuffix, endSuffix] = Array.isArray(mapping)
? mapping
: [mapping.startProp, mapping.endProp, mapping.startSuffix, mapping.endSuffix];
const range = query[rangeProp];
if (Array.isArray(range) && range.length === 2) {
query[startProp] = this.normalizeSearchRangeValue(range[0], startSuffix);
query[endProp] = this.normalizeSearchRangeValue(range[1], endSuffix);
}
delete query[rangeProp];
});
return query;
},
normalizeQueryParams(params = {}) {
let query = { ...params };
if (Object.keys(this.config.searchRangeMap || {}).length) {
query = this.normalizeSearchRangeParams(query);
}
return query;
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeQueryParams({
...params,
...this.query,
allDept: this.allDept,
});
this.api
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data.data;
this.page.total = result.total;
this.data = result.records;
this.selectionClear();
})
.finally(() => {
this.loading = false;
});
},
buildExportParams() {
const query = this.normalizeQueryParams({ ...this.query, allDept: this.allDept });
return {
...query,
ids: this.ids,
...(this.config.exportColumns
? { exportColumns: JSON.stringify(this.config.exportColumns) }
: {}),
[this.website.tokenHeader]: getToken(),
};
},
handleExport() {
this.$confirm(`是否导出${this.config.exportName || this.config.title}?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
NProgress.start();
exportBlob(this.config.exportUrl, this.buildExportParams(), { feedback: true })
.then(res => {
downloadXls(
res.data,
`${this.config.exportName || this.config.title}${this.$dayjs().format(
'YYYY-MM-DD HH:mm:ss'
)}.xlsx`
);
})
.finally(() => {
NProgress.done();
});
});
},
handleTemplate() {
NProgress.start();
exportBlob(
this.config.templateUrl,
{
[this.website.tokenHeader]: getToken(),
},
{ feedback: true }
)
.then(res => {
downloadXls(res.data, `${this.config.title}模板.xlsx`);
})
.finally(() => {
NProgress.done();
});
},
handleImport() {
if (this.config.enableWaybillImport) {
this.$router.push({ path: '/business/waybill-import' });
return;
}
const column = this.findColumn(this.excelOptionConfig.column, 'excelFile');
column.action = this.config.importUrl;
openImportDialog(this, this.config.title, () => this.onLoad(this.page, this.query));
},
handleCopy(row) {
if (this.isStandaloneWaybillPage) {
this.$router.push({
path: standaloneWaybillFormRoute,
query: {
mode: 'add',
copyId: row.id,
name: `复制${this.config.title || ''}`,
},
});
return;
}
// 非独立页面模式,使用原有的弹窗复制逻辑
this.$confirm(`确定复制该${this.config.title}?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => this.api.copy(row.id))
.then(() => {
this.$message.success('复制成功');
this.onLoad(this.page, this.query);
});
},
openMileageDialog(row) {
this.mileageDialog = {
visible: true,
submitting: false,
row,
};
this.mileageForm = {
id: row.id,
mileage: this.normalizeMileageValue(row.mileage),
mileageRemark: row.mileageRemark || '',
};
this.$nextTick(() => this.$refs.mileageFormRef?.clearValidate());
},
handleMaintainMileageInput(value) {
this.mileageForm.mileage = String(value || '')
.replace(/\D/g, '')
.slice(0, 10);
},
async submitMileageMaintenance() {
const valid = await this.$refs.mileageFormRef?.validate().catch(() => false);
if (!valid || this.mileageDialog.submitting) return;
this.mileageDialog.submitting = true;
try {
await this.api.maintainMileage({
id: this.mileageForm.id,
mileage: Number(this.mileageForm.mileage),
mileageRemark: String(this.mileageForm.mileageRemark || '').trim(),
});
this.$message.success('里程维护成功');
this.mileageDialog.visible = false;
this.onLoad(this.page, this.query);
} finally {
this.mileageDialog.submitting = false;
}
},
resetMileageDialog() {
this.mileageDialog.row = null;
this.mileageForm = { id: '', mileage: '', mileageRemark: '' };
this.$refs.mileageFormRef?.clearValidate();
},
handleAction(action, row) {
if (action === 'reassign') {
this.openReassignDrawer(row);
return;
}
const actionName = {
enable: '启用',
disable: '停用',
cancel: '取消',
complete: this.completeActionLabel,
reassign: '重新派单',
confirmReturn: '完成',
}[action];
// 回单确认是终态动作(本轮不做撤销),把后果写在提示里,别让人以为还能退回
const confirmText =
action === 'confirmReturn'
? '回单确认后该运单将置为已完成,且不可撤销。是否继续?'
: `确定${actionName}该${this.config.title}?`;
this.$confirm(confirmText, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => this.api[action](row.id))
.then(() => {
this.$message.success(`${actionName}成功`);
this.onLoad(this.page, this.query);
});
},
driverRejectReasonText(row) {
if (!row) return '';
if (typeof this.config.getDriverRejectReason === 'function') {
return this.config.getDriverRejectReason(row) || '';
}
return String(
row.driverRejectReason ||
row.driverRefuseReason ||
row.acceptRejectReason ||
row.rejectReason ||
''
).trim();
},
openReassignDrawer(row) {
if (!row?.id) return;
this.reassignDrawer.row = row;
this.reassignForm = {
id: row.id,
driverId: '',
driverName: '',
driverPhone: '',
vehicleNo: '',
};
this.reassignDrawer.visible = true;
this.$nextTick(() => this.$refs.reassignFormRef?.clearValidate());
},
resetReassignDrawer() {
this.reassignDrawer.row = null;
this.reassignDrawer.submitting = false;
this.reassignDrawer.driverLoading = false;
this.reassignForm = {
id: '',
driverId: '',
driverName: '',
driverPhone: '',
vehicleNo: '',
};
this.reassignDriverOptions = [];
this.$refs.reassignFormRef?.clearValidate();
},
fetchReassignDriverSuggestions(queryString, callback) {
const keyword = String(queryString || '').trim();
this.reassignDrawer.driverLoading = true;
fetchDriverCandidates({ size: 20, ...(keyword ? { driverName: keyword } : {}), posts: '司机' })
.then(records => {
this.reassignDriverOptions = records;
callback(
records.map(item => ({
...item,
value: item.driverName || item.name || '',
}))
);
})
.catch(error => {
window.console.log(error);
callback([]);
})
.finally(() => {
this.reassignDrawer.driverLoading = false;
});
},
handleReassignDriverSelect(item = {}) {
const value = item.driverName || item.name || '';
const driverPhone = item.mobile || item.phone || item.driverPhone || '';
const drivingVehicle = String(item.drivingVehicle || '').trim();
this.reassignForm.driverId = item.id || '';
this.reassignForm.driverName = value;
if (driverPhone) this.reassignForm.driverPhone = driverPhone;
if (drivingVehicle) this.reassignForm.vehicleNo = drivingVehicle;
},
handleReassignDriverChange(value) {
const driver = this.reassignDriverOptions.find(item =>
[item.driverName, item.name].some(name => String(name || '') === String(value || ''))
);
this.reassignForm.driverId = driver?.id || '';
this.reassignForm.driverName = value || '';
if (driver) {
this.reassignForm.driverPhone =
driver.mobile || driver.phone || driver.driverPhone || this.reassignForm.driverPhone || '';
if (String(driver.drivingVehicle || '').trim()) {
this.reassignForm.vehicleNo = String(driver.drivingVehicle).trim();
}
}
},
submitReassign() {
this.$refs.reassignFormRef?.validate(valid => {
if (!valid) return;
this.reassignDrawer.submitting = true;
this.api
.reassign({
id: this.reassignForm.id,
driverId: this.reassignForm.driverId || null,
driverName: String(this.reassignForm.driverName || '').trim(),
driverPhone: String(this.reassignForm.driverPhone || '').trim(),
vehicleNo: String(this.reassignForm.vehicleNo || '').trim(),
})
.then(() => {
this.$message.success('重新派单成功');
this.reassignDrawer.visible = false;
this.onLoad(this.page, this.query);
})
.finally(() => {
this.reassignDrawer.submitting = false;
});
});
},
handleCustomOperation(operation, row) {
if (operation.action === 'createFromTemplate') {
this.api.getDetail(row.id).then(res => {
const template = res.data?.data || row;
sessionStorage.setItem(
'business-template-create',
JSON.stringify({ target: 'waybill-manage', data: template })
);
this.$router.push({
path: standaloneWaybillFormRoute,
query: { mode: 'add', fromTemplate: Date.now().toString() },
});
});
return;
}
if (operation.action === 'flow') {
this.handleFlow(row);
return;
}
const run = value => {
const args = operation.prompt ? [row.id, value] : [row.id];
this.api[operation.action](...args).then(() => {
this.$message.success(`${operation.label}成功`);
this.onLoad(this.page, this.query);
});
};
if (operation.prompt) {
this.$prompt(operation.prompt, operation.label, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: operation.promptError || '请输入内容',
}).then(({ value }) => run(value));
return;
}
this.$confirm(operation.confirm || `确定${operation.label}该${this.config.title}?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => run());
},
handleFlow(row, loadedDetail = false) {
const processInstanceId = row.processInstanceId || row.processInstanceID || row.procInstId;
if (
!processInstanceId &&
!loadedDetail &&
row.id &&
typeof this.api.getDetail === 'function'
) {
this.api.getDetail(row.id).then(res => this.handleFlow(res.data.data || {}, true));
return;
}
if (!processInstanceId) {
this.$message.warning('流程实例为空,无法查看流程');
return;
}
if (this.website.design.designMode) {
this.processInstanceId = processInstanceId;
} else {
this.flowUrl = `/api/blade-flow/process/diagram-view?processInstanceId=${processInstanceId}`;
}
this.flowBox = true;
},
handleChangeRecordFlow(row) {
const processInstanceId = row.processInstanceId || row.processInstanceID || row.procInstId;
if (!processInstanceId) {
this.$message.warning('该变更记录暂无流程实例');
return;
}
this.handleFlow(row, true);
},
handleBatchComplete() {
if (!this.selectionList.length) {
this.$message.warning('请选择至少一条数据');
return;
}
const pendingReturnCount = this.selectionList.filter(
row => this.statusValue(row) === 'pending_return_confirm'
).length;
const tip =
pendingReturnCount > 0
? `确定完成选中的运单?其中 ${pendingReturnCount} 条为待回单确认,确认后将转为已完成。`
: '确定完成选中的运单?';
this.$confirm(tip, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => this.api.batchComplete(this.ids))
.then(res => {
const result = res.data.data || {};
this.$message.success(
`成功完成${result.successCount || 0}条,跳过${result.skippedCount || 0}条`
);
this.onLoad(this.page, this.query);
});
},
async handleRoadLoading() {
if (!this.selectionList.length) {
this.$message.warning('请选择至少一条数据');
return;
}
const selectedIds = this.selectionList.map(row => row.id).filter(Boolean);
if (!selectedIds.length) {
this.$message.warning('请选择至少一条数据');
return;
}
// 可配载与否只看后端查询结果
let loadableIds;
try {
const res = await getWaybillList(1, selectedIds.length, {
loadableOnly: 1,
ids: selectedIds.join(','),
});
loadableIds = new Set(extractRecords(res).map(row => String(row.id)));
} catch (error) {
this.$message.error('校验运单可配载状态失败,请重试');
return;
}
const rejectedRows = this.selectionList.filter(row => !loadableIds.has(String(row.id)));
if (rejectedRows.length) {
const rejectedText = rejectedRows
.map(row => {
const reason = this.roadLoadingRejectReasonText(row);
const no = row.waybillNo || row.id || '';
return `${no}(${reason})`;
})
.join('、');
this.$message.warning(`${rejectedText};公路配载仅支持公路、待执行且未配载的运单`);
return;
}
try {
sessionStorage.setItem(
loadingCreateWaybillsStorageKey,
JSON.stringify({
source: 'waybill-manage',
rows: this.selectionList,
})
);
} catch (error) {
this.$message.error('暂存待配载运单失败,请重试');
return;
}
this.$router.push({
path: '/business/loading-manage/form',
query: {
mode: 'add',
name: '新增配载管理',
},
});
},
// 只给后端判定为不可配载的运单生成可读原因,本身不参与准入判定
roadLoadingRejectReasonText(row = {}) {
if (!this.waybillIsRoadTransport(row)) return '非公路运输';
if (String(row.businessStatus || '') !== 'pending') return '状态不是待执行';
if (row.loadingNo) return '已关联配载单';
return '运单数据可能已变化,请刷新后重试';
},
},
};
</script>
<style lang="scss" scoped>
// 对齐 customer-archive 的 .archive-form 风格:label 固定宽折行 + 控件 250px
.waybill-manage-page__task-form,
.waybill-manage-page__freight-form {
:deep(.el-form-item__label) {
flex: 0 0 calc(6em + 24px) !important;
width: calc(6em + 24px) !important;
height: auto;
white-space: normal !important;
word-break: break-all;
overflow-wrap: anywhere;
line-height: 18px;
}
:deep(.el-form-item) {
align-items: center;
}
:deep(.el-form-item__content > .el-input),
:deep(.el-form-item__content > .el-select),
:deep(.el-form-item__content > .el-cascader),
:deep(.el-form-item__content > .el-input-number),
:deep(.el-form-item__content > .el-date-editor) {
width: 250px;
max-width: 100%;
}
}
.waybill-manage-page {
&__field {
width: 100%;
}
&__contract-name {
text-align: left;
}
&__contract-expiry-search {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
min-width: 0;
:deep(.el-select) {
width: 160px;
}
}
&__contract-expiry-label {
flex: 0 0 auto;
min-width: 140px;
color: #606266;
text-align: right;
white-space: nowrap;
}
&__contract-expiry-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
:deep(.el-check-tag) {
margin: 0;
white-space: nowrap;
}
}
.dialog-section-title {
display: flex;
align-items: center;
width: 100%;
color: #303133;
font-size: 15px;
font-weight: 600;
&::before {
display: block;
width: 4px;
height: 16px;
margin-right: 8px;
background: #409eff;
content: '';
}
&--action {
justify-content: space-between;
}
}
&__shipping-title,
&__goods-title {
min-width: 100%;
}
&__shipping-route-action {
margin-left: auto;
}
&__goods-title &__section-actions,
&__shipping-title .el-button {
margin-left: auto;
}
&__section-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
&__task {
width: 100%;
}
&__task-mode-switch {
display: flex;
justify-content: flex-start;
width: 100%;
}
&__task-content-title {
margin-bottom: 16px;
}
&__task-full &__task-content-title {
margin-top: 16px;
}
&__task-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 24px;
row-gap: 16px;
width: 100%;
:deep(.el-form-item) {
min-width: 0;
margin-bottom: 0;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-select),
:deep(.el-cascader),
:deep(.el-date-editor.el-input) {
width: 100%;
}
:deep(.el-radio-group) {
width: 100%;
}
:deep(.el-radio-button) {
flex: 1;
}
:deep(.el-radio-button__inner) {
width: 100%;
}
}
&__task-form--full {
display: block;
}
&__task-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 24px;
row-gap: 16px;
margin-bottom: 16px;
width: 100%;
&:last-child {
margin-bottom: 0;
}
:deep(.el-form-item) {
min-width: 0;
margin-bottom: 0;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-select),
:deep(.el-cascader),
:deep(.el-date-editor.el-input) {
width: 100%;
}
}
&__task-span-4 {
grid-column: 1 / -1;
}
&__task-span-1 {
grid-column: span 1;
}
&__label-with-info {
display: inline-flex;
gap: 4px;
align-items: center;
white-space: nowrap;
}
&__info-icon {
color: #909399;
cursor: help;
}
&__task-carrier-type {
:deep(.el-form-item__content) {
flex: 0 1 260px;
max-width: 260px;
}
:deep(.el-radio-group) {
width: auto;
}
:deep(.el-radio-button) {
flex: 0 0 auto;
}
:deep(.el-radio-button__inner) {
width: auto;
}
}
&__task-remark {
grid-column: 1 / -1;
:deep(.el-form-item__content) {
width: 100%;
}
:deep(.el-input),
:deep(.el-textarea) {
width: 100%;
}
}
&__task-note {
grid-column: 1 / -1;
:deep(.el-input) {
width: 100%;
}
}
:global(
.waybill-manage-page--form-page
.waybill-manage-page__task-form
.el-form-item__content
> .el-autocomplete
),
:global(
.waybill-manage-page--form-page
.waybill-manage-page__task-form
.el-form-item__content
> .el-date-editor
) {
width: 250px !important;
max-width: 100%;
}
&__suffix-select {
width: 72px;
margin-right: -8px;
:deep(.el-select__wrapper),
:deep(.el-select__wrapper:hover),
:deep(.el-select__wrapper.is-focused) {
min-height: 24px;
padding: 0 4px;
border: 0;
box-shadow: none !important;
}
}
&__suffix-select--wide {
width: 104px;
}
&__append-select {
width: 92px;
margin-right: -8px;
:deep(.el-select__wrapper),
:deep(.el-select__wrapper:hover),
:deep(.el-select__wrapper.is-focused) {
min-height: 24px;
padding: 0 4px;
border: 0;
box-shadow: none !important;
}
}
&__append-select--wide {
width: 96px;
min-width: 96px;
// 仅这两个窄 append 内的 placeholder 文字水平居中
:deep(.el-select__placeholder) {
left: 50% !important;
transform: translateY(-50%) translateX(-50%) !important;
text-align: center;
width: auto;
}
}
:deep(.waybill-manage-page__goods-info-cell .cell) {
overflow: visible;
height: auto;
max-height: none;
line-height: 20px;
white-space: normal;
word-break: break-word;
text-overflow: clip;
}
:deep(.waybill-manage-page__waybill-address-cell) {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 20px;
white-space: normal;
word-break: break-word;
}
&__route-address-row {
display: grid;
grid-template-columns: 260px minmax(280px, 1fr) 40px 58px minmax(160px, 220px) 72px minmax(
160px,
220px
);
gap: 8px;
align-items: center;
width: 100%;
:deep(.el-input),
:deep(.el-select),
:deep(.el-cascader) {
width: 100%;
}
.el-button {
width: 32px;
min-width: 32px;
padding: 0;
}
.waybill-manage-page__map-suffix {
color: #409eff;
font-size: 18px;
}
:deep(.el-input .el-input__icon) {
color: #409eff;
font-size: 18px;
}
.waybill-manage-page__address-pick-btn {
:deep(.el-icon) {
font-size: 22px;
}
}
}
&__address-choose-link {
display: inline-flex;
align-items: center;
height: 100%;
padding: 0 4px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
&__route-label {
color: #606266;
font-size: 14px;
line-height: 32px;
text-align: right;
white-space: nowrap;
}
&__map-suffix {
cursor: pointer;
color: #909399;
font-size: 18px;
&:hover {
color: #409eff;
}
}
&__cargo-wrap {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
}
&__cargo-import {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 56px;
padding: 12px 0 8px;
:deep(.el-upload__tip) {
margin: 16px 0 0;
color: #606266;
font-size: 16px;
}
}
&__cargo-import-dialog {
:deep(.el-dialog__footer) {
text-align: center;
}
}
&__cargo-import {
padding: 0 !important;
}
&__cargo-import-card {
background: #fff;
border-radius: 6px;
padding: 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
width: 90%;
}
&__cargo-table {
width: 100%;
min-width: 0;
:deep(.el-table__header th) {
background: #f5f7fa;
color: #303133;
font-weight: 600;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-cascader) {
width: 100%;
}
}
&__cargo-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
.el-button {
margin-left: 0;
}
}
&__row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
:deep(.waybill-manage-page__full-form-item) {
width: 100%;
max-width: 100%;
}
:deep(.waybill-manage-page__full-form-item > .el-form-item) {
width: 100%;
}
:deep(.waybill-manage-page__full-form-item > .el-form-item > .el-form-item__label) {
display: none;
width: 0 !important;
padding: 0 !important;
}
:deep(.waybill-manage-page__full-form-item > .el-form-item > .el-form-item__content) {
flex: 1 1 100%;
width: 100%;
max-width: 100%;
margin-left: 0 !important;
}
:deep(.waybill-manage-page__full-form-item > .el-form-item > .el-form-item__content > div) {
width: 100%;
min-width: 0;
}
&__cargo-total {
display: flex;
justify-content: flex-start;
padding-top: 12px;
color: #303133;
font-weight: 600;
}
&__freight-section {
margin-top: 20px;
}
&__freight-table {
width: 100%;
:deep(.el-table__header th) {
background: #f5f7fa;
color: #303133;
font-weight: 600;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
}
&__dialog-search {
padding: 12px 12px 4px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #eff1f7;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
:deep(.el-form-item) {
margin-bottom: 8px;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-cascader) {
width: 100%;
}
}
&__dialog-search-actions {
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
&__freight-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 24px;
row-gap: 16px;
width: 100%;
:deep(.el-form-item) {
min-width: 0;
margin-bottom: 0;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
&--road {
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 24px;
row-gap: 16px;
:deep(.el-form-item__content > .el-input),
:deep(.el-form-item__content > .el-select) {
width: 100%;
max-width: 100%;
}
:deep(.waybill-manage-page__append-select) {
width: 112px;
}
}
}
&__freight-footer-summary {
display: inline-flex;
align-items: center;
gap: 24px;
height: 28px;
color: #303133;
font-size: 13px;
strong {
margin-left: 4px;
font-size: 15px;
}
:deep(.el-link) {
font-size: 13px;
}
}
&__process-config-dialog {
max-height: 70vh;
overflow-y: auto;
:deep(.el-dialog__body) {
padding-top: 8px;
}
}
&__process-config-section {
padding: 16px 0;
border-bottom: 1px solid #eff1f7;
&:last-child {
border-bottom: 0;
}
}
&__process-config-meta {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 16px;
color: #606266;
span:first-child {
color: #303133;
font-size: 16px;
font-weight: 600;
}
}
&__process-timeline {
position: relative;
display: flex;
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 32px 20px 20px;
overflow: hidden;
background: #fff;
}
&__process-timeline-line {
position: absolute;
top: 44px;
right: 8%;
left: 8%;
height: 2px;
background: #dcdfe6;
}
&__process-timeline-item {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
text-align: center;
}
&__process-timeline-dot {
display: block;
width: 14px;
height: 14px;
margin: 0 auto 10px;
border: 3px solid #fff;
border-radius: 50%;
background: #409eff;
box-shadow: 0 0 0 2px #409eff;
}
&__process-timeline-name {
color: #303133;
font-weight: 600;
}
&__process-timeline-desc {
margin-top: 8px;
color: #909399;
font-size: 12px;
line-height: 1.8;
}
&__dialog-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
&__map-toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
.el-input {
flex: 1;
min-width: 0;
}
}
&__map {
width: 100%;
height: 420px;
border: 1px solid #eff1f7;
}
&__map-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
color: #606266;
line-height: 1.6;
}
&__date-range {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
}
&__date {
flex: 1;
min-width: 0;
}
&__date-separator {
flex: 0 0 auto;
padding: 0 8px;
color: #303133;
}
&__inline-number {
display: flex;
align-items: center;
width: 100%;
.el-input {
flex: 1;
min-width: 0;
}
span {
flex: 0 0 auto;
margin-left: 8px;
color: #303133;
}
}
&__required-column {
&::before {
margin-right: 4px;
color: #f56c6c;
content: '*';
}
}
&__attachment {
width: 100%;
}
&__attachment-description {
margin-bottom: 12px;
color: #606266;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
&__attachment-description-item + &__attachment-description-item {
margin-top: 4px;
}
&__attachment-description-label {
color: #303133;
font-weight: 600;
}
&__attachment-head {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 16px;
}
&__attachment-upload {
display: flex;
justify-content: flex-start;
margin-top: 12px;
:deep(.vehicle-attachment-upload) {
width: auto;
}
:deep(.el-upload) {
display: inline-flex;
}
}
&__attachment-table {
width: 100%;
min-width: 0;
:deep(.el-table__header th) {
background: #f5f7fa;
color: #303133;
font-weight: 600;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
}
&__change-table {
width: 100%;
:deep(.el-table__header th) {
background: #f5f7fa;
color: #303133;
font-weight: 600;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
}
&__detail-content {
display: flex;
flex-direction: column;
gap: 12px;
.section-card {
:deep(.el-descriptions__label) {
width: 140px;
color: #606266;
font-weight: 600;
text-align: right;
}
:deep(.el-descriptions__content) {
min-height: 42px;
white-space: pre-wrap;
word-break: break-word;
}
}
}
&__detail-page {
padding: 12px;
background: #f5f6fa;
}
&__detail-footer {
display: flex;
justify-content: flex-end;
padding-top: 12px;
}
&__exception-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
&__exception-dialog {
padding: 4px 0 0;
:deep(.section-card + .section-card) {
margin-top: 16px;
}
}
&__exception-readonly {
color: #606266;
word-break: break-all;
}
&__exception-attachment-link {
display: block;
margin-bottom: 2px;
}
&__exception-photos {
display: flex;
flex-wrap: wrap;
gap: 12px;
min-height: 80px;
:deep(.el-image) {
width: 96px;
height: 96px;
border: 1px solid #eff1f7;
}
}
&__exception-edit-photos {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
&__exception-edit-photo {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
:deep(.el-image) {
width: 96px;
height: 96px;
border: 1px solid #eff1f7;
}
}
&__exception-edit-photo-add {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
color: #909399;
cursor: pointer;
&:hover {
border-color: #409eff;
color: #409eff;
}
}
&__waybill-detail-summary {
:deep(.el-card__body) {
padding: 18px 22px;
}
}
&__waybill-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 18px;
strong {
font-size: 20px;
}
:deep(.el-tag) {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 8px;
font-size: 13px;
line-height: 28px;
}
}
&__waybill-route-change-link {
margin-left: auto;
}
&__waybill-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr)) minmax(360px, 1.5fr);
gap: 16px 24px;
}
&__waybill-summary-item {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
&__waybill-summary-item.is-plan {
grid-column: 1;
grid-row: 2;
}
&__waybill-label {
color: #909399;
}
&__waybill-value {
overflow: hidden;
color: #303133;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
&.is-link {
display: inline;
justify-content: flex-start;
max-width: 100%;
color: #409eff;
font-weight: 500;
vertical-align: baseline;
}
&.is-link[role='link'] {
cursor: pointer;
}
}
&__waybill-route {
display: grid;
grid-template-columns: minmax(100px, 1fr) minmax(36px, 0.7fr) minmax(100px, 1fr);
grid-row: 1 / span 3;
grid-column: 5;
align-items: start;
min-width: 0;
padding: 4px 0;
}
&__waybill-route-item {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
text-align: center;
}
&__waybill-route-marker {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 8px;
background: #409eff;
font-size: 18px;
font-weight: 600;
.is-end & {
background: #67c23a;
}
}
&__waybill-route-line {
width: 100%;
height: 6px;
margin-top: 15px;
border-radius: 1px;
background: #e4e7ed;
}
&__waybill-route-detail {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 5px;
margin-top: 8px;
width: 100%;
}
&__waybill-route-name {
max-width: 100%;
overflow: hidden;
color: #303133;
font-size: 18px;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
&__waybill-route-addr {
display: block;
max-width: 100%;
overflow: hidden;
color: #606266;
font-size: 12px;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
&__waybill-route-contact {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px 8px;
max-width: 100%;
color: #909399;
font-size: 12px;
line-height: 1.5;
}
&__waybill-attachments {
grid-column: 1 / 5;
display: flex;
flex-direction: column;
gap: 8px;
div {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
}
&__waybill-carrier-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px 32px;
padding: 4px 20px 8px;
text-align: left;
}
&__waybill-detail-field {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
text-align: left;
span {
flex: 0 0 auto;
color: #909399;
}
strong {
min-width: 0;
overflow: hidden;
color: #303133;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__waybill-detail-field--remark {
grid-column: 3 / -1;
}
&__waybill-detail-field--remark-full {
grid-column: 1 / -1;
}
&__waybill-fee-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
.waybill-manage-page__waybill-fee-value {
color: #409eff;
font-size: 16px;
}
}
&__waybill-detail-bottom {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
&__waybill-map-empty {
display: grid;
min-height: 230px;
place-items: center;
color: #909399;
border: 1px solid #eff1f7;
}
&__waybill-locate {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
&__waybill-locate-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 16px;
padding: 10px 12px;
border: 1px solid #eff1f7;
background: #fafafa;
color: #606266;
font-size: 13px;
span {
margin-right: 8px;
color: #909399;
}
strong {
color: #303133;
font-weight: 600;
}
}
&__waybill-locate-address {
grid-column: 1 / -1;
}
&__waybill-locate-map {
width: 100%;
min-height: 320px;
height: 320px;
border: 1px solid #eff1f7;
background: #f5f7fa;
}
&__waybill-locate-map--track {
min-height: 420px;
height: 420px;
}
&__waybill-map-tip {
position: absolute;
left: 50%;
top: 50%;
z-index: 2;
transform: translate(-50%, -50%);
padding: 8px 14px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
color: #909399;
font-size: 13px;
text-align: center;
pointer-events: none;
}
&__waybill-track-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 10px;
color: #606266;
font-size: 13px;
}
&__waybill-track-total {
color: #909399;
}
&__waybill-track-actions {
display: flex;
gap: 8px;
}
&__waybill-process-actions {
display: flex;
gap: 8px;
}
&__voucher-image-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
min-height: 120px;
}
&__punch-record {
display: flex;
flex-direction: column;
gap: 6px;
}
&__punch-record-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
// 异常标签与文本链接紧挨在节点名右边,不推到行尾
&__punch-record-exception-link {
font-size: 13px;
font-weight: 400;
}
// 编辑入口推到行尾:它是整条记录的操作,与节点名旁的异常链接区分开
&__punch-record-edit-link {
margin-left: auto;
font-size: 13px;
font-weight: 400;
}
&__punch-edit-readonly {
color: #303133;
}
&__punch-edit-photo-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
&__punch-record-line {
font-size: 13px;
color: #606266;
span + span {
margin-left: 12px;
}
}
&__driver-upload-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin-top: 4px;
min-height: 80px;
}
&__driver-upload-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;
}
}
&__driver-upload-label {
font-size: 12px;
line-height: 1.4;
color: #606266;
text-align: center;
word-break: break-all;
}
&__voucher-image-item {
position: relative;
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
border: 1px solid #eff1f7;
border-radius: 4px;
.el-image {
display: block;
width: 100%;
height: 100%;
}
&--folder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px;
background: #fff;
.el-image {
width: 64px;
height: 64px;
flex: none;
}
}
}
&__voucher-folder-toolbar {
display: flex;
align-items: center;
gap: 8px;
min-height: 32px;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
}
&__voucher-folder-name {
width: 100%;
overflow: hidden;
color: #606266;
font-size: 13px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
&__route-change-dialog {
:deep(.el-dialog__body) {
padding-top: 12px;
}
.section-card {
margin-bottom: 12px;
}
}
&__route-change-original {
margin-bottom: 6px;
color: #909399;
font-size: 12px;
}
&__route-change-location {
color: #409eff;
cursor: pointer;
}
&__route-change-hint {
color: #909399;
font-size: 12px;
}
&__route-change-list {
display: flex;
flex-direction: column;
gap: 8px;
}
&__route-change-item {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) minmax(120px, auto) 24px;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid #eff1f7;
border-radius: 4px;
background: #fff;
cursor: move;
}
&__route-change-type {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 50%;
background: #409eff;
}
&__route-change-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
&__route-change-record-content {
display: flex;
flex-direction: column;
gap: 10px;
line-height: 22px;
white-space: normal;
text-align: left;
}
&__route-change-group {
padding-left: 8px;
border-left: 2px solid #409eff;
}
&__route-change-group-title {
margin-bottom: 4px;
color: #409eff;
font-weight: 500;
}
&__route-change-group-text {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-field {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
}
&__route-change-field-label {
color: #909399;
white-space: nowrap;
}
&__route-change-before,
&__route-change-single {
color: #606266;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-arrow {
color: #c0c4cc;
text-align: right;
}
&__route-change-after {
color: #409eff;
word-break: break-word;
overflow-wrap: anywhere;
}
&__route-change-flag {
margin-left: 6px;
color: #c0c4cc;
font-size: 12px;
}
&__common-address-toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
.el-input {
width: 260px;
}
}
@media (max-width: 1200px) {
&__waybill-summary-grid {
grid-template-columns: repeat(4, minmax(130px, 1fr));
}
&__waybill-attachments {
grid-column: 1 / -1;
}
}
@media (max-width: 800px) {
&__waybill-summary-grid,
&__waybill-detail-bottom {
grid-template-columns: 1fr;
}
&__waybill-route {
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
&__waybill-route-line {
width: 10px;
height: 32px;
margin: 0 auto;
}
&__waybill-attachments {
grid-column: auto;
}
&__waybill-carrier-grid {
grid-template-columns: 1fr;
}
&__waybill-detail-field--remark {
grid-column: auto;
}
&__waybill-detail-field--remark-full {
grid-column: auto;
}
}
@media (max-width: 1280px) {
&__route-address-row {
grid-template-columns: 240px minmax(240px, 1fr) 40px 58px minmax(140px, 1fr);
}
&__route-address-row &__route-label:nth-of-type(2),
&__route-address-row .el-input:last-child {
grid-column: auto;
}
}
@media (max-width: 960px) {
&__route-address-row {
grid-template-columns: 1fr;
}
&__route-label {
text-align: left;
}
&__dialog-search-actions {
justify-content: flex-start;
}
}
}
.section-card {
margin-bottom: 0 !important;
}
/* 编辑弹窗:含分组的 Avue form 渲染为白卡风格 */
:deep(.avue-form__group:has(.dialog-section-title)) {
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
padding: 16px;
margin-bottom: 12px;
.avue-form__group--title {
padding: 0 !important;
margin-bottom: 12px;
}
.avue-form__row {
padding: 0 !important;
}
}
:global(.waybill-manage-form-page-dialog) {
width: 100% !important;
min-height: 100%;
box-sizing: border-box;
//padding: 20px 24px 96px;
background: #f5f6fa;
margin: 0 !important;
border-radius: 0;
box-shadow: none;
}
:global(.waybill-manage-form-page-dialog .el-dialog__body) {
max-height: none;
padding: 20px 24px 96px;
background: #f5f6fa;
}
:global(.el-overlay:has(.waybill-manage-form-page-dialog)) {
position: fixed !important;
inset: 92px 0 0 230px !important;
z-index: 100 !important;
overflow: auto;
background: transparent;
}
:global(.el-overlay:has(.waybill-manage-form-page-dialog) .el-overlay-dialog) {
min-height: 100%;
overflow: visible;
}
:global(.waybill-manage-form-page-dialog .el-dialog__header) {
padding: 18px 24px;
margin-right: 0;
background: #fff;
border-bottom: 1px solid #eff1f7;
}
:global(.waybill-manage-form-page-dialog .avue-form__menu) {
position: fixed !important;
right: 0;
bottom: 0;
left: 230px;
z-index: 1001;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 64px;
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(.waybill-manage-form-page-dialog .waybill-manage-page__freight-footer-summary) {
position: static;
order: 1;
height: auto;
margin-right: auto;
}
:global(
.waybill-manage-form-page-dialog
.avue-form__menu
> .waybill-manage-page__standalone-menu-actions
) {
display: flex;
order: 2;
gap: 8px;
}
:global(.waybill-manage-form-page-dialog .avue-form__menu > .el-button) {
order: 3;
}
:global(.avue--collapse .el-overlay:has(.waybill-manage-form-page-dialog)) {
left: 60px !important;
}
:global(.avue--collapse .waybill-manage-form-page-dialog .avue-form__menu) {
left: 60px;
}
:global(.avue-layout--horizontal .el-overlay:has(.waybill-manage-form-page-dialog)) {
top: 92px !important;
left: 0 !important;
}
:global(.avue-layout--horizontal .waybill-manage-form-page-dialog .avue-form__menu) {
left: 0;
}
/* 独立表单页 footer 的 Avue 提交/清空按钮自带图标(el-icon-check / el-icon-delete),
Avue 源码用 `|| 'el-icon-xxx'` 兜底,无法通过 option 配置去除,统一用 CSS 隐藏只保留文字 */
:global(.waybill-manage-form-page-dialog .avue-form__menu .el-icon) {
display: none;
}
// 运单独立表单页:标题分块使用白底卡片,间距 12px,与 section-card 风格一致
:global(.waybill-manage-form-page-dialog:not(.el-dialog)) {
padding-bottom: 96px;
box-sizing: border-box;
}
:global(.waybill-manage-page--form-page .avue-form) {
background: transparent;
padding: 0;
}
:global(.waybill-manage-page--form-page .avue-form > .el-form > .avue-form__group),
:global(.waybill-manage-page--form-page .avue-group) {
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
margin-bottom: 12px;
padding: 14px 16px 4px;
overflow: hidden;
}
:global(.waybill-manage-page--form-page .avue-form > .el-form > .avue-form__group:last-child),
:global(.waybill-manage-page--form-page .avue-group:last-child) {
margin-bottom: 0;
}
// 分组后的表单:卡片由 .avue-group 承担,内层 .avue-form__group 去卡片化,避免白卡叠白卡
:global(.waybill-manage-page--form-page .avue-form .avue-form__group:has(.dialog-section-title)) {
background: transparent;
border-radius: 0;
box-shadow: none;
padding: 0;
margin-bottom: 0;
}
:global(.waybill-manage-page--form-page .avue-group .el-collapse) {
border-top: none;
border-bottom: none;
}
:global(.waybill-manage-page--form-page .avue-group .el-collapse-item__header) {
display: none;
}
:global(.waybill-manage-page--form-page .avue-group .el-collapse-item__wrap) {
border-bottom: none;
will-change: auto;
}
:global(.waybill-manage-page--form-page .avue-group .el-collapse-item__content) {
padding-bottom: 0;
}
:global(.waybill-manage-page__mileage-dialog .waybill-manage-page__mileage-form) {
padding: 4px 20px 0;
}
:global(.waybill-manage-page__mileage-dialog .waybill-manage-page__mileage-form .el-form-item) {
margin-bottom: 24px;
}
:global(.waybill-manage-page__mileage-dialog .waybill-manage-page__mileage-form .el-input),
:global(.waybill-manage-page__mileage-dialog .waybill-manage-page__mileage-form .el-textarea) {
width: 100%;
}
.waybill-manage-page__status-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.waybill-manage-page__reject-tip-icon {
color: #e6a23c;
cursor: pointer;
font-size: 16px;
vertical-align: middle;
}
:global(.waybill-manage-page__reassign-drawer .el-drawer__body) {
padding: 12px 20px 0;
}
:global(.waybill-manage-page__reassign-drawer .waybill-manage-page__reassign-form) {
width: 100%;
}
.waybill-manage-page__reassign-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 16px;
row-gap: 8px;
margin-bottom: 12px;
align-items: start;
}
.waybill-manage-page__reassign-span-1 {
grid-column: span 1;
margin-bottom: 0;
width: 100%;
}
.waybill-manage-page__reassign-meta {
grid-column: span 1;
display: flex;
align-items: flex-start;
min-width: 0;
line-height: 32px;
font-size: 14px;
}
.waybill-manage-page__reassign-meta--wide {
grid-column: span 3;
}
.waybill-manage-page__reassign-meta-label {
flex: none;
width: 72px;
color: #606266;
text-align: right;
padding-right: 12px;
box-sizing: border-box;
}
.waybill-manage-page__reassign-meta-value {
flex: 1;
min-width: 0;
color: #303133;
word-break: break-all;
}
.waybill-manage-page__reassign-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.el-input__icon {
color: #1e90ff !important;
}
</style>