Files
tms-erp-web/src/views/business/components/transport-plan-page.vue
T
2026-09-16 06:30:30 +08:00

9885 lines
352 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="[
'transport-plan-page',
{
'transport-plan-page--form-page': isStandaloneFormPage,
},
]"
>
<component
:is="crudContainer"
:option="pageFormOption"
:form-page-title="isStandaloneFormPage ? formPageTitle : undefined"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
:status="isStandaloneFormPage ? 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 || config.enableTransportPlanImport) &&
hasPermission(`${config.permission}_import`)
"
@click="handleImport"
>{{ config.importText || '批量导入' }}
</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="danger"
icon="el-icon-delete"
plain
v-if="config.batchDelete !== false && hasPermission(`${config.permission}_delete`)"
@click="handleDelete"
>批量删除
</el-button>
</template>
<template #businessStatus="{ row }">
<el-tag :type="statusTagType(row.businessStatus)" class="status-text">
{{ displayStatus(row, 'businessStatus') }}
</el-tag>
</template>
<template #goodsInfo="{ row }">
<span>{{ formatTransportPlanGoodsInfo(row) || '-' }}</span>
</template>
<template #contractName="{ row }">
<div class="transport-plan-page__contract-name">
<span>{{ row.contractName || '-' }}</span>
</div>
</template>
<template #departureAddress="{ row }">
<el-tooltip
v-if="row.departureAddress || row.departureName"
:content="row.departureAddress || row.departureName"
placement="top"
>
<span>{{ formatTransportPlanListAddress(row, 'departure') }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<template #arrivalAddress="{ row }">
<el-tooltip
v-if="row.arrivalAddress || row.arrivalName"
:content="row.arrivalAddress || row.arrivalName"
placement="top"
>
<span>{{ formatTransportPlanListAddress(row, 'arrival') }}</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 #projectName-form>
<el-select
v-model="selectedProjectId"
class="transport-plan-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="transport-plan-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="transport-plan-page__field"
placeholder="请输入合同名称"
clearable
maxlength="100"
show-word-limit
:disabled="dialogReadonly"
/>
</template>
<template #planName-form>
<el-input
v-model="form.planName"
class="transport-plan-page__field"
placeholder="请输入计划名称"
clearable
maxlength="50"
show-word-limit
:disabled="dialogReadonly || isTemplateCreate"
/>
</template>
<template #shippingInfoTitle-form>
<div
class="dialog-section-title dialog-section-title--action transport-plan-page__shipping-title"
>
<span>收发货信息</span>
<el-link
v-if="!dialogReadonly"
class="transport-plan-page__shipping-route-action"
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportRouteDialog"
>
选择线路
</el-link>
</div>
</template>
<template #departureAddress-form>
<div class="transport-plan-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="transport-plan-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="transport-plan-page__map-suffix"
@click.stop="openTransportMapDialog('departure')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="transport-plan-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
/>
</el-tooltip>
<span class="transport-plan-page__route-label">联系人</span>
<el-input
v-model="form.departureContact"
placeholder="请输入"
:disabled="dialogReadonly"
/>
<span class="transport-plan-page__route-label">联系方式</span>
<el-input v-model="form.departurePhone" placeholder="请输入" :disabled="dialogReadonly" />
</div>
</template>
<template #arrivalAddress-form>
<div class="transport-plan-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="transport-plan-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="transport-plan-page__map-suffix"
@click.stop="openTransportMapDialog('arrival')"
>
<Location />
</el-icon>
</template>
</el-input>
<el-tooltip content="选择常用地址" placement="top">
<el-button
class="transport-plan-page__address-pick-btn"
type="primary"
:icon="OfficeBuilding"
link
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
/>
</el-tooltip>
<span class="transport-plan-page__route-label">联系人</span>
<el-input v-model="form.arrivalContact" placeholder="请输入" :disabled="dialogReadonly" />
<span class="transport-plan-page__route-label">联系方式</span>
<el-input v-model="form.arrivalPhone" placeholder="请输入" :disabled="dialogReadonly" />
</div>
</template>
<template #goodsInfoTitle-form>
<div
v-if="config.enableTransportPlanForm"
class="dialog-section-title dialog-section-title--action transport-plan-page__goods-title"
>
<span>货物信息</span>
<div v-if="!dialogReadonly" class="transport-plan-page__section-actions">
<!-- <el-link type="primary" @click="cargoImportBox = true">导入货物</el-link> -->
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div>
</div>
</template>
<template #goodsJson-form>
<div v-if="config.enableTransportPlanForm" class="transport-plan-page__cargo-wrap">
<el-table
:data="transportCargoRows"
border
class="transport-plan-page__cargo-table"
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="transport-plan-page__required-column">货物类型</span>
</template>
<template #default="{ row }">
<el-cascader
v-model="row.cargoTypePath"
:options="transportCargoTypeOptions"
:props="transportCargoTypeCascaderProps"
:placeholder="row.cargoType || '请选择'"
clearable
filterable
:disabled="dialogReadonly"
:filter-method="filterBillingCargoType"
@visible-change="visible => visible && ensureBillingCargoTypeOptions()"
@change="value => handleTransportCargoTypeChange(row, value)"
/>
</template>
</el-table-column>
<el-table-column label="货物名称" min-width="240" align="center">
<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="transport-plan-page__cargo-autocomplete-item">
<span>{{ formatBillingCargoTypeLabel(item) }}</span>
</div>
</template>
</el-autocomplete>
</template>
</el-table-column>
<el-table-column label="数量" min-width="120" align="center">
<template #default="{ row }">
<el-input
v-model="row.quantity"
placeholder="请输入"
:disabled="dialogReadonly"
@input="value => handleTransportCargoQuantityInput(row, value)"
/>
</template>
</el-table-column>
<el-table-column label="数量单位" min-width="120" align="center">
<template #default="{ row }">
<el-select v-model="row.quantityUnit" clearable :disabled="dialogReadonly">
<el-option
v-for="item in quantityUnitOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="包装" min-width="120" 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 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="transport-plan-page__cargo-actions">
<el-link
v-if="!dialogReadonly"
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 class="transport-plan-page__cargo-total">
运输数量合计:{{ transportCargoQuantityTotal }}
</div>
</div>
</template>
<template #attachmentTitle-form>
<div class="dialog-section-title">{{ config.attachmentTitle || '其它附件' }}</div>
</template>
<template #attachmentsJson-form>
<div class="transport-plan-page__attachment">
<div class="transport-plan-page__attachment-head">
<el-button
type="primary"
:disabled="!attachmentRows.length"
@click="handleBatchDownload"
>
批量下载
</el-button>
</div>
<el-table
:data="attachmentRows"
border
class="transport-plan-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="transport-plan-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="isStandaloneFormPage && !showTransportPlanCreateActions"
class="transport-plan-page__standalone-menu-actions"
>
<el-button class="transport-plan-page__form-close" @click="closeCrudDialog">
取消
</el-button>
</div>
<div v-if="showTransportPlanCreateActions" class="transport-plan-page__create-actions">
<el-button :disabled="Boolean(transportPlanCreateActionLoading)" @click="closeCrudDialog">
取消
</el-button>
<el-button
type="primary"
plain
:loading="transportPlanCreateActionLoading === 'draft'"
:disabled="Boolean(transportPlanCreateActionLoading)"
@click="handleTransportPlanCreateAction('draft')"
>
暂存
</el-button>
<el-button
type="primary"
:loading="transportPlanCreateActionLoading === 'create'"
:disabled="Boolean(transportPlanCreateActionLoading)"
@click="handleTransportPlanCreateAction('create')"
>
确认创建
</el-button>
</div>
</template>
<template #menu="{ row, index }">
<div class="transport-plan-page__row-actions">
<el-link type="primary" v-if="showDetailButton(row)" @click="openDetail(row)"
>详情</el-link
>
<el-link
type="primary"
v-if="showViewButton(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="canCopy(row)" @click="handleCopy(row)"> 复制 </el-link>
<el-link type="primary" v-if="canComplete(row)" @click="handleAction('complete', row)">
{{ completeActionLabel }}
</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>
<empty-pagination
v-show="!isStandaloneFormPage"
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
<el-dialog
v-model="detailBox"
:title="isTransportPlanDetailLayout ? '' : `${config.title}详情`"
append-to-body
top="10px"
:width="isTransportPlanDetailLayout ? 'calc(100% - 16px)' : '96%'"
show-close
:class="[
'transport-plan-page__detail-dialog',
$attrs.option?.dialogCustomClass,
{ 'is-transport-plan-detail': isTransportPlanDetailLayout },
]"
>
<div v-loading="detailLoading" class="transport-plan-page__detail-content">
<template v-if="isTransportPlanDetailLayout">
<section class="transport-plan-page__transport-plan-detail-head">
<div class="transport-plan-page__transport-plan-detail-title-row">
<h2>
{{ detailRow.planName || detailRow.planNo || '运输计划' }}
<span>|</span>
{{ detailRow.planNo || '-' }}
</h2>
<span class="detail-status-text is-dispatching">
{{ displayStatus(detailRow, 'businessStatus') }}
</span>
<span class="detail-status-text detail-transport-type">
{{
detailRow.transportTypeName ||
formatTransportPlanDetailTransportType(detailRow.transportType)
}}
</span>
</div>
<div class="transport-plan-page__transport-plan-detail-overview">
<div class="transport-plan-page__transport-plan-detail-fields">
<div class="transport-plan-page__transport-plan-detail-field">
<span>客户</span>
<strong>{{ detailRow.customerName || '-' }}</strong>
</div>
<div class="transport-plan-page__transport-plan-detail-field">
<span>项目</span>
<strong>{{ detailRow.projectName || '-' }}</strong>
</div>
<div class="transport-plan-page__transport-plan-detail-field">
<span>合同编号</span>
<strong>{{ detailRow.contractNo || '-' }}</strong>
</div>
<div class="transport-plan-page__transport-plan-detail-field">
<span>货物信息</span>
<strong class="is-normal">{{ transportPlanGoodsText }}</strong>
</div>
<div class="transport-plan-page__transport-plan-detail-field">
<span>计划执行时间</span>
<strong class="is-normal">{{ transportPlanDateRange }}</strong>
</div>
<div class="transport-plan-page__transport-plan-detail-field is-attachments">
<span>附件</span>
<div class="transport-plan-page__transport-plan-detail-attachments">
<el-link
v-for="file in attachmentRows"
:key="file.url || file.name || file.originalName"
type="primary"
@click="previewAttachment(file)"
>
{{ attachmentName(file) }}
</el-link>
<span v-if="!attachmentRows.length">-</span>
</div>
</div>
</div>
<div class="transport-plan-page__transport-plan-detail-route">
<div class="transport-plan-page__transport-plan-detail-route-point">
<b class="is-start">起</b>
<div>
<strong>{{ formatTransportPlanDetailRouteName('departure') }}</strong>
<p>{{ detailRow.departureAddress || '-' }}</p>
<p>
{{
[detailRow.departureContact, detailRow.departurePhone]
.filter(Boolean)
.join(' - ') || '-'
}}
</p>
</div>
</div>
<span class="transport-plan-page__transport-plan-detail-route-line" />
<div class="transport-plan-page__transport-plan-detail-route-point">
<b class="is-end">终</b>
<div>
<strong>{{ formatTransportPlanDetailRouteName('arrival') }}</strong>
<p>{{ detailRow.arrivalAddress || '-' }}</p>
<p>
{{
[detailRow.arrivalContact, detailRow.arrivalPhone]
.filter(Boolean)
.join(' - ') || '-'
}}
</p>
</div>
</div>
</div>
</div>
</section>
<section class="transport-plan-page__transport-plan-waybill-card">
<div class="transport-plan-page__transport-plan-waybill-heading">
<h3>运单记录</h3>
<span>总量{{ transportPlanDispatchSummary.total }}吨</span>
<span
>,已调度 <em>{{ transportPlanDispatchSummary.assigned.toFixed(2) }}</em> 吨</span
>
<span
>,剩余 <em>{{ transportPlanDispatchSummary.remaining.toFixed(2) }}</em> 吨</span
>
</div>
<el-table
:data="transportPlanWaybillPageRows"
border
height="calc(100vh - 475px)"
empty-text="暂无运单记录"
class="transport-plan-page__transport-plan-waybill-table"
>
<el-table-column
type="index"
label="序号"
width="96"
align="center"
:index="transportPlanWaybillIndex"
/>
<el-table-column label="运单号" width="192">
<template #default="{ row }">
<el-link
v-if="row.id || row.waybillNo"
type="primary"
@click="openTransportPlanWaybillDetail(row)"
>
{{ row.waybillNo || '-' }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="vehicleNo" label="车牌号/航班号/船号/班列号" width="200" />
<el-table-column prop="driverName" label="司机" width="128" />
<el-table-column prop="carrierName" label="承运商" width="192" />
<el-table-column label="运输方式" width="192">
<template #default="{ row }">
{{ formatTransportPlanDetailTransportType(row.transportType) }}
</template>
</el-table-column>
<el-table-column prop="carrierType" label="承运类型" width="192" />
<el-table-column prop="goodsInfo" label="货物信息" min-width="320" />
<el-table-column label="发货地址" width="220" show-overflow-tooltip>
<template #default="{ row }">
{{ formatTransportPlanProvinceCityDistrict(row.departureAddress) }}
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center" fixed="right">
<template #default="{ row }">
<span
:class="['transport-plan-page__transport-plan-waybill-status', row.statusClass]"
>
{{ row.statusText || '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click="openTransportPlanWaybillDetail(row)">
详情
</el-link>
</template>
</el-table-column>
</el-table>
<div class="transport-plan-page__transport-plan-waybill-pagination">
<el-pagination
v-model:current-page="transportPlanWaybillPage.currentPage"
v-model:page-size="transportPlanWaybillPage.pageSize"
:page-sizes="transportPlanWaybillPage.pageSizes"
layout="total, sizes, prev, pager, next"
:total="transportPlanWaybillRows.length"
@current-change="handleTransportPlanWaybillCurrentChange"
@size-change="handleTransportPlanWaybillSizeChange"
/>
</div>
</section>
</template>
<template v-else>
<section-card
v-for="section in detailSections"
:key="section.title"
:title="section.title"
>
<el-descriptions :column="2" border>
<el-descriptions-item
v-for="field in section.fields"
:key="field[0]"
:label="field[1]"
:span="field[2] || 1"
>
{{ formatDetailValue(detailRow, field[0]) }}
</el-descriptions-item>
</el-descriptions>
</section-card>
<section-card v-if="config.enableTransportPlanForm" title="货物信息">
<el-table :data="transportCargoRows" empty-text="暂无货物信息">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="180" />
<el-table-column prop="cargoType" label="货物类型" min-width="150" />
<el-table-column prop="quantity" label="数量" min-width="100" />
<el-table-column prop="quantityUnit" label="数量单位" min-width="110" />
<el-table-column prop="packageType" label="包装" min-width="110" />
<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="materialCode" label="物料编码" min-width="140" />
<el-table-column prop="deviceCode" label="设备编码" min-width="140" />
<el-table-column prop="remark" label="备注" min-width="160" />
</el-table>
</section-card>
<section-card
v-if="config.enableAttachmentTable"
:title="config.attachmentTitle || '附件'"
>
<div
v-if="config.detailAttachmentDescriptionPlain"
class="transport-plan-page__attachment-description"
>
<div
v-for="(row, index) in attachmentRows"
:key="row.id || row.uid || `${attachmentName(row)}-${index}`"
class="transport-plan-page__attachment-description-item"
>
<span class="transport-plan-page__attachment-description-label">
{{ attachmentName(row) }}
</span>
<span>{{ row.description || '-' }}</span>
</div>
</div>
<el-table :data="attachmentRows" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="文件名" min-width="260">
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
</template>
</el-table-column>
<el-table-column
v-if="!config.detailAttachmentDescriptionPlain"
prop="description"
label="附件描述"
min-width="220"
/>
<el-table-column label="文件大小" width="110">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" />
<el-table-column
prop="uploadTime"
label="上传时间"
:width="config.detailAttachmentUploadTimeWidth || 180"
sortable
/>
</el-table>
</section-card>
</template>
</div>
<template #footer>
<el-button type="primary" @click="detailBox = false">关闭</el-button>
</template>
</el-dialog>
<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-if="config.enableTransportPlanImport"
v-model="transportPlanImportBox"
:title="config.transportPlanImportTitle || `导入${config.title}`"
append-to-body
width="96%"
class="transport-plan-page__transport-plan-import-dialog"
@closed="resetTransportPlanImport"
>
<el-form
ref="transportPlanImportFormRef"
:model="transportPlanImportForm"
:rules="transportPlanImportRules"
label-position="right"
label-width="auto"
class="transport-plan-page__transport-plan-import-form"
>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="项目" prop="projectId" required>
<el-select
v-model="transportPlanImportForm.projectId"
placeholder="模糊查询后选择"
filterable
clearable
:loading="projectLoading"
@visible-change="visible => visible && loadProjectOptions()"
@change="handleTransportPlanImportProjectChange"
>
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.projectName"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="客户" prop="customerName" required>
<el-input
v-model="transportPlanImportForm.customerName"
disabled
placeholder="选择项目后带出"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="客户合同" prop="contractId" required>
<el-select
v-model="transportPlanImportForm.contractId"
placeholder="选择项目后选择"
filterable
clearable
:loading="transportPlanImportContractLoading"
:disabled="!transportPlanImportForm.projectId"
@change="handleTransportPlanImportContractChange"
>
<el-option
v-for="item in transportPlanImportContractOptions"
:key="item.id"
:label="item.contractName"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item
label="计划明细表"
prop="file"
required
class="transport-plan-page__import-file-item"
>
<el-upload
action="#"
accept=".xls,.xlsx"
:auto-upload="false"
:limit="1"
:file-list="transportPlanImportFiles"
:on-change="handleTransportPlanImportFileChange"
:on-remove="handleTransportPlanImportFileRemove"
>
<el-button type="primary">添加附件</el-button>
</el-upload>
<span class="transport-plan-page__import-file-tip"
>请上传计划明细表,仅支持 Excel 格式</span
>
<el-link type="primary" @click="handleTransportPlanTemplate">下载模板</el-link>
</el-form-item>
</el-form>
<section v-if="transportPlanImportRows.length" class="transport-plan-page__import-preview">
<div class="transport-plan-page__import-preview-head">
<div class="dialog-section-title">数据明细</div>
<el-button
type="danger"
plain
:disabled="!transportPlanImportSelection.length"
@click="removeSelectedTransportPlanImportRows"
>
批量删除
</el-button>
</div>
<el-tabs
type="border-card"
v-model="transportPlanImportTab"
class="transport-plan-page__import-preview-tabs"
>
<el-tab-pane :label="`计划单数(${transportPlanImportRows.length}`" name="all" />
<el-tab-pane :label="`疑似重复(${transportPlanImportDuplicateCount})`" name="duplicate" />
</el-tabs>
<div class="transport-plan-page__import-preview-table">
<el-table
:data="transportPlanImportVisibleRows"
border
row-key="_key"
empty-text="暂无明细数据"
@selection-change="handleTransportPlanImportSelectionChange"
>
<el-table-column type="selection" width="52" fixed="left" />
<el-table-column type="index" label="序号" width="70" fixed="left" />
<el-table-column label="计划单号" min-width="150">
<template #default>待生成</template>
</el-table-column>
<el-table-column label="计划名称" min-width="180">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.planName" />
<span v-else>{{ row.planName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="运输类型" min-width="160">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.transportType" />
<span v-else>{{ row.transportType || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="货物名称" min-width="160">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.cargoName" />
<span v-else>{{ row.cargoName || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="货物类型" min-width="160">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.cargoType" />
<span v-else>{{ row.cargoType || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="数量" min-width="120">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.quantity" />
<span v-else>{{ row.quantity || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="计量单位" min-width="130">
<template #default="{ row }">
<el-select v-if="isEditingTransportPlanImportRow(row)" v-model="row.quantityUnit">
<el-option
v-for="item in quantityUnitOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<span v-else>{{ row.quantityUnit || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="发货地址" min-width="260">
<template #default="{ row }">
<el-input
v-if="isEditingTransportPlanImportRow(row)"
v-model="row.departureAddress"
/>
<span v-else>{{ row.departureAddress || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="发货联系人" min-width="140">
<template #default="{ row }">
<el-input
v-if="isEditingTransportPlanImportRow(row)"
v-model="row.departureContact"
/>
<span v-else>{{ row.departureContact || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="发货联系人电话" min-width="160">
<template #default="{ row }">
<el-input
v-if="isEditingTransportPlanImportRow(row)"
v-model="row.departurePhone"
/>
<span v-else>{{ row.departurePhone || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="到货地址" min-width="260">
<template #default="{ row }">
<el-input
v-if="isEditingTransportPlanImportRow(row)"
v-model="row.arrivalAddress"
/>
<span v-else>{{ row.arrivalAddress || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="收货联系人" min-width="140">
<template #default="{ row }">
<el-input
v-if="isEditingTransportPlanImportRow(row)"
v-model="row.arrivalContact"
/>
<span v-else>{{ row.arrivalContact || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="收货联系人电话" min-width="160">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.arrivalPhone" />
<span v-else>{{ row.arrivalPhone || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="开始时间" min-width="140">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.planStartDate" />
<span v-else>{{ row.planStartDate || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="结束时间" min-width="140">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.planEndDate" />
<span v-else>{{ row.planEndDate || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template #default="{ row }">
<el-input v-if="isEditingTransportPlanImportRow(row)" v-model="row.remark" />
<span v-else>{{ row.remark || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<template v-if="isEditingTransportPlanImportRow(row)">
<el-link type="primary" @click="saveTransportPlanImportRow(row)">保存</el-link>
<el-link type="primary" @click="cancelTransportPlanImportRow(row)">取消</el-link>
</template>
<template v-else>
<el-link type="primary" @click="editTransportPlanImportRow(row)">编辑</el-link>
<el-link type="danger" @click="removeTransportPlanImportRow(row)">删除</el-link>
</template>
</template>
</el-table-column>
</el-table>
</div>
</section>
<template #footer>
<el-button
type="primary"
:loading="transportPlanImportLoading"
@click="submitTransportPlanImport"
>提交</el-button
>
<el-button @click="transportPlanImportBox = 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="dispatchDialogTitle"
append-to-body
v-model="dispatchBox"
width="calc(100vw - 32px)"
top="16px"
class="transport-plan-page__dispatch-dialog"
@closed="resetDispatchDialog"
>
<div v-loading="dispatchLoading" class="transport-plan-page__dispatch-shell">
<div class="transport-plan-page__dispatch-top">
<div class="transport-plan-page__dispatch-heading">
<div class="transport-plan-page__dispatch-heading-title">
<span class="transport-plan-page__dispatch-heading-name">
{{ dispatchDialogTitle }}
</span>
<span class="transport-plan-page__dispatch-heading-no">
{{ dispatchDialogNo || '-' }}
</span>
<el-tag
:type="statusTagType(dispatchRow.businessStatus)"
effect="light"
class="status-text"
>
{{ dispatchDialogStatusText }}
</el-tag>
<el-tag type="primary" effect="light">
{{ formatDispatchTransportType(dispatchRow.transportType) || '公路整车' }}
</el-tag>
</div>
</div>
<div class="transport-plan-page__dispatch-summary">
<section-card title="基本信息" class="transport-plan-page__dispatch-summary-card">
<div class="transport-plan-page__dispatch-summary-grid">
<div class="transport-plan-page__dispatch-summary-item">
<div class="transport-plan-page__dispatch-summary-label">客户</div>
<div class="transport-plan-page__dispatch-summary-value">
{{ dispatchRow.customerName || '-' }}
</div>
</div>
<div class="transport-plan-page__dispatch-summary-item">
<div class="transport-plan-page__dispatch-summary-label">项目</div>
<div class="transport-plan-page__dispatch-summary-value">
{{ dispatchRow.projectName || '-' }}
</div>
</div>
<div class="transport-plan-page__dispatch-summary-item">
<div class="transport-plan-page__dispatch-summary-label">合同编号</div>
<div class="transport-plan-page__dispatch-summary-value">
{{ dispatchRow.contractNo || '-' }}
</div>
</div>
<div class="transport-plan-page__dispatch-summary-item">
<div class="transport-plan-page__dispatch-summary-label">计划执行时间</div>
<div class="transport-plan-page__dispatch-summary-value">
{{
[dispatchRow.planStartDate, dispatchRow.planEndDate]
.filter(Boolean)
.join(' ~ ') || '-'
}}
</div>
</div>
<div class="transport-plan-page__dispatch-summary-item is-wide">
<div class="transport-plan-page__dispatch-summary-label">货物信息</div>
<div class="transport-plan-page__dispatch-summary-value">
{{ dispatchPlanCargoInfo || '-' }}
</div>
</div>
<div
class="transport-plan-page__dispatch-summary-item transport-plan-page__dispatch-summary-item--attachments"
>
<div class="transport-plan-page__dispatch-summary-label">附件</div>
<div class="transport-plan-page__dispatch-summary-value">
<el-link
v-for="(item, index) in dispatchAttachmentRows"
:key="dispatchAttachmentLabel(item) + index"
type="primary"
:href="dispatchAttachmentUrl(item) || undefined"
:underline="false"
target="_blank"
class="transport-plan-page__dispatch-attachment-link"
>
{{ dispatchAttachmentLabel(item) }}
</el-link>
<span v-if="!dispatchAttachmentRows.length">-</span>
</div>
</div>
</div>
</section-card>
<section-card title="收发货路线" class="transport-plan-page__dispatch-route-card">
<div class="transport-plan-page__dispatch-route">
<div class="transport-plan-page__dispatch-route-item">
<span class="transport-plan-page__dispatch-route-badge is-start">起</span>
<div class="transport-plan-page__dispatch-route-body">
<div class="transport-plan-page__dispatch-route-title">
{{ parseDispatchRoutePoint(dispatchRow, 'start').name || '-' }}
</div>
<div class="transport-plan-page__dispatch-route-address">
{{
formatTransportPlanProvinceCityDistrict(
parseDispatchRoutePoint(dispatchRow, 'start').address
)
}}
</div>
<div class="transport-plan-page__dispatch-route-contact">
{{ parseDispatchRoutePoint(dispatchRow, 'start').contact || '-' }}
</div>
</div>
</div>
<div class="transport-plan-page__dispatch-route-vehicle">
<el-icon><Van /></el-icon>
</div>
<div class="transport-plan-page__dispatch-route-item">
<span class="transport-plan-page__dispatch-route-badge is-end">终</span>
<div class="transport-plan-page__dispatch-route-body">
<div class="transport-plan-page__dispatch-route-title">
{{ parseDispatchRoutePoint(dispatchRow, 'end').name || '-' }}
</div>
<div class="transport-plan-page__dispatch-route-address">
{{
formatTransportPlanProvinceCityDistrict(
parseDispatchRoutePoint(dispatchRow, 'end').address
)
}}
</div>
<div class="transport-plan-page__dispatch-route-contact">
{{ parseDispatchRoutePoint(dispatchRow, 'end').contact || '-' }}
</div>
</div>
</div>
</div>
</section-card>
</div>
</div>
<section-card class="transport-plan-page__dispatch-list-card">
<template #title>
<span>待调度列表</span>
<span class="transport-plan-page__dispatch-list-summary">
{{ dispatchSummaryText }}
</span>
</template>
<template #extra>
<el-button
type="primary"
:disabled="!dispatchHasAddableQuantity"
@click="handleDispatchAddRow"
>
新增
</el-button>
</template>
<el-table
:data="dispatchRows"
border
height="100%"
row-key="_key"
class="transport-plan-page__dispatch-table"
>
<el-table-column type="index" label="序号" width="70" align="center" fixed="left" />
<el-table-column label="运输方式" min-width="180" align="center" show-overflow-tooltip>
<template #default="{ row }">
{{ formatDispatchTransportType(row.transportType) || '-' }}
</template>
</el-table-column>
<el-table-column
prop="carrierType"
label="承运类型"
min-width="150"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="carrierName"
label="承运商"
min-width="150"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="driverName"
label="司机"
min-width="260"
align="center"
show-overflow-tooltip
class-name="transport-plan-page__dispatch-driver-column"
>
<template #default="{ row }">
<span class="transport-plan-page__dispatch-driver">{{
formatDispatchDriver(row)
}}</span>
</template>
</el-table-column>
<el-table-column
prop="vehicleNo"
label="车牌号/航班号/船号/班列号"
min-width="200"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="cargoType"
label="货物类型"
min-width="170"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="cargoInfo"
label="货物信息"
min-width="280"
align="center"
show-overflow-tooltip
/>
<el-table-column label="发货地址" min-width="220" align="center" show-overflow-tooltip>
<template #default="{ row }">
{{ formatTransportPlanProvinceCityDistrict(row.departureAddress) }}
</template>
</el-table-column>
<el-table-column label="收货地址" min-width="220" align="center" show-overflow-tooltip>
<template #default="{ row }">
{{ formatTransportPlanProvinceCityDistrict(row.arrivalAddress) }}
</template>
</el-table-column>
<el-table-column prop="unitPrice" label="单价" min-width="120" align="center" />
<el-table-column prop="freight" label="运费" min-width="120" align="center" />
<el-table-column
prop="otherFeeTotal"
label="其他费用合计"
min-width="140"
align="center"
/>
<el-table-column prop="freightTotal" label="运费合计" min-width="120" align="center" />
<el-table-column
prop="dispatchUserName"
label="调度人"
min-width="140"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="dispatchTime"
label="调度时间"
min-width="160"
align="center"
show-overflow-tooltip
sortable
/>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row, $index }">
<div class="transport-plan-page__dispatch-actions">
<el-link type="primary" @click="handleDispatchEditRow(row, $index)">编辑</el-link>
<el-link type="danger" @click="removeDispatchRow($index)">删除</el-link>
</div>
</template>
</el-table-column>
</el-table>
</section-card>
</div>
<template #footer>
<div class="transport-plan-page__dispatch-footer">
<el-button @click="dispatchBox = false">关闭</el-button>
<el-button
type="primary"
:loading="dispatchSaving === 'submit'"
@click="handleDispatchSubmit('submit')"
>
确认生成
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
:title="dispatchItemDialogTitle"
append-to-body
v-model="dispatchItemBox"
width="96%"
top="8vh"
class="transport-plan-page__dispatch-item-dialog"
@closed="resetDispatchItemDialog"
>
<el-form
:model="dispatchItemForm"
label-position="right"
label-width="auto"
class="transport-plan-page__dispatch-item-form"
>
<div class="dialog-section-title">收发货信息</div>
<div class="transport-plan-page__dispatch-shipping-form">
<el-form-item label="发货地址" required>
<div class="transport-plan-page__route-address-row">
<el-input
v-model="dispatchItemForm.departureName"
:placeholder="dispatchStationNamePlaceholder"
:readonly="dispatchStationMode"
@click="openDispatchPrimaryAddressPicker('departure')"
>
<template v-if="dispatchStationMode" #suffix>
<el-icon
class="transport-plan-page__map-suffix"
@click.stop="openDispatchSecondaryAddressPicker('departure')"
>
<Search />
</el-icon>
</template>
</el-input>
<el-input
v-model="dispatchItemForm.departureAddress"
:placeholder="dispatchAddressDetailPlaceholder"
:readonly="dispatchStationMode"
@click="handleDispatchSecondaryAddressInputClick('departure')"
>
<template v-if="!dispatchStationMode" #suffix>
<el-link
type="primary"
:underline="false"
class="transport-plan-page__address-choose-link"
@click.stop="openTransportMapDialog('departure')"
>选择</el-link
>
</template>
</el-input>
<span class="transport-plan-page__route-label">联系人</span>
<el-input v-model="dispatchItemForm.departureContact" placeholder="请输入" />
<span class="transport-plan-page__route-label">联系方式</span>
<el-input v-model="dispatchItemForm.departurePhone" placeholder="请输入" />
</div>
</el-form-item>
<el-form-item label="收货地址" required>
<div class="transport-plan-page__route-address-row">
<el-input
v-model="dispatchItemForm.arrivalName"
:placeholder="dispatchStationNamePlaceholder"
:readonly="dispatchStationMode"
@click="openDispatchPrimaryAddressPicker('arrival')"
>
<template v-if="dispatchStationMode" #suffix>
<el-icon
class="transport-plan-page__map-suffix"
@click.stop="openDispatchSecondaryAddressPicker('arrival')"
>
<Search />
</el-icon>
</template>
</el-input>
<el-input
v-model="dispatchItemForm.arrivalAddress"
:placeholder="dispatchAddressDetailPlaceholder"
:readonly="dispatchStationMode"
@click="handleDispatchSecondaryAddressInputClick('arrival')"
>
<template v-if="!dispatchStationMode" #suffix>
<el-link
type="primary"
:underline="false"
class="transport-plan-page__address-choose-link"
@click.stop="openTransportMapDialog('arrival')"
>选择</el-link
>
</template>
</el-input>
<span class="transport-plan-page__route-label">联系人</span>
<el-input v-model="dispatchItemForm.arrivalContact" placeholder="请输入" />
<span class="transport-plan-page__route-label">联系方式</span>
<el-input v-model="dispatchItemForm.arrivalPhone" placeholder="请输入" />
</div>
</el-form-item>
</div>
<div class="transport-plan-page__dispatch-mode-switch">
<el-segmented
v-model="dispatchItemForm.taskEntryMode"
:options="dispatchTaskEntryModeOptions"
@change="handleDispatchTaskEntryModeChange"
/>
</div>
<template v-if="dispatchItemForm.taskEntryMode === 'full'">
<div
class="dialog-section-title dialog-section-title--action transport-plan-page__goods-title"
>
<span>货物信息</span>
<div class="transport-plan-page__section-actions">
<!-- <el-link type="primary" @click="cargoImportBox = true">导入货物</el-link> -->
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div>
</div>
<div class="transport-plan-page__cargo-wrap">
<el-table
:data="dispatchItemCargoRows"
border
class="transport-plan-page__cargo-table transport-plan-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="transport-plan-page__required-column">货物类型</span></template
>
<template #default="{ row }">
<el-autocomplete
v-model="row.cargoType"
placeholder="请输入货物类型"
clearable
:debounce="300"
:fetch-suggestions="fetchDispatchConfiguredCargoTypeSuggestions"
@select="item => handleDispatchConfiguredCargoTypeSelect(row, item)"
@change="value => handleDispatchConfiguredCargoTypeChange(row, value)"
/>
</template>
</el-table-column>
<el-table-column min-width="240" align="center">
<template #header
><span class="transport-plan-page__required-column">货物名称</span></template
>
<template #default="{ row }">
<el-autocomplete
v-model="row.cargoName"
placeholder="请输入货物名称"
clearable
:debounce="300"
:fetch-suggestions="
(queryString, callback) =>
fetchDispatchConfiguredCargoNameSuggestions(queryString, callback, row)
"
@select="item => handleDispatchConfiguredCargoNameSelect(row, item)"
@change="value => handleDispatchConfiguredCargoNameChange(row, value)"
/>
</template>
</el-table-column>
<el-table-column label="包装" min-width="130" align="center">
<template #default="{ row }">
<el-select v-model="row.packageType" clearable>
<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 label="剩余数量" min-width="130" align="center">
<template #default="{ row }">
{{
isDispatchPlanQuantityUnlimited(row)
? '-'
: formatDispatchQuantity(dispatchItemRemainingQuantity(row))
}}
</template>
</el-table-column>
<el-table-column min-width="130" align="center">
<template #header
><span class="transport-plan-page__required-column">本次数量</span></template
>
<template #default="{ row }">
<el-input
v-model="row.quantity"
placeholder="请输入"
@input="value => handleDispatchCargoQuantityInput(row, value)"
/>
</template>
</el-table-column>
<el-table-column min-width="140" align="center">
<template #header
><span class="transport-plan-page__required-column">数量单位</span></template
>
<template #default="{ row }">
<el-select
v-model="row.quantityUnit"
clearable
@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="请输入" /></template
></el-table-column>
<el-table-column label="规格" min-width="130" align="center"
><template #default="{ row }"
><el-input v-model="row.specification" placeholder="请输入" /></template
></el-table-column>
<el-table-column label="型号" min-width="130" align="center"
><template #default="{ row }"
><el-input v-model="row.model" placeholder="请输入" /></template
></el-table-column>
<el-table-column label="物料编码" min-width="150" align="center"
><template #default="{ row }"
><el-input v-model="row.materialCode" placeholder="请输入" /></template
></el-table-column>
<el-table-column label="设备编码" min-width="150" align="center"
><template #default="{ row }"
><el-input v-model="row.deviceCode" placeholder="请输入" /></template
></el-table-column>
<el-table-column label="备注" min-width="180" align="center"
><template #default="{ row }"
><el-input v-model="row.remark" placeholder="请输入" /></template
></el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ $index }">
<div class="transport-plan-page__cargo-actions">
<el-link
v-if="$index === dispatchItemCargoRows.length - 1"
type="primary"
@click="addDispatchCargoRow($index)"
>新增</el-link
>
<el-link type="danger" @click="removeDispatchCargoRow($index)">删除</el-link>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="dialog-section-title">费用信息</div>
<div
class="transport-plan-page__dispatch-item-grid transport-plan-page__dispatch-item-grid--full"
>
<template v-for="group in dispatchItemFreightGroups" :key="group.key">
<el-form-item label="单价">
<el-input
:model-value="dispatchFreightGroupUnitPrice(group)"
placeholder="请输入"
@input="value => handleDispatchFreightGroupNumberInput(group, value)"
@clear="() => handleDispatchFreightGroupNumberInput(group, '')"
>
<template #append
><el-select
:model-value="dispatchFreightGroupPriceUnit(group)"
class="transport-plan-page__dispatch-unit-select"
placeholder="元/吨"
@change="value => handleDispatchFreightGroupPriceUnitChange(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="dispatchFreightGroupQuantity(group)" disabled
><template #append>{{ fixedQuantityUnit }}</template></el-input
></el-form-item
>
<el-form-item label="运费"
><el-input
:model-value="dispatchFreightGroupAmount(group)"
placeholder="请输入运费"
@input="value => handleDispatchFreightGroupAmountInput(group, value)"
@clear="() => handleDispatchFreightGroupAmountInput(group, '')"
><template #suffix>{{ dispatchItemFreightCurrencyLabel }}</template></el-input
></el-form-item
>
</template>
<el-form-item label="其他运费合计"
><el-input
v-model="dispatchItemForm.otherFeeTotal"
placeholder="请输入"
@input="value => handleDispatchNumberInput('otherFeeTotal', value)"
><template #suffix>{{ dispatchItemFreightCurrencyLabel }}</template></el-input
></el-form-item
>
<el-form-item label="运费合计"
><el-input :model-value="dispatchItemFreightTotal" disabled
><template #suffix>{{ dispatchItemFreightCurrencyLabel }}</template></el-input
></el-form-item
>
</div>
</template>
<template v-else>
<div class="dialog-section-title transport-plan-page__dispatch-task-content-title">
任务信息
</div>
<div
class="transport-plan-page__dispatch-item-grid transport-plan-page__dispatch-item-grid--compact"
>
<el-form-item
label="承运类型"
required
class="transport-plan-page__dispatch-item-carrier-type"
>
<el-radio-group
v-model="dispatchItemForm.carrierType"
@change="handleDispatchCarrierTypeChange"
>
<el-radio-button v-for="item in taskCarrierTypes" :key="item" :label="item" />
</el-radio-group>
</el-form-item>
<el-form-item :label="dispatchCarrierLabel" :required="dispatchCarrierRequired">
<el-select
v-model="dispatchCarrierSelection"
:placeholder="`请选择${dispatchCarrierLabel}`"
filterable
clearable
:loading="dispatchCarrierLoading"
@visible-change="visible => visible && loadDispatchCarrierOptions()"
@change="handleDispatchCarrierChange"
>
<el-option
v-for="item in dispatchCarrierOptions"
:key="
item.id || item.customerName || item.carrierName || item.fullName || item.name
"
:label="
item.label ||
item.customerName ||
item.carrierName ||
item.fullName ||
item.name
"
:value="
dispatchIsCarrierMode
? item.carrierContractId
: item.value ||
item.customerName ||
item.carrierName ||
item.fullName ||
item.name
"
/>
</el-select>
</el-form-item>
<el-form-item label="司机" :required="dispatchItemForm.carrierType !== '承运商'">
<el-autocomplete
v-model="dispatchItemForm.driverName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskDriverSuggestions"
:loading="taskDriverLoading"
@select="item => handleTaskDriverSelect('dispatch', item)"
/>
</el-form-item>
<el-form-item
:label="dispatchItemForm.carrierType === '承运商' ? '手机号' : '司机手机号'"
:required="dispatchItemForm.carrierType !== '承运商'"
>
<el-input v-model="dispatchItemForm.driverPhone" placeholder="请输入" />
</el-form-item>
<el-form-item label="车牌号" required>
<el-input v-model="dispatchItemForm.vehicleNo" placeholder="请输入" />
</el-form-item>
<el-form-item v-if="dispatchItemForm.carrierType !== '承运商'" label="挂车车牌号">
<el-input v-model="dispatchItemForm.trailerVehicleNo" placeholder="请输入" />
</el-form-item>
<el-form-item v-if="dispatchItemForm.carrierType !== '承运商'" label="押运人">
<el-autocomplete
v-model="dispatchItemForm.escortName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskEscortSuggestions"
:loading="taskEscortLoading"
@select="handleTaskEscortSelect"
/>
</el-form-item>
<el-form-item v-if="dispatchItemForm.carrierType !== '承运商'" label="押运人手机号">
<el-input v-model="dispatchItemForm.escortPhone" placeholder="请输入" />
</el-form-item>
<el-form-item label="货物类型" required>
<el-autocomplete
v-model="dispatchItemForm.cargoType"
placeholder="请输入货物类型"
clearable
:debounce="300"
:fetch-suggestions="fetchDispatchConfiguredCargoTypeSuggestions"
@select="item => handleDispatchConfiguredCargoTypeSelect(dispatchItemForm, item)"
@change="value => handleDispatchConfiguredCargoTypeChange(dispatchItemForm, value)"
/>
</el-form-item>
<el-form-item label="货物名称" required>
<el-autocomplete
v-model="dispatchItemForm.cargoName"
placeholder="请输入货物名称"
clearable
:debounce="300"
:fetch-suggestions="
(queryString, callback) =>
fetchDispatchConfiguredCargoNameSuggestions(
queryString,
callback,
dispatchItemForm
)
"
@select="item => handleDispatchConfiguredCargoNameSelect(dispatchItemForm, item)"
@change="value => handleDispatchConfiguredCargoNameChange(dispatchItemForm, value)"
/>
</el-form-item>
<el-form-item label="规格">
<el-input v-model="dispatchItemForm.specification" placeholder="请输入" />
</el-form-item>
<el-form-item label="型号">
<el-input v-model="dispatchItemForm.model" placeholder="请输入" />
</el-form-item>
<el-form-item label="本次数量" required>
<el-input v-model="dispatchItemForm.quantity" placeholder="请输入">
<template #append>
<el-select
v-model="dispatchItemForm.quantityUnit"
class="transport-plan-page__dispatch-unit-select"
clearable
>
<el-option
v-for="item in quantityUnitOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</template>
</el-input>
<div
v-if="!isDispatchPlanQuantityUnlimited(dispatchItemForm)"
class="transport-plan-page__dispatch-quantity-hint"
>
剩余数量:{{
formatDispatchQuantity(dispatchItemRemainingQuantity(dispatchItemForm))
}}
{{ dispatchItemForm.quantityUnit || '吨' }}
</div>
</el-form-item>
<el-form-item label="里程(公里)">
<el-input
v-model="dispatchItemForm.mileage"
placeholder="请输入"
maxlength="10"
@input="handleDispatchMileageInput"
/>
</el-form-item>
<el-form-item label="预计发货日期">
<el-date-picker
v-model="dispatchItemForm.estimatedStartTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
</el-form-item>
<el-form-item label="预计完成日期">
<el-date-picker
v-model="dispatchItemForm.estimatedEndTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
</el-form-item>
<el-form-item label="单价">
<el-input v-model="dispatchItemForm.unitPrice" placeholder="请输入">
<template #append>
<el-select
v-model="dispatchItemForm.priceUnit"
class="transport-plan-page__dispatch-unit-select"
placeholder="元/吨"
:loading="taskFeeUnitLoading"
@visible-change="visible => visible && loadTaskFeeUnitOptions()"
>
<el-option
v-if="!taskFeeUnitOptions.some(item => item.value === '元/吨')"
label="元/吨"
value="元/吨"
/>
<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="dispatchItemForm.freightAmount"
placeholder="请输入运费"
@input="value => handleDispatchNumberInput('freightAmount', value)"
>
<template #suffix>{{ dispatchItemFreightCurrencyLabel }}</template>
</el-input>
</el-form-item>
<el-form-item label="其他费用合计">
<el-input v-model="dispatchItemForm.otherFeeTotal" placeholder="请输入">
<template #suffix>{{ dispatchItemFreightCurrencyLabel }}</template>
</el-input>
</el-form-item>
<el-form-item label="运费合计">
<el-input :model-value="dispatchItemFreightTotal" disabled>
<template #suffix>{{ dispatchItemFreightCurrencyLabel }}</template>
</el-input>
</el-form-item>
<el-form-item label="备注" class="transport-plan-page__dispatch-item-span-3">
<el-input
v-model="dispatchItemForm.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
:disabled="dialogReadonly"
placeholder="请输入,200字以内"
/>
</el-form-item>
</div>
</template>
<div
v-if="dispatchItemForm.taskEntryMode === 'full'"
class="dialog-section-title transport-plan-page__dispatch-task-content-title"
>
任务信息
</div>
<div
v-if="dispatchItemForm.taskEntryMode === 'full'"
class="transport-plan-page__dispatch-item-grid transport-plan-page__dispatch-item-grid--carrier"
>
<el-form-item label="承运类型" required class="transport-plan-page__dispatch-item-span-4">
<el-radio-group
v-model="dispatchItemForm.carrierType"
@change="handleDispatchCarrierTypeChange"
>
<el-radio-button v-for="item in taskCarrierTypes" :key="item" :label="item" />
</el-radio-group>
</el-form-item>
<el-form-item :label="dispatchCarrierLabel" :required="dispatchCarrierRequired">
<el-select
v-model="dispatchCarrierSelection"
:placeholder="`请选择${dispatchCarrierLabel}`"
filterable
clearable
:loading="dispatchCarrierLoading"
@visible-change="visible => visible && loadDispatchCarrierOptions()"
@change="handleDispatchCarrierChange"
>
<el-option
v-for="item in dispatchCarrierOptions"
:key="
item.id || item.customerName || item.carrierName || item.fullName || item.name
"
:label="
item.label || item.customerName || item.carrierName || item.fullName || item.name
"
:value="
dispatchIsCarrierMode
? item.carrierContractId
: item.value ||
item.customerName ||
item.carrierName ||
item.fullName ||
item.name
"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="dispatchIsRoadTransport"
label="司机"
:required="dispatchItemForm.carrierType === '自运'"
>
<el-autocomplete
v-model="dispatchItemForm.driverName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskDriverSuggestions"
:loading="taskDriverLoading"
@select="item => handleTaskDriverSelect('dispatch', item)"
/>
</el-form-item>
<el-form-item
v-if="dispatchIsRoadTransport"
label="手机号"
:required="dispatchItemForm.carrierType === '自运'"
>
<el-input v-model="dispatchItemForm.driverPhone" placeholder="请输入" />
</el-form-item>
<el-form-item
v-if="dispatchItemForm.carrierType"
:label="dispatchIsNonRoadTransport ? '船/航/班列号' : '车牌号'"
required
>
<el-input v-model="dispatchItemForm.vehicleNo" placeholder="请输入" />
</el-form-item>
<el-form-item v-if="dispatchIsNonRoadTransport" label="船长">
<el-input v-model="dispatchItemForm.captainName" placeholder="请输入船长" />
</el-form-item>
<el-form-item v-if="dispatchIsNonRoadTransport && dispatchIsCarrierMode" label="联系电话">
<el-input v-model="dispatchItemForm.driverPhone" placeholder="请输入联系电话" />
</el-form-item>
<el-form-item v-if="dispatchIsNonRoadTransport" label="箱号">
<el-input v-model="dispatchItemForm.containerNo" placeholder="请输入箱号" />
</el-form-item>
<el-form-item v-if="dispatchIsNonRoadTransport" label="舱位">
<el-input v-model="dispatchItemForm.cabinNo" placeholder="请输入舱位" />
</el-form-item>
<el-form-item
v-if="dispatchIsRoadTransport && dispatchItemForm.carrierType !== '承运商'"
label="挂车车牌号"
>
<el-input v-model="dispatchItemForm.trailerVehicleNo" placeholder="请输入" />
</el-form-item>
<el-form-item
v-if="dispatchIsRoadTransport && dispatchItemForm.carrierType !== '承运商'"
label="押运人"
>
<el-autocomplete
v-model="dispatchItemForm.escortName"
placeholder="请输入"
clearable
:debounce="300"
:fetch-suggestions="fetchTaskEscortSuggestions"
:loading="taskEscortLoading"
@select="handleTaskEscortSelect"
/>
</el-form-item>
<el-form-item
v-if="dispatchIsRoadTransport && dispatchItemForm.carrierType !== '承运商'"
label="押运人手机号"
>
<el-input v-model="dispatchItemForm.escortPhone" placeholder="请输入" />
</el-form-item>
<el-form-item
v-if="dispatchIsRoadTransport && dispatchItemForm.carrierType !== '承运商'"
label="里程(km)"
>
<el-input
v-model="dispatchItemForm.mileage"
placeholder="请输入"
maxlength="10"
@input="handleDispatchMileageInput"
/>
</el-form-item>
<el-form-item label="预计发货日期">
<el-date-picker
v-model="dispatchItemForm.estimatedStartTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
</el-form-item>
<el-form-item label="预计完成日期">
<el-date-picker
v-model="dispatchItemForm.estimatedEndTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
</el-form-item>
<el-form-item label="备注" class="transport-plan-page__dispatch-item-span-2">
<el-input
v-model="dispatchItemForm.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
:disabled="dialogReadonly"
placeholder="请输入,200字以内"
/>
</el-form-item>
</div>
<div class="dialog-section-title">附件</div>
<div class="transport-plan-page__attachment">
<div class="transport-plan-page__attachment-head">
<el-button
type="primary"
:disabled="!dispatchItemAttachmentRows.length"
@click="handleDispatchItemBatchDownload"
>
批量下载
</el-button>
</div>
<el-table
:data="dispatchItemAttachmentRows"
border
class="transport-plan-page__attachment-table"
@selection-change="handleDispatchItemAttachmentSelectionChange"
>
<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="center" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row, dispatchItemAttachmentRows)">
{{ attachmentName(row) }}
</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="请输入附件描述" />
</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="{ $index }">
<el-link type="danger" @click="removeDispatchItemAttachment($index)">删除</el-link>
</template>
</el-table-column>
</el-table>
<div class="transport-plan-page__attachment-upload">
<vehicle-attachment-upload
v-model="dispatchItemAttachmentRows"
:file-types="attachmentFileTypes"
:max-size="50"
:show-file-list="false"
button-text="上传附件"
@change="handleDispatchItemAttachmentChange"
/>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="dispatchItemBox = false">取消</el-button>
<el-button type="primary" @click="saveDispatchItem">提交</el-button>
</template>
</el-dialog>
<el-dialog
title="选择线路"
append-to-body
v-model="transportRouteBox"
width="1280px"
@opened="loadTransportRouteList"
>
<div class="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-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="transport-plan-page__map"></div>
</div>
<div class="transport-plan-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="transport-plan-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="billingCargoTypeOptions"
:props="billingCargoTypeCascaderProps"
placeholder="请选择"
clearable
filterable
:filter-method="filterBillingCargoType"
@visible-change="visible => visible && ensureBillingCargoTypeOptions()"
@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="transport-plan-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">
<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="transport-plan-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="transport-plan-page__cargo-import-dialog"
@closed="cargoImportRows = []"
>
<div class="transport-plan-page__cargo-import">
<div class="transport-plan-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>
</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 getCommonRouteList } from '@/api/business/common-route';
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 { applyTableMenuWidth } from '@/utils/table-menu';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
import { Location, OfficeBuilding, Search } 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 standaloneTransportPlanFormRoutes = {
'/business/transport-plan': '/business/transport-plan/form',
};
const standaloneTransportPlanFormPaths = [
...Object.keys(standaloneTransportPlanFormRoutes),
...Object.values(standaloneTransportPlanFormRoutes),
];
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
// 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);
};
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));
const dialogClass = attrs.option?.dialogCustomClass || 'transport-plan-form-page-dialog';
const groupOnly = attrs.option?.column?.length === 0 && attrs.option?.group?.length;
return h(
'div',
{ class: [dialogClass, { 'transport-plan-form-page-dialog--group-only': groupOnly }] },
children
);
},
};
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 transportPlanImportColumns = [
['计划名称', 'planName'],
['运输方式', 'transportType'],
['计划开始日期', 'planStartDate'],
['计划结束日期', 'planEndDate'],
['货物名称', 'cargoName'],
['货物类型', 'cargoType'],
['数量', 'quantity'],
['数量单位', 'quantityUnit'],
['包装', 'packageType'],
['品牌', 'brand'],
['规格', 'specification'],
['型号', 'model'],
['物料编码', 'materialCode'],
['设备编码', 'deviceCode'],
['发货地址', 'departureAddress'],
['发货联系人', 'departureContact'],
['发货联系方式', 'departurePhone'],
['收货地址', 'arrivalAddress'],
['收货联系人', 'arrivalContact'],
['收货联系方式', 'arrivalPhone'],
['备注', 'remark'],
];
const TRANSPORT_PLAN_QUANTITY_UNIT = '吨';
const defaultTransportCargo = () => ({
cargoName: '',
cargoType: '',
cargoTypeCode: '',
cargoTypePath: [],
quantity: '',
quantityUnit: '',
unitPrice: '',
priceUnit: '元/吨',
freightAmount: '',
packageType: '',
brand: '',
specification: '',
model: '',
materialCode: '',
deviceCode: '',
remark: '',
});
const defaultDispatchRow = () => ({
taskEntryMode: 'simple',
transportType: '',
carrierType: '承运商',
carrierId: '',
carrierContractId: '',
carrierName: '',
driverId: '',
driverName: '',
driverPhone: '',
vehicleNo: '',
captainName: '',
cabinNo: '',
containerNo: '',
trailerVehicleNo: '',
escortName: '',
escortPhone: '',
mileage: '',
cargoType: '',
cargoTypeCode: '',
cargoTypePath: [],
cargoName: '',
cargoInfo: '',
quantity: '',
quantityUnit: '吨',
specification: '',
model: '',
packageType: '',
brand: '',
materialCode: '',
unitPrice: '',
priceUnit: '元/吨',
freightAmount: '',
otherFeeTotal: '',
freightCurrency: 'RMB',
freightJson: '',
goodsJson: '',
estimatedStartTime: '',
estimatedEndTime: '',
departureAddress: '',
departureName: '',
departureContact: '',
departurePhone: '',
arrivalAddress: '',
arrivalName: '',
arrivalContact: '',
arrivalPhone: '',
dispatchUserName: '',
dispatchTime: '',
attachmentsJson: '',
remark: '',
});
const taskCarrierTypes = ['承运商', '自运'];
export default {
components: {
PageAvueForm,
ElImageViewer,
OpenFileViewer,
},
name: 'TransportPlanPage',
props: {
api: {
type: Object,
required: true,
},
config: {
type: Object,
required: true,
},
crudOption: {
type: Object,
required: true,
},
// 可选:固定操作列宽度;不传则按按钮数量自动估算(沿用既有逻辑)
menuWidth: {
type: Number,
default: null,
},
detailId: {
type: [String, Number],
default: null,
},
standaloneFormPage: {
type: Boolean,
default: false,
},
},
data() {
return {
Location,
OfficeBuilding,
Search,
form: {},
suppressTransportTypeClear: false,
query: {},
loading: true,
data: [],
dialogReadonly: false,
detailBox: false,
detailLoading: false,
detailRow: {},
transportPlanImportBox: false,
transportPlanImportLoading: false,
transportPlanImportFiles: [],
transportPlanImportSourceFile: null,
transportPlanImportRows: [],
transportPlanImportSelection: [],
transportPlanImportTab: 'all',
transportPlanImportEditingKey: '',
transportPlanImportEditSnapshot: null,
transportPlanImportContractOptions: [],
transportPlanImportContractLoading: false,
transportPlanImportForm: {
projectId: '',
projectName: '',
customerName: '',
contractId: '',
contractName: '',
file: null,
},
transportPlanImportRules: {
projectId: [{ required: true, message: '请选择项目', trigger: 'change' }],
customerName: [{ required: true, message: '请选择项目后带出客户', trigger: 'change' }],
contractId: [{ required: true, message: '请选择项目后带出客户合同', trigger: 'change' }],
file: [{ required: true, message: '请上传计划明细表', trigger: 'change' }],
},
crudDialogType: '',
defaultDialogButtons: null,
transportPlanCreateActionLoading: '',
option: (() => {
const opt = this.cloneOption(this.crudOption, estimateMenuButtonCount(this.config));
if (this.menuWidth != null) opt.menuWidth = this.menuWidth;
if (
this.standaloneFormPage &&
standaloneTransportPlanFormPaths.includes(this.$route.path)
) {
opt.dialogAppendToBody = false;
opt.dialogModal = false;
opt.dialogCustomClass = 'transport-plan-form-page-dialog';
opt.dialogTop = '0';
opt.dialogWidth = '100%';
} else if (!opt.dialogCustomClass) {
// 普通弹窗模式:灰底 body + 白卡表单(与独立表单页风格一致)
opt.dialogCustomClass = 'business-crud-form-dialog';
}
return opt;
})(),
selectedProjectId: '',
projectOptions: [],
projectLoading: false,
contractOptions: [],
contractLoading: false,
addressTypeOptions,
flowBox: false,
flowUrl: '',
processInstanceId: '',
dispatchBox: false,
dispatchLoading: false,
dispatchRow: {},
dispatchContractCurrency: 'RMB',
dispatchRows: [],
dispatchSaving: '',
dispatchCarrierOptions: [],
dispatchCarrierContractOptions: [],
dispatchCarrierLoading: false,
dispatchCarrierRequestId: 0,
dispatchItemBox: false,
dispatchItemIndex: -1,
dispatchItemForm: defaultDispatchRow(),
dispatchItemCargoRows: [],
dispatchItemAttachmentRows: [],
selectedDispatchItemAttachmentRows: [],
attachmentRows: [],
selectedAttachmentRows: [],
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
transportCargoRows: [],
currencyOptions: [],
currencyLoading: false,
transportRouteBox: false,
transportRouteLoading: false,
transportRouteQuery: {},
transportRouteRows: [],
transportRoutePage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
transportAddressBox: false,
transportAddressTarget: '',
transportAddressLoading: false,
transportAddressQuery: {},
transportTypeOptions: [],
transportTypeOptionsLoading: false,
transportAddressRows: [],
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: [],
commonCargoPage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
taskCarrierTypes,
taskDriverOptions: [],
taskDriverLoading: false,
taskEscortOptions: [],
taskEscortLoading: false,
taskCargoOptionsMap: {},
taskCargoLoadingMap: {},
taskQuantityUnitOptions: [],
taskQuantityUnitLoading: false,
taskFeeUnitOptions: [],
taskFeeUnitLoading: false,
dispatchTransportTypeOptions: [],
dispatchTransportTypeLoading: false,
transportPlanDetailTransportTypeOptions: [],
transportPlanDetailTransportTypeLoading: false,
transportPlanWaybillPage: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
currentPage: 1,
},
cargoImportBox: false,
cargoImportLoading: false,
cargoImportRows: [],
billingCargoTypeOptions: [],
billingCargoTypeFlatOptions: [],
billingCargoTypeLoading: false,
billingCargoTypeRequest: null,
packageOptions,
quantityUnitOptions: ['吨', '千克', '立方米', '件', '车', '箱', '托盘'],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
page: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
currentPage: 1,
total: 0,
},
selectionList: [],
standaloneFormKey: '',
};
},
created() {
this.loadProjectOptions();
if (this.shippingInfoFormEnabled) this.loadTransportTypeOptions();
if (this.isStandaloneFormPage) {
this.$nextTick(() => {
this.initStandaloneFormPage();
// 先完成独立表单初始化,再回填模板数据,避免被新增表单默认值覆盖。
this.$nextTick(() => this.consumeTemplateCreatePayload());
});
} else {
this.consumeTemplateCreatePayload();
}
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return {
addBtn: this.canCreate,
};
},
detailSections() {
return this.config.detailSections || [];
},
isStandaloneBusinessPage() {
return this.standaloneFormPage && standaloneTransportPlanFormPaths.includes(this.$route.path);
},
isStandaloneFormPage() {
return this.isStandaloneBusinessPage && ['add', 'edit'].includes(this.$route.query.mode);
},
formPageTitle() {
if (this.isStandaloneFormPage) {
return (
this.$route.query.name ||
`${this.$route.query.mode === 'edit' ? '编辑' : '新增'}${this.config.title || ''}`
);
}
return '';
},
crudContainer() {
return this.isStandaloneFormPage ? 'PageAvueForm' : 'avue-crud';
},
pageFormOption() {
if (!this.isStandaloneFormPage) return this.option;
const isTransportPlanCreate =
this.config.enableTransportPlanCreateActions === true && this.$route.query.mode === 'add';
const base = {
...this.option,
boxType: this.$route.query.mode === 'edit' ? 'edit' : 'add',
menuBtn: true,
submitBtn: !isTransportPlanCreate,
emptyBtn: false,
};
return this.buildFormGroupOption(base);
},
isTransportPlanDetailLayout() {
return this.detailBox;
},
transportPlanGoodsText() {
const text = this.formatTransportPlanGoodsInfo(this.detailRow);
return text && text !== '-' ? text : '暂无货物信息';
},
transportPlanDateRange() {
const start = this.detailRow.planStartDate || this.detailRow.planStartTime || '';
const end = this.detailRow.planEndDate || this.detailRow.planEndTime || '';
if (!start && !end) return '-';
return `${String(start).slice(0, 10)}-${String(end || start).slice(0, 10)}`;
},
transportPlanWaybillRows() {
const sources = [
this.detailRow.waybillList,
this.detailRow.waybillRows,
this.detailRow.waybills,
this.detailRow.transportPlanWaybills,
this.detailRow.transportPlanWaybillList,
this.detailRow.dispatchRecords,
this.detailRow.dispatchRecordList,
this.detailRow.dispatchList,
this.detailRow.dispatchRows,
];
const rows = sources.reduce((result, source) => {
const parsed = this.parseTransportPlanWaybillSource(source);
return result.length ? result : parsed;
}, []);
const normalizedRows = rows.map((row, index) =>
this.normalizeTransportPlanWaybillRow(row, index)
);
return normalizedRows;
},
transportPlanWaybillPageRows() {
const start =
(this.transportPlanWaybillPage.currentPage - 1) * this.transportPlanWaybillPage.pageSize;
return this.transportPlanWaybillRows.slice(
start,
start + this.transportPlanWaybillPage.pageSize
);
},
transportPlanDispatchSummary() {
const goodsRows = this.parseJsonArray(this.detailRow.goodsJson);
const total =
goodsRows.reduce(
(sum, row) => sum + Number(row.quantity || row.cargoQuantity || row.goodsQuantity || 0),
0
) || Number(this.detailRow.totalQuantity || this.detailRow.quantity || 0);
const assigned =
this.transportPlanWaybillRows.reduce(
(sum, row) => sum + Number(row.quantity || row.cargoQuantity || row.goodsQuantity || 0),
0
) || Number(this.detailRow.dispatchedQuantity || this.detailRow.dispatchQuantity || 0);
return { total, assigned, remaining: Math.max(total - assigned, 0) };
},
isTemplateCreate() {
return this.config.permission === 'transport_plan' && Boolean(this.$route.query.fromTemplate);
},
completeActionLabel() {
return '完成调度';
},
transportPlanImportDuplicateCount() {
return this.transportPlanImportRows.filter(row => row._duplicate).length;
},
transportPlanImportVisibleRows() {
return this.transportPlanImportTab === 'duplicate'
? this.transportPlanImportRows.filter(row => row._duplicate)
: this.transportPlanImportRows;
},
actionButtonLikeSearch() {
return this.config.actionButtonLikeSearch === true;
},
contractSelectEnabled() {
return this.config.enableContractSelect || this.config.enableTransportPlanForm;
},
shippingInfoFormEnabled() {
return this.config.enableShippingInfoForm || this.config.enableTransportPlanForm;
},
dispatchTaskEntryModeOptions() {
const locked = this.hasMultipleConfiguredGoods(
this.dispatchItemForm,
this.dispatchItemCargoRows
);
return [
{ label: '精简录入', value: 'simple', disabled: locked },
{ label: '完整录入', value: 'full' },
];
},
dispatchPlanGoodsRows() {
return this.dispatchGoodsRows(this.dispatchRow.goodsJson);
},
dispatchPlanCargoInfo() {
const cargoInfo = this.dispatchPlanGoodsRows
.map(row => this.formatDispatchCargoInfo({}, row))
.filter(Boolean)
.join('; ');
return cargoInfo || this.dispatchRow.goodsInfo || '';
},
dispatchSummaryItems() {
const summaryMap = new Map();
const addQuantity = (unit, field, quantity) => {
const normalizedUnit = unit || TRANSPORT_PLAN_QUANTITY_UNIT;
if (!summaryMap.has(normalizedUnit)) {
summaryMap.set(normalizedUnit, { unit: normalizedUnit, total: 0, assigned: 0 });
}
summaryMap.get(normalizedUnit)[field] += this.parseDispatchQuantity(quantity);
};
const goodsRows = this.dispatchPlanGoodsRows.length
? this.dispatchPlanGoodsRows
: [this.dispatchRow];
goodsRows.forEach(row => {
addQuantity(
this.getDispatchQuantityUnit(row),
'total',
row.quantity ||
row.cargoQuantity ||
row.goodsQuantity ||
row.totalQuantity ||
row.totalCargoQuantity
);
});
if (!this.dispatchRows.length) {
this.parseJsonArray(this.dispatchRow.dispatchRows).forEach(row => {
if (!this.hasDispatchVehicleIdentifier(row)) return;
addQuantity(this.getDispatchQuantityUnit(row), 'assigned', row.quantity);
});
}
this.dispatchRows.forEach(row => {
if (!this.hasDispatchVehicleIdentifier(row)) return;
addQuantity(this.getDispatchQuantityUnit(row), 'assigned', row.quantity);
});
return Array.from(summaryMap.values()).map(item => ({
...item,
remaining: Math.max(item.total - item.assigned, 0),
}));
},
dispatchHasRemainingQuantity() {
return this.dispatchSummaryItems.some(item => item.remaining > 0);
},
dispatchTableQuantityItems() {
const quantityMap = new Map();
this.dispatchRows.forEach(row => {
this.getDispatchGoodsSourceRows(row).forEach(goods => {
const unit = this.getDispatchQuantityUnit(goods);
quantityMap.set(
unit,
(quantityMap.get(unit) || 0) +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
)
);
});
});
return this.dispatchSummaryItems.map(item => ({
...item,
listed: quantityMap.get(item.unit) || 0,
}));
},
dispatchHasAddableQuantity() {
const items = this.dispatchTableQuantityItems;
if (!items.length) return true;
// 计划总量为 0 时不限制新增调度
if (items.every(item => item.total <= 0)) return true;
return items.some(item => item.total > 0 && item.listed < item.total - 1e-8);
},
dispatchSummaryText() {
const formatSummary = field =>
this.dispatchSummaryItems
.map(item => `${this.formatDispatchQuantity(item[field])}${item.unit}`)
.join('、');
const unlimited = this.dispatchSummaryItems.every(item => item.total <= 0);
if (unlimited) {
return `已调度 ${formatSummary('assigned') || `0${TRANSPORT_PLAN_QUANTITY_UNIT}`}`;
}
return `共${formatSummary('total')} | 已调度 ${formatSummary(
'assigned'
)},剩余${formatSummary('remaining')}`;
},
dispatchAttachmentRows() {
return this.parseJsonArray(this.dispatchRow.attachmentsJson);
},
dispatchDialogTitle() {
return '计划调度';
},
dispatchItemDialogTitle() {
const mode = this.dispatchItemForm.taskEntryMode === 'full' ? '完整录入' : '精简录入';
return this.dispatchItemIndex < 0 ? `新增/编辑(${mode}` : `编辑调度信息(${mode}`;
},
dispatchTransportMode() {
return this.resolveTransportMode(this.dispatchItemForm.transportType);
},
fixedQuantityUnit() {
return TRANSPORT_PLAN_QUANTITY_UNIT;
},
dispatchIsRoadTransport() {
return this.dispatchTransportMode === 'road';
},
dispatchIsNonRoadTransport() {
return ['water', 'rail', 'air'].includes(this.dispatchTransportMode);
},
dispatchIsCarrierMode() {
return this.dispatchItemForm.carrierType === '承运商';
},
dispatchCarrierRequired() {
return this.isDispatchCarrierRequired(this.dispatchItemForm.carrierType);
},
dispatchCarrierLabel() {
return '承运商';
},
dispatchCarrierSelection: {
get() {
return this.dispatchIsCarrierMode
? this.dispatchItemForm.carrierContractId || ''
: this.dispatchItemForm.carrierName || '';
},
set(value) {
this.handleDispatchCarrierChange(value);
},
},
dispatchStationMode() {
return ['water', 'rail', 'air'].includes(this.dispatchTransportMode);
},
dispatchStationNamePlaceholder() {
if (!this.dispatchStationMode) return '行政区划自动带出';
const labelMap = {
water: '请选择港口/码头',
rail: '请选择车站',
air: '请选择空港',
};
return labelMap[this.dispatchTransportMode] || '请选择站点';
},
dispatchAddressDetailPlaceholder() {
return this.dispatchStationMode ? '选择站点后带出详细地址' : '请输入';
},
dispatchDialogNo() {
return this.dispatchRow.planNo || this.dispatchRow.loadingNo || '';
},
dispatchDialogStatusText() {
return this.displayStatus(this.dispatchRow, this.config.statusProp || 'businessStatus');
},
dispatchItemFreightTotal() {
if (this.dispatchItemForm.taskEntryMode !== 'full') {
const otherFeeTotal = Number(this.dispatchItemForm.otherFeeTotal || 0);
const freight = this.dispatchItemFreightSubtotal;
const hasFreight = freight !== '';
const hasOtherFee = this.dispatchItemForm.otherFeeTotal !== '';
if (!hasFreight && !hasOtherFee) return '';
const total = (hasFreight ? Number(freight) : 0) + (hasOtherFee ? otherFeeTotal : 0);
return Number.isInteger(total) ? String(total) : String(Number(total.toFixed(2)));
}
const otherFeeTotal = Number(this.dispatchItemForm.otherFeeTotal || 0);
const freightSubtotal = this.dispatchItemFreightSubtotal;
const hasOtherFee = this.dispatchItemForm.otherFeeTotal !== '';
if (freightSubtotal === '' && !hasOtherFee) return '';
const total = Number(freightSubtotal || 0) + (hasOtherFee ? otherFeeTotal : 0);
return Number.isInteger(total) ? String(total) : String(Number(total.toFixed(2)));
},
dispatchItemFreightSubtotal() {
if (this.dispatchItemForm.taskEntryMode !== 'full') {
if (
this.dispatchItemForm.freightAmount !== undefined &&
this.dispatchItemForm.freightAmount !== null &&
String(this.dispatchItemForm.freightAmount).trim() !== ''
) {
return this.formatDispatchAmount(this.dispatchItemForm.freightAmount);
}
const quantity = Number(this.dispatchItemForm.quantity || 0);
const unitPrice = Number(this.dispatchItemForm.unitPrice || 0);
if (!quantity || !unitPrice) return '';
const total = quantity * unitPrice;
return Number.isInteger(total) ? String(total) : String(Number(total.toFixed(2)));
}
const total = this.dispatchItemFreightGroups.reduce(
(sum, group) => sum + Number(this.dispatchFreightGroupAmount(group) || 0),
0
);
const hasEnteredAmount = this.dispatchItemFreightGroups.some(group =>
(group.rows || []).some(
cargo =>
cargo.freightAmount !== undefined &&
cargo.freightAmount !== null &&
String(cargo.freightAmount).trim() !== ''
)
);
if (!total && !hasEnteredAmount) return '';
return Number.isInteger(total) ? String(total) : String(Number(total.toFixed(2)));
},
dispatchItemFreightGroups() {
return [
{
key: '__all__',
quantityUnit: TRANSPORT_PLAN_QUANTITY_UNIT,
rows: this.dispatchItemCargoRows || [],
},
];
},
dispatchItemFreightCurrencyLabel() {
const value = this.dispatchItemForm.freightCurrency || 'RMB';
return this.currencyRemark(value);
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
},
canCreate() {
return this.hasPermission(`${this.config.permission}_add`);
},
showTransportPlanCreateActions() {
return (
this.config.enableTransportPlanCreateActions &&
this.crudDialogType === 'add' &&
!this.dialogReadonly
);
},
billingCargoTypeCascaderProps() {
return {
label: 'cargoName',
value: 'id',
children: 'children',
disabled: (data, node) => node.level === 1,
leaf: 'leaf',
checkStrictly: true,
emitPath: true,
};
},
transportCargoTypeOptions() {
return this.billingCargoTypeOptions.filter(item => item.children?.length);
},
transportCargoTypeCascaderProps() {
return {
label: 'cargoName',
value: 'id',
children: 'children',
emitPath: true,
};
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
transportCargoQuantityTotal() {
if (!this.transportCargoRows.length) return '系统自动计算';
const total = this.transportCargoRows.reduce((sum, row) => {
const value = Number(row.quantity);
return Number.isFinite(value) ? sum + value : sum;
}, 0);
return Number.isInteger(total) ? String(total) : total.toFixed(2);
},
transportAddressSelectDisabled() {
return this.dialogReadonly || this.isBillingFieldEmpty(this.form.transportType);
},
transportMode() {
return this.resolveTransportMode(this.form.transportType);
},
transportStationMode() {
return ['water', 'rail', 'air'].includes(this.transportMode);
},
transportStationTypeLabel() {
const mode = this.dispatchItemBox ? this.dispatchTransportMode : this.transportMode;
const labelMap = {
water: '港口/码头',
rail: '铁路车站',
air: '空港机场',
};
return labelMap[mode] || '';
},
transportStationNamePlaceholder() {
if (!this.transportStationMode) return '行政区划自动带出';
const labelMap = {
water: '请选择港口/码头',
rail: '请选择车站',
air: '请选择空港',
};
return labelMap[this.transportMode] || '请选择站点';
},
transportAddressDetailPlaceholder() {
return this.transportStationMode ? '选择站点后带出详细地址' : '请输入';
},
},
watch: {
$route(to, from) {
if (!this.isStandaloneFormPage) 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) {
if (id !== undefined && id !== null && id !== '') {
this.$nextTick(() => this.openDetail({ id }));
}
},
},
'form.transportType'(value, oldValue) {
if (!this.shippingInfoFormEnabled || this.suppressTransportTypeClear) return;
if (String(value ?? '') === String(oldValue ?? '')) return;
this.handleTransportTypeChange(value);
},
},
methods: {
initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const mode = this.$route.query.mode === 'edit' ? 'edit' : 'add';
const id = this.$route.query.id || '';
const key = `${mode}:${id}`;
if (this.standaloneFormKey === key) return;
this.standaloneFormKey = key;
if (mode === 'edit') this.form = { id };
this.beforeOpen(() => {}, mode);
},
buildFormGroupOption(option) {
const sectionTitleProps = [
'basicInfoTitle',
'shippingInfoTitle',
'goodsInfoTitle',
'attachmentTitle',
];
const groups = [];
let current = [];
[...(option.column || [])]
.sort((a, b) => (b.order || 0) - (a.order || 0))
.forEach(column => {
if (sectionTitleProps.includes(column.prop) && current.length) {
groups.push(current);
current = [];
}
current.push(column);
});
if (current.length) groups.push(current);
if (groups.length <= 1) return option;
return {
...option,
// Avue 会始终渲染一个主分组。将四个业务分区全部放进 group,
// 让它们都通过同一个 PlainAvueGroup 组件渲染,主分组由页面样式隐藏。
column: [],
group: groups.map((columns, index) => {
const title = columns.find(column => sectionTitleProps.includes(column.prop));
return {
prop: title?.prop || `transportPlanGroup${index + 1}`,
label: '',
arrow: false,
column: title ? columns.filter(column => column.prop !== title.prop) : columns,
};
}),
};
},
openBusinessForm(mode, row = {}) {
if (this.isStandaloneBusinessPage) {
this.$router.push({
path: standaloneTransportPlanFormRoutes[this.$route.path] || this.$route.path,
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);
},
formatTransportPlanGoodsInfo(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.parseDispatchQuantity(
item.quantity ?? item.cargoQuantity ?? item.goodsQuantity
);
return result;
}, {});
return Object.values(groups)
.map(group => {
const quantity = this.formatDispatchQuantity(group.quantity);
return `${group.typeName}等${group.count}种货物 | ${quantity}${
group.unit ? ` ${group.unit}` : ''
}`;
})
.join(' ');
},
formatTransportPlanProvinceCityDistrict(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;
},
formatTransportPlanListAddress(row = {}, field) {
const address = String(row[`${field}Address`] || '').trim();
const name = String(row[`${field}Name`] || '').trim();
const fromAddress = this.extractTransportPlanProvinceCityDistrict(address);
if (fromAddress) return fromAddress;
const fromName = this.extractTransportPlanProvinceCityDistrict(name);
if (fromName) return fromName;
return name || address || '-';
},
extractTransportPlanProvinceCityDistrict(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('/') : '';
},
formatTransportPlanRoadCityDistrict(value) {
const text = String(value || '').trim();
if (!text) return '-';
const cityMatch = text.match(/市/);
if (!cityMatch) return this.formatTransportPlanProvinceCityDistrict(text);
const cityEnd = cityMatch.index + 1;
const cityWithProvince = text.slice(0, cityEnd);
const city =
cityWithProvince.match(/(?:省|自治区|特别行政区)([^省]+市)$/)?.[1] || cityWithProvince;
const districtText = text.slice(cityEnd);
const districtMatch = districtText.match(
/^(?:.*(?:自治县|自治旗|林区|矿区|新区|开发区|区|县|旗))/
);
if (!districtMatch) return city;
const district = districtMatch[0];
return district ? `${city} ${district}` : city;
},
formatTransportPlanDetailRouteName(type) {
const nameProp = type === 'departure' ? 'departureName' : 'arrivalName';
const addressProp = type === 'departure' ? 'departureAddress' : 'arrivalAddress';
const name = this.detailRow[nameProp] || this.detailRow[addressProp];
const transportMode = this.resolveTransportMode(this.detailRow.transportType);
return transportMode === 'road'
? this.formatTransportPlanRoadCityDistrict(name)
: name || '-';
},
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`) &&
(!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`) &&
(!row.readonly || this.isReadonlyDeleteAllowed(row)) &&
this.inStatus(row, this.config.deleteStatus)
);
},
canCopy(row) {
return (
this.hasAction('copy') &&
this.hasPermission(`${this.config.permission}_copy`) &&
!row.readonly
);
},
canComplete(row) {
if (
!this.hasAction('complete') ||
!this.hasPermission(`${this.config.permission}_complete`) ||
row.readonly
) {
return false;
}
const status = this.statusValue(row);
return this.config.permission === 'transport_plan'
? status === 'dispatching'
: status === 'processing';
},
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.isStandaloneFormPage) 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.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
this.form.planName = this.form.planName || this.form.templateName || '';
this.selectedProjectId = this.form.projectId || '';
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
this.selectedAttachmentRows = [];
if (this.contractSelectEnabled) {
this.syncCurrentContractOption();
this.loadContractOptionsForProject();
}
if (this.config.enableProjectSelect) this.syncCurrentProjectOption();
if (this.config.enableTransportPlanForm) this.initTransportPlanFormRows();
},
canOperation(operation, row) {
const permission = operation.permission || `${this.config.permission}_${operation.action}`;
if (!this.hasPermission(permission) || 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';
},
showViewButton(row) {
if (!this.hasPermission(`${this.config.permission}_view`)) return false;
if (this.statusValue(row) === 'draft') return false;
return !this.showDetailButton(row);
},
formatDetailValue(row, prop) {
if (!row) return '-';
if (prop === 'businessStatus') return this.displayStatus(row, prop);
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;
},
normalizeTransportPlanWaybillRow(row = {}, index = 0) {
const status = row.businessStatus || row.status || row.waybillStatus || '';
const statusMap = {
draft: ['草稿', 'is-draft'],
pending: ['待执行', 'is-pending'],
waiting: ['待执行', 'is-pending'],
processing: ['进行中', 'is-processing'],
running: ['进行中', 'is-processing'],
completed: ['已完成', 'is-completed'],
cancelled: ['已取消', 'is-cancelled'],
};
const statusItem = statusMap[status] || [
row.businessStatusName || row.statusName || status || '-',
'',
];
return {
...row,
_detailIndex: index,
vehicleNo:
row.vehicleNo || row.vehicleNumber || row.flightNo || row.shipNo || row.trainNo || '',
driverName: row.driverName || row.driver || '',
carrierName: row.carrierName || row.carrier || '',
transportType: row.transportTypeName || row.transportType || '',
carrierType: row.carrierTypeName || row.carrierType || '',
goodsInfo: row.goodsInfo || row.cargoInfo || this.formatDetailValue(row, 'goodsInfo'),
departureAddress: row.departureAddress || row.fromAddress || '',
statusText: statusItem[0],
statusClass: statusItem[1],
};
},
parseTransportPlanWaybillSource(value) {
if (Array.isArray(value)) return value;
const parsed = this.parseJsonArray(value);
if (parsed.length) return parsed;
if (!value || typeof value !== 'object') return [];
if (Array.isArray(value.records)) return value.records;
if (Array.isArray(value.rows)) return value.rows;
if (Array.isArray(value.data)) return value.data;
if (value.data && typeof value.data === 'object') {
return this.parseTransportPlanWaybillSource(value.data);
}
return [];
},
openTransportPlanWaybillDetail(row) {
if (!row?.id) {
this.$message.info('当前运单暂无详情数据');
return;
}
this.$router.push({ path: '/business/waybill-manage/detail', query: { id: row.id } });
},
transportPlanWaybillIndex(index) {
return (
(this.transportPlanWaybillPage.currentPage - 1) * this.transportPlanWaybillPage.pageSize +
index +
1
);
},
handleTransportPlanWaybillCurrentChange(currentPage) {
this.transportPlanWaybillPage.currentPage = currentPage;
},
handleTransportPlanWaybillSizeChange(pageSize) {
this.transportPlanWaybillPage.pageSize = pageSize;
this.transportPlanWaybillPage.currentPage = 1;
},
loadTransportPlanDetailTransportTypeOptions() {
if (
this.transportPlanDetailTransportTypeOptions.length ||
this.transportPlanDetailTransportTypeLoading
) {
return Promise.resolve(this.transportPlanDetailTransportTypeOptions);
}
this.transportPlanDetailTransportTypeLoading = true;
return getDictionary({ code: 'transport_type' })
.then(res => {
this.transportPlanDetailTransportTypeOptions = this.normalizeDictOptions(
res.data?.data || []
);
return this.transportPlanDetailTransportTypeOptions;
})
.finally(() => {
this.transportPlanDetailTransportTypeLoading = false;
});
},
ensureTransportPlanDetailTransportTypeName(detail = {}) {
const transportTypes = [
detail.transportType,
...this.transportPlanWaybillRows.map(row => row.transportType),
]
.map(value => String(value || '').trim())
.filter(Boolean);
if (transportTypes.some(value => !/[\u4e00-\u9fff]/.test(value))) {
this.loadTransportPlanDetailTransportTypeOptions();
}
},
formatTransportPlanDetailTransportType(value) {
const transportType = String(value || '').trim();
if (!transportType) return '-';
const item = this.transportPlanDetailTransportTypeOptions.find(
option => String(option.value || '').toLowerCase() === transportType.toLowerCase()
);
return item?.label || transportType;
},
openDetail(row) {
this.detailBox = true;
this.detailLoading = true;
this.detailRow = { ...row };
this.transportPlanWaybillPage.currentPage = 1;
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;
if (!detail.contractId) {
this.detailRow = detail;
this.ensureTransportPlanDetailTransportTypeName(detail);
this.applyFormDetail(detail);
return null;
}
return getContractDetail(detail.contractId)
.then(contractRes => {
const contract = contractRes?.data?.data || contractRes?.data || {};
this.detailRow = {
...detail,
customerName:
contract.partyA || contract.customerName || detail.customerName || '',
};
this.ensureTransportPlanDetailTransportTypeName(this.detailRow);
this.applyFormDetail(this.detailRow);
})
.catch(() => {
this.detailRow = detail;
this.ensureTransportPlanDetailTransportTypeName(detail);
this.applyFormDetail(detail);
});
})
.finally(() => {
this.detailLoading = false;
});
},
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;
},
syncCustomCreateDialogButtons(type) {
if (!this.config.enableTransportPlanCreateActions) {
return;
}
if (!this.defaultDialogButtons) {
this.defaultDialogButtons = {
saveBtn: this.option.saveBtn,
cancelBtn: this.option.cancelBtn,
};
}
const customCreate = type === 'add';
this.option.saveBtn = customCreate ? false : this.defaultDialogButtons.saveBtn;
this.option.cancelBtn = customCreate ? false : this.defaultDialogButtons.cancelBtn;
},
statusTagType(status) {
if (
[
1,
'pending',
'waiting_dispatch',
'processing',
'dispatching',
'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) {
const submitRow = { ...row };
if (
!this.validateMobileFields([
[submitRow.departurePhone, '发货联系方式'],
[submitRow.arrivalPhone, '收货联系方式'],
])
) {
return false;
}
if (!this.validateTransportPlanForm(submitRow)) return false;
submitRow.goodsJson = JSON.stringify(this.transportCargoRows);
submitRow.attachmentsJson = JSON.stringify(this.attachmentRows);
delete submitRow.basicInfoTitle;
delete submitRow.shippingInfoTitle;
delete submitRow.goodsInfoTitle;
delete submitRow.attachmentTitle;
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;
},
validateTransportPlanForm(row) {
const requiredFields = this.config.transportFormRequiredFields || [
['projectName', '项目'],
['contractName', '客户合同'],
['planName', '计划名称'],
['transportType', '运输方式'],
['planStartDate', '计划开始日期'],
['planEndDate', '计划结束日期'],
['departureAddress', '发货地址'],
['arrivalAddress', '收货地址'],
];
const emptyField = requiredFields.find(([prop]) => this.isBillingFieldEmpty(row[prop]));
if (emptyField) {
this.$message.warning(`请选择或输入${emptyField[1]}`);
return false;
}
if (row.planStartDate && row.planEndDate && row.planEndDate < row.planStartDate) {
this.$message.warning('计划结束日期不能早于计划开始日期');
return false;
}
if (row.remark && row.remark.length > 200) {
this.$message.warning('备注不能超过200个字符');
return false;
}
if (row.basicRemark && row.basicRemark.length > 200) {
this.$message.warning('基本信息备注不能超过200个字符');
return false;
}
for (const [index, cargo] of this.transportCargoRows.entries()) {
if (!cargo.cargoType) {
this.$message.warning(`第${index + 1}行货物类型不能为空`);
return false;
}
}
return true;
},
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.currencyOptions.find(
item => String(item.value).toUpperCase() === String(value || '').toUpperCase()
);
return option?.remark || this.currencyName(option?.label || value);
},
normalizeCurrencyValue(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.currencyOptions.find(
item => String(item.value || '').toUpperCase() === code
);
return option?.value || code || 'RMB';
},
getContractCurrency(contract = {}) {
return this.normalizeCurrencyValue(
contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency || 'RMB'
);
},
loadCurrencyOptions() {
if (this.currencyOptions.length || this.currencyLoading) {
return Promise.resolve(this.currencyOptions);
}
this.currencyLoading = true;
return getSystemDictionary({ code: 'currency_type' })
.then(res => {
const currencyNameMap = {
CNY: '人民币',
RMB: '人民币',
USD: '美元',
EUR: '欧元',
JPY: '日元',
GBP: '英镑',
};
this.currencyOptions = 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.currencyOptions.find(
item => String(item.value).toUpperCase() === normalized
);
if (exact) return exact.value;
if (normalized === 'CNY') {
const rmb = this.currencyOptions.find(
item => String(item.value).toUpperCase() === 'RMB'
);
return rmb?.value || value;
}
return value;
};
this.dispatchItemForm.freightCurrency = resolveCurrencyValue(
this.dispatchItemForm.freightCurrency
);
return this.currencyOptions;
})
.finally(() => {
this.currencyLoading = false;
});
},
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.isStandaloneBusinessPage) 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;
}
this.getSaveRequest()(submitRow).then(
() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
if (this.isStandaloneBusinessPage) this.closeCrudDialog();
},
error => {
window.console.log(error);
this.stopSubmitLoading(loading);
}
);
},
getSaveRequest() {
return this.api.submit;
},
handleTransportPlanCreateAction(action) {
if (this.transportPlanCreateActionLoading) {
return;
}
const statusProp = this.config.statusProp || 'businessStatus';
const submitRow = this.normalizeRow({
...this.form,
[statusProp]: action === 'draft' ? 'draft' : 'waiting_dispatch',
});
if (!submitRow) return;
this.transportPlanCreateActionLoading = action;
this.api
.submit(submitRow)
.then(() => {
this.$message.success(action === 'draft' ? '暂存成功' : '创建成功');
this.onLoad(this.page, this.query);
this.closeCrudDialog();
})
.finally(() => {
this.transportPlanCreateActionLoading = '';
});
},
closeCrudDialog() {
if (this.isStandaloneBusinessPage) {
this.$router.$avueRouter?.closeTag?.();
const listPath =
Object.entries(standaloneTransportPlanFormRoutes).find(
([, formPath]) => formPath === this.$route.path
)?.[0] || this.$route.path;
this.$router.push({ path: listPath, 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.syncCustomCreateDialogButtons(type);
this.dialogReadonly = type === 'view';
if (type === 'add') {
this.form = {
...(this.config.defaultForm || {}),
};
this.selectedProjectId = '';
this.attachmentRows = [];
this.selectedAttachmentRows = [];
if (this.contractSelectEnabled) {
this.contractOptions = [];
this.form.contractId = '';
this.form.contractName = '';
}
this.transportCargoRows = [];
this.initTransportPlanFormRows();
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] = '';
});
this.form.mileage = this.normalizeMileageValue(this.form.mileage);
this.$nextTick(() => {
this.suppressTransportTypeClear = false;
});
this.selectedProjectId = this.form.projectId || '';
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
this.selectedAttachmentRows = [];
this.initTransportPlanFormRows();
if (this.config.enableProjectSelect) {
this.syncCurrentProjectOption();
}
if (this.contractSelectEnabled) {
this.syncCurrentContractOption();
}
},
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 {};
}
},
parseDispatchQuantity(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;
},
getDispatchQuantityUnit(row = {}) {
return row.quantityUnit || row.goodsQuantityUnit || row.unit || TRANSPORT_PLAN_QUANTITY_UNIT;
},
getDispatchVehicleIdentifier(row = {}) {
return (
[row.vehicleNo, row.vehicleNumber, row.flightNo, row.shipNo, row.trainNo].find(value =>
String(value ?? '').trim()
) || ''
);
},
hasDispatchVehicleIdentifier(row = {}) {
return Boolean(this.getDispatchVehicleIdentifier(row));
},
getDispatchSummaryItem(unit) {
return this.dispatchSummaryItems.find(item => item.unit === unit);
},
getDispatchRemainingQuantity(unit) {
return this.getDispatchSummaryItem(unit)?.remaining || 0;
},
/** 计划总量为 0 时不限制调度数量 */
isDispatchPlanQuantityUnlimited(row = {}) {
const unit = this.getDispatchQuantityUnit(row);
const hasCargoIdentity = Boolean(
String(row.cargoName || row.goodsName || row.name || '').trim() ||
String(row.cargoType || row.secondCargoTypeName || row.goodsType || row.type || '').trim()
);
const planGoodsRows = this.dispatchPlanGoodsRows;
if (planGoodsRows.length && hasCargoIdentity) {
const cargoKey = this.getDispatchCargoIdentity(row);
const matchedPlanRows = planGoodsRows.filter(
goods => this.getDispatchCargoIdentity(goods) === cargoKey
);
if (!matchedPlanRows.length) {
return (this.getDispatchSummaryItem(unit)?.total || 0) <= 0;
}
const total = matchedPlanRows.reduce(
(sum, goods) =>
sum +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
),
0
);
return total <= 0;
}
if (!planGoodsRows.length) {
return Number(this.dispatchRow.totalQuantity || this.dispatchRow.quantity || 0) <= 0;
}
return (this.getDispatchSummaryItem(unit)?.total || 0) <= 0;
},
formatDispatchQuantity(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);
this.syncCurrentProjectOption();
})
.finally(() => {
this.projectLoading = false;
});
},
syncCurrentProjectOption() {
if (!this.form.projectId || !this.form.projectName) 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 = [];
}
return;
}
this.form.projectId = project.id;
this.form.projectName = project.projectName || '';
this.form.projectCode = project.projectCode || '';
this.form.undertakeDeptName = project.undertakeDeptName || '';
if (this.contractSelectEnabled || this.config.enableTransportPlanForm) {
this.form.customerName =
project.customerName ||
project.customerNames ||
project.customer ||
this.form.customerName ||
'';
}
if (this.config.enableTransportPlanForm) {
this.form.transportType =
project.transportType ||
project.transportMode ||
project.transportWay ||
this.form.transportType ||
'';
}
if (this.contractSelectEnabled) {
this.form.contractId = '';
this.form.contractName = '';
this.loadContractOptionsForProject(true);
}
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();
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 = '';
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.$refs.crud?.validateField?.('contractName');
},
loadDispatchContractCurrency(plan = {}) {
const findContract = () =>
this.contractOptions.find(item => String(item.id) === String(plan.contractId)) ||
this.contractOptions.find(item => item.contractName === plan.contractName);
const applyCurrency = contract => {
this.dispatchContractCurrency = this.getContractCurrency(
contract &&
(contract.settlementCurrency || contract.settlementCurrencyCode || contract.currency)
? contract
: { settlementCurrency: plan.settlementCurrency }
);
return this.dispatchContractCurrency;
};
const current = findContract();
if (current) return Promise.resolve(applyCurrency(current));
if (!plan.projectId || !plan.contractId) {
applyCurrency();
return Promise.resolve(this.dispatchContractCurrency);
}
return getContractList(1, 9999, {
projectId: plan.projectId,
projectName: plan.projectName,
...(this.config.contractQueryParams || {}),
})
.then(res => {
this.contractOptions = extractRecords(res);
const contract = findContract();
return applyCurrency(contract);
})
.catch(() => {
applyCurrency();
return this.dispatchContractCurrency;
});
},
handleDispatchTaskEntryModeChange(value) {
if (
value === 'simple' &&
this.hasMultipleConfiguredGoods(this.dispatchItemForm, this.dispatchItemCargoRows)
) {
this.dispatchItemForm.taskEntryMode = 'full';
this.$message.warning('当前配置了多条货物信息,不能切换为精简录入');
return;
}
this.dispatchItemForm.taskEntryMode = value || 'simple';
if (this.dispatchItemForm.taskEntryMode === 'full' && !this.dispatchItemCargoRows.length) {
this.dispatchItemCargoRows = [this.normalizeTransportCargoRow()];
}
},
handleFreightNumberInput(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();
},
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(target, item = {}) {
const value = item.driverName || item.name || '';
const driverPhone = item.mobile || item.phone || item.driverPhone || '';
const drivingVehicle = String(item.drivingVehicle || '').trim();
if (target === 'dispatch') {
this.dispatchItemForm.driverId = item.id || '';
this.dispatchItemForm.driverName = value;
if (driverPhone) this.dispatchItemForm.driverPhone = driverPhone;
if (drivingVehicle) this.dispatchItemForm.vehicleNo = drivingVehicle;
return;
}
return;
},
handleTaskEscortSelect(item = {}) {
const value = item.driverName || item.name || '';
const escortPhone = item.mobile || item.phone || item.driverPhone || '';
this.dispatchItemForm.escortName = value;
if (escortPhone) this.dispatchItemForm.escortPhone = escortPhone;
},
getTaskCargoTypeOptionsKey(path = []) {
const normalizedPath = this.normalizeBillingCargoTypePath(path);
if (!normalizedPath.length) return '';
const cargoType = this.findBillingCargoTypeByPath(normalizedPath);
return String(cargoType?.cargoCode || cargoType?.code || normalizedPath.join('/'));
},
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.normalizeBillingCargoTypePath(path);
const labels = this.getBillingCargoTypePathLabels(normalizedPath);
const secondCargoType = this.findBillingCargoTypeByPath(normalizedPath);
return {
secondCargoTypeName: labels[1] || '',
secondCargoTypeCode: secondCargoType?.cargoCode || secondCargoType?.code || '',
};
},
loadTaskCargoOptionsByPath(path = [], cargoName = '') {
const normalizedPath = this.normalizeBillingCargoTypePath(path);
if (!normalizedPath.length) return Promise.resolve([]);
return this.ensureBillingCargoTypeOptions().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.normalizeBillingCargoTypePath(path);
// 移除空查询时返回空数组的限制,允许点击时显示货物列表
let loadingKey = '';
this.ensureBillingCargoTypeOptions()
.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.formatBillingCargoTypeLabel(item),
}))
);
return key;
});
})
.catch(error => {
window.console.log(error);
callback([]);
})
.finally(() => {
if (loadingKey) this.setTaskCargoLoadingByKey(loadingKey, false);
});
},
async handleTaskCargoRowSelect(row, item = {}) {
const value = this.formatBillingCargoTypeLabel(item);
row.cargoName = value;
row.specification = item.specification || item.spec || row.specification || '';
row.model = item.model || item.modelName || row.model || '';
// 确保货物类型树已加载
try {
await this.ensureBillingCargoTypeOptions();
} catch (error) {
console.warn('货物类型树加载失败', error);
this.syncTransportCargoJson();
return;
}
// 从常用货物返回的数据中查找货物类型并反选
const matchedCargoType = this.findBillingCargoTypeOption(item);
if (matchedCargoType?.path?.length) {
// 找到匹配的货物类型,回填到当前行
const fullPath = this.normalizeBillingCargoTypePath(matchedCargoType.path);
row.cargoTypePath = fullPath;
row.cargoType = matchedCargoType.cargoName || row.cargoType || '';
row.cargoTypeCode =
matchedCargoType.cargoCode || matchedCargoType.code || matchedCargoType.id || '';
console.log('货物名称选择后反选货物类型:', {
cargoName: row.cargoName,
cargoType: row.cargoType,
cargoTypePath: row.cargoTypePath,
matchedCargoType: matchedCargoType,
});
} else {
console.warn('未找到匹配的货物类型,item数据:', item);
}
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 || '',
}));
},
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)) : '';
},
handleDispatchMileageInput(value) {
this.dispatchItemForm.mileage = String(value || '')
.replace(/\D/g, '')
.slice(0, 10);
},
initTransportPlanFormRows() {
if (!this.config.enableTransportPlanForm) return;
const goodsRows = this.parseJsonArray(this.form.goodsJson);
this.transportCargoRows = goodsRows.map(row => this.normalizeTransportCargoRow(row));
if (!this.transportCargoRows.length) {
this.transportCargoRows.push(this.normalizeTransportCargoRow());
}
this.syncTransportCargoJson();
this.syncCurrentContractOption();
this.restoreTransportCargoTypePaths();
},
restoreTransportCargoTypePaths() {
if (!this.transportCargoRows.length) return Promise.resolve();
return this.ensureBillingCargoTypeOptions().then(() => {
this.transportCargoRows.forEach(row => {
if (this.normalizeBillingCargoTypePath(row.cargoTypePath).length) return;
const cargoType = this.findBillingCargoTypeByCodeOrName({
cargoTypeCode: row.cargoTypeCode,
cargoType: row.cargoType,
});
if (!cargoType?.path) return;
row.cargoTypePath = cargoType.path;
row.cargoType =
this.getBillingCargoTypePathLabels(cargoType.path).at(-1) || row.cargoType;
row.cargoTypeCode = cargoType.cargoCode || cargoType.code || row.cargoTypeCode;
});
this.syncTransportCargoJson();
});
},
normalizeTransportCargoRow(row = {}) {
const cargo = {
...defaultTransportCargo(),
...row,
cargoTypePath: this.normalizeBillingCargoTypePath(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', 'freightAmount'].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.config.enableTransportPlanForm) 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.config.enableTransportPlanForm) {
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.normalizeBillingCargoTypePath(value);
const cargoType = this.findBillingCargoTypeByPath(path);
const labels = this.getBillingCargoTypePathLabels(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.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);
}
},
ensureTransportTypeBeforeAddress() {
if (this.dispatchItemBox) {
const transportType =
this.dispatchItemForm.transportType || this.dispatchRow?.transportType || '';
if (!this.isBillingFieldEmpty(transportType)) return true;
this.$message.warning('请先选择运输方式');
return false;
}
if (!this.shippingInfoFormEnabled || !this.isBillingFieldEmpty(this.form.transportType)) {
return true;
}
this.$message.warning('请先选择运输方式');
return false;
},
openTransportRouteDialog() {
if (!this.ensureTransportTypeBeforeAddress()) return;
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.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;
const stationMode = this.dispatchItemBox ? this.dispatchStationMode : this.transportStationMode;
if (!stationMode) return;
this.transportStationTarget = target;
this.transportStationQuery = {};
this.transportStationPage.currentPage = 1;
this.transportStationBox = true;
},
getTransportStationListRequest() {
const mode = this.dispatchItemBox ? this.dispatchTransportMode : this.transportMode;
const requestMap = {
water: getPortTerminalList,
rail: getRailwayStationList,
air: getAirportMasterList,
};
return requestMap[mode];
},
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.resetTransportAddressQuery();
this.transportAddressPage.currentPage = 1;
this.transportAddressBox = true;
};
// Avue 远程字典可能尚未写入 option,先加载后再解析运输方式,避免按空类型查询全部地址。
if (!this.getTransportFixedAddressType()) {
this.loadTransportTypeOptions().finally(open);
return;
}
open();
},
openTransportAddressPicker(target) {
// 常用地址按钮统一使用地址库弹窗,按运输方式筛选并锁定地址类型。
this.openTransportAddressDialog(target);
},
resetTransportAddressQuery() {
const addressType = this.getTransportFixedAddressType();
this.transportAddressQuery = addressType ? { addressType } : {};
},
getTransportFixedAddressType() {
if (!this.config.fixedTransportAddressType) return '';
const typeMap = {
road: '常规地址',
rail: '铁路车站',
water: '港口/码头',
air: '空港机场',
};
const mode = this.dispatchItemBox ? this.dispatchTransportMode : this.transportMode;
return typeMap[mode] || '';
},
getTransportAddressForm() {
return this.dispatchItemBox ? this.dispatchItemForm : this.form;
},
openDispatchPrimaryAddressPicker(target) {
if (this.dispatchStationMode) {
this.openTransportStationDialog(target);
}
},
openDispatchSecondaryAddressPicker(target) {
if (this.dispatchStationMode) {
this.openTransportStationDialog(target);
return;
}
this.openTransportMapDialog(target);
},
handleDispatchSecondaryAddressInputClick(target) {
if (this.dispatchStationMode) {
this.openDispatchSecondaryAddressPicker(target);
}
},
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.applyTransportAddress(this.transportAddressTarget, {
...row,
preferRegionName: true,
});
this.transportAddressBox = false;
},
applyTransportAddress(target, address = {}) {
const prefix = target === 'departure' ? 'departure' : 'arrival';
const addressForm = this.getTransportAddressForm();
addressForm[`${prefix}Name`] = address.preferRegionName
? address.regionName || address.addressName || ''
: address.addressName || address.regionName || '';
addressForm[`${prefix}Address`] = address.detailAddress || address.address || '';
addressForm[`${prefix}AddressCode`] = address.addressCode || '';
addressForm[`${prefix}SiteCode`] =
address.addressType === '常规地址'
? '/'
: address.siteCodeDisplay || address.siteCode || '';
addressForm[`${prefix}Longitude`] = address.longitude || '';
addressForm[`${prefix}Latitude`] = address.latitude || '';
addressForm[`${prefix}Contact`] =
address.contactName || addressForm[`${prefix}Contact`] || '';
addressForm[`${prefix}Phone`] = address.contactPhone || addressForm[`${prefix}Phone`] || '';
// 地址值在本轮响应式更新后再清理校验状态,避免 Avue 以旧空值重新触发必填提示。
this.$nextTick(() => {
this.$refs.crud?.clearValidate?.([`${prefix}Address`]);
});
},
openTransportMapDialog(target) {
if (this.dialogReadonly && !this.dispatchItemBox) return;
if (!this.ensureTransportTypeBeforeAddress()) return;
const prefix = target === 'departure' ? 'departure' : 'arrival';
const addressForm = this.getTransportAddressForm();
this.transportMapTarget = prefix;
this.transportMapKeyword =
addressForm[`${prefix}Address`] || addressForm[`${prefix}Name`] || '';
this.transportMapSelected = this.buildTransportMapSelection({
lng: addressForm[`${prefix}Longitude`],
lat: addressForm[`${prefix}Latitude`],
address: addressForm[`${prefix}Address`],
regionName: addressForm[`${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;
const addressForm = this.getTransportAddressForm();
addressForm[`${prefix}Address`] =
this.transportMapSelected.detailAddress || addressForm[`${prefix}Address`];
addressForm[`${prefix}Name`] =
this.transportMapSelected.regionName || addressForm[`${prefix}Name`];
addressForm[`${prefix}Longitude`] = this.transportMapSelected.longitude;
addressForm[`${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.commonCargoQuery = {};
this.commonCargoPage.currentPage = 1;
this.commonCargoBox = true;
},
loadCommonCargoList() {
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();
},
handleCommonCargoTypeChange(value) {
const path = this.normalizeBillingCargoTypePath(value);
const labels = this.getBillingCargoTypePathLabels(path);
const firstCargoType = this.billingCargoTypeFlatOptions.find(
item => String(item.id) === String(path[0] || '') && item.path?.length === 1
);
const secondCargoType = this.findBillingCargoTypeByPath(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) {
await this.ensureBillingCargoTypeOptions();
const cargo = this.mapCommonCargoRow(row);
if (this.dispatchItemBox && this.dispatchItemForm.taskEntryMode === 'full') {
this.addDispatchCargoRow(this.dispatchItemCargoRows.length - 1, cargo);
} else {
this.addTransportCargoRow(-1, cargo);
}
this.commonCargoBox = false;
},
mapCommonCargoRow(row = {}) {
const cargoTypePath = this.resolveCommonCargoTypePath(row);
const cargoType = this.findBillingCargoTypeByPath(cargoTypePath);
const labels = this.getBillingCargoTypePathLabels(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 => {
if (this.dispatchItemBox && this.dispatchItemForm.taskEntryMode === 'full') {
this.addDispatchCargoRow(this.dispatchItemCargoRows.length - 1, row);
} else {
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');
});
},
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);
});
},
handleDispatchItemAttachmentChange(list) {
const userName = this.userInfo?.realName || this.userInfo?.userName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.dispatchItemAttachmentRows = (list || []).map(item => ({
...item,
description: item.description || '',
uploadUserName: item.uploadUserName || userName,
uploadTime: item.uploadTime || uploadTime,
}));
this.selectedDispatchItemAttachmentRows = [];
},
removeDispatchItemAttachment(index) {
this.dispatchItemAttachmentRows.splice(index, 1);
this.selectedDispatchItemAttachmentRows = this.selectedDispatchItemAttachmentRows.filter(
item => this.dispatchItemAttachmentRows.includes(item)
);
},
handleDispatchItemBatchDownload() {
const rows = this.selectedDispatchItemAttachmentRows.length
? this.selectedDispatchItemAttachmentRows
: this.dispatchItemAttachmentRows;
rows.forEach(row => this.downloadAttachment(row));
},
handleDispatchItemAttachmentSelectionChange(rows) {
this.selectedDispatchItemAttachmentRows = rows || [];
},
ensureBillingCargoTypeOptions() {
if (!this.config.enableTransportPlanForm) {
return Promise.resolve([]);
}
if (this.billingCargoTypeOptions.length) {
return Promise.resolve(this.billingCargoTypeOptions);
}
if (this.billingCargoTypeRequest) {
return this.billingCargoTypeRequest;
}
this.billingCargoTypeLoading = true;
this.billingCargoTypeRequest = getCargoTypeList(1, 9999)
.then(res => {
this.billingCargoTypeOptions = this.buildBillingCargoTypeTree(extractRecords(res));
this.billingCargoTypeFlatOptions = this.flattenBillingCargoTypeOptions(
this.billingCargoTypeOptions
);
return this.billingCargoTypeOptions;
})
.finally(() => {
this.billingCargoTypeLoading = false;
this.billingCargoTypeRequest = null;
});
return this.billingCargoTypeRequest;
},
buildBillingCargoTypeTree(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.formatBillingCargoTypeLabel(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.normalizeBillingCargoTypeNode(item, 1)).filter(Boolean);
},
normalizeBillingCargoTypeNode(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.normalizeBillingCargoTypeNode(item, level + 1, path))
.filter(Boolean)
: [];
return {
...cargoType,
id,
cargoName: this.formatBillingCargoTypeLabel(cargoType),
cargoCode: cargoType.cargoCode || cargoType.code || '',
path,
leaf: level === 2,
children: children.length ? children : undefined,
};
},
flattenBillingCargoTypeOptions(options = []) {
const result = [];
const collectOptions = list => {
(list || []).forEach(item => {
result.push(item);
if (item.children?.length) collectOptions(item.children);
});
};
collectOptions(options);
return result;
},
formatBillingCargoTypeLabel(item = {}) {
return item.cargoName || item.name || item.typeName || item.label || '';
},
filterBillingCargoType(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)
);
},
getBillingCargoTypePathLabels(path = []) {
let options = this.billingCargoTypeOptions;
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);
},
findBillingCargoTypeByPath(path = []) {
const value = Array.isArray(path) && path.length >= 2 ? String(path[1]) : '';
if (!value) return null;
return (
this.billingCargoTypeFlatOptions.find(
item => String(item.id) === value && item.path?.length >= 2
) || null
);
},
findBillingCargoTypeByCodeOrName(condition = {}) {
const code = String(condition.cargoTypeCode || '').trim();
const name = String(condition.cargoType || '').trim();
return (
this.billingCargoTypeFlatOptions.find(item => {
const cargoCode = String(item.cargoCode || item.code || item.id || '');
return item.path?.length >= 2 && code && cargoCode === code;
}) ||
this.billingCargoTypeFlatOptions.find(item => {
const cargoName = this.formatBillingCargoTypeLabel(item);
return item.path?.length >= 2 && name && (cargoName === name || name.endsWith(cargoName));
}) ||
null
);
},
findBillingCargoTypeOption(row = {}) {
const path = this.normalizeBillingCargoTypePath(row.cargoTypePath);
if (path.length) {
const pathKey = path.join('/');
const pathMatch = this.billingCargoTypeFlatOptions.find(item => item.path?.join('/') === pathKey);
if (pathMatch) return pathMatch;
}
// 优先通过二级类型编码查找(最准确)
if (row.secondCargoTypeCode) {
const secondMatch = this.billingCargoTypeFlatOptions.find(
item => String(item.cargoCode || item.code || '').trim() === String(row.secondCargoTypeCode).trim()
);
if (secondMatch) {
console.log('通过 secondCargoTypeCode 找到货物类型:', secondMatch);
return secondMatch;
}
}
// 其他候选编码
const codeCandidates = [row.cargoTypeCode, row.firstCargoTypeCode]
.map(value => String(value || '').trim())
.filter(Boolean);
const nameCandidates = [
row.secondCargoTypeName,
row.cargoType,
row.goodsType,
row.firstCargoTypeName,
]
.map(value => String(value || '').trim())
.filter(Boolean);
return (
this.billingCargoTypeFlatOptions.find(item =>
codeCandidates.some(
code => code === String(item.cargoCode || item.code || item.id || '').trim()
)
) ||
this.billingCargoTypeFlatOptions.find(item =>
nameCandidates.some(name => name === String(item.cargoName || '').trim())
)
);
},
resolveCommonCargoTypePath(row = {}) {
const path = this.normalizeBillingCargoTypePath(row.cargoTypePath);
if (path.length) return path;
const cargoType =
this.findBillingCargoTypeByCodeOrName({
cargoTypeCode: row.secondCargoTypeCode || row.firstCargoTypeCode || row.cargoTypeCode,
cargoType: row.secondCargoTypeName || row.firstCargoTypeName || row.cargoType,
}) ||
this.findBillingCargoTypeByCodeOrName({
cargoTypeCode: row.firstCargoTypeCode || '',
cargoType: row.firstCargoTypeName || '',
});
return cargoType?.path || [];
},
normalizeBillingCargoTypePath(path) {
return Array.isArray(path) && path.length >= 2
? path.slice(0, 2).map(value => String(value))
: [];
},
isBillingFieldEmpty(value) {
return value === undefined || value === null || String(value).trim() === '';
},
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);
},
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: 0,
});
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: 0 });
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();
});
});
},
handleImport() {
if (this.config.enableTransportPlanImport) {
this.$router.push('/business/transport-plan/import');
return;
}
this.$message.warning('运输计划导入接口未配置');
},
resetTransportPlanImport() {
this.transportPlanImportForm = {
projectId: '',
projectName: '',
customerName: '',
contractId: '',
contractName: '',
file: null,
};
this.transportPlanImportFiles = [];
this.transportPlanImportSourceFile = null;
this.transportPlanImportRows = [];
this.transportPlanImportSelection = [];
this.transportPlanImportTab = 'all';
this.transportPlanImportEditingKey = '';
this.transportPlanImportEditSnapshot = null;
this.transportPlanImportContractOptions = [];
this.transportPlanImportContractLoading = false;
this.$refs.transportPlanImportFormRef?.clearValidate();
},
handleTransportPlanImportProjectChange(projectId) {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
this.transportPlanImportForm.customerName =
project?.customerName || project?.customerNames || project?.customer || '';
this.transportPlanImportForm.projectName = project?.projectName || '';
this.transportPlanImportForm.contractId = '';
this.transportPlanImportForm.contractName = '';
this.transportPlanImportContractOptions = [];
if (!projectId) return;
this.transportPlanImportContractLoading = true;
getContractList(1, 9999, {
projectId,
projectName: project?.projectName,
...(this.config.contractQueryParams || {}),
})
.then(res => {
this.transportPlanImportContractOptions = extractRecords(res);
const contract = this.transportPlanImportContractOptions[0];
this.transportPlanImportForm.customerName =
this.transportPlanImportForm.customerName || contract?.customerName || '';
this.transportPlanImportForm.contractId = contract?.id || '';
this.transportPlanImportForm.contractName = contract?.contractName || '';
})
.finally(() => {
this.transportPlanImportContractLoading = false;
});
},
handleTransportPlanImportContractChange(contractId) {
const contract = this.transportPlanImportContractOptions.find(
item => String(item.id) === String(contractId)
);
this.transportPlanImportForm.contractName = contract?.contractName || '';
},
async handleTransportPlanImportFileChange(file, fileList) {
const isExcel = /\.(xls|xlsx)$/i.test(file.name || '');
if (!isExcel) {
this.$message.error('请上传 .xls 或 .xlsx 格式文件');
this.transportPlanImportFiles = [];
this.transportPlanImportForm.file = null;
return;
}
try {
const XLSX = await import('xlsx');
const workbook = XLSX.read(await file.raw.arrayBuffer(), {
type: 'array',
cellDates: false,
});
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const values = XLSX.utils.sheet_to_json(worksheet, {
header: 1,
defval: '',
raw: false,
blankrows: false,
});
const headers = values[0] || [];
const headerMap = headers.reduce((result, label, index) => {
const normalized = String(label || '')
.replace(/^\*/, '')
.trim();
if (normalized) result[normalized] = index;
return result;
}, {});
const rows = values.slice(1).reduce((result, cells, index) => {
const row = {
_key: `${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`,
};
transportPlanImportColumns.forEach(([label, prop]) => {
row[prop] = String(cells[headerMap[label]] ?? '').trim();
});
if (transportPlanImportColumns.some(([, prop]) => row[prop])) result.push(row);
return result;
}, []);
if (!rows.length) {
this.$message.warning('未读取到计划明细数据');
this.transportPlanImportFiles = [];
this.transportPlanImportForm.file = null;
return;
}
this.transportPlanImportFiles = fileList.slice(-1);
this.transportPlanImportSourceFile = file.raw;
this.transportPlanImportForm.file = file.raw;
this.transportPlanImportRows = rows;
this.markTransportPlanImportDuplicates();
this.$refs.transportPlanImportFormRef?.validateField('file');
this.$message.success(`已读取${rows.length}条计划明细`);
} catch (error) {
this.transportPlanImportFiles = [];
this.transportPlanImportSourceFile = null;
this.transportPlanImportForm.file = null;
this.transportPlanImportRows = [];
this.$message.error('计划明细表读取失败,请检查文件格式');
}
},
handleTransportPlanImportFileRemove() {
this.transportPlanImportFiles = [];
this.transportPlanImportSourceFile = null;
this.transportPlanImportForm.file = null;
this.transportPlanImportRows = [];
this.transportPlanImportSelection = [];
this.transportPlanImportEditingKey = '';
this.transportPlanImportEditSnapshot = null;
},
transportPlanImportDuplicateKey(row) {
return transportPlanImportColumns
.map(([, prop]) => String(row[prop] || '').trim())
.join('\u0001');
},
markTransportPlanImportDuplicates() {
const countMap = this.transportPlanImportRows.reduce((result, row) => {
const key = this.transportPlanImportDuplicateKey(row);
result[key] = (result[key] || 0) + 1;
return result;
}, {});
this.transportPlanImportRows.forEach(row => {
row._duplicate = countMap[this.transportPlanImportDuplicateKey(row)] > 1;
});
},
handleTransportPlanImportSelectionChange(rows) {
this.transportPlanImportSelection = rows;
},
isEditingTransportPlanImportRow(row) {
return this.transportPlanImportEditingKey === row._key;
},
editTransportPlanImportRow(row) {
if (this.transportPlanImportEditingKey && this.transportPlanImportEditingKey !== row._key) {
this.$message.warning('请先保存或取消当前编辑');
return;
}
this.transportPlanImportEditingKey = row._key;
this.transportPlanImportEditSnapshot = { ...row };
},
saveTransportPlanImportRow() {
this.transportPlanImportEditingKey = '';
this.transportPlanImportEditSnapshot = null;
this.markTransportPlanImportDuplicates();
},
cancelTransportPlanImportRow(row) {
Object.assign(row, this.transportPlanImportEditSnapshot || {});
this.transportPlanImportEditingKey = '';
this.transportPlanImportEditSnapshot = null;
this.markTransportPlanImportDuplicates();
},
removeTransportPlanImportRow(row) {
this.transportPlanImportRows = this.transportPlanImportRows.filter(
item => item._key !== row._key
);
this.transportPlanImportSelection = this.transportPlanImportSelection.filter(
item => item._key !== row._key
);
this.markTransportPlanImportDuplicates();
},
removeSelectedTransportPlanImportRows() {
const selectedKeys = new Set(this.transportPlanImportSelection.map(row => row._key));
this.transportPlanImportRows = this.transportPlanImportRows.filter(
row => !selectedKeys.has(row._key)
);
this.transportPlanImportSelection = [];
this.markTransportPlanImportDuplicates();
},
validateTransportPlanImportRows() {
if (!this.transportPlanImportRows.length) {
this.$message.warning('请保留至少一条计划明细');
return false;
}
const requiredFields = [
['planName', '计划名称'],
['transportType', '运输方式'],
['planStartDate', '计划开始日期'],
['planEndDate', '计划结束日期'],
['cargoType', '货物类型'],
['departureAddress', '发货地址'],
['arrivalAddress', '收货地址'],
];
for (const [index, row] of this.transportPlanImportRows.entries()) {
const emptyField = requiredFields.find(([prop]) => !String(row[prop] || '').trim());
if (emptyField) {
this.$message.warning(`第${index + 1}行${emptyField[1]}不能为空`);
return false;
}
const isValidDate = value => {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return false;
const [, year, month, day] = match;
const date = new Date(Number(year), Number(month) - 1, Number(day));
return (
date.getFullYear() === Number(year) &&
date.getMonth() === Number(month) - 1 &&
date.getDate() === Number(day)
);
};
if (!isValidDate(row.planStartDate) || !isValidDate(row.planEndDate)) {
this.$message.warning(`第${index + 1}行计划日期格式必须为 YYYY-MM-DD`);
return false;
}
if (row.planEndDate < row.planStartDate) {
this.$message.warning(`第${index + 1}行计划结束日期不能早于计划开始日期`);
return false;
}
if (
row.quantity !== '' &&
(!Number.isFinite(Number(row.quantity)) || Number(row.quantity) < 0)
) {
this.$message.warning(`第${index + 1}行数量必须为非负数`);
return false;
}
}
return true;
},
async buildTransportPlanImportFile() {
const XLSX = await import('xlsx');
const headers = transportPlanImportColumns.map(([label]) => {
return [
'计划名称',
'运输方式',
'计划开始日期',
'计划结束日期',
'货物类型',
'发货地址',
'收货地址',
].includes(label)
? `*${label}`
: label;
});
const values = [
headers,
...this.transportPlanImportRows.map(row =>
transportPlanImportColumns.map(([, prop]) => String(row[prop] ?? ''))
),
];
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(values), '运输计划导入模板');
const binary = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const fileName = (
this.transportPlanImportSourceFile?.name || '运输计划导入模板.xlsx'
).replace(/\.xls$/i, '.xlsx');
return new File([binary], fileName, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
},
handleTransportPlanTemplate() {
exportBlob(this.config.transportPlanTemplateUrl, {}, { feedback: true }).then(res => {
downloadXls(res.data, '运输计划导入模板.xlsx');
});
},
async submitTransportPlanImport() {
const valid = await this.$refs.transportPlanImportFormRef?.validate().catch(() => false);
if (!valid || this.transportPlanImportLoading) return;
if (!this.validateTransportPlanImportRows()) return;
if (typeof this.api.importTransportPlan !== 'function') {
this.$message.error('运输计划导入接口不可用');
return;
}
this.transportPlanImportLoading = true;
try {
const file = await this.buildTransportPlanImportFile();
const res = await this.api.importTransportPlan({ ...this.transportPlanImportForm, file });
const contentType = res.headers?.['content-type'] || res.data?.type || '';
if (
contentType.includes('application/vnd.ms-excel') ||
contentType.includes('spreadsheetml')
) {
downloadXls(
res.data,
`运输计划导入失败明细${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
);
this.$message.warning('部分数据导入失败,已下载失败明细');
} else {
const text = await res.data.text();
const result = text ? JSON.parse(text) : {};
if (result.code !== 200) throw new Error(result.msg || '导入失败');
this.$message.success('导入完成');
}
this.transportPlanImportBox = false;
this.onLoad(this.page, this.query);
} catch (error) {
this.$message.error(error.message || '导入失败');
} finally {
this.transportPlanImportLoading = false;
}
},
handleCopy(row) {
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);
});
},
handleAction(action, row) {
const actionName = {
complete: this.completeActionLabel,
}[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);
});
},
handleCustomOperation(operation, row) {
if (operation.action === 'flow') {
this.handleFlow(row);
return;
}
if (operation.action === 'dispatch') {
this.openDispatchDialog(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;
},
openDispatchDialog(row) {
if (!row?.id) {
this.$message.warning('运输计划数据异常,无法调度');
return;
}
// 跳转到独立的调度页面
this.$router.push({
path: '/business/transport-plan-dispatch',
query: {
planId: row.id,
},
});
},
loadDispatchTransportTypeOptions() {
if (this.dispatchTransportTypeOptions.length || this.dispatchTransportTypeLoading) {
return Promise.resolve(this.dispatchTransportTypeOptions);
}
this.dispatchTransportTypeLoading = true;
return getDictionary({ code: 'transport_type' })
.then(res => {
this.dispatchTransportTypeOptions = this.normalizeDictOptions(res.data?.data || []);
return this.dispatchTransportTypeOptions;
})
.finally(() => {
this.dispatchTransportTypeLoading = false;
});
},
formatDispatchTransportType(value) {
const item = this.dispatchTransportTypeOptions.find(
option => String(option.value) === String(value ?? '')
);
return item?.label || value || '';
},
formatDispatchDriver(row = {}) {
return [row.driverName, row.driverPhone].filter(Boolean).join(' / ') || '-';
},
resetDispatchDialog() {
this.dispatchRow = {};
this.dispatchContractCurrency = 'RMB';
this.dispatchRows = [];
this.dispatchLoading = false;
this.dispatchSaving = '';
this.dispatchCarrierRequestId += 1;
this.dispatchCarrierOptions = [];
this.dispatchCarrierLoading = false;
this.dispatchItemBox = false;
this.dispatchItemIndex = -1;
this.dispatchItemForm = defaultDispatchRow();
this.dispatchItemCargoRows = [];
this.dispatchItemAttachmentRows = [];
this.selectedDispatchItemAttachmentRows = [];
},
buildDispatchRows(plan = {}) {
const persistedSources = [
plan.dispatchRows,
plan.dispatchRowList,
plan.dispatchRecords,
plan.dispatchRecordList,
plan.dispatchList,
plan.waybillList,
plan.waybillRows,
plan.waybills,
];
const persistedRows = persistedSources.reduce((result, source) => {
const rows = this.parseTransportPlanWaybillSource(source);
return result.length ? result : rows;
}, []);
const goodsRows = this.dispatchGoodsRows(plan.goodsJson);
if (!goodsRows.length) {
const normalizedPersistedRows = persistedRows.map((item, index) =>
this.normalizeDispatchRow(plan, item, index)
);
return normalizedPersistedRows.length
? normalizedPersistedRows
: [this.normalizeDispatchRow(plan, plan, 0)];
}
const assignedQuantities = new Map();
const pendingQuantities = new Map();
persistedRows.forEach(row => {
if (!this.hasDispatchVehicleIdentifier(row)) return;
this.getDispatchGoodsSourceRows(row).forEach(goods => {
const key = this.getDispatchCargoIdentity(goods);
assignedQuantities.set(
key,
(assignedQuantities.get(key) || 0) +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
)
);
});
});
const normalizedPersistedRows = [];
persistedRows.forEach((item, index) => {
if (this.hasDispatchVehicleIdentifier(item)) {
normalizedPersistedRows.push(this.normalizeDispatchRow(plan, item, index));
return;
}
const pendingGoodsRows = this.getDispatchGoodsSourceRows(item)
.map(goods => {
const totalQuantity = this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
);
if (totalQuantity <= 0) return goods;
const remainingQuantity = Math.max(
totalQuantity - (assignedQuantities.get(this.getDispatchCargoIdentity(goods)) || 0),
0
);
if (!remainingQuantity) return null;
return {
...goods,
quantity: this.formatDispatchQuantity(remainingQuantity),
};
})
.filter(Boolean);
if (!pendingGoodsRows.length) return;
pendingGoodsRows.forEach(goods => {
const key = this.getDispatchCargoIdentity(goods);
pendingQuantities.set(
key,
(pendingQuantities.get(key) || 0) +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
)
);
});
normalizedPersistedRows.push(
this.normalizeDispatchRow(
plan,
{
...item,
goodsJson: JSON.stringify(pendingGoodsRows),
quantity: pendingGoodsRows[0].quantity || item.quantity || '',
},
index
)
);
});
const remainingRows = goodsRows
.map((goods, index) => {
const totalQuantity = this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
);
const key = this.getDispatchCargoIdentity(goods);
const remainingQuantity = Math.max(
totalQuantity - (assignedQuantities.get(key) || 0) - (pendingQuantities.get(key) || 0),
0
);
if (!remainingQuantity) return null;
const pendingGoods = {
...goods,
quantity: this.formatDispatchQuantity(remainingQuantity),
};
const row = this.normalizeDispatchRow(
plan,
{
...pendingGoods,
goodsJson: JSON.stringify([pendingGoods]),
taskEntryMode: 'simple',
},
persistedRows.length + index
);
row.quantity = this.formatDispatchQuantity(remainingQuantity);
row.taskEntryMode = 'simple';
return row;
})
.filter(Boolean);
return [...normalizedPersistedRows, ...remainingRows];
},
getDispatchGoodsSourceRows(row = {}) {
const rows = this.dispatchGoodsRows(row.goodsJson);
return rows.length ? rows : [row];
},
getDispatchUnscheduledGoodsRows() {
const goodsRows = this.dispatchPlanGoodsRows;
if (!goodsRows.length) return [];
const assignedQuantities = new Map();
this.dispatchRows.forEach(row => {
if (!this.hasDispatchVehicleIdentifier(row)) return;
this.getDispatchGoodsSourceRows(row).forEach(goods => {
const key = this.getDispatchCargoIdentity(goods);
assignedQuantities.set(
key,
(assignedQuantities.get(key) || 0) +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
)
);
});
});
return goodsRows
.filter(goods => {
const totalQuantity = this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
);
const assignedQuantity =
assignedQuantities.get(this.getDispatchCargoIdentity(goods)) || 0;
return totalQuantity <= 0 || assignedQuantity < totalQuantity;
})
.map(goods => this.normalizeTransportCargoRow({ ...goods, quantity: '' }));
},
getDispatchCargoIdentity(row = {}) {
return [
row.cargoName || row.goodsName || row.name || '',
row.cargoType || row.secondCargoTypeName || row.goodsType || row.type || '',
this.getDispatchQuantityUnit(row),
]
.map(value => String(value || '').trim())
.join('|');
},
normalizeDispatchRow(plan = {}, item = {}, index = 0) {
const itemGoodsRows = this.dispatchGoodsRows(item.goodsJson);
const itemGoods = itemGoodsRows[0] || {};
const cargoInfo = this.formatDispatchCargoInfo({}, item);
const waybillFallback = this.getDispatchWaybillFallback(plan, item, index);
const feeFields = this.getDispatchFeeFields(plan, item);
return {
_key: `${plan.id || 'dispatch'}-${index}-${
item.id || item.cargoName || item.vehicleNo || 'row'
}`,
taskEntryMode: item.taskEntryMode || 'simple',
transportType: item.transportType || plan.transportType || '',
carrierType: item.carrierType || plan.carrierType || '承运商',
carrierId: item.carrierId || plan.carrierId || '',
carrierContractId: item.carrierContractId || plan.carrierContractId || '',
carrierName: item.carrierName || plan.carrierName || '',
driverName: item.driverName || plan.driverName || '',
driverPhone: item.driverPhone || plan.driverPhone || '',
vehicleNo:
item.vehicleNo ||
item.vehicleNumber ||
item.flightNo ||
item.shipNo ||
item.trainNo ||
plan.vehicleNo ||
plan.vehicleNumber ||
plan.flightNo ||
plan.shipNo ||
plan.trainNo ||
'',
trailerVehicleNo: item.trailerVehicleNo || '',
escortName: item.escortName || '',
escortPhone: item.escortPhone || '',
mileage: item.mileage || '',
cargoType:
itemGoods.cargoType ||
itemGoods.goodsType ||
item.cargoType ||
item.goodsType ||
plan.cargoType ||
'',
cargoName:
itemGoods.cargoName ||
itemGoods.goodsName ||
item.cargoName ||
item.goodsName ||
plan.cargoName ||
'',
cargoInfo,
quantity:
itemGoods.quantity ||
itemGoods.cargoQuantity ||
itemGoods.goodsQuantity ||
item.quantity ||
item.cargoQuantity ||
item.goodsQuantity ||
'',
quantityUnit:
itemGoods.quantityUnit ||
itemGoods.goodsQuantityUnit ||
itemGoods.unit ||
item.quantityUnit ||
item.goodsQuantityUnit ||
item.unit ||
TRANSPORT_PLAN_QUANTITY_UNIT,
specification:
item.specification || item.spec || itemGoods.specification || itemGoods.spec || '',
model: item.model || itemGoods.model || '',
packageType:
item.packageType || item.package || itemGoods.packageType || itemGoods.package || '',
estimatedStartTime: item.estimatedStartTime || plan.planStartDate || '',
estimatedEndTime: item.estimatedEndTime || plan.planEndDate || '',
departureAddress: item.departureAddress || plan.departureAddress || '',
departureName: item.departureName || plan.departureName || '',
departureContact: item.departureContact || plan.departureContact || '',
departurePhone: item.departurePhone || plan.departurePhone || '',
arrivalAddress: item.arrivalAddress || plan.arrivalAddress || '',
arrivalName: item.arrivalName || plan.arrivalName || '',
arrivalContact: item.arrivalContact || plan.arrivalContact || '',
arrivalPhone: item.arrivalPhone || plan.arrivalPhone || '',
...feeFields,
dispatchUserName:
item.dispatchUserName ||
plan.dispatchUserName ||
waybillFallback.createUserName ||
item.createUserName ||
'',
dispatchTime:
item.dispatchTime ||
plan.dispatchTime ||
waybillFallback.createTime ||
item.createTime ||
'',
goodsJson: item.goodsJson || '',
attachmentsJson: item.attachmentsJson || '',
remark: item.remark || item.taskRemark || plan.remark || '',
};
},
getDispatchFeeFields(plan = {}, item = {}) {
const freightInfo = this.parseJsonObject(item.freightJson || plan.freightJson);
const itemGoods = this.dispatchGoodsRows(item.goodsJson)[0] || {};
const unitPrice =
item.unitPrice ??
itemGoods.unitPrice ??
plan.unitPrice ??
freightInfo.unitPrice ??
freightInfo.freightItems?.[0]?.unitPrice ??
'';
const otherFeeTotal =
item.otherFeeTotal ??
plan.otherFeeTotal ??
freightInfo.otherFeeTotal ??
freightInfo.otherFreightAmount ??
'';
const quantity = Number(
item.quantity ||
item.cargoQuantity ||
item.goodsQuantity ||
itemGoods.quantity ||
itemGoods.cargoQuantity ||
itemGoods.goodsQuantity ||
0
);
const calculatedFreight = unitPrice !== '' && quantity ? Number(unitPrice) * quantity : '';
const explicitFreight = [
item.freight,
item.freightAmount,
item.transportFee,
freightInfo.freightAmount,
freightInfo.transportFee,
plan.freight,
plan.freightAmount,
].find(value => value !== undefined && value !== null && String(value).trim() !== '');
const freight = explicitFreight ?? calculatedFreight;
const freightTotal =
freight !== '' || otherFeeTotal !== ''
? Number(freight || 0) + Number(otherFeeTotal || 0)
: item.freightTotal ??
item.totalFreight ??
plan.freightTotal ??
plan.totalFreight ??
freightInfo.totalFreightAmount ??
freightInfo.freightTotal ??
'';
return { unitPrice, freight, freightAmount: freight, otherFeeTotal, freightTotal };
},
getDispatchWaybillFallback(plan = {}, item = {}, index = 0) {
const sources = [
plan.waybillList,
plan.waybillRows,
plan.waybills,
plan.transportPlanWaybills,
plan.transportPlanWaybillList,
plan.dispatchRecords,
plan.dispatchRecordList,
plan.dispatchList,
];
const rows = sources.reduce((result, source) => {
const parsed = this.parseTransportPlanWaybillSource(source);
return result.length ? result : parsed;
}, []);
if (!rows.length) return {};
const itemWaybillId = item.waybillId || item.waybillID || item.waybillNo;
const matched = itemWaybillId
? rows.find(
row =>
String(row.id || row.waybillId || row.waybillID || row.waybillNo || '') ===
String(itemWaybillId)
)
: null;
const fallback = matched || rows[index] || rows[0] || {};
return {
createUserName:
fallback.createUserName ||
fallback.creatorName ||
fallback.createdUserName ||
fallback.createdByName ||
'',
createTime: fallback.createTime || fallback.createdAt || '',
};
},
formatDispatchCargoInfo(plan = {}, item = {}) {
const goodsRows = this.dispatchGoodsRows(plan.goodsJson);
const itemGoodsRows = this.dispatchGoodsRows(item.goodsJson);
const sourceRows = goodsRows.length
? goodsRows
: itemGoodsRows.length
? itemGoodsRows
: [item];
const groups = sourceRows.reduce((result, goods = {}) => {
const cargoType =
goods.secondCargoTypeName ||
goods.secondCargoType ||
goods.cargoType ||
goods.goodsType ||
goods.type ||
plan.cargoType ||
'货物';
const unit = this.getDispatchQuantityUnit(goods);
if (!result[unit]) {
result[unit] = {
cargoType,
count: 0,
quantity: 0,
unit,
};
}
result[unit].count += 1;
result[unit].quantity += this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
);
return result;
}, {});
const cargoInfo = Object.values(groups)
.map(
group =>
`${group.cargoType}等${group.count}种货物 | ${this.formatDispatchQuantity(
group.quantity
)}${group.unit ? ` ${group.unit}` : ''}`
)
.join(' ');
return cargoInfo || plan.goodsInfo || '';
},
dispatchGoodsRows(value) {
const rows = this.parseJsonArray(value);
if (rows.length) return rows;
const item = this.parseJsonObject(value);
return Object.keys(item).length ? [item] : [];
},
getDispatchConfiguredGoodsOptions() {
const sourceRows = this.dispatchPlanGoodsRows.length
? this.dispatchPlanGoodsRows
: this.dispatchRow.cargoName || this.dispatchRow.cargoType
? [this.dispatchRow]
: [];
return sourceRows
.map((item, index) => {
const cargoTypePath = this.normalizeBillingCargoTypePath(item.cargoTypePath);
const cargoTypeNode =
(cargoTypePath.length && this.findBillingCargoTypeByPath(cargoTypePath)) ||
this.findBillingCargoTypeByCodeOrName({
cargoTypeCode:
item.secondCargoTypeCode || item.cargoTypeCode || item.goodsTypeCode || '',
cargoType:
item.secondCargoTypeName || item.cargoType || item.goodsType || item.goodsTypeName,
});
const path = cargoTypePath.length ? cargoTypePath : cargoTypeNode?.path || [];
const labels = path.length ? this.getBillingCargoTypePathLabels(path) : [];
const cargoTypeLabel =
item.cargoType ||
item.secondCargoTypeName ||
item.goodsType ||
item.goodsTypeName ||
labels.at(-1) ||
'';
const cargoTypeCode =
item.secondCargoTypeCode ||
item.cargoTypeCode ||
item.goodsTypeCode ||
cargoTypeNode?.cargoCode ||
cargoTypeNode?.code ||
'';
const cargoName = item.cargoName || item.goodsName || item.name || '';
return {
...item,
_dispatchGoodsIndex: index,
cargoTypePath: path,
cargoTypeLabel,
cargoTypeCode,
cargoTypeValue: cargoTypeCode || (path.length ? path.join('/') : cargoTypeLabel),
cargoName,
};
})
.filter(item => item.cargoTypeLabel || item.cargoName);
},
getDispatchConfiguredCargoTypeOptions() {
const options = new Map();
this.getDispatchConfiguredGoodsOptions().forEach(item => {
if (!item.cargoTypeLabel || options.has(item.cargoTypeValue)) return;
options.set(item.cargoTypeValue, {
label: item.cargoTypeLabel,
value: item.cargoTypeLabel,
cargoTypeCode: item.cargoTypeCode,
cargoTypePath: item.cargoTypePath,
});
});
return Array.from(options.values());
},
getDispatchConfiguredCargoNameOptions(row = {}) {
const goodsOptions = this.getDispatchConfiguredGoodsOptions();
const rowType = String(row.cargoType || '').trim();
const rowTypePath = this.normalizeBillingCargoTypePath(row.cargoTypePath);
const rowTypeValue = rowType || row.cargoTypeCode || rowTypePath.join('/');
const filtered = rowTypeValue
? goodsOptions.filter(
item =>
item.cargoTypeLabel === rowType ||
item.cargoTypeValue === rowTypeValue ||
item.cargoTypeCode === row.cargoTypeCode ||
item.cargoTypePath.join('/') === rowTypePath.join('/')
)
: goodsOptions;
const source = filtered.length ? filtered : goodsOptions;
const options = new Map();
source.forEach(item => {
if (!item.cargoName || options.has(item.cargoName)) return;
options.set(item.cargoName, {
label: item.cargoName,
value: item.cargoName,
cargoTypeLabel: item.cargoTypeLabel,
cargoTypeCode: item.cargoTypeCode,
cargoTypePath: item.cargoTypePath,
specification: item.specification || item.spec || '',
model: item.model || item.modelName || '',
});
});
return Array.from(options.values());
},
fetchDispatchConfiguredCargoTypeSuggestions(queryString, callback) {
const keyword = String(queryString || '').trim().toLowerCase();
const options = this.getDispatchConfiguredCargoTypeOptions().map(item => ({
...item,
value: item.label,
}));
callback(
keyword
? options.filter(item => String(item.value).toLowerCase().includes(keyword))
: options
);
},
fetchDispatchConfiguredCargoNameSuggestions(queryString, callback, row = {}) {
const keyword = String(queryString || '').trim().toLowerCase();
const options = this.getDispatchConfiguredCargoNameOptions(row).map(item => ({
...item,
value: item.label,
}));
callback(
keyword
? options.filter(item => String(item.value).toLowerCase().includes(keyword))
: options
);
},
handleDispatchConfiguredCargoTypeSelect(row, item = {}) {
this.handleDispatchConfiguredCargoTypeChange(row, item.value || item.label || '');
},
handleDispatchConfiguredCargoNameSelect(row, item = {}) {
this.handleDispatchConfiguredCargoNameChange(row, item.value || item.label || '');
},
handleDispatchConfiguredCargoTypeChange(row, value) {
const nextValue = String(value ?? row.cargoType ?? '').trim();
const option = this.getDispatchConfiguredCargoTypeOptions().find(
item => String(item.value) === nextValue || item.label === nextValue
);
if (option) {
row.cargoType = option.label || '';
row.cargoTypeCode = option.cargoTypeCode || '';
row.cargoTypePath = option.cargoTypePath || [];
} else {
row.cargoType = nextValue;
row.cargoTypeCode = '';
row.cargoTypePath = [];
}
row.cargoName = '';
row.specification = '';
row.model = '';
},
handleDispatchConfiguredCargoNameChange(row, value) {
const nextValue = String(value ?? row.cargoName ?? '').trim();
const option = this.getDispatchConfiguredCargoNameOptions(row).find(
item => String(item.value) === nextValue || item.label === nextValue
);
if (!option) {
row.cargoName = nextValue;
return;
}
const previousType = String(row.cargoType || '').trim();
const isKnownType = this.getDispatchConfiguredCargoTypeOptions().some(
item => item.label === previousType || String(item.value) === previousType
);
row.cargoName = option.value || '';
if (!previousType || isKnownType) {
row.cargoType = option.cargoTypeLabel || previousType;
row.cargoTypeCode = option.cargoTypeCode || row.cargoTypeCode || '';
row.cargoTypePath = option.cargoTypePath || row.cargoTypePath || [];
}
row.specification = option.specification || row.specification || '';
row.model = option.model || row.model || '';
},
openDispatchItemDialog(index = -1, row = defaultDispatchRow()) {
const defaultSummaryItem =
this.dispatchSummaryItems.find(item => item.remaining > 0) || this.dispatchSummaryItems[0];
const defaultGoods =
this.dispatchPlanGoodsRows.find(
item => this.getDispatchQuantityUnit(item) === defaultSummaryItem?.unit
) ||
this.dispatchPlanGoodsRows[0] ||
{};
const defaultUnit = defaultSummaryItem?.unit || this.getDispatchQuantityUnit(defaultGoods);
const baseRow = {
...defaultDispatchRow(),
transportType: this.dispatchRow.transportType || '',
carrierType: this.dispatchRow.carrierType || '承运商',
carrierName: this.dispatchRow.carrierName || '',
driverName: this.dispatchRow.driverName || '',
driverPhone: this.dispatchRow.driverPhone || '',
vehicleNo: this.dispatchRow.vehicleNo || '',
cargoName: defaultGoods.cargoName || this.dispatchRow.cargoName || '',
cargoType: defaultGoods.cargoType || this.dispatchRow.cargoType || '',
quantity: '',
quantityUnit: defaultUnit,
specification: defaultGoods.specification || '',
model: defaultGoods.model || '',
packageType: defaultGoods.packageType || '',
departureName: this.dispatchRow.departureName || '',
departureAddress: this.dispatchRow.departureAddress || '',
departureContact: this.dispatchRow.departureContact || '',
departurePhone: this.dispatchRow.departurePhone || '',
arrivalName: this.dispatchRow.arrivalName || '',
arrivalAddress: this.dispatchRow.arrivalAddress || '',
arrivalContact: this.dispatchRow.arrivalContact || '',
arrivalPhone: this.dispatchRow.arrivalPhone || '',
};
this.dispatchItemIndex = index;
this.dispatchItemForm = {
...baseRow,
...(index >= 0 ? row : {}),
};
this.dispatchItemForm.carrierType = this.dispatchItemForm.carrierType || '承运商';
this.loadDispatchCarrierOptions(this.dispatchRow).then(() => {
this.applyDispatchSelfCarrierDefaults();
});
const goodsRows = this.parseJsonArray(this.dispatchItemForm.goodsJson);
this.dispatchItemCargoRows = (goodsRows.length ? goodsRows : [this.dispatchItemForm]).map(
item => this.normalizeTransportCargoRow(item)
);
const hasMultiplePlanGoods = this.dispatchPlanGoodsRows.length > 1;
const shouldAutoFillFullGoods =
hasMultiplePlanGoods &&
(index < 0 || !this.hasDispatchVehicleIdentifier(this.dispatchItemForm));
if (shouldAutoFillFullGoods) {
const unscheduledGoodsRows = this.getDispatchUnscheduledGoodsRows();
this.dispatchItemForm.taskEntryMode = 'full';
this.dispatchItemForm.quantity = '';
this.dispatchItemCargoRows = unscheduledGoodsRows.length
? unscheduledGoodsRows
: this.dispatchPlanGoodsRows.map(goods =>
this.normalizeTransportCargoRow({ ...goods, quantity: '' })
);
}
if (!this.dispatchItemCargoRows.length) {
this.dispatchItemCargoRows = [this.normalizeTransportCargoRow()];
}
if (this.hasMultipleConfiguredGoods(this.dispatchItemForm, this.dispatchItemCargoRows)) {
this.dispatchItemForm.taskEntryMode = 'full';
}
const freightInfo = this.parseJsonObject(this.dispatchItemForm.freightJson);
this.dispatchItemForm.freightCurrency = this.dispatchContractCurrency || 'RMB';
this.loadCurrencyOptions();
if (this.dispatchItemForm.taskEntryMode === 'full' && freightInfo.freightItems?.length) {
this.dispatchItemCargoRows = this.dispatchItemCargoRows.map((cargo, itemIndex) => ({
...cargo,
unitPrice: cargo.unitPrice || freightInfo.freightItems[itemIndex]?.unitPrice || '',
priceUnit: cargo.priceUnit || freightInfo.freightItems[itemIndex]?.priceUnit || '元/吨',
freightAmount:
cargo.freightAmount || freightInfo.freightItems[itemIndex]?.freightAmount || '',
}));
}
this.dispatchItemAttachmentRows = this.parseJsonArray(
index >= 0 ? row.attachmentsJson : this.dispatchRow.attachmentsJson
);
this.selectedDispatchItemAttachmentRows = [];
this.dispatchItemBox = true;
},
handleDispatchCarrierTypeChange(value) {
const nextValue = value || '';
this.dispatchItemForm.carrierName = '';
this.dispatchItemForm.carrierId = '';
this.dispatchItemForm.carrierContractId = '';
if (nextValue === '承运商') {
this.dispatchItemForm.trailerVehicleNo = '';
this.dispatchItemForm.escortName = '';
this.dispatchItemForm.escortPhone = '';
}
this.dispatchItemForm.carrierType = nextValue;
this.loadDispatchCarrierOptions(this.dispatchRow);
},
isDispatchCarrierRequired(carrierType) {
return String(carrierType || '').trim() === '承运商';
},
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;
},
loadDispatchCarrierOptions(plan = this.dispatchRow) {
const projectId = plan?.projectId;
const requestId = ++this.dispatchCarrierRequestId;
const carrierTypeAtRequest = this.dispatchItemForm.carrierType || '承运商';
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
this.dispatchCarrierOptions = this.getProjectCarrierOptions({ ...project, ...plan });
this.dispatchCarrierContractOptions = [];
if (!projectId) return Promise.resolve(this.dispatchCarrierOptions);
this.dispatchCarrierLoading = true;
const detailPromise = getProjectDetail(projectId)
.then(res => {
if (requestId !== this.dispatchCarrierRequestId) return this.dispatchCarrierOptions;
const detail = res?.data?.data || {};
this.dispatchCarrierOptions = this.getProjectCarrierOptions({
...project,
...plan,
...detail,
});
return this.dispatchCarrierOptions;
})
.catch(() => this.dispatchCarrierOptions);
const contractPromise = getContractList(1, 9999, {
projectId,
projectName: plan?.projectName || project?.projectName,
contractCategory: '承运商合同',
})
.then(res => {
if (requestId !== this.dispatchCarrierRequestId) return [];
this.dispatchCarrierContractOptions = this.getWaybillCarrierContractOptions(
extractRecords(res)
);
return this.dispatchCarrierContractOptions;
})
.catch(() => {
this.dispatchCarrierContractOptions = [];
return [];
});
return Promise.all([detailPromise, contractPromise])
.then(([, carrierContracts]) => {
if (carrierTypeAtRequest !== '自运') {
this.dispatchCarrierOptions = (carrierContracts || []).map(item => ({
...item,
value: item.carrierContractId || item.id,
}));
const current = this.dispatchCarrierOptions.find(
item =>
String(item.carrierContractId) === String(this.dispatchItemForm.carrierContractId)
);
if (current) {
this.dispatchItemForm.carrierName = current.carrierName;
} else if (!this.dispatchItemForm.carrierContractId) {
const matches = this.dispatchCarrierOptions.filter(
item => String(item.carrierName) === String(this.dispatchItemForm.carrierName || '')
);
if (matches.length === 1) {
this.dispatchItemForm.carrierContractId = matches[0].carrierContractId;
}
}
return this.dispatchCarrierOptions;
}
this.applyDispatchSelfCarrierDefaults(carrierContracts);
return this.dispatchCarrierOptions;
})
.finally(() => {
if (requestId === this.dispatchCarrierRequestId) {
this.dispatchCarrierLoading = false;
}
});
},
getWaybillCarrierContractOptions(contracts = []) {
const counts = contracts.reduce((result, item) => {
const name = String(
item.partyB ||
item.partyBName ||
item.contractPartyB ||
item.customerContractPartyB ||
''
).trim();
if (name) result[name] = (result[name] || 0) + 1;
return result;
}, {});
return contracts
.map(contract => {
const carrierName = String(
contract.partyB ||
contract.partyBName ||
contract.contractPartyB ||
contract.customerContractPartyB ||
''
).trim();
if (!carrierName || !contract.id) return null;
const identifier = contract.contractName || contract.contractNo || contract.id;
return {
id: contract.id,
value: contract.id,
carrierContractId: contract.id,
carrierId:
contract.partyBId ||
contract.partyBUserId ||
contract.contractPartyBId ||
contract.customerContractPartyBId ||
'',
carrierName,
fullName: carrierName,
label: counts[carrierName] > 1 ? `${carrierName}${identifier}` : carrierName,
};
})
.filter(Boolean);
},
applyDispatchSelfCarrierDefaults(carrierContracts = this.dispatchCarrierContractOptions) {
if (this.dispatchItemForm.carrierType !== '自运') return;
const options = carrierContracts || [];
if (options.length) {
this.dispatchCarrierOptions = options.map(item => ({ ...item, value: item.carrierName }));
const current = this.dispatchCarrierOptions.find(
item => String(item.carrierName) === String(this.dispatchItemForm.carrierName || '')
);
if (!current) {
this.dispatchItemForm.carrierName = this.dispatchCarrierOptions[0].carrierName;
this.dispatchItemForm.carrierId = '';
}
return;
}
const customerContract = this.contractOptions.find(
item => String(item.id) === String(this.dispatchRow?.contractId)
);
const carrierName = String(customerContract?.partyB || '').trim();
if (!carrierName) return;
if (!this.dispatchCarrierOptions.some(item => String(item.value) === carrierName)) {
this.dispatchCarrierOptions.push({
id: `customer-contract-${carrierName}`,
value: carrierName,
carrierName,
fullName: carrierName,
label: carrierName,
});
}
this.dispatchItemForm.carrierName = carrierName;
this.dispatchItemForm.carrierId = '';
},
handleDispatchCarrierChange(value) {
const carrier = this.dispatchCarrierOptions.find(item => {
const optionValue = this.dispatchIsCarrierMode ? item.carrierContractId : item.value;
return String(optionValue || '') === String(value || '');
});
if (this.dispatchItemForm.carrierType !== '自运') {
this.dispatchItemForm.carrierContractId = carrier?.carrierContractId || '';
this.dispatchItemForm.carrierId = carrier?.carrierId || '';
this.dispatchItemForm.carrierName = carrier?.carrierName || '';
return;
}
this.dispatchItemForm.carrierContractId = '';
this.dispatchItemForm.carrierId =
this.dispatchItemForm.carrierType === '自运' ? '' : carrier?.id || '';
this.dispatchItemForm.carrierName = carrier?.carrierName || value || '';
},
handleDispatchCargoQuantityInput(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];
},
handleDispatchCargoNumberInput(row, prop, value) {
this.handleFreightNumberInput(row, prop, value);
},
handleDispatchNumberInput(prop, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
this.dispatchItemForm[prop] =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
},
addDispatchCargoRow(index = -1) {
const nextRow = this.normalizeTransportCargoRow({ priceUnit: '元/吨' });
if (index > -1) this.dispatchItemCargoRows.splice(index + 1, 0, nextRow);
else this.dispatchItemCargoRows.push(nextRow);
},
removeDispatchCargoRow(index) {
this.dispatchItemCargoRows.splice(index, 1);
if (!this.dispatchItemCargoRows.length) {
this.dispatchItemCargoRows.push(this.normalizeTransportCargoRow());
}
},
dispatchCargoFreightAmount(cargo = {}) {
if (cargo.freightAmount !== undefined && cargo.freightAmount !== null) {
const enteredAmount = String(cargo.freightAmount).trim();
if (enteredAmount !== '' && Number.isFinite(Number(enteredAmount))) {
const amount = Number(enteredAmount);
return Number.isInteger(amount) ? String(amount) : String(Number(amount.toFixed(2)));
}
}
const quantity = Number(cargo.quantity || 0);
const unitPrice = Number(cargo.unitPrice || 0);
if (!quantity || !unitPrice) return '';
const amount = quantity * unitPrice;
return Number.isInteger(amount) ? String(amount) : String(Number(amount.toFixed(2)));
},
formatDispatchAmount(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount)) return '';
return Number(amount.toFixed(2)).toString();
},
dispatchFreightGroupQuantity(group = {}) {
const quantity = (group.rows || []).reduce(
(sum, cargo) => sum + Number(cargo.quantity || 0),
0
);
return this.formatDispatchQuantity(quantity);
},
dispatchFreightGroupUnitPrice(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];
},
dispatchFreightGroupPriceUnit(group = {}) {
return group.rows?.[0]?.priceUnit || (group.quantityUnit ? `元/${group.quantityUnit}` : '');
},
dispatchFreightGroupAmount(group = {}) {
const amount = (group.rows || []).reduce(
(sum, cargo) => sum + Number(this.dispatchCargoFreightAmount(cargo) || 0),
0
);
return Number.isInteger(amount) ? String(amount) : String(Number(amount.toFixed(2)));
},
handleDispatchFreightGroupNumberInput(group, value) {
this.handleFreightNumberInput(group.rows?.[0] || {}, 'unitPrice', value);
const unitPrice = group.rows?.[0]?.unitPrice || '';
(group.rows || []).forEach(cargo => {
cargo.unitPrice = unitPrice;
cargo.freightAmount = '';
});
},
handleDispatchFreightGroupPriceUnitChange(group, value) {
(group.rows || []).forEach(cargo => {
cargo.priceUnit = value || '';
});
},
handleDispatchFreightGroupAmountInput(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.formatDispatchAmount(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.formatDispatchAmount(amount);
});
},
dispatchItemRemainingQuantity(row = {}) {
const unit = this.getDispatchQuantityUnit(row);
const hasCargoIdentity = Boolean(
String(row.cargoName || row.goodsName || row.name || '').trim() ||
String(row.cargoType || row.secondCargoTypeName || row.goodsType || row.type || '').trim()
);
const planGoodsRows = this.dispatchPlanGoodsRows;
const cargoKey = this.getDispatchCargoIdentity(row);
// 完整录入时按货物身份计算,不能把同单位的其他货物数量合并到当前行。
if (planGoodsRows.length && hasCargoIdentity) {
const matchedPlanRows = planGoodsRows.filter(
goods => this.getDispatchCargoIdentity(goods) === cargoKey
);
if (!matchedPlanRows.length) return 0;
const total = matchedPlanRows.reduce(
(sum, goods) =>
sum +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
),
0
);
const assigned = this.dispatchRows.reduce((sum, dispatchRow, index) => {
if (index === this.dispatchItemIndex || !this.hasDispatchVehicleIdentifier(dispatchRow)) {
return sum;
}
return (
sum +
this.getDispatchGoodsSourceRows(dispatchRow).reduce((goodsSum, goods) => {
if (this.getDispatchCargoIdentity(goods) !== cargoKey) return goodsSum;
return (
goodsSum +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
)
);
}, 0)
);
}, 0);
return Math.max(total - assigned, 0);
}
// 兼容没有货物明细的旧数据,以及尚未选择货物的空白行。
const remaining = this.getDispatchRemainingQuantity(unit);
const editingRow = this.dispatchRows[this.dispatchItemIndex];
if (!editingRow || !this.hasDispatchVehicleIdentifier(editingRow)) return remaining;
const editingGoodsRows = this.dispatchGoodsRows(editingRow.goodsJson);
const goodsRows = editingGoodsRows.length ? editingGoodsRows : [editingRow];
return (
remaining +
goodsRows.reduce((total, goods) => {
if (this.getDispatchQuantityUnit(goods) !== unit) return total;
return (
total +
this.parseDispatchQuantity(goods.quantity || goods.cargoQuantity || goods.goodsQuantity)
);
}, 0)
);
},
pruneDispatchPendingRows(rows = []) {
const planQuantities = new Map();
this.dispatchPlanGoodsRows.forEach(goods => {
const key = this.getDispatchCargoIdentity(goods);
const quantity = this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
);
planQuantities.set(key, (planQuantities.get(key) || 0) + quantity);
});
const assignedQuantities = new Map();
rows.forEach(row => {
if (!this.hasDispatchVehicleIdentifier(row)) return;
this.getDispatchGoodsSourceRows(row).forEach(goods => {
const key = this.getDispatchCargoIdentity(goods);
assignedQuantities.set(
key,
(assignedQuantities.get(key) || 0) +
this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
)
);
});
});
const pendingRemaining = new Map(
Array.from(planQuantities.entries()).map(([key, total]) => [
key,
Math.max(total - (assignedQuantities.get(key) || 0), 0),
])
);
return rows.reduce((result, row) => {
if (this.hasDispatchVehicleIdentifier(row)) {
result.push(row);
return result;
}
const pendingGoodsRows = this.getDispatchGoodsSourceRows(row)
.map(goods => {
const quantity = this.parseDispatchQuantity(
goods.quantity || goods.cargoQuantity || goods.goodsQuantity
);
if (quantity <= 0) return goods;
const key = this.getDispatchCargoIdentity(goods);
const available = pendingRemaining.has(key) ? pendingRemaining.get(key) : quantity;
const remainingQuantity = Math.min(quantity, Math.max(available, 0));
if (!remainingQuantity) return null;
if (pendingRemaining.has(key)) {
pendingRemaining.set(key, Math.max(available - remainingQuantity, 0));
}
return {
...goods,
quantity: this.formatDispatchQuantity(remainingQuantity),
};
})
.filter(Boolean);
if (!pendingGoodsRows.length) return result;
const nextRow = {
...row,
goodsJson: JSON.stringify(pendingGoodsRows),
cargoType: pendingGoodsRows[0].cargoType || row.cargoType || '',
cargoName: pendingGoodsRows[0].cargoName || row.cargoName || '',
quantity: pendingGoodsRows[0].quantity || row.quantity || '',
quantityUnit: pendingGoodsRows[0].quantityUnit || row.quantityUnit || '吨',
};
nextRow.cargoInfo = this.formatDispatchCargoInfo({}, nextRow);
result.push(nextRow);
return result;
}, []);
},
validateDispatchItemData(row = {}, goodsRows = [], rowIndex = -1) {
const detailPrefix = rowIndex >= 0 ? `第${rowIndex + 1}条调度明细` : '';
const warn = message => {
this.$message.warning(detailPrefix ? `${detailPrefix}${message}` : message);
return false;
};
const requiredMessage = label =>
warn(detailPrefix ? `的${label}不能为空` : `请选择或输入${label}`);
const requiredFields = [
['transportType', '运输方式'],
['departureAddress', '发货地址'],
['arrivalAddress', '收货地址'],
['carrierType', '承运类型'],
[
'vehicleNo',
['water', 'rail', 'air'].includes(this.resolveTransportMode(row.transportType))
? '船/航/班列号'
: '车牌号',
],
];
if (this.isDispatchCarrierRequired(row.carrierType)) {
requiredFields.push(['carrierName', '承运商']);
}
if (row.carrierType === '承运商') {
requiredFields.push(['carrierContractId', '承运商合同']);
}
const fullEntry = row.taskEntryMode === 'full';
const roadTransport = this.resolveTransportMode(row.transportType) === 'road';
if (fullEntry) {
if (roadTransport && row.carrierType === '自运') {
requiredFields.push(['driverName', '司机']);
requiredFields.push(['driverPhone', '手机号']);
}
} else if (row.carrierType && row.carrierType !== '承运商') {
requiredFields.push(['driverName', '司机']);
requiredFields.push(['driverPhone', '司机手机号']);
}
const emptyField = requiredFields.find(([prop]) => this.isBillingFieldEmpty(row[prop]));
if (emptyField) return requiredMessage(emptyField[1]);
if (!goodsRows.length) {
return warn(detailPrefix ? '的货物信息不能为空' : '请至少添加一条货物信息');
}
for (const [goodsIndex, goods] of goodsRows.entries()) {
const goodsPrefix = fullEntry ? `第${goodsIndex + 1}行` : '';
const emptyGoodsField = [
['cargoType', '货物类型'],
['cargoName', '货物名称'],
['quantity', '本次数量'],
['quantityUnit', '数量单位'],
].find(([prop]) => this.isBillingFieldEmpty(goods[prop]));
if (emptyGoodsField) {
return warn(
detailPrefix
? `的${goodsPrefix}${emptyGoodsField[1]}不能为空`
: `请选择或输入${goodsPrefix}${emptyGoodsField[1]}`
);
}
const quantity = Number(String(goods.quantity).replace(/,/g, ''));
if (!Number.isFinite(quantity) || quantity <= 0) {
return warn(
detailPrefix ? `的${goodsPrefix}本次数量必须大于0` : `${goodsPrefix}本次数量必须大于0`
);
}
if (!this.isBillingFieldEmpty(goods.unitPrice) && Number(goods.unitPrice) < 0) {
return warn(
detailPrefix ? `的${goodsPrefix}单价不能小于0` : `${goodsPrefix}单价不能小于0`
);
}
if (!this.isBillingFieldEmpty(goods.freightAmount) && Number(goods.freightAmount) < 0) {
return warn(
detailPrefix ? `的${goodsPrefix}运费不能小于0` : `${goodsPrefix}运费不能小于0`
);
}
}
const invalidPhoneField = [
[row.departurePhone, '发货联系方式'],
[row.arrivalPhone, '收货联系方式'],
[row.driverPhone, roadTransport ? '手机号' : '联系电话'],
[row.escortPhone, '押运人手机号'],
].find(([value]) => String(value || '').trim() && !isMobile(value));
if (invalidPhoneField) {
return warn(
detailPrefix ? `的${invalidPhoneField[1]}格式不正确` : `${invalidPhoneField[1]}格式不正确`
);
}
const lengthFields = roadTransport
? [
['driverName', '司机', 20],
['driverPhone', '手机号', 20],
['vehicleNo', '车牌号', 30],
['trailerVehicleNo', '挂车车牌号', 30],
['escortName', '押运人', 20],
['escortPhone', '押运人手机号', 20],
]
: [
['vehicleNo', '船/航/班列号', 30],
['captainName', '船长', 20],
['cabinNo', '舱位', 30],
['containerNo', '箱号', 30],
];
const invalidLengthField = lengthFields.find(
([prop, , max]) => String(row[prop] || '').length > max
);
if (invalidLengthField) {
return warn(
detailPrefix
? `的${invalidLengthField[1]}不能超过${invalidLengthField[2]}个字符`
: `${invalidLengthField[1]}不能超过${invalidLengthField[2]}个字符`
);
}
if (row.mileage && (!/^\d{1,10}$/.test(String(row.mileage)) || Number(row.mileage) <= 0)) {
return warn(
detailPrefix ? '的里程必须为不超过10位的正整数' : '里程必须为不超过10位的正整数'
);
}
if (!this.isBillingFieldEmpty(row.otherFeeTotal) && Number(row.otherFeeTotal) < 0) {
return warn(detailPrefix ? '的其他费用合计不能小于0' : '其他费用合计不能小于0');
}
if (
row.estimatedStartTime &&
row.estimatedEndTime &&
row.estimatedEndTime < row.estimatedStartTime
) {
return warn(
detailPrefix ? '的预计完成日期不能早于预计发货日期' : '预计完成日期不能早于预计发货日期'
);
}
if (String(row.remark || row.taskRemark || '').length > 200) {
return warn(detailPrefix ? '的备注不能超过200个字符' : '备注不能超过200个字符');
}
return true;
},
validateDispatchItemForm() {
const goodsRows =
this.dispatchItemForm.taskEntryMode === 'full'
? this.dispatchItemCargoRows
: [this.dispatchItemForm];
if (!this.validateDispatchItemData(this.dispatchItemForm, goodsRows)) return false;
const quantityGroups = new Map();
goodsRows.forEach(goods => {
const unit = this.getDispatchQuantityUnit(goods);
const hasCargoIdentity = Boolean(
String(goods.cargoName || goods.goodsName || goods.name || '').trim() ||
String(
goods.cargoType || goods.secondCargoTypeName || goods.goodsType || goods.type || ''
).trim()
);
const key = hasCargoIdentity
? `cargo:${this.getDispatchCargoIdentity(goods)}`
: `unit:${unit}`;
const group = quantityGroups.get(key) || { row: goods, quantity: 0 };
group.quantity += this.parseDispatchQuantity(goods.quantity);
quantityGroups.set(key, group);
});
for (const { row: goods, quantity } of quantityGroups.values()) {
if (this.isDispatchPlanQuantityUnlimited(goods)) continue;
const unit = this.getDispatchQuantityUnit(goods);
const remaining = this.dispatchItemRemainingQuantity(goods);
if (quantity - remaining > 1e-8) {
this.$message.warning(
`本次调度${unit}合计不能超过剩余数量${this.formatDispatchQuantity(remaining)}${unit}`
);
return false;
}
}
return true;
},
saveDispatchItem() {
if (!this.validateDispatchItemForm()) return;
const goodsRows =
this.dispatchItemForm.taskEntryMode === 'full'
? this.dispatchItemCargoRows.map(row => this.normalizeTransportCargoRow(row))
: [
this.normalizeTransportCargoRow({
...this.dispatchItemForm,
remark: this.dispatchItemForm.remark || '',
}),
];
const firstGoods = goodsRows[0] || {};
const nextRow = {
...this.dispatchItemForm,
goodsJson: JSON.stringify(goodsRows),
cargoType: firstGoods.cargoType || this.dispatchItemForm.cargoType || '',
cargoTypeCode: firstGoods.cargoTypeCode || this.dispatchItemForm.cargoTypeCode || '',
cargoTypePath: firstGoods.cargoTypePath || this.dispatchItemForm.cargoTypePath || [],
cargoName: firstGoods.cargoName || this.dispatchItemForm.cargoName || '',
quantity: firstGoods.quantity || this.dispatchItemForm.quantity || '',
quantityUnit: firstGoods.quantityUnit || this.dispatchItemForm.quantityUnit || '',
unitPrice: firstGoods.unitPrice || '',
priceUnit: firstGoods.priceUnit || '',
freightTotal: this.dispatchItemFreightTotal,
freightJson: JSON.stringify({
currency: this.dispatchContractCurrency || this.dispatchItemForm.freightCurrency || 'RMB',
totalFreightAmount: this.dispatchItemFreightSubtotal,
otherFreightAmount: this.dispatchItemForm.otherFeeTotal || '',
freightItems: goodsRows.map((cargo, index) => ({
cargoIndex: index,
cargoName: cargo.cargoName || '',
cargoType: cargo.cargoType || '',
unitPrice: cargo.unitPrice || '',
priceUnit: cargo.priceUnit || '',
quantity: cargo.quantity || '',
quantityUnit: cargo.quantityUnit || '',
freightAmount: this.dispatchCargoFreightAmount(cargo),
})),
}),
attachmentsJson: JSON.stringify(this.dispatchItemAttachmentRows),
};
nextRow.cargoInfo = this.formatDispatchCargoInfo({}, nextRow);
nextRow.freight = this.dispatchItemFreightSubtotal;
nextRow.otherFeeTotal = this.dispatchItemForm.otherFeeTotal || '';
const quantityUnit = this.getDispatchQuantityUnit(nextRow);
const otherDispatchedQuantity = this.dispatchRows.reduce((total, row, index) => {
if (
index === this.dispatchItemIndex ||
this.getDispatchQuantityUnit(row) !== quantityUnit ||
!this.hasDispatchVehicleIdentifier(row)
) {
return total;
}
return total + this.parseDispatchQuantity(row.quantity);
}, 0);
const currentQuantity = this.parseDispatchQuantity(nextRow.quantity);
const totalQuantity = this.getDispatchSummaryItem(quantityUnit)?.total || 0;
const persistedDispatchedQuantity = this.dispatchRows.length
? 0
: this.parseJsonArray(this.dispatchRow.dispatchRows).reduce((total, row) => {
if (
this.getDispatchQuantityUnit(row) !== quantityUnit ||
!this.hasDispatchVehicleIdentifier(row)
) {
return total;
}
return total + this.parseDispatchQuantity(row.quantity);
}, 0);
const dispatchedQuantity =
persistedDispatchedQuantity + otherDispatchedQuantity + currentQuantity;
if (totalQuantity > 0 && dispatchedQuantity - totalQuantity > 1e-8) {
this.$message.warning(
`已调度${quantityUnit}合计不能超过总数量${this.formatDispatchQuantity(
totalQuantity
)}${quantityUnit}`
);
return;
}
if (this.dispatchItemIndex >= 0) {
this.dispatchRows.splice(this.dispatchItemIndex, 1, nextRow);
} else {
this.dispatchRows.push(nextRow);
}
this.dispatchRows = this.pruneDispatchPendingRows(this.dispatchRows);
this.dispatchItemBox = false;
this.dispatchItemIndex = -1;
this.dispatchItemForm = defaultDispatchRow();
this.dispatchItemCargoRows = [];
this.dispatchItemAttachmentRows = [];
this.selectedDispatchItemAttachmentRows = [];
},
resetDispatchItemDialog() {
this.dispatchItemIndex = -1;
this.dispatchItemForm = defaultDispatchRow();
this.dispatchItemCargoRows = [];
this.dispatchItemAttachmentRows = [];
this.selectedDispatchItemAttachmentRows = [];
},
removeDispatchRow(index) {
this.$confirm('确定删除该调度明细?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
this.dispatchRows.splice(index, 1);
});
},
handleDispatchAddRow() {
if (!this.dispatchHasAddableQuantity) {
this.$message.warning('待调度列表货物总量已达到计划总量,不能新增调度明细');
return;
}
this.openDispatchItemDialog();
},
handleDispatchEditRow(row, index) {
this.openDispatchItemDialog(index, row);
},
buildDispatchPayload(mode) {
const rows = this.dispatchRows.map(
({ _key, cargoInfo, dispatchUserName, dispatchTime, ...rest }) => rest
);
return {
id: this.dispatchRow.id,
mode,
waybills: rows.map(row => ({
...row,
taskEntryMode: row.taskEntryMode || 'simple',
taskRemark: row.remark || '',
estimatedStartTime: this.formatDispatchDate(row.estimatedStartTime),
estimatedEndTime: this.formatDispatchDate(row.estimatedEndTime),
goodsJson:
row.goodsJson ||
JSON.stringify([
{
cargoName: row.cargoName,
cargoType: row.cargoType,
quantity: row.quantity,
quantityUnit: row.quantityUnit,
specification: row.specification,
model: row.model,
packageType: row.packageType,
},
]),
})),
};
},
formatDispatchDate(value) {
if (!value) return '';
const date = this.$dayjs(value);
return date.isValid() ? date.format('YYYY-MM-DD') : value;
},
validateDispatchRows(mode) {
if (!this.dispatchRows.length) {
this.$message.warning('请先添加调度明细');
return false;
}
for (const [index, row] of this.dispatchRows.entries()) {
const goodsRows = row.taskEntryMode === 'full' ? this.parseJsonArray(row.goodsJson) : [row];
if (mode === 'draft') {
if (row.taskEntryMode === 'full' && !goodsRows.length) {
this.$message.warning(`第${index + 1}条调度明细的货物信息不能为空`);
return false;
}
const invalidPhoneField = [
[row.departurePhone, '发货联系方式'],
[row.arrivalPhone, '收货联系方式'],
[row.driverPhone, '手机号'],
[row.escortPhone, '押运人手机号'],
].find(([value]) => String(value || '').trim() && !isMobile(value));
if (invalidPhoneField) {
this.$message.warning(`第${index + 1}条调度明细的${invalidPhoneField[1]}格式不正确`);
return false;
}
continue;
}
if (!this.validateDispatchItemData(row, goodsRows, index)) return false;
}
return true;
},
handleDispatchSubmit(mode) {
if (!this.dispatchRow?.id || typeof this.api.dispatch !== 'function') {
this.$message.warning('运输计划调度接口不可用');
return;
}
if (!this.validateDispatchRows(mode)) {
return;
}
if (mode === 'submit') {
this.$confirm(
`请确认调度信息,将生成 <span style="color: #409eff; font-size: 20px; padding: 0 4px;">${this.dispatchRows.length}</span> 条运单,是否继续?`,
'提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
dangerouslyUseHTMLString: true,
type: 'warning',
}
)
.then(() => this.submitDispatch(mode))
.catch(() => {});
return;
}
this.submitDispatch(mode);
},
submitDispatch(mode) {
this.dispatchSaving = mode;
const shouldCompletePlan = mode === 'submit' && !this.dispatchHasRemainingQuantity;
this.api
.dispatch(this.buildDispatchPayload(mode))
.then(() => {
if (shouldCompletePlan && typeof this.api.complete === 'function') {
return this.api.complete(this.dispatchRow.id);
}
return undefined;
})
.then(() => {
const message =
mode === 'draft'
? '生成草稿成功'
: shouldCompletePlan
? '确认生成成功,运输计划已完成'
: '确认生成成功';
this.$message.success(message);
this.dispatchBox = false;
this.onLoad(this.page, this.query);
})
.finally(() => {
this.dispatchSaving = '';
});
},
dispatchAttachmentLabel(item) {
return (
item?.originalName ||
item?.name ||
item?.fileName ||
item?.attachmentName ||
item?.title ||
'附件'
);
},
dispatchAttachmentUrl(item) {
return item?.url || item?.fileUrl || item?.downloadUrl || '';
},
formatDispatchRouteContact(contact = '', phone = '') {
return [contact, phone].filter(Boolean).join(' - ');
},
parseDispatchRoutePoint(row, type) {
const prefix = type === 'start' ? 'departure' : 'arrival';
const name = row[`${prefix}Name`] || row[`${prefix}Address`] || '';
const address = row[`${prefix}Address`] || row[`${prefix}DetailAddress`] || '';
const contact = this.formatDispatchRouteContact(
row[`${prefix}Contact`],
row[`${prefix}Phone`]
);
return { name, address, contact };
},
},
};
</script>
<style lang="scss" scoped>
// 对齐 customer-archive 的 .archive-form 风格:label 固定宽折行 + 控件 250px
.transport-plan-page__dispatch-item-form,
.transport-plan-page__transport-plan-import-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-autocomplete),
:deep(.el-form-item__content > .el-input-number),
:deep(.el-form-item__content > .el-date-editor) {
width: 250px;
max-width: 100%;
}
}
.transport-plan-page {
&__field {
width: 100%;
}
&__contract-name {
text-align: left;
}
.dialog-section-title {
display: flex;
align-items: center;
width: 100%;
gap: 0;
color: #303133;
font-size: 14px;
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 {
box-sizing: border-box;
min-width: 100%;
max-width: 100%;
}
&__shipping-title,
&__goods-title {
justify-content: flex-start !important;
padding-left: 0;
text-align: left;
}
&__shipping-route-action {
margin-left: auto;
}
&__goods-title &__section-actions {
margin-left: auto;
}
&__section-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
&__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;
}
.transport-plan-page__map-suffix {
color: #409eff;
font-size: 18px;
}
:deep(.el-input .el-input__icon) {
color: #409eff;
font-size: 18px;
}
.transport-plan-page__address-pick-btn {
:deep(.el-icon) {
font-size: 22px;
}
}
}
&__dispatch-shipping-form {
:deep(.el-form-item) {
margin-bottom: 14px;
}
}
&__address-choose-link {
display: inline-flex;
align-items: center;
height: 100%;
padding: 0 4px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
&__dispatch-shipping-form &__route-address-row {
grid-template-columns: 260px minmax(280px, 360px) 58px minmax(160px, 220px) 72px minmax(
160px,
220px
);
}
&__dispatch-mode-switch {
display: flex;
justify-content: flex-start;
width: 100%;
margin-top: 16px;
}
&__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;
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
&__cargo-import {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 56px;
padding: 0 !important;
: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-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(.transport-plan-page__full-form-item) {
width: 100%;
max-width: 100%;
}
:deep(.transport-plan-page__full-form-item > .el-form-item) {
width: 100%;
}
:deep(.transport-plan-page__full-form-item > .el-form-item > .el-form-item__label) {
display: none;
width: 0 !important;
padding: 0 !important;
}
:deep(.transport-plan-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(.transport-plan-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;
}
&__create-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
&__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;
}
&__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;
}
&__dispatch-dialog {
:deep(.el-dialog__header) {
display: block;
height: 0;
padding: 0;
}
:deep(.el-dialog__headerbtn) {
z-index: 2;
top: 12px;
right: 12px;
}
:deep(.el-dialog__body) {
padding: 16px;
background: #f5f6fa;
display: flex;
height: calc(100vh - 140px);
max-height: calc(100vh - 140px);
box-sizing: border-box;
overflow: hidden;
}
}
&__dispatch-shell {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
&__dispatch-top {
padding: 0;
}
&__dispatch-heading {
margin-bottom: 14px;
}
&__dispatch-heading-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
&__dispatch-heading-name {
font-size: 18px;
font-weight: 600;
color: #303133;
}
&__dispatch-heading-no {
font-size: 14px;
color: #606266;
}
&__dispatch-summary {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
gap: 16px;
align-items: stretch;
margin-bottom: 12px;
}
&__dispatch-summary-card,
&__dispatch-route-card {
display: flex;
flex-direction: column;
:deep(.section-card__body) {
flex: 1;
}
}
&__dispatch-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
&__dispatch-summary-item {
min-width: 0;
&.is-wide {
grid-column: span 2;
}
}
&__dispatch-summary-label {
margin-bottom: 4px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
&__dispatch-summary-value {
min-height: 20px;
font-size: 14px;
line-height: 1.45;
color: #303133;
word-break: break-word;
}
&__dispatch-summary-item--attachments {
grid-column: 1 / -1;
}
&__dispatch-attachment-link {
display: inline-block;
margin-right: 16px;
margin-bottom: 4px;
font-size: 14px;
}
&__dispatch-route {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 56px minmax(0, 1fr);
gap: 40px;
align-items: center;
//height: 100%;
margin: 20px 0;
}
&__dispatch-route-item {
display: flex;
align-items: center;
gap: 12px;
max-width: 90%;
padding: 16px;
min-height: 80px;
border: 1px dashed #e3e4ec;
border-radius: 12px;
}
&__dispatch-route-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
color: #fff;
font-size: 16px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&.is-start {
background: #2a92ff;
}
&.is-end {
background: #ffb11a;
}
}
&__dispatch-route-body {
min-width: 0;
text-align: left;
}
&__dispatch-route-title {
font-size: 18px;
line-height: 1.35;
color: #303133;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__dispatch-route-address {
margin-top: 4px;
font-size: 13px;
line-height: 1.5;
color: #606266;
}
&__dispatch-route-contact {
margin-top: 2px;
font-size: 13px;
color: #909399;
}
&__dispatch-route-vehicle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin: 0 auto;
border-radius: 50%;
background: #fff;
border: 2px solid var(--el-color-primary);
color: var(--el-color-primary);
font-size: 22px;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.18);
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
width: 20px;
height: 0;
border-top: 2px dashed var(--el-color-primary-light-5);
transform: translateY(-50%);
}
&::before {
right: 100%;
margin-right: 6px;
}
&::after {
left: 100%;
margin-left: 6px;
}
}
&__dispatch-list-summary {
margin-left: 8px;
font-size: 14px;
color: #3c3c3c;
}
&__dispatch-table {
flex: 1;
min-height: 0;
font-size: 13px;
:deep(.el-table__header .cell) {
font-size: 13px;
font-weight: 600;
}
:deep(.cell) {
white-space: normal;
line-height: 1.35;
}
:deep(.transport-plan-page__dispatch-driver) {
display: inline-block;
max-width: 100%;
white-space: nowrap;
}
}
&__dispatch-list-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
:deep(.section-card__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
}
&__dispatch-actions {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
.el-button {
margin-left: 0;
}
}
&__dispatch-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-right: 0;
padding-top: 16px;
margin-top: 16px;
border-top: 1px solid #e9edf5;
}
&__dispatch-item-dialog {
width: calc(100% - 32px) !important;
max-width: 1400px;
:deep(.el-dialog__body) {
min-width: 0;
padding-top: 20px;
overflow-x: hidden;
}
}
&__dispatch-item-form {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
padding: 16px;
border: 1px solid #ebeef5;
background: #fff;
> .dialog-section-title {
margin-top: 16px;
&:first-child {
margin-top: 0;
}
}
> .transport-plan-page__dispatch-shipping-form,
> .transport-plan-page__cargo-wrap,
> .transport-plan-page__dispatch-item-grid,
> .transport-plan-page__attachment {
background: #fff;
}
> .transport-plan-page__attachment {
padding: 0 0 4px;
}
}
&__dispatch-unit-select {
width: 104px;
:deep(.el-select__wrapper),
:deep(.el-select__wrapper:hover),
:deep(.el-select__wrapper.is-focused) {
width: 104px;
min-height: 24px;
padding: 0 8px;
border: 0;
box-shadow: none !important;
}
}
&__dispatch-quantity-hint {
display: block;
width: 100%;
margin-top: 4px;
color: #909399;
font-size: 12px;
line-height: 18px;
}
&__dispatch-item-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 24px;
:deep(.el-form-item) {
margin-bottom: 0;
min-width: 0;
}
&--compact {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
&--full {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
&--carrier {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
&__dispatch-item-span-2 {
grid-column: span 2;
}
&__dispatch-item-span-3 {
grid-column: span 3;
}
&__dispatch-item-span-4 {
grid-column: 1 / -1;
}
&__dispatch-item-carrier-type {
grid-column: 1 / -1;
width: calc((100% - 48px) / 3);
}
&__required-column {
&::before {
margin-right: 4px;
color: #f56c6c;
content: '*';
}
}
&__attachment {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-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;
box-sizing: border-box;
align-items: center;
justify-content: flex-end;
width: 100%;
min-width: 0;
max-width: 100%;
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;
}
}
&__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-dialog.is-transport-plan-detail {
:deep(.el-dialog) {
max-width: none;
margin: 0 auto;
}
:deep(.el-dialog__header) {
display: none;
}
:deep(.el-dialog__body) {
padding: 8px;
background: #f5f6f8;
}
}
&__transport-plan-detail-head,
&__transport-plan-waybill-card {
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
&__transport-plan-detail-head {
padding: 20px 40px 24px;
}
&__transport-plan-detail-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
span {
margin: 0 8px;
color: #303133;
}
}
:deep(.el-tag) {
height: 36px;
padding: 0 14px;
border: 0;
border-radius: 8px;
font-size: 14px;
line-height: 36px;
}
:deep(.is-dispatching) {
color: #67c23a;
background: #e1f3d8;
}
}
&__transport-plan-detail-overview {
display: grid;
grid-template-columns: minmax(540px, 1fr) minmax(560px, 1.2fr);
gap: 22px;
}
&__transport-plan-detail-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px 32px;
align-content: start;
}
&__transport-plan-detail-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
span {
color: #606266;
font-size: 14px;
}
strong {
overflow: hidden;
color: #409eff;
font-size: 16px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
strong.is-normal {
color: #303133;
font-weight: 400;
white-space: normal;
word-break: break-word;
}
&.is-attachments {
grid-column: 1 / -1;
}
}
&__transport-plan-detail-attachments {
display: flex;
flex-wrap: wrap;
gap: 14px 32px;
.el-link,
span {
font-size: 16px;
}
}
&__transport-plan-detail-route {
display: flex;
min-width: 0;
align-items: flex-start;
padding: 12px 0 0;
}
&__transport-plan-detail-route-point {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 12px;
b {
display: inline-flex;
flex: 0 0 auto;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 8px;
background: #409eff;
font-size: 14px;
&.is-end {
background: #e6a23c;
}
}
div {
min-width: 0;
text-align: center;
}
strong {
display: block;
color: #303133;
font-size: 18px;
white-space: nowrap;
}
p {
max-width: 280px;
margin: 10px 0 0;
color: #303133;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
}
}
&__transport-plan-detail-route-line {
flex: 1;
min-width: 56px;
height: 22px;
margin: 0 14px;
border-bottom: 4px solid #e4e7ed;
}
&__transport-plan-waybill-card {
//margin-top: 8px;
padding: 16px 14px 12px;
}
&__transport-plan-waybill-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0;
margin: 0 0 14px;
padding-left: 16px;
h3 {
margin: 0 30px 0 0;
color: #303133;
font-size: 18px;
}
span {
color: #303133;
font-size: 16px;
}
em {
color: #409eff;
font-style: normal;
}
}
&__transport-plan-waybill-table {
width: 100%;
:deep(.el-table__header th) {
color: #303133;
background: #f7f7f7;
font-size: 14px;
font-weight: 600;
}
:deep(.el-table__cell) {
height: 52px;
color: #303133;
border-color: #eff1f7;
font-size: 14px;
}
:deep(.el-table__body tr:nth-child(even) > td) {
background: #fafafa;
}
:deep(.el-table__fixed-right) {
background: #fff;
}
}
&__transport-plan-waybill-pagination {
display: flex;
justify-content: flex-end;
padding-top: 12px;
}
&__transport-plan-waybill-status {
&.is-processing {
color: #67c23a;
}
&.is-completed {
color: #409eff;
}
&.is-pending,
&.is-draft,
&.is-cancelled {
color: #606266;
}
}
&__transport-plan-import-form {
padding: 12px 24px 4px;
.el-select,
.el-input {
width: 100%;
}
}
&__import-file-item {
margin-top: 28px;
:deep(.el-form-item__content) {
gap: 16px;
}
}
&__import-file-tip {
color: #606266;
}
&__import-preview {
margin: 8px 24px 0;
padding-top: 16px;
border-top: 1px solid #eff1f7;
}
&__import-preview-head {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 12px;
.dialog-section-title {
margin-bottom: 0;
}
}
&__import-preview-tabs {
margin-bottom: 12px;
}
&__import-preview-table {
overflow-x: auto;
:deep(.el-table) {
min-width: 1550px;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
:deep(.el-table__row:nth-child(even) td) {
background: #fafafa;
}
}
@media (max-width: 1280px) {
&__route-address-row {
grid-template-columns: 240px minmax(240px, 1fr) 40px 58px minmax(140px, 1fr);
}
&__dispatch-shipping-form &__route-address-row {
grid-template-columns: 240px minmax(240px, 300px) 58px minmax(140px, 180px) 72px minmax(
140px,
180px
);
}
&__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;
}
&__dispatch-shipping-form &__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;
}
/* 独立表单页:四个业务分区统一使用普通白色圆角模块。 */
:global(.transport-plan-form-page-dialog .transport-plan-page__full-form-item) {
width: 100%;
max-width: 100%;
}
:global(.transport-plan-form-page-dialog .transport-plan-page__full-form-item > .el-form-item) {
width: 100%;
}
:global(
.transport-plan-form-page-dialog
.transport-plan-page__full-form-item
> .el-form-item
> .el-form-item__label
) {
display: none;
width: 0 !important;
padding: 0 !important;
}
:global(
.transport-plan-form-page-dialog
.transport-plan-page__full-form-item
> .el-form-item
> .el-form-item__content
) {
flex: 1 1 100%;
width: 100%;
max-width: 100%;
margin-left: 0 !important;
}
:global(
.transport-plan-form-page-dialog
.transport-plan-page__full-form-item
> .el-form-item
> .el-form-item__content
> div
) {
width: 100%;
min-width: 0;
}
:global(.transport-plan-form-page-dialog) {
width: 100% !important;
min-height: 100%;
box-sizing: border-box;
padding-bottom: 96px;
background: #f5f6fa;
margin: 0 !important;
border-radius: 0;
box-shadow: none;
}
:global(.transport-plan-form-page-dialog .avue-form) {
padding: 0;
background: transparent;
box-shadow: none;
}
:global(.transport-plan-form-page-dialog .avue-form > .el-form > .el-row) {
margin: 0 !important;
}
:global(.transport-plan-form-page-dialog .avue-group) {
width: 100%;
box-sizing: border-box;
margin: 0 0 12px;
overflow: hidden;
border-radius: 6px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
:global(.transport-plan-form-page-dialog .avue-group:last-child) {
margin-bottom: 0;
}
:global(
.transport-plan-form-page-dialog--group-only
.avue-form
> .el-form
> .el-row
> .avue-group:first-child
) {
display: none;
}
:global(.transport-plan-form-page-dialog .avue-group .avue-form__group) {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
box-sizing: border-box;
margin: 0;
padding: 0 16px 4px;
background: transparent;
box-shadow: none;
}
:global(.transport-plan-form-page-dialog .avue-group .avue-group__header) {
height: auto;
min-height: 20px;
box-sizing: border-box;
padding: 0 16px !important;
border: 0;
line-height: 20px;
}
:global(.transport-plan-form-page-dialog .avue-group .avue-form__group--flex) {
display: flex;
}
:global(.transport-plan-form-page-dialog .avue-group .avue-form__row) {
padding: 0 !important;
}
:global(.transport-plan-form-page-dialog .avue-group .el-form-item) {
margin-bottom: 16px;
}
:global(.transport-plan-form-page-dialog .avue-group .dialog-section-title) {
min-height: 20px;
margin: 8px 0 16px;
gap: 0;
color: #303133;
font-size: 14px;
font-weight: 600;
text-align: left;
}
:global(.transport-plan-form-page-dialog .avue-group .dialog-section-title::before) {
flex: none;
width: 4px;
height: 16px;
margin-right: 8px;
background: #409eff;
content: '';
}
:global(.transport-plan-form-page-dialog .avue-group .transport-plan-page__shipping-title),
:global(.transport-plan-form-page-dialog .avue-group .transport-plan-page__goods-title) {
justify-content: flex-start !important;
padding-left: 0;
text-align: left;
}
/* 卡片视觉由外层 avue-group 统一承担,内层表单不再叠加白底和阴影。 */
:global(.transport-plan-form-page-dialog .avue-group .avue-form__group:has(.dialog-section-title)) {
padding: 0 16px 4px;
background: transparent;
border-radius: 0;
box-shadow: none;
}
:global(.transport-plan-form-page-dialog .el-dialog__body) {
max-height: none;
padding: 20px 24px 96px;
background: #f5f6fa;
}
:global(.el-overlay:has(.transport-plan-form-page-dialog)) {
position: fixed !important;
inset: 92px 0 0 230px !important;
z-index: 100 !important;
overflow: auto;
background: transparent;
}
:global(.el-overlay:has(.transport-plan-form-page-dialog) .el-overlay-dialog) {
min-height: 100%;
overflow: visible;
}
:global(.transport-plan-form-page-dialog .el-dialog__header) {
padding: 18px 24px;
margin-right: 0;
background: #fff;
border-bottom: 1px solid #eff1f7;
}
:global(.transport-plan-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(
.transport-plan-form-page-dialog
.avue-form__menu
> .transport-plan-page__standalone-menu-actions
) {
display: flex;
order: 2;
gap: 8px;
}
:global(.transport-plan-form-page-dialog .avue-form__menu > .el-button) {
order: 3;
}
:global(.avue--collapse .el-overlay:has(.transport-plan-form-page-dialog)) {
left: 60px !important;
}
:global(.avue--collapse .transport-plan-form-page-dialog .avue-form__menu) {
left: 60px;
}
:global(.avue-layout--horizontal .el-overlay:has(.transport-plan-form-page-dialog)) {
top: 92px !important;
left: 0 !important;
}
:global(.avue-layout--horizontal .transport-plan-form-page-dialog .avue-form__menu) {
left: 0;
}
/* 独立表单页 footer 的 Avue 提交/清空按钮自带图标(el-icon-check / el-icon-delete),
Avue 源码用 `|| 'el-icon-xxx'` 兜底,无法通过 option 配置去除,统一用 CSS 隐藏只保留文字 */
:global(.transport-plan-form-page-dialog .avue-form__menu .el-icon) {
display: none;
}
.el-input__icon {
color: #1e90ff !important;
}
</style>