Files
tms-erp-web/src/views/business/components/shipping-template-page.vue
T
b2894lxlx 6f0f1842cb 1、修复基础配置
2、修复业务模块
3、拆分页面代码
2026-09-03 03:31:30 +08:00

3259 lines
114 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.
<!-- 发货模板专用页面页面只负责模板 CRUD 及模板表单相关交互 -->
<template>
<basic-container
:class="[
'shipping-template-page',
{ 'shipping-template-page--form-page': isStandaloneFormPage },
]"
>
<component
:is="crudContainer"
ref="crud"
:option="pageFormOption"
:form-page-title="isStandaloneFormPage ? formPageTitle : undefined"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
:status="isStandaloneFormPage ? dialogReadonly : undefined"
: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 v-if="canCreate" type="primary" @click="openBusinessForm('add')">
{{ config.createText || '新增' }}
</el-button>
<el-button
v-if="config.exportUrl && hasPermission(`${config.permission}_export`)"
type="primary"
plain
@click="handleExport"
>
批量导出
</el-button>
<el-button
v-if="config.batchDelete !== false && hasPermission(`${config.permission}_delete`)"
type="danger"
plain
@click="handleDelete"
>
批量删除
</el-button>
</template>
<template #contractName="{ row }">
<span>{{ row.contractName || '-' }}</span>
</template>
<template #departureAddress="{ row }">
<el-tooltip v-if="row.departureAddress" :content="row.departureAddress" placement="top">
<span>{{ row.departureAddress }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<template #arrivalAddress="{ row }">
<el-tooltip v-if="row.arrivalAddress" :content="row.arrivalAddress" placement="top">
<span>{{ row.arrivalAddress }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<template #templateInfoTitle-form>
<div class="dialog-section-title">模板信息</div>
</template>
<template #basicInfoTitle-form>
<div class="dialog-section-title">基本信息</div>
</template>
<template #projectName-form>
<el-select
v-model="selectedProjectId"
class="shipping-template-page__field"
placeholder="请选择项目名称"
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-model="form.contractId"
class="shipping-template-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>
</template>
<template #shippingInfoTitle-form>
<div
class="dialog-section-title dialog-section-title--action shipping-template-page__shipping-title"
>
<span>收发货信息</span>
<el-link
v-if="!dialogReadonly"
class="shipping-template-page__shipping-route-action"
type="primary"
@click="openTransportRouteDialog"
>
选择线路
</el-link>
</div>
</template>
<template #departureAddress-form>
<div class="shipping-template-page__route-row">
<el-input
v-model="form.departureName"
readonly
placeholder="行政区划/站点名称"
:disabled="transportAddressSelectDisabled"
@click="openTransportPrimaryAddressPicker('departure')"
/>
<el-input
v-model="form.departureAddress"
placeholder="请输入发货地址"
:readonly="transportStationMode || !config.editableRoadAddress"
:disabled="transportAddressSelectDisabled"
@click="handleTransportSecondaryAddressInputClick('departure')"
>
<template #suffix>
<el-icon
v-if="!transportStationMode"
@click.stop="openTransportMapDialog('departure')"
>
<Location />
</el-icon>
</template>
</el-input>
<span>联系人</span>
<el-input
v-model="form.departureContact"
placeholder="请输入"
:disabled="dialogReadonly"
/>
<span>联系方式</span>
<el-input v-model="form.departurePhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('departure')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
<template #arrivalAddress-form>
<div class="shipping-template-page__route-row">
<el-input
v-model="form.arrivalName"
readonly
placeholder="行政区划/站点名称"
:disabled="transportAddressSelectDisabled"
@click="openTransportPrimaryAddressPicker('arrival')"
/>
<el-input
v-model="form.arrivalAddress"
placeholder="请输入收货地址"
:readonly="transportStationMode || !config.editableRoadAddress"
:disabled="transportAddressSelectDisabled"
@click="handleTransportSecondaryAddressInputClick('arrival')"
>
<template #suffix>
<el-icon v-if="!transportStationMode" @click.stop="openTransportMapDialog('arrival')">
<Location />
</el-icon>
</template>
</el-input>
<span>联系人</span>
<el-input v-model="form.arrivalContact" placeholder="请输入" :disabled="dialogReadonly" />
<span>联系方式</span>
<el-input v-model="form.arrivalPhone" placeholder="请输入" :disabled="dialogReadonly" />
<el-tooltip content="选择常用地址" placement="top">
<el-button
link
type="primary"
:disabled="transportAddressSelectDisabled"
@click="openTransportAddressPicker('arrival')"
>
选择
</el-button>
</el-tooltip>
</div>
</template>
<template #goodsInfoTitle-form>
<div
class="dialog-section-title dialog-section-title--action shipping-template-page__goods-title"
>
<span>货物信息</span>
<div v-if="!dialogReadonly" class="shipping-template-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 class="shipping-template-page__cargo-wrap">
<el-table :data="transportCargoRows" border empty-text="暂无货物信息">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column label="货物类型" min-width="200" align="center">
<template #default="{ row }">
<el-cascader
v-model="row.cargoTypePath"
:options="cargoTypeOptions"
:props="cargoTypeCascaderProps"
placeholder="请选择"
clearable
filterable
:disabled="dialogReadonly"
@visible-change="visible => visible && ensureCargoTypeOptions()"
@change="value => handleCargoTypeChange(row, value)"
/>
</template>
</el-table-column>
<el-table-column label="货物名称" min-width="220" align="center">
<template #default="{ row }">
<el-autocomplete
v-model="row.cargoName"
placeholder="请输入"
clearable
:fetch-suggestions="(query, cb) => fetchCargoSuggestions(query, cb, row)"
:disabled="dialogReadonly"
@select="item => selectCargoSuggestion(row, item)"
/>
</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="120" align="center">
<template #default="{ row }">
<el-input
v-model="row.quantity"
placeholder="请输入"
:disabled="dialogReadonly"
@input="value => handleCargoQuantityInput(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"
@change="syncFreightItems"
>
<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-input v-model="row.brand" :disabled="dialogReadonly"
/></template>
</el-table-column>
<el-table-column label="规格" min-width="120" align="center">
<template #default="{ row }"
><el-input v-model="row.specification" :disabled="dialogReadonly"
/></template>
</el-table-column>
<el-table-column label="型号" min-width="120" align="center">
<template #default="{ row }"
><el-input v-model="row.model" :disabled="dialogReadonly"
/></template>
</el-table-column>
<el-table-column label="物料编码" min-width="140" align="center">
<template #default="{ row }"
><el-input v-model="row.materialCode" :disabled="dialogReadonly"
/></template>
</el-table-column>
<el-table-column label="设备编码" min-width="140" align="center">
<template #default="{ row }"
><el-input v-model="row.deviceCode" :disabled="dialogReadonly"
/></template>
</el-table-column>
<el-table-column label="备注" min-width="160" align="center">
<template #default="{ row }"
><el-input v-model="row.remark" maxlength="200" :disabled="dialogReadonly"
/></template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ $index }">
<el-link v-if="!dialogReadonly" type="primary" @click="addCargoRow($index)"
>新增</el-link
>
<el-link v-if="!dialogReadonly" type="danger" @click="removeCargoRow($index)"
>删除</el-link
>
</template>
</el-table-column>
</el-table>
<div class="shipping-template-page__cargo-total">
运输数量合计:{{ cargoQuantityTotal }}
</div>
<section v-if="freightVisible" class="shipping-template-page__freight-section">
<div class="dialog-section-title">
运费信息{{ transportMode === 'road' ? '(公路)' : '' }}
</div>
<el-form
:model="shippingTemplateFreight"
label-position="right"
label-width="auto"
:class="[
'shipping-template-page__freight-form',
`shipping-template-page__freight-form--${
transportMode === 'road' ? 'road' : 'non-road'
}`,
]"
>
<template v-if="transportMode === 'road'">
<template
v-for="(item, index) in shippingTemplateFreight.freightItems"
:key="item.quantityUnit || `empty-${index}`"
>
<el-form-item :label="`单价${index + 1}`">
<el-input
v-model="item.unitPrice"
:disabled="dialogReadonly"
@input="value => handleFreightNumberInput(item, 'unitPrice', value)"
>
<template #append
><el-select
v-model="item.priceUnit"
class="shipping-template-page__append-select"
placeholder="单位"
:disabled="dialogReadonly"
@visible-change="visible => visible && loadFeeUnitOptions()"
><el-option
v-for="unit in feeUnitOptions"
:key="unit.value"
:label="unit.label"
:value="unit.value" /></el-select
></template>
</el-input>
</el-form-item>
<el-form-item :label="`运费${index + 1}`"
><el-input
:model-value="freightAmount(item)"
placeholder="请输入"
:disabled="dialogReadonly"
@input="value => handleFreightAmountInput(item, value)"
><template #append>{{ currencyLabel }}</template>
</el-input></el-form-item
>
</template>
</template>
<el-form-item v-else label="运费"
><el-input
v-model="shippingTemplateFreight.freightAmount"
placeholder="请输入"
:disabled="dialogReadonly"
@input="
value =>
handleFreightNumberInput(shippingTemplateFreight, 'freightAmount', value)
"
><template #append>{{ currencyLabel }}</template></el-input
></el-form-item
>
<el-form-item label="其他运费合计"
><el-input
v-model="shippingTemplateFreight.otherFreightAmount"
placeholder="请输入"
:disabled="dialogReadonly"
@input="
value =>
handleFreightNumberInput(shippingTemplateFreight, 'otherFreightAmount', value)
"
><template #append>{{ currencyLabel }}</template></el-input
></el-form-item
>
<el-form-item label="运费合计"
><el-input :model-value="freightTotal" disabled
><template #append>{{ currencyLabel }}</template>
</el-input></el-form-item
>
</el-form>
</section>
</div>
</template>
<template #attachmentTitle-form
><div class="dialog-section-title">{{ config.attachmentTitle || '附件' }}</div></template
>
<template #attachmentsJson-form>
<div class="shipping-template-page__attachment">
<div class="shipping-template-page__attachment-head">
<el-button
type="primary"
:disabled="!attachmentRows.length"
@click="handleBatchDownload"
>批量下载</el-button
>
</div>
<el-table
:data="attachmentRows"
border
@selection-change="handleAttachmentSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220"
><template #default="{ row }"
><el-link type="primary" @click="previewAttachment(row)">{{
attachmentName(row)
}}</el-link></template
></el-table-column
>
<el-table-column label="附件描述" min-width="220"
><template #default="{ row }"
><el-input v-model="row.description" :disabled="dialogReadonly" /></template
></el-table-column>
<el-table-column label="文件大小" width="110"
><template #default="{ row }">{{
formatFileSize(row.size)
}}</template></el-table-column
>
<el-table-column prop="uploadUserName" label="上传人" width="130" />
<el-table-column prop="uploadTime" label="上传时间" width="170" />
<el-table-column label="操作" width="100"
><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="shipping-template-page__attachment-upload">
<vehicle-attachment-upload
v-model="attachmentRows"
:readonly="dialogReadonly"
:file-types="attachmentFileTypes"
:max-size="500"
:show-file-list="false"
button-text="上传附件"
@change="handleAttachmentChange"
/>
</div>
</div>
</template>
<template #menu-form>
<div
v-if="
((config.enableShippingTemplateFreight === true && freightVisible) ||
hasProjectProcessConfig) &&
!dialogReadonly
"
class="shipping-template-page__footer-summary"
>
<span v-if="freightVisible"
>运费合计:<strong>¥{{ footerFreightTotal }}</strong></span
>
<span v-else />
<el-link
v-if="hasProjectProcessConfig"
type="primary"
@click="openShippingTemplateProcessConfig"
>
查看过程配置
</el-link>
</div>
<div v-if="isStandaloneFormPage" class="shipping-template-page__standalone-actions">
<el-button @click="closeCrudDialog">取消</el-button>
</div>
</template>
<template #menu="{ row, index }">
<div class="shipping-template-page__row-actions">
<el-link
v-if="hasPermission(`${config.permission}_view`)"
type="primary"
@click="openDetail(row)"
>查看</el-link
>
<el-link v-if="canEdit(row)" type="primary" @click="openBusinessForm('edit', row)"
>编辑</el-link
>
<el-link v-if="canCopy(row)" type="primary" @click="handleCopy(row)">复制</el-link>
<el-link
v-for="operation in customOperations(row)"
:key="operation.action"
:type="operation.type || 'primary'"
@click="handleCustomOperation(operation, row)"
>{{ operation.label }}</el-link
>
<el-link v-if="canDelete(row)" type="danger" @click="rowDel(row)">删除</el-link>
</div>
</template>
</component>
<el-dialog
v-model="detailBox"
title="发货模板详情"
append-to-body
width="92%"
class="shipping-template-page__detail-dialog"
>
<div v-loading="detailLoading" class="shipping-template-page__detail">
<section class="shipping-template-page__detail-section">
<div class="dialog-section-title">模板信息</div>
<el-form
label-position="right"
label-width="auto"
class="shipping-template-page__detail-form shipping-template-page__detail-grid"
>
<el-form-item label="模板编号"><span>{{ detailRow.templateCode || '-' }}</span></el-form-item>
<el-form-item label="模板名称"><span>{{ detailRow.templateName || '-' }}</span></el-form-item>
<el-form-item label="模板类型"><span>{{ detailRow.templateType || '-' }}</span></el-form-item>
<el-form-item label="备注" class="shipping-template-page__detail-form-wide">
<span>{{ detailRow.remark || '-' }}</span>
</el-form-item>
</el-form>
</section>
<section class="shipping-template-page__detail-section">
<div class="dialog-section-title">基本信息</div>
<el-form
label-position="right"
label-width="auto"
class="shipping-template-page__detail-form shipping-template-page__detail-grid"
>
<el-form-item label="项目"><span>{{ detailRow.projectName || '-' }}</span></el-form-item>
<el-form-item label="客户合同"><span>{{ detailRow.contractName || '-' }}</span></el-form-item>
<el-form-item label="运输方式">
<span>{{ getTransportTypeLabel(detailRow.transportType) || detailRow.transportType || '-' }}</span>
</el-form-item>
<el-form-item label="备注" class="shipping-template-page__detail-form-wide">
<span>{{ detailRow.basicRemark || '-' }}</span>
</el-form-item>
</el-form>
</section>
<section class="shipping-template-page__detail-section">
<div class="dialog-section-title">收发货信息</div>
<el-form label-position="right" label-width="auto" class="shipping-template-page__detail-form">
<el-form-item label="发货地址">
<div class="shipping-template-page__detail-route-values">
<span>{{ detailRow.departureName || '-' }}</span>
<span>{{ detailRow.departureAddress || '-' }}</span>
<span>联系人:{{ detailRow.departureContact || '-' }}</span>
<span>联系方式:{{ detailRow.departurePhone || '-' }}</span>
</div>
</el-form-item>
<el-form-item label="收货地址">
<div class="shipping-template-page__detail-route-values">
<span>{{ detailRow.arrivalName || '-' }}</span>
<span>{{ detailRow.arrivalAddress || '-' }}</span>
<span>联系人:{{ detailRow.arrivalContact || '-' }}</span>
<span>联系方式:{{ detailRow.arrivalPhone || '-' }}</span>
</div>
</el-form-item>
</el-form>
</section>
<section class="shipping-template-page__detail-section">
<div class="dialog-section-title">货物信息</div>
<div class="shipping-template-page__cargo-wrap">
<el-table :data="detailGoodsRows" border empty-text="暂无货物信息">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="cargoType" label="货物类型" min-width="200" align="center" />
<el-table-column prop="cargoName" label="货物名称" min-width="220" align="center" />
<el-table-column prop="packageType" label="包装" min-width="120" align="center" />
<el-table-column prop="quantity" label="数量" min-width="120" align="center" />
<el-table-column prop="quantityUnit" label="数量单位" min-width="120" align="center" />
<el-table-column prop="brand" label="品牌" min-width="120" align="center" />
<el-table-column prop="specification" label="规格" min-width="120" align="center" />
<el-table-column prop="model" label="型号" min-width="120" align="center" />
<el-table-column prop="materialCode" label="物料编码" min-width="140" align="center" />
<el-table-column prop="deviceCode" label="设备编码" min-width="140" align="center" />
<el-table-column prop="remark" label="备注" min-width="160" align="center" />
</el-table>
</div>
<div class="shipping-template-page__cargo-total">
运输数量合计:{{ detailCargoQuantityTotal }}
</div>
<section v-if="detailFreightVisible" class="shipping-template-page__freight-section">
<div class="dialog-section-title">
运费信息{{ detailTransportMode === 'road' ? '(公路)' : '' }}
</div>
<el-form
label-position="right"
label-width="auto"
:class="[
'shipping-template-page__freight-form',
`shipping-template-page__freight-form--${detailTransportMode === 'road' ? 'road' : 'non-road'}`,
]"
>
<template v-if="detailTransportMode === 'road'">
<template v-for="(item, index) in detailFreightItems" :key="item.quantityUnit || index">
<el-form-item :label="`单价${index + 1}`">
<el-input :model-value="item.unitPrice || '-'" disabled>
<template #append>{{ item.priceUnit || '-' }}</template>
</el-input>
</el-form-item>
<el-form-item :label="`运费${index + 1}`">
<el-input :model-value="item.freightAmount || '-'" disabled>
<template #append>{{ detailFreight.currency || '-' }}</template>
</el-input>
</el-form-item>
</template>
</template>
<el-form-item v-else label="运费">
<el-input :model-value="detailFreight.freightAmount || '-'" disabled>
<template #append>{{ detailFreight.currency || '-' }}</template>
</el-input>
</el-form-item>
<el-form-item label="其他运费合计">
<el-input :model-value="detailFreight.otherFreightAmount || '-'" disabled>
<template #append>{{ detailFreight.currency || '-' }}</template>
</el-input>
</el-form-item>
<el-form-item label="运费合计">
<el-input :model-value="detailFreightTotal" disabled>
<template #append>{{ detailFreight.currency || '-' }}</template>
</el-input>
</el-form-item>
</el-form>
</section>
</section>
<section class="shipping-template-page__detail-section">
<div class="dialog-section-title">{{ config.attachmentTitle || '附件' }}</div>
<div class="shipping-template-page__attachment">
<div class="shipping-template-page__attachment-head">
<el-button
type="primary"
:disabled="!detailAttachmentRows.length"
@click="handleDetailBatchDownload"
>
批量下载
</el-button>
</div>
<el-table :data="detailAttachmentRows" border empty-text="暂无附件">
<el-table-column type="index" label="序号" width="70" />
<el-table-column label="文件名" min-width="220">
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">{{ attachmentName(row) }}</el-link>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="220">
<template #default="{ row }">{{ row.description || '-' }}</template>
</el-table-column>
<el-table-column label="文件大小" width="110">
<template #default="{ row }">{{ formatFileSize(row.size) || '-' }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="130" />
<el-table-column prop="uploadTime" label="上传时间" width="170" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-link type="primary" @click="downloadAttachment(row)">下载</el-link>
</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
<template #footer
><el-button type="primary" @click="detailBox = false">关闭</el-button></template
>
</el-dialog>
<empty-pagination
v-show="!isStandaloneFormPage"
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
<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
title="选择线路"
v-model="transportRouteBox"
append-to-body
width="1000px"
@opened="loadTransportRouteList"
>
<el-form :model="transportRouteQuery" inline
><el-form-item label="线路名称"
><el-input v-model="transportRouteQuery.routeName" clearable /></el-form-item
><el-button type="primary" @click="handleTransportRouteSearch">查询</el-button
><el-button @click="handleTransportRouteReset">重置</el-button></el-form
>
<el-table :data="transportRouteRows" border v-loading="transportRouteLoading"
><el-table-column prop="routeName" label="线路名称" min-width="180" /><el-table-column
prop="departureAddress"
label="发货地址"
min-width="260"
/><el-table-column prop="arrivalAddress" label="收货地址" min-width="260" /><el-table-column
label="操作"
width="90"
><template #default="{ row }"
><el-link type="primary" @click="selectTransportRouteRow(row)">选择</el-link></template
></el-table-column
></el-table
>
<template #footer
><el-pagination
v-model:current-page="transportRoutePage.currentPage"
v-model:page-size="transportRoutePage.pageSize"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50, 100]"
:total="transportRoutePage.total"
@current-change="handleTransportRouteCurrentChange"
@size-change="handleTransportRouteSizeChange"
/></template>
</el-dialog>
<el-dialog
title="选择常用地址"
v-model="transportAddressBox"
append-to-body
width="1100px"
@opened="loadTransportAddressList"
>
<el-form :model="transportAddressQuery" inline
><el-form-item label="地址名称"
><el-input v-model="transportAddressQuery.addressName" clearable /></el-form-item
><el-form-item label="详细地址"
><el-input v-model="transportAddressQuery.detailAddress" clearable /></el-form-item
><el-button type="primary" @click="handleTransportAddressSearch">查询</el-button
><el-button @click="handleTransportAddressReset">重置</el-button></el-form
>
<el-table :data="transportAddressRows" border v-loading="transportAddressLoading"
><el-table-column prop="addressName" label="地址名称" min-width="160" /><el-table-column
prop="addressType"
label="类型"
width="120"
/><el-table-column prop="detailAddress" label="详细地址" min-width="280" /><el-table-column
prop="contactName"
label="联系人"
width="120"
/><el-table-column prop="contactPhone" label="联系方式" width="140" /><el-table-column
label="操作"
width="90"
><template #default="{ row }"
><el-link type="primary" @click="selectTransportAddressRow(row)"
>选择</el-link
></template
></el-table-column
></el-table
>
<template #footer
><el-pagination
v-model:current-page="transportAddressPage.currentPage"
v-model:page-size="transportAddressPage.pageSize"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50, 100]"
:total="transportAddressPage.total"
@current-change="handleTransportAddressCurrentChange"
@size-change="handleTransportAddressSizeChange"
/></template>
</el-dialog>
<el-dialog
:title="`选择${transportStationTypeLabel || '站点'}`"
v-model="transportStationBox"
append-to-body
width="1050px"
@opened="loadTransportStationList"
>
<el-form :model="transportStationQuery" inline
><el-form-item label="名称"
><el-input v-model="transportStationQuery.name" clearable /></el-form-item
><el-form-item label="详细地址"
><el-input v-model="transportStationQuery.detailAddress" clearable /></el-form-item
><el-button type="primary" @click="handleTransportStationSearch">查询</el-button></el-form
>
<el-table :data="transportStationRows" border v-loading="transportStationLoading"
><el-table-column prop="stationName" label="站点名称" min-width="180" /><el-table-column
prop="stationCode"
label="站点编码"
width="140"
/><el-table-column prop="detailAddress" label="详细地址" min-width="300" /><el-table-column
prop="regionName"
label="行政区划"
width="180"
/><el-table-column label="操作" width="90"
><template #default="{ row }"
><el-link type="primary" @click="selectTransportStationRow(row)"
>选择</el-link
></template
></el-table-column
></el-table
>
<template #footer
><el-pagination
v-model:current-page="transportStationPage.currentPage"
v-model:page-size="transportStationPage.pageSize"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50, 100]"
:total="transportStationPage.total"
@current-change="handleTransportStationCurrentChange"
@size-change="handleTransportStationSizeChange"
/></template>
</el-dialog>
<el-dialog
title="地图选择地址"
v-model="transportMapBox"
append-to-body
width="920px"
@opened="initTransportMap"
>
<div class="shipping-template-page__map-toolbar">
<el-input
v-model="transportMapKeyword"
clearable
@keyup.enter="searchTransportMapKeyword"
/><el-button type="primary" @click="searchTransportMapKeyword">搜索</el-button>
</div>
<div ref="transportMap" class="shipping-template-page__map" />
<div class="shipping-template-page__map-info">{{ transportMapStatus }}</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="选择常用货物"
v-model="commonCargoBox"
append-to-body
width="1200px"
@opened="loadCommonCargoList"
>
<el-form :model="commonCargoQuery" inline
><el-form-item label="货物名称"
><el-input v-model="commonCargoQuery.cargoName" clearable /></el-form-item
><el-button type="primary" @click="handleCommonCargoSearch">查询</el-button
><el-button @click="handleCommonCargoReset">重置</el-button></el-form
>
<el-table
:data="commonCargoRows"
border
v-loading="commonCargoLoading"
@selection-change="handleCommonCargoSelectionChange"
><el-table-column type="selection" width="55" /><el-table-column
prop="cargoName"
label="货物名称"
min-width="160"
/><el-table-column
prop="firstCargoTypeName"
label="一级类型"
min-width="140"
/><el-table-column
prop="secondCargoTypeName"
label="二级类型"
min-width="140"
/><el-table-column prop="packageType" label="包装" width="110" /><el-table-column
prop="brand"
label="品牌"
width="120"
/><el-table-column prop="specification" label="规格" width="120" /><el-table-column
prop="model"
label="型号"
width="120"
/><el-table-column label="操作" width="90"
><template #default="{ row }"
><el-link type="primary" @click="selectCommonCargoRow(row)">选择</el-link></template
></el-table-column
></el-table
>
<template #footer
><el-pagination
v-model:current-page="commonCargoPage.currentPage"
v-model:page-size="commonCargoPage.pageSize"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50, 100]"
:total="commonCargoPage.total"
@current-change="handleCommonCargoCurrentChange"
@size-change="handleCommonCargoSizeChange"
/><el-button
type="primary"
:disabled="!commonCargoSelected.length"
@click="confirmCommonCargoSelection"
>确定</el-button
></template
>
</el-dialog>
<el-dialog
title="导入货物"
v-model="cargoImportBox"
append-to-body
width="560px"
@closed="cargoImportRows = []"
>
<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></el-upload
>
<el-link type="primary" @click="handleCargoImportTemplate">下载模板</el-link>
<template #footer
><el-button @click="closeCargoImportDialog">关闭</el-button
><el-button
type="primary"
:loading="cargoImportLoading"
:disabled="!cargoImportRows.length"
@click="confirmCargoImport"
>确定</el-button
></template
>
</el-dialog>
<el-dialog
v-model="shippingTemplateProcessConfigBox"
title="过程配置"
append-to-body
width="900px"
>
<div v-loading="shippingTemplateProcessConfigLoading">
<el-empty
v-if="!shippingTemplateProcessConfigRows.length"
description="当前项目暂无过程配置"
/>
<section
v-for="row in shippingTemplateProcessConfigRows"
:key="row.id || row.configCode"
class="shipping-template-page__process-section"
>
<strong>{{ row.configName || '过程配置' }}</strong>
<div
v-for="node in getProcessConfigEnabledNodes(row)"
:key="node.key"
class="shipping-template-page__process-node"
>
{{ node.name }}{{ processConfigTimelineDescriptions(node).join('') }}
</div>
</section>
</div>
<template #footer
><el-button type="primary" @click="shippingTemplateProcessConfigBox = false"
>关闭</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 { getList as getContractList } from '@/api/business/contract-manage';
import { getList as getProjectList } from '@/api/business/project-apply';
import { getList as getCommonRouteList } from '@/api/business/common-route';
import {
getDetail as getProcessConfigDetail,
getList as getProcessConfigList,
} from '@/api/business/process-config';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
import { getDictionary } from '@/api/system/dictbiz';
import { packageOptions } from '@/option/business/common';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { isMobile } from '@/utils/validate';
import { Location } 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';
const AMAP_KEY = '653b7cf105ad7fb8ec9b2f5198ade315';
const AMAP_SECURITY_CODE = '5aab65c632e48ebae0d0e28f5b28e21a';
let amapLoader;
const attachmentViewerPlugins = [
imagePlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc, useFetchData: true }),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const defaultCargo = () => ({
cargoName: '',
cargoType: '',
cargoTypeCode: '',
cargoTypePath: [],
packageType: '',
quantity: '',
quantityUnit: '',
brand: '',
specification: '',
model: '',
materialCode: '',
deviceCode: '',
remark: '',
});
const defaultFreight = () => ({
currency: 'RMB',
freightAmount: '',
otherFreightAmount: '',
freightItems: [],
});
const extractRecords = res => {
const data = res?.data?.data || res?.data || res || {};
if (Array.isArray(data)) return data;
return data.records || data.rows || data.data?.records || [];
};
// 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 save = attrs.onRowSave;
const update = attrs.onRowUpdate;
delete attrs.onRowSave;
delete attrs.onRowUpdate;
attrs.onSubmit = (row, hide) => {
if (attrs.option?.boxType === 'edit') update?.(row, undefined, hide, hide);
else save?.(row, hide, hide);
};
const slots = Object.fromEntries(
Object.entries(this.$slots).flatMap(([name, slot]) => {
if (name === 'menu-form' || name === 'menu-form-before') return [[name, slot]];
const prop = name.replace(/-form$/, '');
return [
[prop, slot],
[`${prop}-header`, slot],
];
})
);
const children = [];
if (this.formPageTitle)
children.push(h('div', { class: 'archive-page-form__title' }, this.formPageTitle));
children.push(h(getPlainAvueForm(resolveComponent('avue-form')), attrs, slots));
return h(
'div',
{ class: attrs.option?.dialogCustomClass || 'shipping-template-form-page-dialog' },
children
);
},
};
export default {
name: 'ShippingTemplatePage',
components: { PageAvueForm, ElImageViewer, OpenFileViewer },
props: {
api: { type: Object, required: true },
config: { type: Object, required: true },
crudOption: { type: Object, required: true },
crudExcelOption: { type: Object, default: () => ({ column: [] }) },
menuWidth: { type: Number, default: null },
detailId: { type: [String, Number], default: null },
standaloneFormPage: { type: Boolean, default: false },
},
data() {
return {
Location,
form: {},
query: {},
loading: true,
data: [],
page: { pageSize: 10, pageSizes: [10, 20, 50, 100], currentPage: 1, total: 0 },
selectionList: [],
dialogReadonly: false,
crudDialogType: '',
standaloneFormKey: '',
detailBox: false,
detailLoading: false,
detailRow: {},
option: this.buildOption(this.crudOption),
selectedProjectId: '',
projectOptions: [],
projectLoading: false,
contractOptions: [],
contractLoading: false,
cargoTypeOptions: [],
cargoTypeFlatOptions: [],
cargoTypeLoading: false,
cargoTypeRequest: null,
transportCargoRows: [],
packageOptions,
quantityUnitOptions: ['吨', '千克', '立方米', '立方', '公里', '单', '件', '车', '箱', '托盘'],
feeUnitOptions: [],
currencyOptions: [],
currencyLoading: false,
shippingTemplateFreight: defaultFreight(),
attachmentRows: [],
selectedAttachmentRows: [],
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
transportTypeOptions: [],
transportTypeOptionsLoading: false,
transportRouteBox: false,
transportRouteLoading: false,
transportRouteQuery: {},
transportRouteRows: [],
transportRoutePage: { pageSize: 10, currentPage: 1, total: 0 },
transportAddressBox: false,
transportAddressTarget: '',
transportAddressLoading: false,
transportAddressQuery: {},
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: '',
transportMapKeyword: '',
transportMapLoading: false,
transportMapStatus: '可搜索地址或点击地图选点',
transportMapSelected: {},
transportMapInstance: null,
transportMapMarker: null,
transportMapGeocoder: null,
commonCargoBox: false,
commonCargoLoading: false,
commonCargoQuery: {},
commonCargoRows: [],
commonCargoSelected: [],
commonCargoPage: { pageSize: 10, currentPage: 1, total: 0 },
cargoImportBox: false,
cargoImportLoading: false,
cargoImportRows: [],
shippingTemplateProcessConfigBox: false,
shippingTemplateProcessConfigLoading: false,
shippingTemplateProcessConfigRows: [],
hasProjectProcessConfig: false,
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return { addBtn: this.canCreate };
},
isAdmin() {
return (
String(this.userInfo?.authority || '').includes('admin') ||
this.userInfo?.authority?.includes?.('admin')
);
},
canCreate() {
return this.hasPermission(`${this.config.permission}_add`);
},
isStandaloneBusinessPage() {
return (
this.standaloneFormPage &&
['/business/shipping-template', '/business/shipping-template/form'].includes(
this.$route.path
)
);
},
isStandaloneFormPage() {
return this.isStandaloneBusinessPage && ['add', 'edit'].includes(this.$route.query.mode);
},
formPageTitle() {
return this.isStandaloneFormPage
? this.$route.query.name ||
`${this.$route.query.mode === 'edit' ? '编辑' : '新增'}${this.config.title}`
: '';
},
crudContainer() {
return this.isStandaloneFormPage ? 'PageAvueForm' : 'avue-crud';
},
pageFormOption() {
if (!this.isStandaloneFormPage) return this.option;
return this.buildFormGroupOption({
...this.option,
boxType: this.$route.query.mode === 'edit' ? 'edit' : 'add',
menuBtn: true,
submitBtn: true,
emptyBtn: false,
dialogCustomClass: 'shipping-template-form-page-dialog',
});
},
transportMode() {
return this.resolveTransportMode(this.form.transportType);
},
transportStationMode() {
return ['water', 'rail', 'air'].includes(this.transportMode);
},
transportStationTypeLabel() {
return { water: '港口/码头', rail: '铁路车站', air: '空港机场' }[this.transportMode] || '';
},
transportAddressSelectDisabled() {
return this.dialogReadonly || !this.form.transportType;
},
cargoTypeCascaderProps() {
return { label: 'cargoName', value: 'id', children: 'children', emitPath: true };
},
cargoQuantityTotal() {
const total = this.transportCargoRows.reduce(
(sum, row) => sum + (Number(row.quantity) || 0),
0
);
return total ? String(Number(total.toFixed(3))) : '系统自动计算';
},
freightVisible() {
return (
this.config.enableShippingTemplateFreight === true && this.form.templateType === '运单'
);
},
currencyLabel() {
const option = this.currencyOptions.find(
item =>
String(item.value).toUpperCase() ===
String(this.shippingTemplateFreight.currency || '').toUpperCase()
);
return this.currencyName(option?.label || this.shippingTemplateFreight.currency);
},
roadFreightTotal() {
const total = this.shippingTemplateFreight.freightItems.reduce(
(sum, item) => sum + Number(this.freightAmount(item) || 0),
0
);
return this.formatFreightNumber(total);
},
freightTotal() {
const base =
this.transportMode === 'road'
? Number(this.roadFreightTotal || 0)
: Number(this.shippingTemplateFreight.freightAmount || 0);
return this.formatFreightNumber(
base + Number(this.shippingTemplateFreight.otherFreightAmount || 0)
);
},
footerFreightTotal() {
return Number(this.freightTotal || 0).toFixed(2);
},
detailGoodsRows() {
return this.parseJsonArray(this.detailRow.goodsJson);
},
detailCargoQuantityTotal() {
const total = this.detailGoodsRows.reduce(
(sum, row) => sum + (Number(row.quantity) || 0),
0
);
return total ? String(Number(total.toFixed(3))) : '系统自动计算';
},
detailAttachmentRows() {
return this.parseJsonArray(this.detailRow.attachmentsJson);
},
detailFreight() {
return this.parseJsonObject(this.detailRow.freightJson);
},
detailFreightVisible() {
return (
this.config.enableShippingTemplateFreight === true &&
this.detailRow.templateType === '运单' &&
Boolean(this.detailFreight.currency)
);
},
detailTransportMode() {
return this.resolveTransportMode(this.detailRow.transportType);
},
detailFreightItems() {
return Array.isArray(this.detailFreight.freightItems)
? this.detailFreight.freightItems
: [];
},
detailFreightTotal() {
const storedTotal = Number(this.detailFreight.totalFreightAmount);
if (
this.detailFreight.totalFreightAmount !== undefined &&
this.detailFreight.totalFreightAmount !== null &&
this.detailFreight.totalFreightAmount !== '' &&
Number.isFinite(storedTotal)
)
return this.formatFreightNumber(storedTotal);
const base =
this.detailTransportMode === 'road'
? this.detailFreightItems.reduce(
(sum, item) => sum + (Number(item.freightAmount) || 0),
0
)
: Number(this.detailFreight.freightAmount || 0);
return this.formatFreightNumber(base + Number(this.detailFreight.otherFreightAmount || 0));
},
},
watch: {
$route(to, from) {
if (
this.isStandaloneFormPage &&
(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) this.openDetail({ id });
},
},
'form.transportType'(value, oldValue) {
if (value !== oldValue) {
this.handleTransportTypeChange(value);
this.syncFreightItems();
}
},
},
created() {
this.loadProjectOptions();
this.ensureCargoTypeOptions();
this.loadFeeUnitOptions();
this.loadCurrencyOptions();
if (this.isStandaloneFormPage) this.$nextTick(() => this.initStandaloneFormPage());
},
methods: {
buildOption(option) {
const next = { ...option, column: (option.column || []).map(column => ({ ...column })) };
if (this.menuWidth != null) next.menuWidth = this.menuWidth;
if (
this.standaloneFormPage &&
['/business/shipping-template', '/business/shipping-template/form'].includes(
this.$route.path
)
) {
next.dialogAppendToBody = false;
next.dialogModal = false;
next.dialogCustomClass = 'shipping-template-form-page-dialog';
next.dialogTop = '0';
next.dialogWidth = '100%';
} else {
next.dialogCustomClass = next.dialogCustomClass || 'shipping-template-dialog';
}
return next;
},
buildFormGroupOption(option) {
const titles = [
'templateInfoTitle',
'basicInfoTitle',
'shippingInfoTitle',
'goodsInfoTitle',
'attachmentTitle',
];
const groups = [];
let current = [];
[...(option.column || [])]
.sort((a, b) => (b.order || 0) - (a.order || 0))
.forEach(column => {
if (titles.includes(column.prop) && current.length) {
groups.push(current);
current = [];
}
current.push(column);
});
if (current.length) groups.push(current);
if (groups.length <= 1) return option;
const [mainColumn, ...groupColumns] = groups;
return {
...option,
column: mainColumn,
group: groupColumns.map((column, index) => {
const title = column.find(item => titles.includes(item.prop));
return {
prop: title?.prop || `shippingTemplateGroup${index + 1}`,
label: '',
arrow: false,
column: title ? column.filter(item => item.prop !== title.prop) : column,
};
}),
};
},
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
hasAction(action) {
return (this.config.actions || []).includes(action);
},
canEdit(row) {
return this.hasPermission(`${this.config.permission}_edit`) && !row.readonly;
},
canDelete(row) {
return this.hasPermission(`${this.config.permission}_delete`) && !row.readonly;
},
canCopy(row) {
return (
this.hasAction('copy') &&
this.hasPermission(`${this.config.permission}_copy`) &&
!row.readonly
);
},
customOperations(row) {
return (this.config.operations || []).filter(
operation =>
this.hasPermission(
operation.permission || `${this.config.permission}_${operation.action}`
) && !row.readonly
);
},
openBusinessForm(mode, row = {}) {
if (this.isStandaloneBusinessPage) {
this.$router.push({
path: '/business/shipping-template/form',
query: {
mode,
...(mode === 'edit' && row.id ? { id: row.id } : {}),
name: `${mode === 'edit' ? '编辑' : '新增'}${this.config.title}`,
},
});
return;
}
this.$refs.crud?.[mode === 'edit' ? 'rowEdit' : 'rowAdd'](row);
},
initStandaloneFormPage() {
if (!this.isStandaloneFormPage) return;
const mode = this.$route.query.mode;
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);
},
parseJsonArray(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const result = JSON.parse(value);
return Array.isArray(result) ? result : [];
} catch {
return [];
}
},
parseJsonObject(value) {
if (!value) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
try {
const result = JSON.parse(value);
return result && typeof result === 'object' && !Array.isArray(result) ? result : {};
} catch {
return {};
}
},
normalizeSearch(params = {}) {
return Object.keys(params).reduce((result, key) => {
const value = params[key];
if (value !== undefined && value !== null && value !== '') result[key] = value;
return result;
}, {});
},
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
searchChange(params, done) {
this.query = this.normalizeSearch(params);
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
done?.();
},
selectionChange(list) {
this.selectionList = list || [];
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
this.onLoad(this.page, this.query);
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page, params = {}) {
this.loading = true;
this.api
.getList(page.currentPage, page.pageSize, { ...this.query, ...params })
.then(res => {
const data = res?.data?.data || {};
this.data = extractRecords(res);
this.page.total = data.total || this.data.length;
})
.finally(() => {
this.loading = false;
});
},
handleExport() {
if (!this.config.exportUrl) return;
exportBlob(this.config.exportUrl, this.normalizeSearch(this.query)).then(res =>
downloadXls(res.data, `${this.config.title}导出.xlsx`)
);
},
handleDelete() {
if (!this.selectionList.length) return this.$message.warning('请选择至少一条数据');
this.$confirm('确定将选择数据删除?', '提示', { type: 'warning' })
.then(() => this.api.remove(this.selectionList.map(row => row.id).join(',')))
.then(() => {
this.$message.success('删除成功');
this.onLoad(this.page, this.query);
});
},
rowDel(row) {
this.$confirm('确定将选择数据删除?', '提示', { type: 'warning' })
.then(() => this.api.remove(row.id))
.then(() => {
this.$message.success('删除成功');
this.onLoad(this.page, this.query);
});
},
rowSave(row, done, loading) {
const submit = this.normalizeRow(row);
if (!submit) {
loading?.();
return;
}
this.api
.submit(submit)
.then(() => {
this.$message.success('操作成功');
done?.();
this.closeCrudDialog();
this.onLoad(this.page, this.query);
})
.catch(() => loading?.());
},
rowUpdate(row, index, done, loading) {
const submit = this.normalizeRow(row);
if (!submit) {
loading?.();
return;
}
this.api
.submit(submit)
.then(() => {
this.$message.success('操作成功');
done?.();
this.closeCrudDialog();
this.onLoad(this.page, this.query);
})
.catch(() => loading?.());
},
beforeOpen(done, type) {
this.crudDialogType = type;
this.dialogReadonly = type === 'view';
if (type === 'add') {
this.form = { ...(this.config.defaultForm || {}) };
this.selectedProjectId = '';
this.contractOptions = [];
this.transportCargoRows = [defaultCargo()];
this.attachmentRows = [];
this.shippingTemplateFreight = defaultFreight();
this.loadCurrencyOptions();
if (typeof this.api.nextCode === 'function')
this.api
.nextCode()
.then(res => {
this.form.templateCode = res?.data?.data || '';
})
.finally(() => done?.());
else done?.();
return;
}
if (['edit', 'view'].includes(type)) {
this.api
.getDetail(this.form.id)
.then(res => {
this.applyFormDetail(res?.data?.data || {});
done?.();
})
.catch(() => done?.());
return;
}
done?.();
},
applyFormDetail(row) {
this.form = { ...(row || {}) };
this.selectedProjectId = this.form.projectId || '';
this.transportCargoRows = this.parseJsonArray(this.form.goodsJson).map(item =>
this.normalizeCargo(item)
);
if (!this.transportCargoRows.length) this.transportCargoRows = [defaultCargo()];
this.attachmentRows = this.parseJsonArray(this.form.attachmentsJson);
this.shippingTemplateFreight = {
...defaultFreight(),
...this.parseJsonObject(this.form.freightJson),
};
this.setFreightCurrencyFromContract();
this.syncFreightItems();
this.syncCurrentProjectOption();
this.syncCurrentContractOption();
if (this.form.projectId) this.loadContractOptionsForProject();
this.loadProjectProcessConfigState();
},
normalizeRow(row) {
const submit = { ...row };
const required = this.config.transportFormRequiredFields || [
['projectName', '项目'],
['contractName', '客户合同'],
['transportType', '运输方式'],
['departureAddress', '发货地址'],
['arrivalAddress', '收货地址'],
];
const missing = required.find(([prop]) => !String(submit[prop] || '').trim());
if (missing) {
this.$message.warning(`请选择或输入${missing[1]}`);
return null;
}
if (
!this.validateMobileFields([
[submit.departurePhone, '发货联系方式'],
[submit.arrivalPhone, '收货联系方式'],
])
)
return null;
if (!this.validateCargoRows()) return null;
if (this.freightVisible && !this.validateFreight()) return null;
submit.goodsJson = JSON.stringify(
this.transportCargoRows.map(item => this.normalizeCargo(item))
);
submit.freightJson = this.freightVisible ? this.serializeFreight() : '';
submit.attachmentsJson = JSON.stringify(this.attachmentRows);
[
'templateInfoTitle',
'basicInfoTitle',
'shippingInfoTitle',
'goodsInfoTitle',
'attachmentTitle',
].forEach(prop => delete submit[prop]);
Object.keys(submit).forEach(key => {
if (typeof submit[key] === 'string') submit[key] = submit[key].trim();
});
return submit;
},
validateMobileFields(fields) {
const invalid = fields.find(([value]) => value && !isMobile(value));
if (invalid) {
this.$message.warning(`${invalid[1]}格式不正确`);
return false;
}
return true;
},
validateCargoRows() {
if (!this.transportCargoRows.length) {
this.$message.warning('请至少添加一条货物信息');
return false;
}
for (const [index, row] of this.transportCargoRows.entries()) {
if (!row.cargoType || !row.cargoName || !row.quantity || !row.quantityUnit) {
this.$message.warning(`第${index + 1}行货物信息不完整`);
return false;
}
if (Number(row.quantity) <= 0) {
this.$message.warning(`第${index + 1}行数量必须大于0`);
return false;
}
if (String(row.remark || '').length > 200) {
this.$message.warning(`第${index + 1}行备注不能超过200个字符`);
return false;
}
}
return true;
},
validateFreight() {
if (!this.shippingTemplateFreight.currency) {
this.$message.warning('请选择币种');
return false;
}
if (this.transportMode === 'road') {
const invalid = this.shippingTemplateFreight.freightItems.findIndex(
item =>
Number(item.unitPrice || 0) < 0 ||
!item.priceUnit ||
(!this.isFreightAutoCalculable(item) &&
(item.freightAmount === '' ||
item.freightAmount === null ||
item.freightAmount === undefined)) ||
Number(item.freightAmount || 0) < 0
);
if (invalid > -1) {
this.$message.warning(`第${invalid + 1}条货物的单价、计价单位或运费无效`);
return false;
}
} else if (Number(this.shippingTemplateFreight.freightAmount || 0) < 0) {
this.$message.warning('运费不能小于0');
return false;
}
if (Number(this.shippingTemplateFreight.otherFreightAmount || 0) < 0) {
this.$message.warning('其他运费合计不能小于0');
return false;
}
return true;
},
validateFormText() {
return true;
},
closeCrudDialog() {
if (this.isStandaloneBusinessPage) {
this.$router.push({ path: '/business/shipping-template', query: {} });
return;
}
this.$refs.crud?.closeDialog?.();
},
openDetail(row = {}) {
if (!row.id) return;
this.detailBox = true;
this.detailLoading = true;
const request =
typeof this.api.getDetail === 'function'
? this.api.getDetail(row.id)
: Promise.resolve({ data: { data: row } });
request
.then(res => {
this.detailRow = res?.data?.data || res?.data || row;
this.attachmentRows = this.parseJsonArray(this.detailRow.attachmentsJson);
})
.finally(() => {
this.detailLoading = false;
});
},
handleCopy(row) {
if (typeof this.api.copy !== 'function') return;
this.api.copy(row.id).then(() => {
this.$message.success('复制成功');
this.onLoad(this.page, this.query);
});
},
handleCustomOperation(operation, row) {
if (operation.action === 'createFromTemplate') {
const loadTemplate =
typeof this.api.getDetail === 'function'
? this.api.getDetail(row.id).then(res => res?.data?.data || row)
: Promise.resolve(row);
loadTemplate.then(template => {
const templateType = template.templateType || template.templateTypeName;
const listPath =
templateType === '运单' ? '/business/waybill-manage' : '/business/transport-plan';
sessionStorage.setItem(
'business-template-create',
JSON.stringify({ target: listPath.slice('/business/'.length), data: template })
);
this.$router.push({
path: `${listPath}/form`,
query: { mode: 'add', fromTemplate: Date.now().toString() },
});
});
}
},
loadProjectOptions() {
if (this.projectLoading) return;
this.projectLoading = true;
getProjectList(1, 9999, this.config.projectQueryParams || {})
.then(res => {
this.projectOptions = extractRecords(res).filter(
item => !this.config.excludeVoidedProjects || item.approvalStatus !== 'voided'
);
this.syncCurrentProjectOption();
})
.finally(() => {
this.projectLoading = false;
});
},
syncCurrentProjectOption() {
if (
this.form.projectId &&
this.form.projectName &&
!this.projectOptions.some(item => String(item.id) === String(this.form.projectId))
)
this.projectOptions.unshift({
id: this.form.projectId,
projectName: this.form.projectName,
});
},
handleProjectChange(projectId) {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
if (!project) {
this.form.projectId = '';
this.form.projectName = '';
this.form.contractId = '';
this.form.contractName = '';
this.contractOptions = [];
this.setFreightCurrencyFromContract();
return;
}
this.form.projectId = project.id;
this.form.projectName = project.projectName || '';
this.form.projectCode = project.projectCode || '';
this.form.contractId = '';
this.form.contractName = '';
this.setFreightCurrencyFromContract();
this.loadContractOptionsForProject(true);
this.loadProjectProcessConfigState();
},
loadContractOptionsForProject(autoPick = false) {
if (!this.form.projectId || this.contractLoading) return;
this.contractLoading = true;
getContractList(1, 9999, {
projectId: this.form.projectId,
...(this.config.contractQueryParams || {}),
})
.then(res => {
this.contractOptions = extractRecords(res);
this.syncCurrentContractOption();
this.syncFreightCurrencyFromContract();
if (autoPick && this.contractOptions.length) this.applyContract(this.contractOptions[0]);
})
.finally(() => {
this.contractLoading = false;
});
},
syncCurrentContractOption() {
if (
this.form.contractId &&
this.form.contractName &&
!this.contractOptions.some(item => String(item.id) === String(this.form.contractId))
)
this.contractOptions.unshift({
id: this.form.contractId,
contractName: this.form.contractName,
settlementCurrency: this.form.settlementCurrency || '',
});
},
handleContractChange(id) {
const contract = this.contractOptions.find(item => String(item.id) === String(id));
if (contract) this.applyContract(contract);
else this.setFreightCurrencyFromContract();
},
applyContract(contract) {
this.form.contractId = contract.id || '';
this.form.contractName = contract.contractName || '';
this.form.customerName = contract.customerName || this.form.customerName || '';
this.setFreightCurrencyFromContract(contract);
},
syncFreightCurrencyFromContract() {
const contract = this.contractOptions.find(
item => String(item.id) === String(this.form.contractId)
);
this.setFreightCurrencyFromContract(contract);
},
setFreightCurrencyFromContract(contract = null) {
const value =
contract?.settlementCurrency ||
contract?.settlementCurrencyCode ||
contract?.currency ||
this.form.settlementCurrency ||
'RMB';
this.shippingTemplateFreight.currency = this.normalizeCurrencyValue(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';
},
normalizeDictOptions(list = []) {
return list.map(item => ({
label: item.dictValue || item.label || item.value || '',
value: item.dictKey || item.value || item.dictValue || '',
remark: item.remark || '',
}));
},
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
);
},
loadFeeUnitOptions() {
getDictionary({ code: 'unit_fee' }).then(res => {
this.feeUnitOptions = this.normalizeDictOptions(res?.data?.data || []);
});
},
loadCurrencyOptions() {
if (this.currencyLoading || this.currencyOptions.length)
return Promise.resolve(this.currencyOptions);
this.currencyLoading = true;
return getSystemDictionary({ code: 'currency_type' })
.then(res => {
const names = {
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} - ${names[code] || item.label || item.value}`,
};
});
this.shippingTemplateFreight.currency = this.normalizeCurrencyValue(
this.shippingTemplateFreight.currency
);
return this.currencyOptions;
})
.finally(() => {
this.currencyLoading = false;
});
},
resolveTransportMode(value) {
const text = [value, this.form.transportTypeName, this.getTransportTypeLabel(value)]
.map(item => (typeof item === 'object' ? Object.values(item).join(' ') : item))
.filter(Boolean)
.join(' ')
.toLowerCase();
if (/公路|道路|汽车|road|highway|gl/.test(text)) return 'road';
if (/水路|水运|海运|港|water|ship|sea|sl/.test(text)) return 'water';
if (/铁路|rail|train|tl/.test(text)) return 'rail';
if (/航空|空运|空港|air|airport|hk/.test(text)) return 'air';
return '';
},
getTransportTypeLabel(value) {
const column = (this.option.column || []).find(item => item.prop === 'transportType');
const item = [...(column?.dicData || []), ...this.transportTypeOptions].find(
dic => String(dic.value ?? dic.dictKey) === String(value)
);
return item?.label || item?.dictValue || '';
},
handleTransportTypeChange(value) {
this.form.transportTypeName = this.getTransportTypeLabel(value);
},
openTransportPrimaryAddressPicker(target) {
if (this.transportStationMode) this.openTransportStationDialog(target);
},
handleTransportSecondaryAddressInputClick(target) {
if (this.transportStationMode || !this.config.editableRoadAddress)
this.openTransportSecondaryAddressPicker(target);
},
openTransportSecondaryAddressPicker(target) {
this.transportStationMode
? this.openTransportStationDialog(target)
: this.openTransportMapDialog(target);
},
openTransportRouteDialog() {
if (!this.form.transportType) return this.$message.warning('请先选择运输方式');
this.transportRoutePage.currentPage = 1;
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.handleTransportRouteSearch();
},
handleTransportRouteCurrentChange(page) {
this.transportRoutePage.currentPage = page;
this.loadTransportRouteList();
},
handleTransportRouteSizeChange(size) {
this.transportRoutePage.pageSize = size;
this.handleTransportRouteSearch();
},
selectTransportRouteRow(row) {
this.applyAddress('departure', {
addressName: row.departureName,
detailAddress: row.departureAddress,
contactName: row.departureContact,
contactPhone: row.departurePhone,
longitude: row.departureLongitude,
latitude: row.departureLatitude,
});
this.applyAddress('arrival', {
addressName: row.arrivalName,
detailAddress: row.arrivalAddress,
contactName: row.arrivalContact,
contactPhone: row.arrivalPhone,
longitude: row.arrivalLongitude,
latitude: row.arrivalLatitude,
});
this.transportRouteBox = false;
},
openTransportAddressPicker(target) {
if (!this.form.transportType) return this.$message.warning('请先选择运输方式');
this.transportAddressTarget = target;
this.transportAddressPage.currentPage = 1;
this.transportAddressBox = true;
},
loadTransportAddressList() {
this.transportAddressLoading = true;
getCommonAddressList(
this.transportAddressPage.currentPage,
this.transportAddressPage.pageSize,
{ ...this.transportAddressQuery, addressType: this.getFixedAddressType(), 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.transportAddressQuery = {};
this.handleTransportAddressSearch();
},
handleTransportAddressCurrentChange(page) {
this.transportAddressPage.currentPage = page;
this.loadTransportAddressList();
},
handleTransportAddressSizeChange(size) {
this.transportAddressPage.pageSize = size;
this.handleTransportAddressSearch();
},
getFixedAddressType() {
if (!this.config.fixedTransportAddressType) return '';
return (
{ road: '常规地址', rail: '铁路车站', water: '港口/码头', air: '空港机场' }[
this.transportMode
] || ''
);
},
selectTransportAddressRow(row) {
this.applyAddress(this.transportAddressTarget, row);
this.transportAddressBox = false;
},
applyAddress(target, address = {}) {
const prefix = target === 'departure' ? 'departure' : 'arrival';
this.form[`${prefix}Name`] = address.regionName || address.addressName || '';
this.form[`${prefix}Address`] = address.detailAddress || address.address || '';
this.form[`${prefix}AddressCode`] = address.addressCode || '';
this.form[`${prefix}SiteCode`] = address.siteCode || '';
this.form[`${prefix}Longitude`] = address.longitude || '';
this.form[`${prefix}Latitude`] = address.latitude || '';
this.form[`${prefix}Contact`] = address.contactName || this.form[`${prefix}Contact`] || '';
this.form[`${prefix}Phone`] = address.contactPhone || this.form[`${prefix}Phone`] || '';
},
openTransportStationDialog(target) {
if (!this.form.transportType) return this.$message.warning('请先选择运输方式');
this.transportStationTarget = target;
this.transportStationPage.currentPage = 1;
this.transportStationBox = true;
},
loadTransportStationList() {
const request = {
water: getPortTerminalList,
rail: getRailwayStationList,
air: getAirportMasterList,
}[this.transportMode];
if (!request) return;
this.transportStationLoading = true;
request(
this.transportStationPage.currentPage,
this.transportStationPage.pageSize,
this.transportStationQuery
)
.then(res => {
const data = res?.data?.data || {};
this.transportStationRows = (data.records || []).map(row => ({
...row,
stationName: row.name || row.stationName || row.addressName,
stationCode: row.code || row.siteCode,
detailAddress: row.detailAddress || row.address,
regionName: [row.provinceName, row.cityName, row.districtName].filter(Boolean).join(''),
}));
this.transportStationPage.total = data.total || 0;
})
.finally(() => {
this.transportStationLoading = false;
});
},
handleTransportStationSearch() {
this.transportStationPage.currentPage = 1;
this.loadTransportStationList();
},
handleTransportStationCurrentChange(page) {
this.transportStationPage.currentPage = page;
this.loadTransportStationList();
},
handleTransportStationSizeChange(size) {
this.transportStationPage.pageSize = size;
this.handleTransportStationSearch();
},
selectTransportStationRow(row) {
this.applyAddress(this.transportStationTarget, {
addressName: row.stationName,
detailAddress: row.detailAddress,
regionName: row.regionName,
siteCode: row.stationCode,
longitude: row.longitude,
latitude: row.latitude,
});
this.transportStationBox = false;
},
openTransportMapDialog(target) {
if (this.dialogReadonly || !this.form.transportType) return;
this.transportMapTarget = target;
this.transportMapKeyword = this.form[`${target}Address`] || '';
this.transportMapBox = 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;
},
initTransportMap() {
this.loadAmap()
.then(() => {
this.$nextTick(() => {
if (!this.transportMapInstance) {
this.transportMapInstance = new window.AMap.Map(this.$refs.transportMap, {
center: [116.40769, 39.89945],
zoom: 11,
});
this.transportMapInstance.on('click', event => this.pickMapPoint(event.lnglat));
} else this.transportMapInstance.resize();
});
})
.catch(() => {
this.$message.error('高德地图组件加载失败');
this.transportMapBox = false;
});
},
searchTransportMapKeyword() {
if (!this.transportMapKeyword) return this.$message.warning('请输入地址关键词');
this.loadAmap().then(() => {
this.transportMapLoading = true;
window.AMap.plugin('AMap.Geocoder', () => {
this.transportMapGeocoder = this.transportMapGeocoder || new window.AMap.Geocoder();
this.transportMapGeocoder.getLocation(this.transportMapKeyword, (status, result) => {
this.transportMapLoading = false;
const location = status === 'complete' && result.geocodes?.[0]?.location;
if (location) this.pickMapPoint(location, this.transportMapKeyword);
else this.$message.warning('地图搜索无匹配地址');
});
});
});
},
pickMapPoint(point, keyword = '') {
const longitude = point.getLng ? point.getLng() : point.lng;
const latitude = point.getLat ? point.getLat() : point.lat;
if (longitude === undefined || latitude === undefined) return;
this.transportMapSelected = {
longitude: Number(longitude).toFixed(6),
latitude: Number(latitude).toFixed(6),
detailAddress: keyword || this.transportMapKeyword,
};
this.transportMapStatus = '已选点,可确认回填';
if (this.transportMapInstance) {
this.transportMapMarker?.setMap(null);
this.transportMapMarker = new window.AMap.Marker({ position: [longitude, latitude] });
this.transportMapMarker.setMap(this.transportMapInstance);
this.transportMapInstance.setCenter([longitude, latitude]);
}
},
confirmTransportMapPick() {
const prefix = this.transportMapTarget;
this.applyAddress(prefix, this.transportMapSelected);
this.transportMapBox = false;
},
ensureCargoTypeOptions() {
if (this.cargoTypeRequest) return this.cargoTypeRequest;
if (this.cargoTypeOptions.length) return Promise.resolve(this.cargoTypeOptions);
this.cargoTypeLoading = true;
this.cargoTypeRequest = getCargoTypeList(1, 9999)
.then(res => {
this.cargoTypeOptions = this.buildCargoTree(extractRecords(res));
this.cargoTypeFlatOptions = this.flattenCargo(this.cargoTypeOptions);
return this.cargoTypeOptions;
})
.finally(() => {
this.cargoTypeLoading = false;
this.cargoTypeRequest = null;
});
return this.cargoTypeRequest;
},
buildCargoTree(list = []) {
const flat = [];
const flatten = (items, parentId = '') => {
(items || []).forEach(item => {
const id = String(item.id ?? item.cargoCode ?? item.code);
flat.push({ ...item, id, parentId: item.parentId ?? parentId, children: [] });
flatten(item.children, id);
});
};
flatten(list);
const nodes = flat.map(item => ({
...item,
cargoName: item.cargoName || item.name || item.label,
cargoCode: item.cargoCode || item.code || '',
}));
const map = new Map(nodes.map(item => [item.id, item]));
const roots = [];
nodes.forEach(node => {
const parent = map.get(String(node.parentId || node.parentCode || ''));
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
});
const normalize = (node, parentPath = []) => {
const path = [...parentPath, node.id];
return {
...node,
path,
leaf: !node.children.length,
children: node.children.length
? node.children.map(child => normalize(child, path))
: undefined,
};
};
return roots.map(node => normalize(node));
},
flattenCargo(options, result = []) {
options.forEach(item => {
result.push(item);
if (item.children) this.flattenCargo(item.children, result);
});
return result;
},
cargoPath(path) {
return Array.isArray(path) ? path.slice(0, 2).map(String) : [];
},
normalizeCargo(row = {}) {
return {
...defaultCargo(),
...row,
cargoTypePath: this.cargoPath(row.cargoTypePath),
cargoType: row.cargoType || row.secondCargoTypeName || row.firstCargoTypeName || '',
cargoTypeCode: row.cargoTypeCode || row.secondCargoTypeCode || row.firstCargoTypeCode || '',
cargoName: row.cargoName || row.goodsName || '',
};
},
handleCargoTypeChange(row, value) {
const path = this.cargoPath(value);
const item = this.cargoTypeFlatOptions.find(
option => option.path?.join('/') === path.join('/')
);
const labels = path
.map(id => this.cargoTypeFlatOptions.find(option => option.id === id)?.cargoName)
.filter(Boolean);
row.cargoTypePath = path;
row.cargoType = labels.at(-1) || '';
row.cargoTypeCode = item?.cargoCode || '';
row.cargoName = '';
},
fetchCargoSuggestions(query, callback, row) {
getCommonCargoList(1, 20, {
cargoName: query,
...(row.cargoTypeCode ? { secondCargoTypeCode: row.cargoTypeCode } : {}),
allDept: 0,
})
.then(res =>
callback(extractRecords(res).map(item => ({ ...item, value: item.cargoName })))
)
.catch(() => callback([]));
},
selectCargoSuggestion(row, item) {
Object.assign(row, this.normalizeCargo(item));
row.value = undefined;
this.syncFreightItems();
},
handleCargoQuantityInput(row, value) {
row.quantity = String(value || '')
.replace(/[^\d.]/g, '')
.replace(/(\.\d{0,3}).*/, '$1');
},
addCargoRow(index = -1, row = {}) {
const next = this.normalizeCargo(row);
if (index >= 0) this.transportCargoRows.splice(index + 1, 0, next);
else if (
this.transportCargoRows.length === 1 &&
!this.transportCargoRows[0].cargoName &&
!this.transportCargoRows[0].cargoType
)
this.transportCargoRows.splice(0, 1, next);
else this.transportCargoRows.push(next);
this.syncFreightItems();
},
removeCargoRow(index) {
this.transportCargoRows.splice(index, 1);
if (!this.transportCargoRows.length) this.transportCargoRows.push(defaultCargo());
this.syncFreightItems();
},
cargoQuantity(quantityUnit) {
const unit = String(quantityUnit || '');
const total = this.transportCargoRows.reduce((sum, row) => {
if (String(row.quantityUnit || '') !== unit) return sum;
return sum + (Number(row.quantity) || 0);
}, 0);
return this.formatFreightNumber(total);
},
handleFreightNumberInput(row, prop, value) {
row[prop] = String(value || '')
.replace(/[^\d.]/g, '')
.replace(/(\.\d{0,2}).*/, '$1');
},
handleFreightAmountInput(item, value) {
this.handleFreightNumberInput(item, 'freightAmount', value);
item.freightAmountManual = true;
},
syncFreightItems() {
if (this.transportMode !== 'road') return;
const previousItems = Array.isArray(this.shippingTemplateFreight.freightItems)
? this.shippingTemplateFreight.freightItems
: [];
const quantityUnits = [];
this.transportCargoRows.forEach(row => {
const quantityUnit = row.quantityUnit || '';
if (!quantityUnits.some(unit => unit === quantityUnit)) quantityUnits.push(quantityUnit);
});
this.shippingTemplateFreight.freightItems = quantityUnits.map((quantityUnit, index) => {
const matched =
previousItems.find(item => String(item.quantityUnit || '') === String(quantityUnit)) ||
previousItems[index] ||
{};
return {
quantityUnit,
unitPrice: matched.unitPrice || '',
priceUnit: matched.priceUnit || '',
freightAmount: matched.freightAmount ?? '',
freightAmountManual:
matched.freightAmountManual === true || matched.manualFreightAmount === true,
};
});
},
quantityUnits() {
return [
...new Set(
this.transportCargoRows
.map(row => String(row.quantityUnit || '').trim())
.filter(Boolean)
),
];
},
priceUnitMatchesQuantity(priceUnit, quantityUnit) {
const normalizeUnit = value =>
String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
.replace(/立方米|立方公尺|方/g, '立方');
const unit = normalizeUnit(quantityUnit).replace(/[·*.]/g, '');
const value = String(priceUnit || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '');
if (!unit || !value) return false;
const parts = value.split('/');
if (parts.length !== 2) return false;
const moneyUnit = part => /元|人民币|rmb|cny|\$|美元|usd/.test(part);
const quantityPart = part => normalizeUnit(part).replace(/[·*.]/g, '');
return (
(quantityPart(parts[0]) === unit && moneyUnit(parts[1])) ||
(quantityPart(parts[1]) === unit && moneyUnit(parts[0]))
);
},
isFreightAutoCalculable(item = {}) {
const units = this.quantityUnits();
return (
units.length === 1 &&
String(item.quantityUnit || '').trim() === units[0] &&
this.priceUnitMatchesQuantity(item.priceUnit, units[0]) &&
item.unitPrice !== '' &&
item.unitPrice !== null &&
item.unitPrice !== undefined &&
Number.isFinite(Number(item.unitPrice)) &&
Number(item.unitPrice) >= 0
);
},
freightAmount(item) {
if (item.freightAmountManual && item.freightAmount !== '') {
return this.formatFreightNumber(item.freightAmount);
}
if (!this.isFreightAutoCalculable(item)) {
return this.formatFreightNumber(item.freightAmount);
}
return this.formatFreightNumber(
Number(item.unitPrice || 0) * Number(this.cargoQuantity(item.quantityUnit) || 0)
);
},
formatFreightNumber(value) {
const number = Number(value || 0);
if (!Number.isFinite(number)) return '';
return Number(number.toFixed(2)).toString();
},
serializeFreight() {
const freight = {
currency: this.shippingTemplateFreight.currency,
otherFreightAmount: this.shippingTemplateFreight.otherFreightAmount,
};
if (this.transportMode === 'road') {
freight.totalFreightAmount = this.roadFreightTotal;
freight.freightItems = this.shippingTemplateFreight.freightItems.map(item => ({
...item,
quantity: this.cargoQuantity(item.quantityUnit),
freightAmount: this.freightAmount(item),
manualFreightAmount: item.freightAmountManual === true,
}));
} else {
freight.quantity = this.cargoQuantityTotal;
freight.freightAmount = this.shippingTemplateFreight.freightAmount;
}
freight.totalFreightAmount = this.freightTotal;
return JSON.stringify(freight);
},
openCommonCargoDialog() {
this.commonCargoSelected = [];
this.commonCargoPage.currentPage = 1;
this.commonCargoBox = true;
},
loadCommonCargoList() {
this.commonCargoLoading = true;
getCommonCargoList(this.commonCargoPage.currentPage, this.commonCargoPage.pageSize, {
...this.normalizeSearch(this.commonCargoQuery),
allDept: 0,
})
.then(res => {
const data = res?.data?.data || {};
this.commonCargoRows = data.records || [];
this.commonCargoPage.total = data.total || 0;
})
.finally(() => {
this.commonCargoLoading = false;
});
},
handleCommonCargoSearch() {
this.commonCargoPage.currentPage = 1;
this.loadCommonCargoList();
},
handleCommonCargoReset() {
this.commonCargoQuery = {};
this.handleCommonCargoSearch();
},
handleCommonCargoCurrentChange(page) {
this.commonCargoPage.currentPage = page;
this.loadCommonCargoList();
},
handleCommonCargoSizeChange(size) {
this.commonCargoPage.pageSize = size;
this.handleCommonCargoSearch();
},
handleCommonCargoSelectionChange(rows) {
this.commonCargoSelected = rows || [];
},
selectCommonCargoRow(row) {
this.addCargoRow(-1, row);
this.commonCargoBox = false;
},
confirmCommonCargoSelection() {
this.commonCargoSelected.forEach(row => this.addCargoRow(-1, row));
this.commonCargoBox = false;
},
handleCargoImportFileChange(file) {
const raw = file.raw;
if (!raw || !/\.(xls|xlsx)$/i.test(raw.name || ''))
return this.$message.warning('请上传 Excel 文件');
this.cargoImportLoading = true;
importBlob('/blade-transport/shipping-template/import-goods', raw)
.then(async res => {
const type = res.headers?.['content-type'] || res.data?.type || '';
if (type.includes('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);
this.cargoImportRows = result.data || [];
})
.catch(() => this.$message.error('货物导入失败,请检查文件格式'))
.finally(() => {
this.cargoImportLoading = false;
});
},
confirmCargoImport() {
this.cargoImportRows.forEach(row => this.addCargoRow(-1, row));
this.closeCargoImportDialog();
},
closeCargoImportDialog() {
if (!this.cargoImportLoading) {
this.cargoImportBox = false;
this.cargoImportRows = [];
}
},
handleCargoImportTemplate() {
exportBlob('/blade-transport/shipping-template/export-goods-template').then(res =>
downloadXls(res.data, '发货模板货物导入模板.xlsx')
);
},
handleAttachmentChange(list) {
const name = this.userInfo?.realName || this.userInfo?.userName || '';
const time = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.attachmentRows = (list || []).map(item => ({
...item,
description: item.description || '',
uploadUserName: item.uploadUserName || name,
uploadTime: item.uploadTime || time,
}));
this.form.attachmentsJson = JSON.stringify(this.attachmentRows);
},
handleAttachmentSelectionChange(rows) {
this.selectedAttachmentRows = rows || [];
},
removeAttachment(index) {
this.attachmentRows.splice(index, 1);
this.form.attachmentsJson = JSON.stringify(this.attachmentRows);
},
attachmentName(row = {}) {
return row.originalName || row.name || row.fileName || '附件';
},
attachmentUrl(row = {}) {
return row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || '';
},
attachmentExtension(row) {
const source = this.attachmentName(row) || this.attachmentUrl(row);
return source.split('?')[0].split('.').pop().toLowerCase();
},
formatFileSize(size) {
const value = Number(size);
if (!value) return '';
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / 1024 / 1024).toFixed(1)}MB`;
},
previewAttachment(row) {
const url = this.attachmentUrl(row);
if (!url) return this.$message.warning('附件地址为空');
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(this.attachmentExtension(row))) {
this.attachmentImagePreviewUrls = this.attachmentRows
.filter(item =>
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(this.attachmentExtension(item))
)
.map(item => this.attachmentUrl(item));
this.attachmentImagePreviewIndex = Math.max(
this.attachmentImagePreviewUrls.indexOf(url),
0
);
this.attachmentImagePreviewVisible = true;
} else {
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) downloadFileByUrl(url, this.attachmentName(row));
},
handleBatchDownload() {
(this.selectedAttachmentRows.length
? this.selectedAttachmentRows
: this.attachmentRows
).forEach(row => this.downloadAttachment(row));
},
handleDetailBatchDownload() {
this.detailAttachmentRows.forEach(row => this.downloadAttachment(row));
},
openShippingTemplateProcessConfig() {
if (!this.form.projectId) return;
this.shippingTemplateProcessConfigBox = true;
this.shippingTemplateProcessConfigLoading = true;
this.getProjectProcessConfigRows(this.form.projectId)
.then(rows =>
Promise.all(
rows.map(row =>
getProcessConfigDetail(row.id)
.then(res => ({ ...row, ...(res?.data?.data || {}) }))
.catch(() => row)
)
)
)
.then(rows => {
this.shippingTemplateProcessConfigRows = rows;
})
.finally(() => {
this.shippingTemplateProcessConfigLoading = false;
});
},
getProjectProcessConfigRows(projectId) {
return getProcessConfigList(1, 100, { projectIds: projectId, status: 1 }).then(res =>
extractRecords(res).filter(row =>
String(row.projectIds || '')
.split(',')
.includes(String(projectId))
)
);
},
loadProjectProcessConfigState() {
if (!this.form.projectId) {
this.hasProjectProcessConfig = false;
return;
}
this.getProjectProcessConfigRows(this.form.projectId)
.then(rows => {
this.hasProjectProcessConfig = rows.length > 0;
})
.catch(() => {
this.hasProjectProcessConfig = false;
});
},
getProcessConfigEnabledNodes(row = {}) {
const parsed = this.parseJsonArray(row.nodeConfigJson);
const value = parsed.length ? parsed : this.parseJsonObject(row.nodeConfigJson);
const nodes = Array.isArray(value) ? value : value.nodes || [];
return nodes.filter(node => node.enabled !== false);
},
processConfigTimelineDescriptions(node = {}) {
return [`打卡操作:${node.punch ? '是' : '否'}`, `打卡定位:${node.location ? '是' : '否'}`];
},
},
};
</script>
<style lang="scss" scoped>
.shipping-template-page__field {
width: 100%;
}
.dialog-section-title {
display: flex;
align-items: center;
width: 100%;
min-height: 20px;
box-sizing: border-box;
gap: 0;
color: #303133;
font-size: 14px;
font-weight: 600;
margin: 8px 0 16px;
&::before {
margin-right: 8px;
}
}
.dialog-section-title--action {
justify-content: space-between;
}
.shipping-template-page__route-row {
display: grid;
grid-template-columns:
minmax(160px, 1fr) minmax(220px, 2fr) auto minmax(140px, 1fr) auto minmax(160px, 1fr)
auto;
gap: 8px;
align-items: center;
}
.shipping-template-page__section-actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.shipping-template-page__cargo-wrap {
width: 100%;
overflow-x: auto;
}
.shipping-template-page__cargo-total {
display: flex;
justify-content: flex-start;
padding: 12px 0;
color: #303133;
font-weight: 600;
}
.shipping-template-page__freight-section {
margin-top: 8px;
border-top: 1px solid #eff1f7;
padding-top: 12px;
}
.shipping-template-page__freight-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 24px;
row-gap: 16px;
width: 100%;
:deep(.el-form-item) {
min-width: 0;
margin-bottom: 0;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-input),
:deep(.el-select) {
width: 100%;
}
&--road {
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 56px;
:deep(.shipping-template-page__append-select) {
width: 112px;
}
}
}
.shipping-template-page__attachment-head {
margin-bottom: 8px;
}
.shipping-template-page__footer-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 8px;
color: #606266;
}
.shipping-template-page__footer-summary strong {
margin-left: 4px;
color: #303133;
font-size: 15px;
}
.shipping-template-page__attachment-upload {
margin-top: 12px;
}
.shipping-template-page__row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.shipping-template-page__detail {
padding: 0;
background: #f5f6fa;
}
.shipping-template-page__detail-section {
width: 100%;
box-sizing: border-box;
margin-bottom: 12px;
padding: 0 16px 4px;
overflow: hidden;
border-radius: 6px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.shipping-template-page__detail-section:last-child {
margin-bottom: 0;
}
.shipping-template-page__detail-section > .dialog-section-title {
margin: 8px 0 16px;
}
.shipping-template-page__detail-form {
width: 100%;
margin: 0;
}
.shipping-template-page__detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 24px;
}
.shipping-template-page__detail-form :deep(.el-form-item) {
min-width: 0;
margin-bottom: 16px;
}
.shipping-template-page__detail-form :deep(.el-form-item__label) {
color: #909399;
line-height: 32px;
white-space: nowrap;
}
.shipping-template-page__detail-form :deep(.el-form-item__content) {
min-width: 0;
color: #303133;
line-height: 32px;
word-break: break-all;
}
.shipping-template-page__detail-form-wide {
grid-column: 1 / -1;
}
.shipping-template-page__detail-route-values {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(220px, 2fr) minmax(140px, 1fr) minmax(
160px,
1fr
);
gap: 8px;
width: 100%;
}
.shipping-template-page__detail-route-values > span {
min-width: 0;
min-height: 32px;
box-sizing: border-box;
padding: 4px 11px;
overflow: hidden;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #f5f7fa;
color: #606266;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.shipping-template-page__detail-freight {
margin-bottom: 8px;
}
.shipping-template-page__detail-grid {
margin-bottom: 0;
}
.shipping-template-page__detail-attachments {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.shipping-template-page__standalone-actions {
display: flex;
justify-content: flex-end;
}
:global(.shipping-template-page__detail-dialog .el-dialog__body) {
padding: 12px;
background: #f5f6fa;
}
:global(.shipping-template-form-page-dialog .shipping-template-page__full-form-item) {
width: 100%;
max-width: 100%;
}
:global(
.shipping-template-form-page-dialog .shipping-template-page__full-form-item > .el-form-item
) {
width: 100%;
}
:global(
.shipping-template-form-page-dialog
.shipping-template-page__full-form-item
> .el-form-item
> .el-form-item__label
) {
display: none;
width: 0 !important;
padding: 0 !important;
}
:global(
.shipping-template-form-page-dialog
.shipping-template-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(
.shipping-template-form-page-dialog
.shipping-template-page__full-form-item
> .el-form-item
> .el-form-item__content
> div
) {
width: 100%;
min-width: 0;
}
/* 独立表单页:每个业务分组独立成白色圆角模块,底部操作区固定在视口底部。 */
:global(.shipping-template-form-page-dialog) {
width: 100% !important;
min-height: 100%;
box-sizing: border-box;
padding-bottom: 80px;
background: #f5f6fa;
}
:global(.shipping-template-form-page-dialog .avue-form) {
padding: 0;
background: transparent;
box-shadow: none;
}
:global(.shipping-template-form-page-dialog .avue-form > .el-form > .el-row) {
margin: 0 !important;
}
:global(.shipping-template-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(.shipping-template-form-page-dialog .avue-group:last-child) {
margin-bottom: 0;
}
:global(.shipping-template-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(.shipping-template-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(.shipping-template-form-page-dialog .avue-group .avue-form__group--flex) {
display: flex;
}
:global(.shipping-template-form-page-dialog .avue-group .shipping-template-page__shipping-title),
:global(.shipping-template-form-page-dialog .avue-group .shipping-template-page__goods-title) {
justify-content: flex-start !important;
padding-left: 0;
text-align: left;
}
:global(
.shipping-template-form-page-dialog .avue-group .shipping-template-page__shipping-title > span
) {
text-align: left;
}
:global(
.shipping-template-form-page-dialog .avue-group .shipping-template-page__shipping-route-action
),
:global(
.shipping-template-form-page-dialog
.avue-group
.shipping-template-page__goods-title
.shipping-template-page__section-actions
) {
margin-left: auto;
}
:global(.shipping-template-form-page-dialog .avue-group .avue-form__row) {
padding: 0 !important;
}
:global(.shipping-template-form-page-dialog .avue-group .el-form-item) {
margin-bottom: 16px;
}
:global(.shipping-template-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(
.shipping-template-form-page-dialog .avue-form__menu > .shipping-template-page__footer-summary
) {
order: 1;
margin-right: auto;
margin-bottom: 0;
}
:global(
.shipping-template-form-page-dialog
.avue-form__menu
> .shipping-template-page__standalone-actions
) {
display: flex;
order: 2;
gap: 8px;
}
:global(.shipping-template-form-page-dialog .avue-form__menu > .el-button) {
order: 3;
margin-left: 8px;
}
:global(.shipping-template-form-page-dialog .avue-form__menu > .el-button .el-icon) {
display: none;
}
:global(.avue--collapse .shipping-template-form-page-dialog .avue-form__menu) {
left: 60px;
}
:global(.avue-layout--horizontal .shipping-template-form-page-dialog .avue-form__menu) {
left: 0;
}
.shipping-template-page__map-toolbar {
display: flex;
gap: 8px;
}
.shipping-template-page__map {
height: 480px;
margin-top: 12px;
}
.shipping-template-page__map-info {
margin-top: 8px;
color: #606266;
}
.shipping-template-page__process-section {
padding: 12px 0;
border-bottom: 1px solid #eff1f7;
}
.shipping-template-page__process-node {
padding: 8px 0 0 12px;
color: #606266;
}
@media (max-width: 900px) {
.shipping-template-page__route-row {
grid-template-columns: 1fr;
}
.shipping-template-page__detail-grid,
.shipping-template-page__detail-route-values {
grid-template-columns: 1fr;
}
.shipping-template-page__detail-form-wide {
grid-column: auto;
}
.shipping-template-page__route-row > span {
display: none;
}
}
</style>