Files
tms-erp-web/src/views/business/components/waybill-manage-page.vue
T
2026-09-20 13:17:05 +08:00

10531 lines
365 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="item.contractName"
: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"
@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 taskFeeUnitOptions"
: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="taskFullFreightGroupQuantity(group)" disabled>
<template #append>
<el-select
:model-value="group.quantityUnit"
class="waybill-manage-page__append-select waybill-manage-page__append-select--wide"
placeholder="单位"
disabled
>
<el-option :label="fixedQuantityUnit" :value="fixedQuantityUnit" />
</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 :model-value="taskFullFreightTotal" disabled>
<template #suffix>{{ taskFreightCurrencyLabel }}</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)"
>
<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"
/>
</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"
/>
</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="货物名称">
<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="数量">
<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 taskFeeUnitOptions"
: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>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ row.originalName || row.name }}
</el-link>
</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="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"
/>
</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>
<span class="detail-status-text status-text-success">{{
displayStatus(detailRow, 'businessStatus')
}}</span>
<span class="detail-status-text detail-transport-type">
{{ getTransportTypeLabel(waybillTransportMode(detailRow)) || '-' }}
</span>
<el-link
v-if="detailRow.id"
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 :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>
<el-link
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
@click="previewAttachment(file)"
>
{{ file.originalName || file.name || '-' }}
</el-link>
<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
v-if="!waybillIsCarrier(detailRow)"
class="waybill-manage-page__waybill-detail-field"
>
<span>里程(km)</span><strong>{{ detailRow.mileage || '-' }}</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>
</template>
<template v-else>
<div class="waybill-manage-page__waybill-detail-field">
<span>{{ waybillVehicleNoLabel(detailRow) }}</span>
<strong>{{ detailRow.vehicleNo || '-' }}</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>
<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="`${record.nodeCode || record.id || 'punch'}-${index}`"
:timestamp="record.punchTime || record.statusName || '未打卡'"
:type="record.punched ? 'primary' : 'info'"
:color="record.punched ? '#2b6ce8' : '#c0c4cc'"
placement="top"
>
<div class="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
>
<el-tag
v-if="record.type === 'enroute'"
size="small"
type="info"
effect="plain"
>在途</el-tag
>
<el-tag
v-if="record.exceptionFlag"
size="small"
type="danger"
effect="plain"
>异常</el-tag
>
</div>
<template v-if="record.punched">
<div
v-if="record.address"
class="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>
<div
v-if="record.remark"
class="waybill-manage-page__punch-record-line"
>
备注:{{ record.remark }}
</div>
<div
v-if="record.photos && record.photos.length"
class="waybill-manage-page__punch-record-photos"
>
<div
v-for="(photo, pi) in record.photos"
:key="`${record.nodeCode || index}-${pi}`"
class="waybill-manage-page__driver-upload-item"
@click="previewDriverUploadPhoto(record.photos, pi)"
>
<el-image :src="photo.url" fit="cover" lazy />
<div class="waybill-manage-page__driver-upload-label">
{{ photo.label || `${record.nodeName}-凭证` }}
</div>
</div>
</div>
</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"
>
<div
v-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-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 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>
<el-empty
v-if="!waybillPunchRecordsLoading && !waybillDriverUploads.length"
description="暂无司机上传数据"
:image-size="50"
/>
</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="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
v-model="row.departureAddress"
placeholder="请选择或输入地址"
@input="updateWaybillRouteChangeNodes"
>
<template #suffix>
<el-icon
class="waybill-manage-page__route-change-location"
@click="openWaybillCommonAddressDialog(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
v-model="row.arrivalAddress"
placeholder="请选择或输入地址"
@input="updateWaybillRouteChangeNodes"
>
<template #suffix>
<el-icon
class="waybill-manage-page__route-change-location"
@click="openWaybillCommonAddressDialog(row, 'arrival')"
>
<Location />
</el-icon>
</template>
</el-input>
</template>
</el-table-column>
</el-table>
</section-card>
<section-card title="路线编辑">
<template #extra>
<span class="waybill-manage-page__route-change-hint">拖拽调整顺序</span>
</template>
<div
v-if="waybillRouteChangeNodes.length"
class="waybill-manage-page__route-change-list"
>
<div
v-for="(node, index) in waybillRouteChangeNodes"
:key="node.key || index"
class="waybill-manage-page__route-change-item"
draggable="true"
@dragstart="handleWaybillRouteChangeDragStart(index)"
@dragover.prevent
@drop="handleWaybillRouteChangeDrop(index)"
>
<span class="waybill-manage-page__route-change-type">
{{ waybillRouteChangeTypeText(index) }}
</span>
<span>{{ node.address }}</span>
<span class="waybill-manage-page__route-change-tags">
<el-tag v-for="tag in node.tags || []" :key="tag" size="small">{{ tag }}</el-tag>
</span>
<el-icon><Rank /></el-icon>
</div>
</div>
<el-empty v-else description="暂无路线" :image-size="60" />
</section-card>
<el-form label-width="auto">
<el-form-item label="变更备注" 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">
<template #default="{ row }">
<div class="waybill-manage-page__route-change-record-content">
{{ row.content || '-' }}
</div>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
</el-table>
<el-empty
v-if="!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>
<el-dialog
v-model="waybillCommonAddressBox"
title="选择常用地址"
append-to-body
width="900px"
class="waybill-manage-page__common-address-dialog"
>
<div class="waybill-manage-page__common-address-toolbar">
<el-input
v-model="waybillCommonAddressQuery.addressName"
clearable
placeholder="请输入地址名称"
@keyup.enter="loadWaybillCommonAddresses"
/>
<el-button type="primary" @click="loadWaybillCommonAddresses">查询</el-button>
</div>
<el-table
v-loading="waybillCommonAddressLoading"
:data="waybillCommonAddressRows"
border
@row-dblclick="selectWaybillCommonAddress"
>
<el-table-column prop="addressName" label="地址名称" min-width="180" />
<el-table-column prop="regionName" label="区域" min-width="180" />
<el-table-column prop="detailAddress" label="详细地址" min-width="260" />
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-link type="primary" @click="selectWaybillCommonAddress(row)">选择</el-link>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-pagination
v-model:current-page="waybillCommonAddressPage.currentPage"
v-model:page-size="waybillCommonAddressPage.pageSize"
layout="total, prev, pager, next"
:total="waybillCommonAddressPage.total"
@current-change="loadWaybillCommonAddresses"
/>
<el-button @click="waybillCommonAddressBox = false">关闭</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"
>
<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">
<map-search-results :results="transportMapSearchResults" @select="selectTransportMapSearchResult" />
<div ref="transportAmap" class="waybill-manage-page__map"></div>
</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="transportMapBox = false">取消</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 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 { getPunchRecords as getWaybillPunchRecords, locateVehicle, trackVehicle } from '@/api/business/waybill-manage';
import { getList as getDriverList } from '@/api/transportCapacity/driver';
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 } from '@/utils/auth';
import { openImportDialog } from '@/utils/import-excel';
import { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
import { InfoFilled, Location, OfficeBuilding, Rank, Search, WarnTriangleFilled } from '@element-plus/icons-vue';
import { 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';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
let amapLoader;
const standaloneWaybillFormRoute = '/business/waybill-manage/form';
const standaloneWaybillFormPaths = ['/business/waybill-manage', standaloneWaybillFormRoute];
const standaloneWaybillDetailRoute = '/business/waybill-manage/detail';
const loadingCreateWaybillsStorageKey = 'loading-manage-create-waybills';
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,
Rank,
ElImageViewer,
OpenFileViewer,
},
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,
waybillRouteChangeBox: false,
waybillRouteChangeTab: 'change',
waybillRouteChangeRows: [],
waybillRouteChangeNodes: [],
waybillRouteChangeRemark: '',
waybillRouteChangeRecords: [],
waybillRouteChangeDragIndex: -1,
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',
},
],
},
waybillCommonAddressBox: false,
waybillCommonAddressLoading: false,
waybillCommonAddressQuery: {
addressName: '',
},
waybillCommonAddressRows: [],
waybillCommonAddressTarget: null,
waybillCommonAddressPage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
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,
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 || '';
if (this.config.enableAllDept && this.isAdmin) {
this.allDept = 1;
}
if (this.config.enableProjectSelect) {
this.loadProjectOptions();
}
if (this.config.enableTaskInfoForm) {
this.loadTaskQuantityUnitOptions();
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();
}
},
// 标签切走(实例被 keep-alive 缓存)或销毁时,收起本页所有 append-to-body 的弹窗。
// 否则 Teleport 出去的弹窗 DOM 会一直留在 body 上,盖住后续打开的页面。
deactivated() {
this.closeInnerDialogs();
},
beforeUnmount() {
this.closeInnerDialogs();
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return {
addBtn: this.canCreate,
};
},
// 打卡时间轴按打卡时间先后展示:已打卡记录按打卡时间升序在前,
// 未打卡记录保持流程节点原顺序排在其后。
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);
},
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() {
return this.form.carrierType === '自运'
? this.form.carrierName || ''
: this.form.carrierContractId || 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)));
},
fixedQuantityUnit() {
return WAYBILL_QUANTITY_UNIT;
},
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);
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
},
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) {
if (!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: {
// 页面被 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.waybillCommonAddressBox = false;
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;
},
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);
return `${group.typeName}等${group.count}种货物 | ${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) {
return (
this.hasAction('copy') &&
this.hasPermission(`${this.config.permission}_copy`) &&
!this.readonlyScope &&
!row.readonly
);
},
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;
}
const status = this.statusValue(row);
return ['pending', 'processing', 'running'].includes(status);
},
canComplete(row) {
if (
!this.hasAction('complete') ||
!this.hasPermission(`${this.config.permission}_complete`) ||
this.readonlyScope ||
row.readonly
) {
return false;
}
const status = this.statusValue(row);
return status === 'processing' || status === 'running';
},
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 || '元/吨',
});
},
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) {
if (!this.config.detailButton || !this.hasPermission(`${this.config.permission}_view`))
return false;
const status = this.statusValue(row);
return status !== 'draft';
},
formatDetailValue(row, prop) {
if (!row) return '-';
if (prop === 'businessStatus') return this.displayStatus(row, prop);
if (prop === 'feeGenerationMode') {
return row[prop] === 'manual' ? '账单导入生成' : '系统生成';
}
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 && 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 === undefined || value === null || value === '' ? '-' : 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() === '承运商';
},
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;
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();
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;
});
},
closeDetail() {
this.destroyWaybillLocateMap();
this.destroyWaybillTrackMap();
this.waybillTrackMode = '';
this.waybillLocateInfo = null;
this.waybillLocateHint = '';
this.waybillTrackInfo = null;
this.waybillTrackHint = '';
if (this.isStandaloneWaybillDetailPage) {
this.$router.$avueRouter?.closeTag?.();
this.$router.push({ path: '/business/waybill-manage', 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: '配载单详情' },
});
},
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;
},
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;
});
},
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) 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;
},
waybillRouteChangeTypeText(index) {
if (index === 0) return '起';
if (index === this.waybillRouteChangeNodes.length - 1) return '终';
return '经';
},
handleWaybillRouteChangeDragStart(index) {
this.waybillRouteChangeDragIndex = index;
},
handleWaybillRouteChangeDrop(index) {
if (this.waybillRouteChangeDragIndex < 0 || this.waybillRouteChangeDragIndex === index)
return;
const nodes = [...this.waybillRouteChangeNodes];
const [node] = nodes.splice(this.waybillRouteChangeDragIndex, 1);
nodes.splice(index, 0, node);
this.waybillRouteChangeNodes = nodes;
this.waybillRouteChangeDragIndex = -1;
},
updateWaybillRouteChangeNodes() {
this.waybillRouteChangeNodes = this.buildWaybillRouteNodes(this.waybillRouteChangeRows);
},
openWaybillCommonAddressDialog(row, field) {
this.waybillCommonAddressTarget = { row, field };
this.waybillCommonAddressQuery = { addressName: '' };
this.waybillCommonAddressPage.currentPage = 1;
this.waybillCommonAddressBox = true;
this.loadWaybillCommonAddresses();
},
loadWaybillCommonAddresses() {
this.waybillCommonAddressLoading = true;
getCommonAddressList(
this.waybillCommonAddressPage.currentPage,
this.waybillCommonAddressPage.pageSize,
{
...this.waybillCommonAddressQuery,
allDept: 0,
}
)
.then(res => {
const data = res.data?.data || {};
this.waybillCommonAddressRows = data.records || [];
this.waybillCommonAddressPage.total = data.total || 0;
})
.finally(() => {
this.waybillCommonAddressLoading = false;
});
},
selectWaybillCommonAddress(address) {
const target = this.waybillCommonAddressTarget;
if (!target) return;
target.row[`${target.field}Address`] =
[address.regionName, address.detailAddress || address.addressName]
.filter(Boolean)
.join('') ||
address.addressName ||
'';
this.updateWaybillRouteChangeNodes();
this.waybillCommonAddressBox = false;
},
async confirmWaybillRouteChange() {
const row = this.waybillRouteChangeRows[0] || {};
const changed =
row.departureAddress !== row.originalDepartureAddress ||
row.arrivalAddress !== row.originalArrivalAddress;
const routeJson = JSON.stringify(this.waybillRouteChangeNodes);
const originalRouteJson = this.detailRow.routeJson || '';
if (!changed && routeJson === originalRouteJson && !this.waybillRouteChangeRemark) {
this.$message.warning('请修改地址、调整路线或填写变更备注');
return;
}
if (typeof this.api.changeRoute !== 'function') {
this.$message.warning('当前接口未接入路线变更功能');
return;
}
const content = changed
? `${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 longitude = Number(locateInfo?.longitude);
const latitude = Number(locateInfo?.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
this.$message.warning('定位坐标无效,无法在地图上展示');
return;
}
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>坐标:${longitude}, ${latitude}</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) {
const path = (points || [])
.map(item => [Number(item.longitude), Number(item.latitude)])
.filter(item => Number.isFinite(item[0]) && Number.isFinite(item[1]));
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', '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';
}
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 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);
}
if (this.config.saveAsDraft && saveAsDraft) {
submitRow[this.config.draftStatusProp || 'approvalStatus'] =
this.config.draftStatus || 'draft';
}
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();
}
});
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';
this.taskFreightCurrency = this.normalizeFreightCurrency(value);
},
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) {
const submitRow = this.normalizeRow(row);
if (!submitRow) {
this.stopSubmitLoading(loading);
return;
}
this.getSaveRequest()(submitRow).then(
() => {
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(
() => {
this.attachmentUploadMode = false;
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
if (this.isStandaloneWaybillPage) this.closeCrudDialog();
},
error => {
this.attachmentUploadMode = false;
window.console.log(error);
this.stopSubmitLoading(loading);
}
);
},
getSaveRequest() {
return this.api.submit;
},
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.onLoad(this.page, this.query);
this.closeCrudDialog();
})
.finally(() => {
this.draftSaveLoading = false;
});
},
closeCrudDialog() {
if (this.isStandaloneWaybillPage) {
this.$router.$avueRouter?.closeTag?.();
this.$router.push({ path: '/business/waybill-manage', 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 || {};
// 清除不应该复制的字段(业务状态与原运单保持一致)
delete sourceData.id;
delete sourceData.code;
delete sourceData.waybillNo;
delete sourceData.waybillStatus;
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 = '手工创建';
done();
});
return;
}
if (type === 'add') {
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'].forEach(prop => {
if (Number(this.form[prop]) === -1) 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.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.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.form.unitPrice || taskInfo.unitPrice || goodsInfo.unitPrice || '';
['quantity', 'mileage', 'unitPrice'].forEach(prop => {
if (Number(this.form[prop]) === -1) this.form[prop] = '';
});
this.form.priceUnit =
this.form.priceUnit || taskInfo.priceUnit || goodsInfo.priceUnit || '元/吨';
this.form.otherFeeTotal =
this.form.otherFeeTotal || taskInfo.otherFeeTotal || goodsInfo.otherFeeTotal || '';
if (Number(this.form.otherFeeTotal) === -1) {
this.form.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 quantityUnit = quantityUnits[0];
return rows.every(row => {
const priceUnit = String(row.priceUnit || '').trim();
const separatorIndex = Math.max(priceUnit.lastIndexOf('/'), priceUnit.lastIndexOf(''));
const priceQuantityUnit =
separatorIndex >= 0 ? priceUnit.slice(separatorIndex + 1).trim() : priceUnit;
return !this.isBillingFieldEmpty(row.unitPrice) && priceQuantityUnit === quantityUnit;
});
},
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)
);
},
taskFullFreightGroupQuantity(group = {}) {
const total = (group.rows || []).reduce((sum, cargo) => sum + Number(cargo.quantity || 0), 0);
return this.formatTaskFullFreightNumber(total);
},
taskFullFreightGroupUnitPrice(group = {}) {
const prices = (group.rows || [])
.map(cargo => String(cargo.unitPrice ?? '').trim())
.filter(Boolean);
if (!prices.length || prices.some(price => price !== prices[0])) return '';
return prices[0];
},
taskFullFreightGroupPriceUnit(group = {}) {
return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.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 '';
const number = Number(value || 0);
if (!Number.isFinite(number)) return '';
return Number(number.toFixed(2)).toString();
},
handleTaskCarrierTypeChange(value) {
const nextValue = value || '承运商';
this.form.carrierName = '';
this.form.carrierId = '';
this.form.carrierContractId = '';
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 !== '自运') {
return item.carrierContractId || item.value || item.id || '';
}
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;
},
getWaybillCarrierContractOptions(contracts = []) {
const carrierNameCounts = contracts.reduce((counts, contract) => {
const carrierName = String(
contract.partyB ||
contract.partyBName ||
contract.contractPartyB ||
contract.customerContractPartyB ||
contract.secondParty ||
contract.secondPartyName ||
''
).trim();
if (carrierName) counts[carrierName] = (counts[carrierName] || 0) + 1;
return counts;
}, {});
return contracts
.map(contract => {
const carrierName = String(
contract.partyB ||
contract.partyBName ||
contract.contractPartyB ||
contract.customerContractPartyB ||
contract.secondParty ||
contract.secondPartyName ||
''
).trim();
if (!carrierName || !contract.id) return null;
const contractIdentifier = contract.contractName || contract.contractNo || contract.id;
return {
id: contract.id,
carrierId:
contract.partyBId ||
contract.partyBUserId ||
contract.contractPartyBId ||
contract.customerContractPartyBId ||
contract.secondPartyId ||
contract.secondPartyUserId ||
contract.carrierId ||
'',
value: contract.id,
label:
carrierNameCounts[carrierName] > 1
? `${carrierName}${contractIdentifier}`
: carrierName,
carrierName,
fullName: carrierName,
carrierContractId: contract.id,
};
})
.filter(Boolean);
},
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 = '';
}
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.carrierId = carrier?.carrierId || '';
this.form.carrierName = carrier?.carrierName || '';
return;
}
this.form.carrierId = carrier?.carrierContractId ? '' : carrier?.id || '';
this.form.carrierName = value || '';
},
loadTaskDriverOptions() {
if (!this.taskInfoFormEnabled || this.taskDriverLoading) return Promise.resolve([]);
this.taskDriverLoading = true;
return getDriverList(1, 9999, { posts: '司机' })
.then(res => {
this.taskDriverOptions = extractRecords(res);
return this.taskDriverOptions;
})
.finally(() => {
this.taskDriverLoading = false;
});
},
fetchTaskDriverSuggestions(queryString, callback) {
const keyword = String(queryString || '').trim();
this.taskDriverLoading = true;
getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '司机' })
.then(res => {
const records = extractRecords(res);
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;
getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '押运员' })
.then(res => {
const records = extractRecords(res);
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 || '元/吨';
if (this.isBillingFieldEmpty(cargo.freightAmount)) {
cargo.freightAmount = 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 getDictionary({ code: 'quantity_unit' })
.then(res => {
this.taskQuantityUnitOptions = this.normalizeDictOptions(res.data.data || []);
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', '手机号']);
}
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'].forEach(prop => {
if (this.isBillingFieldEmpty(row[prop]) || Number(row[prop]) === -1) 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) && Number(row.otherFeeTotal) !== -1) {
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: row.freightAmount ?? row.amount ?? row.totalAmount ?? '',
};
['quantity', 'mileage', 'unitPrice'].forEach(prop => {
if (Number(cargo[prop]) === -1) 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 || '元/吨',
...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();
},
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() === '';
},
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`]);
});
},
openTransportMapDialog(target) {
if (this.dialogReadonly) return;
if (!this.ensureTransportTypeBeforeAddress()) return;
const prefix = target === 'departure' ? 'departure' : 'arrival';
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;
}
this.loadAmap()
.then(() => {
this.transportMapLoading = true;
this.ensureTransportAmapGeocoder()
.then(() => this.runAmapGeocode('location', keyword))
.then(result => {
this.transportMapSearchResults = (result.geocodes || []).map((item, index) => ({
id: item.id || index,
name: item.formattedAddress || keyword,
address: item.formattedAddress || keyword,
location: item.location,
}));
const point = this.resolveMapPoint(result);
if (!point) {
this.transportMapStatus = '未找到匹配地址';
this.$message.warning('地图搜索无匹配地址');
return;
}
this.pickTransportMapPoint(point, keyword);
})
.catch(() => {
this.transportMapStatus = '地图搜索失败';
this.$message.error('地图搜索失败,请稍后重试');
})
.finally(() => {
this.transportMapLoading = false;
});
})
.catch(() => {
this.$message.error('高德地图组件加载失败,请稍后重试或重新打开弹窗');
});
},
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;
}
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);
},
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: row.mileage === null || row.mileage === undefined ? '' : String(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: '重新派单',
}[action];
this.$confirm(`确定${actionName}该${this.config.title}?`, '提示', {
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;
getDriverList(1, 20, { ...(keyword ? { driverName: keyword } : {}), posts: '司机' })
.then(res => {
const records = extractRecords(res);
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;
}
this.$confirm('确定完成选中的运单?', '提示', {
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);
});
},
handleRoadLoading() {
if (!this.selectionList.length) {
this.$message.warning('请选择至少一条数据');
return;
}
const nonRoadWaybills = this.selectionList.filter(row => !this.waybillIsRoadTransport(row));
if (nonRoadWaybills.length) {
this.$message.warning('公路配载仅支持运输方式为“公路运输”的运单');
return;
}
const unavailableWaybills = this.selectionList.filter(
row =>
!['processing', 'running'].includes(this.statusValue(row)) ||
row.loadingId ||
row.loadingManageId ||
row.loadingNo
);
if (unavailableWaybills.length) {
this.$message.warning('公路配载仅支持进行中且未配载的运单');
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: '新增配载管理',
},
});
},
},
};
</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(3, minmax(0, 1fr));
column-gap: 56px;
row-gap: 16px;
: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;
}
&__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;
}
}
&__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 {
color: #409eff;
}
}
&__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-line {
font-size: 13px;
color: #606266;
span + span {
margin-left: 12px;
}
}
&__punch-record-photos,
&__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 {
white-space: pre-wrap;
}
&__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: 16px;
}
: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>