Files
tms-erp-web/src/views/business/components/master-order-editor.vue
T
gxwebsoft c9f673342a style(ui): 统一底部操作栏按钮间距,取消所有gap样式
- 移除各底部操作栏(footer/__actions/page-actions)内所有gap属性
- 按钮间距统一采用 Element Plus 全局默认的相邻按钮12px margin-left规则
- 修改 .el-dialog__footer 内.el-button间距由8px调整为12px,保持全站一致
- 确认 Avue 表单底部按钮无自带间距,去gap后间距由 Element Plus 提供
- 涉及模块覆盖 vehicle、payment、business、settlement,移除了相关组件样式中gap属性
- MEMORY.md规范更新,补充底栏按钮间距统一为无gap纯文字规则
2026-08-27 11:39:13 +08:00

2071 lines
83 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>
<div class="master-editor" v-loading="loading">
<section>
<h3>基本信息</h3>
<el-form ref="form" :model="form" :rules="rules" label-position="right" label-width="auto"
><el-row :gutter="24"
><el-col :span="6"
><el-form-item label="项目" prop="projectName"
><el-select
v-model="form.projectId"
filterable
placeholder="请输入并选择"
@change="projectChange"
><el-option
v-for="project in projects"
:key="project.id"
:label="project.projectName"
:value="project.id" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="客户合同" prop="contractName"
><el-select
v-model="form.contractId"
:disabled="!form.projectId"
placeholder="选择项目后带出"
@change="contractChange"
><el-option
v-for="contract in contracts"
:key="contract.id"
:label="contract.contractName"
:value="contract.id" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="总单号"
><el-input
:model-value="form.masterNo || '系统自动生成'"
readonly /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="运输组织类型"
><el-select v-model="form.transportOrganizationType" disabled
><el-option label="多式联运" value="多式联运" /></el-select></el-form-item></el-col
><el-col :span="24"
><el-form-item label="备注"
><el-input
v-model="form.remark"
maxlength="200"
show-word-limit
placeholder="请输入" /></el-form-item></el-col></el-row
></el-form>
</section>
<section>
<div class="section-head">
<h3>收发货信息</h3>
<div class="section-head__actions">
<el-link type="primary" @click="addRoute">新增途经点</el-link>
<el-link type="primary" @click="openRouteDialog">选择线路</el-link>
</div>
</div>
<el-form :model="form" label-position="right" label-width="auto"
><div class="route-steps">
<el-steps direction="vertical" :active="form.routes.length + 1">
<el-step>
<template #icon><span class="route-step-icon route-step-icon--start"></span></template>
<template #description>
<div class="route-node-form">
<el-form-item label="发货地址" required class="route-address-form-item"
><div class="route-address-inputs"
><el-cascader v-model="addressRegionPaths.departure" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('departure', value)" /><el-input v-model="form.departureAddress" class="route-address-detail" readonly placeholder="请输入详细地址" @click="openAddressMap('departure')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" @click="openCommonAddress('departure')" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人"
><el-input v-model="form.departureContact" placeholder="请输入"
/></el-form-item>
<el-form-item label="联系方式"
><el-input v-model="form.departurePhone" placeholder="请输入"
/></el-form-item>
<el-form-item label="计划开始日期"
><el-date-picker
v-model="form.planStartTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
style="width: 200px"
/></el-form-item>
</div>
</template>
</el-step>
<el-step v-for="(route, index) in form.routes" :key="index">
<template #icon><span class="route-step-icon route-step-icon--middle"></span></template>
<template #title><div class="route-step-title"><span class="route-segment-label">{{ index + 1 }}</span><el-tooltip :content="getRouteTitle(index).replace(`段${index + 1}`, '')" placement="top"><span class="route-title-path">{{ formatRouteTitle(getRouteTitle(index), index + 1) }}</span></el-tooltip><div class="route-transport-control"><span>运输方式</span><el-select v-model="route.transportType" placeholder="请选择"><el-option v-for="type in transports" :key="type" :label="type" :value="type" /></el-select></div></div></template>
<template #description>
<div class="route-node-form">
<el-form-item label="途经地" required class="route-address-form-item"
><div class="route-address-inputs"
><el-cascader v-model="addressRegionPaths[`route-${index}`]" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="!route.transportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange(`route-${index}`, value)" /><el-input v-model="route.departureAddress" class="route-address-detail" readonly :disabled="!route.transportType || !isRoadTransportType(route.transportType)" placeholder="请先选择运输方式" @click="openAddressMap(`route-${index}`)" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="!route.transportType" @click="openCommonAddress(`route-${index}`)" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人"><el-input v-model="route.departureContact" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式"><div class="route-contact-actions"><el-input v-model="route.departurePhone" placeholder="请输入" /><el-tooltip v-if="index === form.routes.length - 1" content="新增途经点" placement="top"><el-button class="route-action-button" type="primary" link @click="addRoute"><el-icon><CirclePlus /></el-icon></el-button></el-tooltip><el-tooltip content="删除途经点" placement="top"><el-button class="route-action-button" type="danger" link @click="removeRoute(index)"><el-icon><Remove /></el-icon></el-button></el-tooltip></div></el-form-item>
</div>
</template>
</el-step>
<el-step>
<template #icon><span class="route-step-icon route-step-icon--end"></span></template>
<template #title><div class="route-step-title"><span class="route-segment-label">{{ form.routes.length + 1 }}</span><el-tooltip :content="getFinalRouteTitle().replace(`段${form.routes.length + 1}`, '')" placement="top"><span class="route-title-path">{{ formatRouteTitle(getFinalRouteTitle(), form.routes.length + 1) }}</span></el-tooltip><div class="route-transport-control"><span>运输方式</span><el-select v-model="form.finalTransportType" placeholder="请选择"><el-option v-for="type in transports" :key="type" :label="type" :value="type" /></el-select></div></div></template>
<template #description>
<div class="route-node-form">
<el-form-item label="收货地址" required class="route-address-form-item"
><div class="route-address-inputs"
><el-cascader v-model="addressRegionPaths.arrival" class="route-address-name" :options="regionOptions" :props="regionCascaderProps" :loading="regionLoading" :disabled="!form.finalTransportType" placeholder="请选择省市区" clearable filterable @visible-change="visible => visible && loadRegionOptions()" @change="value => handleAddressRegionChange('arrival', value)" /><el-input v-model="form.arrivalAddress" class="route-address-detail" readonly :disabled="!form.finalTransportType || !isRoadTransportType(form.finalTransportType)" placeholder="请选择省市区" @click="openAddressMap('arrival')" /><el-tooltip content="选择常用地址" placement="top"><el-button class="address-picker-button" type="primary" link :icon="OfficeBuilding" :disabled="!form.finalTransportType" @click="openCommonAddress('arrival')" /></el-tooltip></div
></el-form-item>
<el-form-item label="联系人"
><el-input v-model="form.arrivalContact" placeholder="请输入" /></el-form-item>
<el-form-item label="联系方式"
><el-input v-model="form.arrivalPhone" placeholder="请输入" /></el-form-item>
<el-form-item label="计划结束日期"
><el-date-picker
v-model="form.planEndTime"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择"
style="width: 200px"
/></el-form-item>
</div>
</template>
</el-step>
</el-steps>
</div></el-form
>
</section>
<section>
<div class="goods-heading">
<h3>货物信息</h3>
<div class="goods-toolbar">
<el-link type="primary" @click="cargoImportVisible = true">导入货物</el-link>
<el-link type="primary" @click="openCommonCargoDialog">常用货物</el-link>
</div>
</div>
<el-table :data="form.goods" border
><el-table-column type="index" label="序号" width="60" /><el-table-column
label="货物名称"
min-width="130"
><template #default="{ row }"
><el-autocomplete
v-model="row.cargoName"
clearable
placeholder="请输入货物名称"
:debounce="300"
:fetch-suggestions="
(queryString, callback) => fetchCargoSuggestions(queryString, callback, row.cargoTypePath)
"
:loading="isCargoOptionsLoading(row.cargoTypePath)"
@select="cargo => selectCargoSuggestion(row, cargo)"
><template #default="{ item }">
<div class="master-editor__cargo-autocomplete-item">
<span>{{ item.cargoName || item.name || '' }}</span>
<small v-if="item.cargoCode">{{ item.cargoCode }}</small>
</div>
</template></el-autocomplete
></template></el-table-column
><el-table-column label="货物类型" min-width="130"
><template #default="{ row }"
><el-cascader
v-model="row.cargoTypePath"
:options="cargoTypeOptions"
:props="cargoTypeCascaderProps"
:placeholder="row.cargoType || '请选择'"
clearable
filterable
:loading="cargoTypeLoading"
@visible-change="visible => visible && loadCargoTypeOptions()"
@change="value => handleCargoTypeChange(row, value)"
/></template></el-table-column
><el-table-column label="数量" width="180" class-name="master-editor__quantity-cell"
><template #default="{ row }"
><el-input
v-model="row.quantity"
inputmode="decimal"
placeholder="请输入"
@input="value => handleQuantityInput(row, value)" /></template></el-table-column
><el-table-column label="数量单位" width="120"
><template #default="{ row }"
><el-select v-model="row.quantityUnit"
><el-option v-for="unit in quantityUnitOptions" :key="unit" :label="unit" :value="unit" /></el-select></template></el-table-column
><el-table-column label="包装" width="130"
><template #default="{ row }"
><el-input v-model="row.packageType" placeholder="请选择" /></template></el-table-column
><el-table-column label="品牌" min-width="120"
><template #default="{ row }"
><el-input v-model="row.brand" placeholder="请输入" /></template></el-table-column
><el-table-column label="规格" min-width="120"
><template #default="{ row }"
><el-input
v-model="row.specification"
placeholder="请输入" /></template></el-table-column
><el-table-column label="型号" min-width="120"
><template #default="{ row }"
><el-input v-model="row.model" placeholder="请输入" /></template></el-table-column
><el-table-column label="操作" width="140"
><template #default="{ $index }"
><div class="goods-actions"
><el-link
v-if="$index === form.goods.length - 1"
type="primary"
@click="addGoods($index)"
>新增</el-link
><el-link
type="danger"
:disabled="form.goods.length === 1"
@click="removeGoods($index)"
>删除</el-link
></div
></template
></el-table-column
></el-table
>
</section>
<section>
<h3>附件</h3>
<div class="master-editor__attachment">
<div class="master-editor__attachment-head">
<el-button
type="primary"
:disabled="!attachmentRows.length"
@click="handleAttachmentBatchDownload"
>
批量下载
</el-button>
</div>
<el-table
:data="attachmentRows"
border
class="master-editor__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="240" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
</template>
</el-table-column>
<el-table-column label="文件大小" width="120" 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="170" align="center" sortable />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row, $index }">
<el-link type="danger" @click="removeAttachment($index)">删除</el-link>
<el-link type="primary" @click="downloadAttachment(row)">下载</el-link>
</template>
</el-table-column>
</el-table>
<div class="master-editor__attachment-upload">
<vehicle-attachment-upload
v-model="attachmentRows"
:file-types="attachmentFileTypes"
:max-size="500"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
/>
</div>
</div>
</section>
<el-dialog
v-model="attachmentDocumentPreviewVisible"
:title="attachmentPreviewFile.name || '附件预览'"
append-to-body
destroy-on-close
width="90%"
top="4vh"
class="master-editor__attachment-viewer-dialog"
>
<open-file-viewer
v-if="attachmentDocumentPreviewVisible && attachmentPreviewFile.url"
:file="attachmentPreviewFile.url"
:file-name="attachmentPreviewFile.name"
:mime-type="attachmentPreviewFile.mimeType"
width="100%"
height="72vh"
fit="contain"
theme="auto"
locale="zh-CN"
:toolbar="attachmentViewerToolbar"
:plugins="attachmentViewerPlugins"
@unsupported="handleAttachmentPreviewUnsupported"
@error="handleAttachmentPreviewError"
/>
</el-dialog>
<el-image-viewer
v-if="attachmentImagePreviewVisible"
:url-list="attachmentImagePreviewUrls"
:initial-index="attachmentImagePreviewIndex"
@close="attachmentImagePreviewVisible = false"
/>
<el-dialog
v-model="addressDialogVisible"
title="选择常用地址"
append-to-body
width="1100px"
@opened="loadAddressList"
>
<div class="route-dialog__search common-address-dialog__search">
<el-form :model="addressQuery" label-position="right" label-width="auto">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="地址名称">
<el-input v-model="addressQuery.addressName" clearable placeholder="请输入" @keyup.enter="searchAddressList" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="详细地址">
<el-input v-model="addressQuery.detailAddress" clearable placeholder="请输入" @keyup.enter="searchAddressList" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="行政区划">
<el-input v-model="addressQuery.regionName" clearable placeholder="请输入" @keyup.enter="searchAddressList" />
</el-form-item>
</el-col>
<el-col :span="6" class="route-dialog__search-actions">
<el-button type="primary" @click="searchAddressList">查询</el-button>
<el-button @click="resetAddressList">重置</el-button>
</el-col>
</el-row>
</el-form>
</div>
<el-table v-loading="addressLoading" :data="addressRows" border height="360">
<el-table-column type="index" label="序号" width="70" />
<el-table-column prop="addressName" label="地址名称" min-width="150" />
<el-table-column
prop="detailAddress"
label="详细地址"
min-width="260"
show-overflow-tooltip
/>
<el-table-column prop="regionName" label="行政区划" min-width="150" />
<el-table-column prop="contactName" label="联系人" min-width="120" />
<el-table-column prop="contactPhone" label="联系方式" min-width="140" />
<el-table-column label="操作" width="100" fixed="right"
><template #default="{ row }"
><el-link type="primary" @click="selectAddress(row)">选择</el-link></template
></el-table-column
>
</el-table>
<div class="address-pagination">
<el-pagination
v-model:current-page="addressPage.current"
v-model:page-size="addressPage.size"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next, sizes"
:total="addressPage.total"
@size-change="loadAddressList"
@current-change="loadAddressList"
/>
</div>
</el-dialog>
<el-dialog
v-model="mapDialogVisible"
title="地图选择地址"
append-to-body
width="920px"
@opened="initAddressMap"
>
<div class="address-map-toolbar">
<el-input
v-model="mapKeyword"
clearable
placeholder="输入地址关键词"
@keyup.enter="searchAddressMap"
/><el-button type="primary" :icon="Search" :loading="mapLoading" @click="searchAddressMap"
>搜索</el-button
>
</div>
<div ref="addressMap" class="address-map" />
<div class="address-map-status">
{{ mapStatus
}}<span v-if="mapSelected.regionName"> 行政区划:{{ mapSelected.regionName }}</span>
</div>
<template #footer
><el-button @click="mapDialogVisible = false">取消</el-button
><el-button type="primary" :disabled="!mapSelected.longitude" @click="confirmAddressMap"
>确定</el-button
></template
>
</el-dialog>
<el-dialog
v-model="stationDialogVisible"
:title="`选择${stationTypeLabel}`"
append-to-body
width="1100px"
@opened="loadStationList"
>
<div class="route-dialog__search">
<el-form :model="stationQuery" label-position="right" label-width="auto">
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="站点名称"><el-input v-model="stationQuery.name" clearable placeholder="请输入" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="详细地址"><el-input v-model="stationQuery.detailAddress" clearable placeholder="请输入" /></el-form-item></el-col>
<el-col :span="8" class="route-dialog__search-actions"><el-button type="primary" @click="searchStationList">查询</el-button><el-button @click="resetStationList">重置</el-button></el-col>
</el-row>
</el-form>
</div>
<el-table v-loading="stationLoading" :data="stationRows" border height="360">
<el-table-column type="index" label="序号" width="70" />
<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="regionName" label="行政区划" min-width="150" />
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-link type="primary" @click="selectStation(row)">选择</el-link></template></el-table-column>
</el-table>
<div class="address-pagination"><el-pagination v-model:current-page="stationPage.current" v-model:page-size="stationPage.size" :page-sizes="[10, 20, 50]" layout="total, prev, pager, next, sizes" :total="stationPage.total" @size-change="loadStationList" @current-change="loadStationList" /></div>
</el-dialog>
<el-dialog v-model="commonCargoVisible" title="选择常用货物" append-to-body width="1100px" @opened="loadCommonCargoList">
<div class="route-dialog__search common-cargo-dialog__search">
<el-form :model="commonCargoQuery" label-position="right" label-width="86px">
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="货物名称"><el-input v-model="commonCargoQuery.cargoName" clearable placeholder="请输入" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="货物类型"><el-cascader v-model="commonCargoQuery.cargoTypePath" :options="cargoTypeOptions" :props="cargoTypeCascaderProps" clearable filterable placeholder="请选择" :loading="cargoTypeLoading" @visible-change="visible => visible && loadCargoTypeOptions()" @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="route-dialog__search-actions"><el-button type="primary" @click="searchCommonCargoList">查询</el-button><el-button @click="resetCommonCargoList">重置</el-button></el-col>
</el-row>
</el-form>
</div>
<el-table v-loading="commonCargoLoading" :data="commonCargoRows" border height="360" @selection-change="handleCommonCargoSelectionChange">
<el-table-column type="selection" width="55" align="center" fixed="left" />
<el-table-column type="index" label="序号" width="70" /><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="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 label="操作" width="100" fixed="right"><template #default="{ row }"><el-link type="primary" @click="selectCommonCargo(row)">选择</el-link></template></el-table-column>
</el-table>
<div class="address-pagination"><el-pagination v-model:current-page="commonCargoPage.current" v-model:page-size="commonCargoPage.size" :page-sizes="[10, 20, 50]" layout="total, prev, pager, next, sizes" :total="commonCargoPage.total" @size-change="loadCommonCargoList" @current-change="loadCommonCargoList" /></div>
<template #footer><el-button @click="commonCargoVisible = false">关闭</el-button><el-button type="primary" :disabled="!commonCargoSelected.length" @click="confirmCommonCargoSelection">确定</el-button></template>
</el-dialog>
<el-dialog v-model="cargoImportVisible" title="导入货物" append-to-body width="560px">
<section-card>
<div class="cargo-import">
<el-upload
action="#"
accept=".xls,.xlsx"
:auto-upload="false"
:show-file-list="false"
:disabled="cargoImportLoading"
:on-change="handleCargoImport"
>
<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" plain @click="downloadCargoTemplate">点击下载模板</el-button>
</div>
</section-card>
<template #footer><el-button @click="cargoImportVisible = false">关闭</el-button></template>
</el-dialog>
<el-dialog v-model="routeDialogVisible" title="选择线路" width="1280px" @opened="loadRouteList">
<div class="route-dialog__search">
<el-form :model="routeQuery" label-position="right" label-width="86px">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="线路名称">
<el-input v-model="routeQuery.routeName" clearable placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="发货地址">
<el-input v-model="routeQuery.departureAddress" clearable placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="收货地址">
<el-input v-model="routeQuery.arrivalAddress" clearable placeholder="请输入" />
</el-form-item>
</el-col>
<el-col :span="6" class="route-dialog__search-actions">
<el-button type="primary" @click="searchRouteList">查询</el-button>
<el-button @click="resetRouteList">重置</el-button>
</el-col>
</el-row>
</el-form>
</div>
<el-table v-loading="routeLoading" :data="routeRows" border height="320">
<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="formatRouteUpdateUserName"
/>
<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="selectRoute(row)">选择</el-link>
</template>
</el-table-column>
</el-table>
<div class="route-dialog__pagination">
<el-pagination
v-model:current-page="routePage.current"
v-model:page-size="routePage.size"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
:total="routePage.total"
@size-change="changeRoutePageSize"
@current-change="changeRoutePage"
/>
</div>
<template #footer>
<el-button @click="routeDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<footer>
<el-button @click="$emit('back')">取消</el-button>
<el-button type="primary" plain @click="saveDraft">暂存</el-button>
<el-button type="primary" plain @click="confirmCreate">确认创建</el-button>
<el-button type="primary" @click="createAndDispatch">创建并调度</el-button>
</footer>
</div>
</template>
<script>
import { getList as getCommonAddressList } from '@/api/base/common-address';
import { getList as getAirportMasterList } from '@/api/base/airport-master';
import { getList as getPortTerminalList } from '@/api/base/port-terminal';
import { getList as getRailwayStationList } from '@/api/base/railway-station';
import { getList as getCargoTypeList } from '@/api/base/cargo-type';
import { getLazyTree as getRegionLazyTree } from '@/api/base/region';
import { getList as getCommonCargoList } from '@/api/business/common-cargo';
import { exportBlob, importBlob } from '@/api/common';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { 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 { mapGetters } from 'vuex';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
const quantityUnitOptions = ['吨', '件', '方'];
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
let amapLoader;
import { getList as getCommonRouteList } from '@/api/business/common-route';
import * as api from '@/api/business/master-order';
import { formatUpdateUserName } from '@/utils/audit';
import { isMobile } from '@/utils/validate';
export default {
components: {
ElImageViewer,
OpenFileViewer,
},
props: { id: [String, Number] },
emits: ['back', 'dispatch'],
data() {
return {
OfficeBuilding,
Search,
loading: false,
projects: [],
contracts: [],
routeDialogVisible: false,
routeLoading: false,
routeQuery: {},
routeRows: [],
routePage: { current: 1, size: 10, total: 0 },
addressDialogVisible: false,
addressLoading: false,
addressTarget: '',
addressRows: [],
addressQuery: {},
addressPage: { current: 1, size: 10, total: 0 },
addressRegionPaths: { departure: [], arrival: [] },
regionOptions: [],
regionLoading: false,
regionRequest: null,
stationDialogVisible: false,
stationLoading: false,
stationQuery: {},
stationRows: [],
stationPage: { current: 1, size: 10, total: 0 },
mapDialogVisible: false,
mapLoading: false,
mapTarget: '',
mapKeyword: '',
mapStatus: '可搜索地址或点击地图选点',
mapSelected: {},
addressMapInstance: null,
addressMapMarker: null,
addressMapGeocoder: null,
commonCargoVisible: false,
commonCargoLoading: false,
commonCargoRows: [],
commonCargoQuery: {},
commonCargoSelected: [],
commonCargoPage: { current: 1, size: 10, total: 0 },
cargoTypeLoading: false,
cargoTypeOptions: [],
cargoTypeFlatOptions: [],
cargoOptionsMap: {},
cargoOptionsLoadingMap: {},
cargoImportVisible: false,
cargoImportLoading: false,
attachmentRows: [],
selectedAttachmentRows: [],
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
transports: ['公路运输', '铁路运输', '水路运输', '航空运输'],
quantityUnitOptions,
form: {
routes: [{ segmentNo: '段1', transportType: '' }],
goods: [{ quantityUnit: quantityUnitOptions[0] }],
attachmentsJson: '[]',
transportOrganizationType: '多式联运',
},
rules: {
projectName: [{ required: true, message: '请选择项目', trigger: 'change' }],
contractName: [{ required: true, message: '请选择客户合同', trigger: 'change' }],
},
};
},
computed: {
...mapGetters(['userInfo']),
lastRouteName() {
return this.form.routes[this.form.routes.length - 1]?.departureName || '途经地';
},
stationTypeLabel() {
return {
rail: '铁路车站',
water: '港口/码头',
air: '空港机场',
}[this.addressTransportMode(this.addressTarget)] || '站点';
},
cargoTypeCascaderProps() {
return {
value: 'id',
label: 'label',
children: 'children',
emitPath: true,
};
},
regionCascaderProps() {
return {
value: 'id',
label: 'title',
children: 'children',
emitPath: true,
};
},
},
async mounted() {
this.loading = true;
try {
await Promise.all([this.loadProjects(), this.loadCargoTypeOptions(), this.loadRegionOptions()]);
if (this.id) {
await this.loadDetail(this.id);
}
} finally {
this.loading = false;
}
},
methods: {
responseData(response) {
return response?.data?.data || response?.data || response || {};
},
async loadRegionOptions() {
if (this.regionOptions.length) return this.regionOptions;
if (this.regionRequest) return this.regionRequest;
this.regionLoading = true;
this.regionRequest = getRegionLazyTree()
.then(response => {
const data = response?.data?.data || response?.data || response || [];
this.regionOptions = this.extractChinaRegionOptions(this.buildRegionTree(data));
this.syncAddressRegionPaths();
return this.regionOptions;
})
.finally(() => {
this.regionLoading = false;
this.regionRequest = null;
});
return this.regionRequest;
},
buildRegionTree(regions = []) {
const flat = [];
const collect = list =>
(list || []).forEach(item => {
flat.push({ ...item, children: undefined });
if (item.children?.length) collect(item.children);
});
collect(regions);
const nodeMap = new Map();
flat.forEach(item => {
const id = item.id ?? item.code ?? item.value;
if (id === undefined || id === null) return;
nodeMap.set(String(id), {
...item,
id: String(id),
parentId: String(item.parentId ?? item.parentCode ?? ''),
children: [],
});
});
const roots = [];
nodeMap.forEach(node => {
const parent = nodeMap.get(node.parentId);
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
});
const normalize = node => {
const children = node.children.map(item => normalize(item));
return { ...node, title: node.title || node.name || '', children: children.length ? children : undefined, leaf: !children.length };
};
return roots.map(item => normalize(item));
},
extractChinaRegionOptions(regions = []) {
const china = regions.find(item => ['中国', '中华人民共和国'].includes(item.title));
if (china?.children?.length) return china.children;
if (regions.length === 1 && regions[0].children?.length) return regions[0].children;
return regions;
},
findRegionPathByName(options = [], regionName = '', parents = []) {
const target = String(regionName || '').replace(/\s+/g, '');
if (!target) return [];
for (const item of options) {
const nextParents = [...parents, item];
const name = nextParents.map(region => region.title || region.name || '').join('');
if (name === target) return nextParents.map(region => region.id);
const path = this.findRegionPathByName(item.children, target, nextParents);
if (path.length) return path;
}
return [];
},
findRegionPathByCode(options = [], regionCode = '', parents = []) {
const target = String(regionCode || '').trim();
if (!target) return [];
for (const item of options) {
const nextParents = [...parents, item];
if (String(item.id) === target) return nextParents.map(region => region.id);
const path = this.findRegionPathByCode(item.children, target, nextParents);
if (path.length) return path;
}
return [];
},
regionLabels(path = []) {
let options = this.regionOptions;
const labels = [];
path.forEach(value => {
const option = options.find(item => String(item.id) === String(value));
if (!option) return;
labels.push(option.title || option.name || '');
options = option.children || [];
});
return labels;
},
handleAddressRegionChange(target, value) {
const field = this.addressModel(target);
const path = Array.isArray(value) ? value.map(item => String(item)) : [];
this.addressRegionPaths = { ...this.addressRegionPaths, [target]: path };
if (!field) return;
const labels = this.regionLabels(path);
field.model[field.name] = labels.join('');
field.model[field.regionCode] = path.at(-1) || '';
},
syncAddressRegionPath(target) {
const field = this.addressModel(target);
if (!field) return;
const codePath = this.findRegionPathByCode(this.regionOptions, field.model[field.regionCode]);
const path = codePath.length
? codePath
: this.findRegionPathByName(this.regionOptions, field.model[field.name]);
this.addressRegionPaths = { ...this.addressRegionPaths, [target]: path };
},
syncAddressRegionPaths() {
if (!this.regionOptions.length) return;
['departure', 'arrival', ...this.form.routes.map((route, index) => `route-${index}`)].forEach(target => {
this.syncAddressRegionPath(target);
});
},
async loadDetail(id) {
const res = await api.getDetail(id);
const detail = this.responseData(res);
const routes = detail.routes || [];
const finalSegment = routes[routes.length - 1];
const hasFinalSegment =
finalSegment &&
finalSegment.departureAddress === detail.arrivalAddress &&
finalSegment.departureName === detail.arrivalName;
this.form = {
...detail,
planStartTime: this.normalizeDateValue(detail.planStartTime),
planEndTime: this.normalizeDateValue(detail.planEndTime),
routes: hasFinalSegment ? routes.slice(0, -1) : routes.length ? routes : [this.createRoute(0)],
finalTransportType: hasFinalSegment
? finalSegment.transportType
: detail.finalTransportType || '',
goods: detail.goods || [{ quantityUnit: quantityUnitOptions[0] }],
attachmentsJson: detail.attachmentsJson || '[]',
transportOrganizationType: '多式联运',
};
this.syncAddressRegionPaths();
this.restoreGoodsCargoTypePaths();
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
this.selectedAttachmentRows = [];
if (this.form.projectId) await this.loadContracts(this.form.projectId, false);
},
parseJsonArray(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const data = JSON.parse(value);
return Array.isArray(data) ? data : [];
} catch (error) {
return [];
}
},
normalizeDateValue(value) {
if (!value) return '';
if (value instanceof Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
return String(value).replace('T', ' ').slice(0, 10);
},
normalizeDateTimeValue(value, endOfDay = false) {
const date = this.normalizeDateValue(value);
return date ? `${date} ${endOfDay ? '23:59:59' : '00:00:00'}` : '';
},
getRegionDisplay(target, fallback) {
const labels = this.regionLabels(this.addressRegionPaths[target] || []);
return labels.length ? labels.join('/') : fallback;
},
getRouteTitle(index) {
const departureName =
index === 0
? this.getRegionDisplay('departure', this.form.departureName || '发货地址')
: this.getRegionDisplay(
`route-${index - 1}`,
this.form.routes[index - 1].departureName || '途经地'
);
const arrivalName = this.getRegionDisplay(
`route-${index}`,
this.form.routes[index].departureName || '途经地'
);
return `段${index + 1}${departureName} → ${arrivalName}`;
},
truncateRouteAddress(value) {
const text = String(value || '');
return text.length > 6 ? `${text.slice(0, 6)}...` : text;
},
formatRouteTitle(title, segmentNo) {
const fullTitle = String(title || '').replace(`段${segmentNo}`, '');
const separator = ' → ';
const separatorIndex = fullTitle.indexOf(separator);
if (separatorIndex < 0) return this.truncateRouteAddress(fullTitle);
const departure = fullTitle.slice(0, separatorIndex);
const arrival = fullTitle.slice(separatorIndex + separator.length);
return `${this.truncateRouteAddress(departure)}${separator}${this.truncateRouteAddress(arrival)}`;
},
getFinalRouteTitle() {
const departureName = this.getRegionDisplay(
`route-${this.form.routes.length - 1}`,
this.lastRouteName
);
const arrivalName = this.getRegionDisplay('arrival', this.form.arrivalName || '收货地址');
return `段${this.form.routes.length + 1}${departureName} → ${arrivalName}`;
},
responseRecords(response) {
const data = response?.data?.data || response?.data || response;
return Array.isArray(data) ? data : data?.records || [];
},
async loadProjects() {
const response = await api.getAvailableProjects();
this.projects = this.responseRecords(response);
},
async loadContracts(projectId, selectFirst = true) {
const response = await api.getProjectContracts(projectId);
this.contracts = this.responseRecords(response).filter(
item =>
String(item.projectId) === String(projectId) && item.contractCategory === '客户合同'
);
if (selectFirst) {
const first = this.contracts[0];
this.form.contractId = first?.id;
this.form.contractName = first?.contractName || '';
}
},
async projectChange(projectId) {
const project = this.projects.find(item => String(item.id) === String(projectId));
this.form.projectName = project?.projectName || '';
this.form.customerName = project?.customerNames || '';
this.form.contractId = '';
this.form.contractName = '';
await this.loadContracts(projectId);
},
contractChange(contractId) {
const contract = this.contracts.find(item => String(item.id) === String(contractId));
this.form.contractName = contract?.contractName || '';
},
addressModel(target) {
if (target.startsWith('route-')) {
const route = this.form.routes[Number(target.slice(6))];
return route
? {
model: route,
name: 'departureName',
address: 'departureAddress',
contact: 'departureContact',
phone: 'departurePhone',
longitude: 'departureLongitude',
latitude: 'departureLatitude',
regionCode: 'departureRegionCode',
siteCode: 'departureSiteCode',
}
: null;
}
const prefix = target === 'arrival' ? 'arrival' : 'departure';
return {
model: this.form,
name: `${prefix}Name`,
address: `${prefix}Address`,
contact: `${prefix}Contact`,
phone: `${prefix}Phone`,
longitude: `${prefix}Longitude`,
latitude: `${prefix}Latitude`,
regionCode: `${prefix}RegionCode`,
siteCode: `${prefix}SiteCode`,
};
},
addressTransportType(target) {
if (target.startsWith('route-')) {
return this.form.routes[Number(target.slice(6))]?.transportType || '';
}
return target === 'arrival' ? this.form.finalTransportType : '';
},
addressTransportMode(target) {
const type = this.addressTransportType(target);
if (type.includes('铁路')) return 'rail';
if (type.includes('水路') || type.includes('水运') || type.includes('海运')) return 'water';
if (type.includes('航空') || type.includes('空运')) return 'air';
return 'road';
},
isRoadTransportType(type) {
const value = String(type || '').trim().toLowerCase();
return value === 'road' || value.includes('公路');
},
openCommonAddress(target) {
this.addressTarget = target;
if (this.addressTransportMode(target) !== 'road') {
this.stationQuery = {};
this.stationPage.current = 1;
this.stationDialogVisible = true;
return;
}
this.addressQuery = {};
this.addressPage.current = 1;
this.addressDialogVisible = true;
},
stationListRequest() {
return {
rail: getRailwayStationList,
water: getPortTerminalList,
air: getAirportMasterList,
}[this.addressTransportMode(this.addressTarget)];
},
normalizeStation(row = {}) {
return {
...row,
stationName: row.name || row.stationName || row.addressName || '',
stationType: row.category || this.stationTypeLabel,
stationCode: row.code || row.tmisCode || row.iataCode || row.icaoCode || row.siteCode || '',
detailAddress: row.detailAddress || row.address || '',
regionName:
row.regionName || [row.provinceName, row.cityName, row.districtName].filter(Boolean).join(''),
};
},
async loadStationList() {
const request = this.stationListRequest();
if (!request) return;
this.stationLoading = true;
try {
const response = await request(this.stationPage.current, this.stationPage.size, this.stationQuery);
const data = response?.data?.data || response?.data || response || {};
this.stationRows = (data.records || []).map(row => this.normalizeStation(row));
this.stationPage.total = data.total || 0;
} finally {
this.stationLoading = false;
}
},
searchStationList() {
this.stationPage.current = 1;
this.loadStationList();
},
resetStationList() {
this.stationQuery = {};
this.stationPage.current = 1;
this.loadStationList();
},
selectStation(row) {
const target = this.addressModel(this.addressTarget);
if (!target) return;
const station = this.normalizeStation(row);
target.model[target.name] = station.regionName || station.stationName;
target.model[target.address] = station.detailAddress;
target.model[target.longitude] = station.longitude || '';
target.model[target.latitude] = station.latitude || '';
target.model[target.regionCode] = station.regionCode || '';
target.model[target.siteCode] = station.stationCode;
this.stationDialogVisible = false;
this.syncAddressRegionPath(this.addressTarget);
},
async loadAddressList() {
this.addressLoading = true;
try {
const response = await getCommonAddressList(
this.addressPage.current,
this.addressPage.size,
{ ...this.addressQuery, allDept: 0 }
);
const data = response?.data?.data || response?.data || response || {};
this.addressRows = data.records || [];
this.addressPage.total = data.total || 0;
} finally {
this.addressLoading = false;
}
},
searchAddressList() {
this.addressPage.current = 1;
this.loadAddressList();
},
resetAddressList() {
this.addressQuery = {};
this.addressPage.current = 1;
this.loadAddressList();
},
selectAddress(row) {
const target = this.addressModel(this.addressTarget);
if (!target) return;
target.model[target.name] = row.regionName || row.addressName || '';
target.model[target.address] = row.detailAddress || row.address || '';
target.model[target.contact] = row.contactName || target.model[target.contact] || '';
target.model[target.phone] = row.contactPhone || target.model[target.phone] || '';
target.model[target.longitude] = row.longitude || '';
target.model[target.latitude] = row.latitude || '';
target.model[target.regionCode] = row.regionCode || '';
this.addressDialogVisible = false;
this.syncAddressRegionPath(this.addressTarget);
},
openAddressMap(target) {
if ((target.startsWith('route-') || target === 'arrival') && !this.isRoadTransportType(this.addressTransportType(target))) {
this.$message.info('非公路运输地址不能使用地图选择');
return;
}
const field = this.addressModel(target);
if (!field) return;
this.mapTarget = target;
this.mapKeyword = field.model[field.address] || field.model[field.name] || '';
this.mapSelected = {
longitude: field.model[field.longitude] || '',
latitude: field.model[field.latitude] || '',
regionCode: field.model[field.regionCode] || '',
detailAddress: this.mapKeyword,
regionName: field.model[field.name] || '',
};
this.mapStatus = this.mapSelected.longitude
? '已加载当前选点,可重新选点'
: '可搜索地址或点击地图选点';
this.mapDialogVisible = true;
},
loadAmap() {
if (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.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
}
return amapLoader;
},
initAddressMap() {
this.loadAmap()
.then(() =>
this.$nextTick(() => {
const center = this.mapSelected.longitude
? [Number(this.mapSelected.longitude), Number(this.mapSelected.latitude)]
: [116.40769, 39.89945];
if (!this.addressMapInstance) {
this.addressMapInstance = new window.AMap.Map(this.$refs.addressMap, {
center,
zoom: this.mapSelected.longitude ? 14 : 11,
});
window.AMap.plugin(['AMap.Geocoder'], () => {
this.addressMapGeocoder = new window.AMap.Geocoder();
});
this.addressMapInstance.on('click', event => this.pickAddressMap(event.lnglat));
} else this.addressMapInstance.resize();
if (this.mapSelected.longitude) this.renderAddressMarker(center);
})
)
.catch(() => this.$message.error('高德地图组件加载失败'));
},
searchAddressMap() {
if (!this.mapKeyword.trim()) return this.$message.warning('请输入地址关键词');
this.loadAmap()
.then(() => this.ensureAddressGeocoder())
.then(
() =>
new Promise((resolve, reject) =>
this.addressMapGeocoder.getLocation(this.mapKeyword, (status, result) =>
status === 'complete' ? resolve(result) : reject(result)
)
)
)
.then(result => {
const point = result?.geocodes?.[0]?.location;
if (!point) return this.$message.warning('地图搜索无匹配地址');
this.pickAddressMap(point, this.mapKeyword);
})
.catch(() => this.$message.error('地图搜索失败'));
},
ensureAddressGeocoder() {
if (this.addressMapGeocoder) return Promise.resolve(this.addressMapGeocoder);
return new Promise(resolve =>
window.AMap.plugin(['AMap.Geocoder'], () => {
this.addressMapGeocoder = new window.AMap.Geocoder();
resolve(this.addressMapGeocoder);
})
);
},
pickAddressMap(point, keyword) {
const longitude = point.getLng ? point.getLng() : point.lng;
const latitude = point.getLat ? point.getLat() : point.lat;
this.mapSelected = {
longitude,
latitude,
detailAddress: keyword || this.mapKeyword,
regionName: '',
regionCode: '',
};
this.renderAddressMarker([longitude, latitude]);
this.mapStatus = '已选点,可确认回填';
this.ensureAddressGeocoder().then(() =>
this.addressMapGeocoder.getAddress([longitude, latitude], (status, result) => {
if (status === 'complete') {
const component = result.regeocode?.addressComponent || {};
this.mapSelected.detailAddress =
result.regeocode?.formattedAddress || this.mapSelected.detailAddress;
this.mapSelected.regionName = [component.province, component.city, component.district]
.filter(Boolean)
.join('');
this.mapSelected.regionCode = component.adcode || '';
this.mapKeyword = this.mapSelected.detailAddress;
this.mapStatus = '已解析地址,可确认回填';
}
})
);
},
renderAddressMarker(position) {
if (!this.addressMapInstance) return;
this.addressMapMarker?.setMap(null);
this.addressMapMarker = new window.AMap.Marker({ position });
this.addressMapMarker.setMap(this.addressMapInstance);
this.addressMapInstance.setCenter(position);
},
confirmAddressMap() {
const target = this.addressModel(this.mapTarget);
if (!target || !this.mapSelected.longitude) return;
target.model[target.name] = this.mapSelected.regionName || target.model[target.name] || '';
target.model[target.address] = this.mapSelected.detailAddress || this.mapKeyword;
target.model[target.longitude] = this.mapSelected.longitude;
target.model[target.latitude] = this.mapSelected.latitude;
target.model[target.regionCode] = this.mapSelected.regionCode || '';
this.mapDialogVisible = false;
this.syncAddressRegionPath(this.mapTarget);
},
openCommonCargoDialog() {
this.commonCargoQuery = {};
this.commonCargoSelected = [];
this.commonCargoPage.current = 1;
this.commonCargoVisible = true;
},
async loadCommonCargoList() {
this.commonCargoSelected = [];
this.commonCargoLoading = true;
try {
const { cargoTypePath, ...query } = this.commonCargoQuery;
const response = await getCommonCargoList(this.commonCargoPage.current, this.commonCargoPage.size, { ...query, allDept: 0 });
const data = response?.data?.data || response?.data || response || {};
this.commonCargoRows = data.records || [];
this.commonCargoPage.total = data.total || 0;
} finally {
this.commonCargoLoading = false;
}
},
searchCommonCargoList() {
this.commonCargoPage.current = 1;
this.loadCommonCargoList();
},
resetCommonCargoList() {
this.commonCargoQuery = {};
this.commonCargoPage.current = 1;
this.loadCommonCargoList();
},
handleCommonCargoTypeChange(value) {
const path = Array.isArray(value) ? value.map(item => String(item)) : [];
const firstCargoType = this.getCargoTypeByPath(path.slice(0, 1));
const secondCargoType = path.length > 1 ? this.getCargoTypeByPath(path) : null;
this.commonCargoQuery = {
...this.commonCargoQuery,
cargoTypePath: path,
firstCargoTypeName: firstCargoType?.label || '',
firstCargoTypeCode: firstCargoType?.cargoCode || firstCargoType?.code || '',
secondCargoTypeName: secondCargoType?.label || '',
secondCargoTypeCode: secondCargoType?.cargoCode || secondCargoType?.code || '',
};
},
handleCommonCargoSelectionChange(rows) {
this.commonCargoSelected = rows || [];
},
async loadCargoTypeOptions() {
if (this.cargoTypeOptions.length || this.cargoTypeLoading) return;
this.cargoTypeLoading = true;
try {
const response = await getCargoTypeList(1, 9999, { allDept: 0 });
const rows = this.responseRecords(response);
this.cargoTypeOptions = this.buildCargoTypeTree(rows);
} finally {
this.cargoTypeLoading = false;
}
},
buildCargoTypeTree(rows = []) {
const nodes = new Map();
rows.forEach(row => {
const id = String(row.id);
nodes.set(id, {
...row,
id,
label: row.cargoName || row.cargoTypeName || row.name || row.cargoCode || '',
children: [],
});
});
const roots = [];
nodes.forEach(node => {
const parentId = node.parentId == null ? '' : String(node.parentId);
if (parentId && parentId !== '0' && nodes.has(parentId)) {
nodes.get(parentId).children.push(node);
} else {
roots.push(node);
}
});
this.cargoTypeFlatOptions = [];
const normalize = (node, path = []) => {
const nextPath = [...path, node.id];
const children = node.children.map(child => normalize(child, nextPath));
const normalized = { ...node, children };
this.cargoTypeFlatOptions.push({ ...normalized, path: nextPath });
return normalized;
};
return roots.map(node => normalize(node));
},
getCargoTypeByPath(path = []) {
const normalizedPath = Array.isArray(path) ? path.map(value => String(value)) : [];
const id = normalizedPath.at(-1);
return this.cargoTypeFlatOptions.find(item => item.id === id) || null;
},
getCargoOptionsKey(path = []) {
const cargoType = this.getCargoTypeByPath(path);
return String(cargoType?.cargoCode || cargoType?.code || cargoType?.id || '');
},
getCargoOptionsByType(path = []) {
const key = this.getCargoOptionsKey(path);
return key ? this.cargoOptionsMap[key] || [] : [];
},
isCargoOptionsLoading(path = []) {
const key = this.getCargoOptionsKey(path);
return key ? Boolean(this.cargoOptionsLoadingMap[key]) : false;
},
async loadCargoOptionsByType(path = []) {
const cargoType = this.getCargoTypeByPath(path);
const key = this.getCargoOptionsKey(path);
if (!key || !cargoType) return [];
if (this.cargoOptionsMap[key] || this.cargoOptionsLoadingMap[key]) {
return this.cargoOptionsMap[key] || [];
}
this.cargoOptionsLoadingMap = { ...this.cargoOptionsLoadingMap, [key]: true };
try {
const response = await getCommonCargoList(1, 500, {
allDept: 0,
secondCargoTypeName: cargoType.label,
secondCargoTypeCode: cargoType.cargoCode || cargoType.code || '',
});
const options = this.responseRecords(response);
this.cargoOptionsMap = { ...this.cargoOptionsMap, [key]: options };
return options;
} finally {
this.cargoOptionsLoadingMap = { ...this.cargoOptionsLoadingMap, [key]: false };
}
},
async fetchCargoSuggestions(queryString, callback, path = []) {
const keyword = String(queryString || '').trim();
const cargoType = this.getCargoTypeByPath(path);
const key = this.getCargoOptionsKey(path);
if (!keyword && !cargoType) {
callback([]);
return;
}
if (key) this.cargoOptionsLoadingMap = { ...this.cargoOptionsLoadingMap, [key]: true };
try {
const response = await getCommonCargoList(1, 50, {
allDept: 0,
...(cargoType
? {
secondCargoTypeName: cargoType.label,
secondCargoTypeCode: cargoType.cargoCode || cargoType.code || '',
}
: {}),
...(keyword ? { cargoName: keyword } : {}),
});
const options = this.responseRecords(response);
if (key) this.cargoOptionsMap = { ...this.cargoOptionsMap, [key]: options };
callback(
options.map(item => ({
...item,
value: item.cargoName || item.name || '',
}))
);
} catch (error) {
window.console.log(error);
callback([]);
} finally {
if (key) this.cargoOptionsLoadingMap = { ...this.cargoOptionsLoadingMap, [key]: false };
}
},
selectCargoSuggestion(goods, cargo = {}) {
goods.cargoName = cargo.cargoName || cargo.name || '';
Object.assign(goods, this.normalizeCargoRow(cargo, goods.cargoTypePath));
},
selectCargoName(goods, cargoName) {
if (!cargoName) return;
const cargo = this.getCargoOptionsByType(goods.cargoTypePath).find(
item => item.cargoName === cargoName
);
if (!cargo) return;
Object.assign(goods, this.normalizeCargoRow(cargo, goods.cargoTypePath));
},
handleCargoTypeChange(goods, value) {
const path = Array.isArray(value) ? value.map(item => String(item)) : [];
const cargoType = this.getCargoTypeByPath(path);
goods.cargoTypePath = path;
goods.cargoType = cargoType?.label || '';
goods.cargoTypeCode = cargoType?.cargoCode || cargoType?.code || cargoType?.id || '';
goods.cargoName = '';
goods.specification = '';
goods.model = '';
this.loadCargoOptionsByType(path);
},
restoreGoodsCargoTypePaths() {
this.form.goods.forEach(goods => {
if (Array.isArray(goods.cargoTypePath) && goods.cargoTypePath.length) return;
const cargoType = this.cargoTypeFlatOptions.find(
item =>
String(item.cargoCode || item.code || '') === String(goods.cargoTypeCode || '') ||
item.label === goods.cargoType
);
if (cargoType) goods.cargoTypePath = cargoType.path;
});
},
selectCommonCargo(row) {
this.appendCommonCargoRows([row]);
this.commonCargoVisible = false;
},
confirmCommonCargoSelection() {
if (!this.commonCargoSelected.length) return;
this.appendCommonCargoRows(this.commonCargoSelected);
this.$message.success(`成功选择${this.commonCargoSelected.length}条常用货物`);
this.commonCargoVisible = false;
},
appendCommonCargoRows(rows = []) {
const firstGoods = this.form.goods[0];
const isFirstGoodsEmpty =
firstGoods &&
![
'cargoName',
'cargoType',
'quantity',
'packageType',
'brand',
'specification',
'model',
'remark',
].some(key => firstGoods[key]);
rows.forEach((row, index) => {
const cargo = this.normalizeCargoRow(row, this.resolveCargoTypePath(row));
if (isFirstGoodsEmpty && index === 0) this.form.goods.splice(0, 1, cargo);
else this.form.goods.push(cargo);
});
},
resolveCargoTypePath(row = {}) {
return (
this.cargoTypeFlatOptions.find(
item =>
String(item.cargoCode || item.code || '') ===
String(row.secondCargoTypeCode || row.cargoTypeCode || '') ||
item.label === (row.secondCargoTypeName || row.cargoType || row.goodsType)
)?.path || []
);
},
normalizeCargoRow(row = {}, cargoTypePath = this.resolveCargoTypePath(row)) {
const cargoType = this.getCargoTypeByPath(cargoTypePath);
return {
...row,
cargoName: row.cargoName || row.goodsName || '',
cargoType:
cargoType?.label ||
row.cargoType ||
row.goodsType ||
row.secondCargoTypeName ||
row.firstCargoTypeName ||
'',
cargoTypeCode:
cargoType?.cargoCode ||
cargoType?.code ||
row.cargoTypeCode ||
row.secondCargoTypeCode ||
'',
cargoTypePath,
quantity: row.quantity || '',
quantityUnit: row.quantityUnit || quantityUnitOptions[0],
packageType: row.packageType || row.package || '',
brand: row.brand || '',
specification: row.specification || row.spec || '',
model: row.model || '',
remark: row.remark || row.descriptionOne || '',
};
},
async handleCargoImport(file) {
const rawFile = file.raw;
if (!rawFile) return;
if (!/\.(xls|xlsx)$/i.test(rawFile.name || '')) return this.$message.warning('请上传 .xlsx、.xls 格式文件');
if (rawFile.size > 50 * 1024 * 1024) return this.$message.warning('单个文件大小不能超过50M');
this.cargoImportLoading = true;
try {
const response = await importBlob('/blade-transport/shipping-template/import-goods', rawFile);
const contentType = response.headers?.['content-type'] || response.data?.type || '';
if (contentType.includes('application/vnd.ms-excel') || contentType.includes('spreadsheetml')) {
downloadXls(response.data, `发货模板货物导入失败明细${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`);
this.$message.warning('部分数据导入失败,已下载失败明细');
return;
}
const result = JSON.parse(await response.data.text());
if (result.code !== 200) throw new Error(result.msg || '货物导入失败');
const rows = result.data || [];
if (!rows.length) return this.$message.warning('未读取到货物数据');
rows.forEach(row => this.form.goods.push(this.normalizeCargoRow(row)));
this.$message.success(`成功导入${rows.length}条货物`);
this.cargoImportVisible = false;
} catch (error) {
window.console.log(error);
this.$message.error('货物导入失败,请检查文件格式');
} finally {
this.cargoImportLoading = false;
}
},
downloadCargoTemplate() {
exportBlob('/blade-transport/shipping-template/export-goods-template').then(response => {
downloadXls(response.data, '发货模板货物导入模板.xlsx');
});
},
openRouteDialog() {
this.routeQuery = {};
this.routePage.current = 1;
this.routeDialogVisible = true;
},
async loadRouteList() {
this.routeLoading = true;
try {
const response = await getCommonRouteList(this.routePage.current, this.routePage.size, {
...this.routeQuery,
allDept: 0,
});
const data = response?.data?.data || response?.data || response || {};
this.routeRows = data.records || [];
this.routePage.total = data.total || 0;
} finally {
this.routeLoading = false;
}
},
searchRouteList() {
this.routePage.current = 1;
this.loadRouteList();
},
resetRouteList() {
this.routeQuery = {};
this.routePage.current = 1;
this.loadRouteList();
},
changeRoutePageSize(size) {
this.routePage.size = size;
this.routePage.current = 1;
this.loadRouteList();
},
changeRoutePage(current) {
this.routePage.current = current;
this.loadRouteList();
},
formatRouteUpdateUserName(row) {
return formatUpdateUserName(row);
},
selectRoute(route) {
this.form.departureName = route.departureName || '';
this.form.departureAddress = route.departureAddress || '';
this.form.departureContact = route.departureContact || '';
this.form.departurePhone = route.departurePhone || '';
this.form.arrivalName = route.arrivalName || '';
this.form.arrivalAddress = route.arrivalAddress || '';
this.form.arrivalContact = route.arrivalContact || '';
this.form.arrivalPhone = route.arrivalPhone || '';
this.syncAddressRegionPaths();
this.form.finalTransportType = '公路运输';
if (!this.form.routes.length) this.addRoute();
this.routeDialogVisible = false;
},
createRoute(index = this.form.routes.length) {
return { segmentNo: `段${index + 1}`, transportType: '' };
},
addRoute() {
this.form.routes.push(this.createRoute());
},
removeRoute(index) {
this.form.routes.splice(index, 1);
this.syncAddressRegionPaths();
},
addGoods(index) {
this.form.goods.splice(index + 1, 0, { quantityUnit: quantityUnitOptions[0] });
},
removeGoods(index) {
if (this.form.goods.length > 1) this.form.goods.splice(index, 1);
},
handleQuantityInput(row, value) {
const text = String(value || '').replace(/[^\d.]/g, '');
const [integer = '', ...decimals] = text.split('.');
row.quantity = decimals.length ? `${integer}.${decimals.join('').slice(0, 3)}` : integer;
},
syncAttachmentsJson() {
this.form.attachmentsJson = JSON.stringify(this.attachmentRows);
},
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.syncAttachmentsJson();
},
handleAttachmentSelectionChange(rows) {
this.selectedAttachmentRows = rows || [];
},
removeAttachment(index) {
this.attachmentRows.splice(index, 1);
this.selectedAttachmentRows = this.selectedAttachmentRows.filter(item =>
this.attachmentRows.includes(item)
);
this.syncAttachmentsJson();
},
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.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) {
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法预览');
return;
}
if (this.isAttachmentImage(row)) {
this.attachmentImagePreviewUrls = this.attachmentRows
.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));
},
handleAttachmentBatchDownload() {
const rows = this.selectedAttachmentRows.length
? this.selectedAttachmentRows
: this.attachmentRows;
rows.forEach(row => this.downloadAttachment(row));
},
async save(draft = false) {
const phoneFields = [
[this.form.departurePhone, '发货联系方式'],
...this.form.routes.map((route, index) => [
route.departurePhone,
`第${index + 1}个途经地联系方式`,
]),
[this.form.arrivalPhone, '收货联系方式'],
];
const invalidPhone = phoneFields.find(
([value]) => String(value || '').trim() && !isMobile(value)
);
if (invalidPhone) {
this.$message.warning(`${invalidPhone[1]}格式不正确`);
return false;
}
if (!draft) {
await this.$refs.form.validate();
if (!this.form.departureAddress || !this.form.arrivalAddress)
return this.$message.warning('请填写收发货地址');
if (!this.form.goods.some(item => item.cargoType && item.quantity > 0 && item.quantityUnit))
return this.$message.warning('请至少填写一条有效货物');
if (
this.form.routes.some(item => !item.departureName || !item.transportType) ||
!this.form.finalTransportType
)
return this.$message.warning('请完整填写途经地及运输方式');
}
const payload = {
...this.form,
planStartTime: this.normalizeDateTimeValue(this.form.planStartTime),
planEndTime: this.normalizeDateTimeValue(this.form.planEndTime, true),
attachmentsJson: JSON.stringify(this.attachmentRows),
routes: [
...this.form.routes.map((route, index) => ({ ...route, segmentNo: `段${index + 1}` })),
{
segmentNo: `段${this.form.routes.length + 1}`,
departureName: this.form.arrivalName,
departureAddress: this.form.arrivalAddress,
departureContact: this.form.arrivalContact,
departurePhone: this.form.arrivalPhone,
departureLongitude: this.form.arrivalLongitude,
departureLatitude: this.form.arrivalLatitude,
departureRegionCode: this.form.arrivalRegionCode,
departureSiteCode: this.form.arrivalSiteCode,
transportType: this.form.finalTransportType,
},
],
};
delete payload.finalTransportType;
const res = await (draft ? api.saveDraft(payload) : api.submit(payload));
this.$message.success('保存成功');
// 兼容 Axios 响应及 BladeX 常见的 data.data 两层响应结构。
return res?.data?.data || res?.data || res;
},
async saveDraft() {
const result = await this.save(true);
if (result === false) return;
this.$emit('back');
},
async confirmCreate() {
const result = await this.save();
if (result === false) return;
this.$emit('back');
},
async createAndDispatch() {
const data = await this.save();
if (data === false) return;
const id = data?.id || data?.masterOrderId || data?.masterId;
if (!id) {
this.$message.error('保存成功但未获取到总单ID,无法进入调度');
return;
}
this.$emit('dispatch', id);
},
},
};
</script>
<style scoped lang="scss">
.master-editor section {
margin-bottom: 12px;
padding: 16px;
border: 1px solid #eff1f7;
background: #fff;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
h3 {
margin: 0 0 16px;
padding-left: 12px;
border-left: 4px solid #409eff;
font-size: 16px;
}
.el-form-item {
margin-bottom: 14px;
}
}
.goods-heading {
display: flex;
margin-bottom: 8px;
justify-content: flex-start;
align-items: center;
h3 {
margin-bottom: 0;
}
}
.goods-toolbar {
display: flex;
margin-left: auto;
gap: 8px;
}
.goods-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.master-editor__attachment {
width: 100%;
}
.master-editor__attachment-head {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 16px;
}
.master-editor__attachment-upload {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 12px;
:deep(.el-upload) {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
text-align: left;
}
:deep(.el-upload__tip) {
display: inline-block;
margin: 0 0 0 8px;
color: #909399;
font-size: 13px;
line-height: 1.4;
white-space: normal;
text-align: left;
}
}
.master-editor__attachment-upload .vehicle-attachment-upload {
width: auto;
}
.master-editor__attachment-table {
width: 100%;
:deep(.el-table__header th) {
background: #f5f7fa;
color: #303133;
font-weight: 600;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
:deep(.el-table__row:nth-child(even) td.el-table__cell) {
background: #fafafa;
}
}
:deep(.master-editor__quantity-cell .el-input) {
width: 156px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
margin-bottom: 0;
}
}
.section-head__actions {
display: flex;
gap: 16px;
}
.route-dialog__search {
margin-bottom: 8px;
padding: 12px 12px 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.common-address-dialog__search,
.common-cargo-dialog__search {
background: #fff;
}
.route-dialog__search-actions {
display: flex;
justify-content: flex-end;
}
.route-dialog__pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.route-steps {
position: relative;
.el-steps {
--el-text-color-placeholder: #a8abb2;
}
:deep(.el-step__main) {
padding-bottom: 16px;
}
:deep(.el-step__title) {
font-size: 16px;
color: #303133;
}
:deep(.el-step__description) {
padding: 12px 0 0;
}
:deep(.el-step__line) {
background-color: #dcdfe6;
}
:deep(.el-step__icon) {
width: 32px;
height: 32px;
flex-shrink: 0;
border: 0;
background: transparent;
}
.route-step-icon {
display: inline-flex;
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #409eff;
color: #fff;
font-size: 16px;
font-weight: 600;
line-height: 1;
&--middle {
background: #67c23a;
}
&--end {
background: #e6a23c;
}
}
}
.route-node-form {
display: grid;
grid-template-columns: repeat(2, minmax(420px, 1fr)) repeat(2, minmax(260px, 1fr));
gap: 0 16px;
align-items: start;
:deep(.el-form-item) {
margin: 0 0 12px;
}
:deep(.el-form-item.route-address-form-item) {
grid-column: span 2;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor) {
width: 100%;
}
.route-address-inputs,
.route-contact-actions {
display: flex;
width: 100%;
gap: 8px;
}
.route-address-name {
flex: 0 0 220px;
}
.route-address-detail,
.route-contact-actions :deep(.el-input) {
flex: 1;
}
.address-picker-button {
flex: 0 0 auto;
:deep(.el-icon) {
font-size: 22px;
}
}
}
.route-step-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
width: 100%;
color: #303133;
font-size: 16px;
.route-segment-label {
flex: 0 0 auto;
}
.route-title-path {
display: inline-block;
max-width: 24em;
overflow: hidden;
color: #606266;
font-size: 14px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-transport-control {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
color: #606266;
font-size: 14px;
font-weight: 400;
white-space: nowrap;
:deep(.el-select) {
width: 180px;
}
}
}
.route-action-button {
flex: 0 0 auto;
}
.address-pagination {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.address-map-toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.address-map {
height: 440px;
}
.address-map-status {
margin-top: 8px;
color: #606266;
}
.cargo-import {
display: flex;
flex-direction: column;
gap: 16px;
.el-upload__tip {
margin-top: 8px;
line-height: 1.5;
}
}
@media screen and (max-width: 1440px) {
.route-node-form {
grid-template-columns: repeat(2, minmax(320px, 1fr));
:deep(.el-form-item.route-address-form-item) {
grid-column: span 2;
}
}
.route-step-title {
align-items: flex-start;
gap: 8px;
}
}
footer {
display: flex;
justify-content: flex-end;
padding: 12px 24px;
position: fixed;
right: 0;
left: 230px;
bottom: 0;
margin: 0;
z-index: 10;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
:global(.avue--collapse footer) {
left: 60px;
}
:global(.avue-layout--horizontal footer) {
left: 0;
}
</style>