Files
tms-erp-web/src/views/transportCapacity/ship.vue
T
gxwebsoft 851379f4de refactor(business): 优化 process-config 弹窗及多页面基本信息布局
- process-config 弹窗改为手写 el-dialog,统一与 credit-score-quantification 风格
- 弹窗内三张 section-card 堆叠,卡间间距12px,底部按钮新增保存草稿/保存并启用
- 删除原 Avue 内置 dialog 及相关表单插槽,移除无用样式,调整弹窗最大高度
- 多页面「基本信息」类分区由3列(span 8)改为4列(span 6),包含结算、合同、船舶等多处页面
- contract-manage-change.vue 手写表单列宽全部由8改6,保留备注与分区标题整行显示
- business-crud-page.vue contract配置中基本信息分区改为4列显示,调整表单 label 宽度为150px
- temporary-credit-limit 详情弹窗加 align-center 实现上下居中,新增 computed 绑定配置开关
- temporary-credit-limit 弹窗宽度修改为96%,与其他业务页面保持一致
- ship.vue 标签宽度调整为最多6汉字宽,允许超长标签换行,改善显示效果
- common-route.vue 调整部分输入框宽度适配布局
- element-ui.scss 调整 process-config 相关弹窗样式,取消原 Avue 透明化样式,全局灰底内卡片间距统一
- 精简 process-config 相关 option 配置,去除 dialog 相关冗余属性
- 过程配置弹窗新增打开、保存、关闭等手写逻辑,支持查看、编辑不同状态,保持业务逻辑完整
- 删除 process-config 内部 avue-crud 相关操作,整体改用标准 el-dialog 和 el-form 实现
- 其他局部样式细节调整,保证表单布局和交互统一规范
2026-08-21 15:40:16 +08:00

1378 lines
47 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<basic-container class="transport-ship-page">
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="form"
ref="crud"
:permission="permissionList"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
icon="el-icon-plus"
v-if="hasPermission('transport_ship_add')"
@click="openShip()"
>
新增
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="hasPermission('transport_ship_export')"
@click="handleExport"
>
导出
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="hasPermission('transport_ship_delete')"
@click="handleDelete"
>
批量删除
</el-button>
</template>
<template #search-menu>
<div class="transport-ship-page__expiry-tags">
<el-check-tag
v-for="item in expiryTagOptions"
:key="item.value"
:checked="query.expireStatus === item.value"
@change="handleExpireChange(item.value)"
>
{{ item.label }}{{ expiryStat[item.statKey] || 0 }}
</el-check-tag>
</div>
</template>
<template #shipName="{ row }">
<el-button type="primary" link @click="openShip(row, true)">
{{ row.shipName }}
</el-button>
</template>
<template #organizationName="{ row }">
{{ String(row.organizationName || '').replace(/^[\s\u3000]+/, '') }}
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'primary' : 'info'">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
<template #nationalityCertEndDate="{ row }">
<span :class="getDateClass(row, 'nationalityCertEndDate', 'nationalityCertLongTerm')">
{{ formatEndDate(row.nationalityCertEndDate, row.nationalityCertLongTerm) }}
</span>
</template>
<template #safeManningCertEndDate="{ row }">
<span
:class="
getCertificateExpiryClass(row, 'safeManningCertEndDate', 'safeManningCertLongTerm')
"
>
{{ formatEndDate(row.safeManningCertEndDate, row.safeManningCertLongTerm) }}
</span>
</template>
<template #businessTransportCertEndDate="{ row }">
<span
:class="
getCertificateExpiryClass(
row,
'businessTransportCertEndDate',
'businessTransportCertLongTerm'
)
"
>
{{ formatEndDate(row.businessTransportCertEndDate, row.businessTransportCertLongTerm) }}
</span>
</template>
<template #leaseEndDate="{ row }">
<span :class="getDateClass(row, 'leaseEndDate', 'leaseLongTerm')">
{{ formatEndDate(row.leaseEndDate, row.leaseLongTerm) }}
</span>
</template>
<template #menu="{ row }">
<el-link type="primary" v-if="hasPermission('transport_ship_edit')" @click="openShip(row)">
修改
</el-link>
<el-link
type="primary"
v-if="hasPermission('transport_ship_view')"
@click="openShipLedger(row)"
>
综合台账
</el-link>
<el-link
type="primary"
v-if="hasPermission('transport_ship_status')"
@click="handleStatus(row)"
>
{{ row.status === 1 ? '停用' : '启用' }}
</el-link>
</template>
</avue-crud>
<ship-ledger v-model="shipLedgerBox" :ship="ledgerShip" />
<el-dialog
:title="dialogTitle"
append-to-body
v-model="shipBox"
width="92%"
top="4vh"
@closed="resetShip"
>
<el-form
ref="shipForm"
:model="shipForm"
:rules="formRules"
label-position="right"
label-width="100"
:disabled="readonly"
class="ship-form archive-form"
>
<section-card title="基础船舶信息">
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="船舶所属组织" prop="organizationName">
<el-select
v-model="shipForm.organizationName"
filterable
clearable
placeholder="请选择"
>
<el-option
v-for="item in organizationOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="船名" prop="shipName">
<el-input v-model="shipForm.shipName" maxlength="20" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="安放龙骨日期/建造完工日期" prop="keelLayingDate">
<div class="date-range-inline">
<el-date-picker
v-model="shipForm.keelLayingDate"
type="date"
value-format="YYYY-MM-DD"
@change="validateField('keelLayingDate')"
/>
<span></span>
<el-date-picker
v-model="shipForm.buildCompletionDate"
type="date"
value-format="YYYY-MM-DD"
@change="validateField('keelLayingDate')"
/>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="总长" prop="totalLength">
<el-input
v-model="shipForm.totalLength"
@input="normalizeNonNegativeDecimal('totalLength')"
>
<template #suffix>m</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="船宽" prop="shipWidth">
<el-input
v-model="shipForm.shipWidth"
@input="normalizeNonNegativeDecimal('shipWidth')"
>
<template #suffix>m</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="型深" prop="moldedDepth">
<el-input
v-model="shipForm.moldedDepth"
@input="normalizeNonNegativeDecimal('moldedDepth')"
>
<template #suffix>m</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="最大船高" prop="maxShipHeight">
<el-input
v-model="shipForm.maxShipHeight"
@input="normalizeNonNegativeDecimal('maxShipHeight')"
>
<template #suffix>m</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="空载吃水" prop="lightDraft">
<el-input
v-model="shipForm.lightDraft"
@input="normalizeNonNegativeDecimal('lightDraft')"
>
<template #suffix>t</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="满载吃水" prop="fullLoadDraft">
<el-input
v-model="shipForm.fullLoadDraft"
@input="normalizeNonNegativeDecimal('fullLoadDraft')"
>
<template #suffix>t</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="航区" prop="navigationArea">
<div class="navigation-area-field">
<el-select v-model="shipForm.navigationArea" placeholder="请选择">
<el-option label="A级" value="A级" />
<el-option label="B级" value="B级" />
</el-select>
<el-tooltip effect="light" placement="top" popper-class="ship-navigation-tooltip">
<template #content>
<table class="navigation-cargo-table">
<tbody>
<tr>
<th rowspan="2">参考<br />载货量</th>
<th>航区</th>
<th>A级</th>
<th>B级</th>
<th>FA</th>
<th>FB</th>
</tr>
<tr>
<th>载货量(t)</th>
<td>864.00</td>
<td>864.00</td>
<td>965.00</td>
<td>1015.00</td>
</tr>
</tbody>
</table>
</template>
<el-icon class="navigation-info-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
</el-form-item>
</el-col>
</el-row>
</section-card>
<section-card title="船舶证书信息">
<div class="cert-block">
<div class="cert-block__title">船舶所有权证书</div>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="登记号码" prop="ownershipRegistrationNo">
<el-input v-model="shipForm.ownershipRegistrationNo" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="初次登记号码" prop="initialRegistrationNo">
<el-input v-model="shipForm.initialRegistrationNo" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="船舶所有人" prop="shipOwner">
<el-input v-model="shipForm.shipOwner" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="取得所有权日期" prop="ownershipAcquisitionDate">
<el-date-picker
v-model="shipForm.ownershipAcquisitionDate"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="船舶识别号" prop="shipIdentifierNo">
<el-input
v-model="shipForm.shipIdentifierNo"
maxlength="50"
@input="
shipForm.shipIdentifierNo = String(
shipForm.shipIdentifierNo || ''
).toUpperCase()
"
/>
</el-form-item>
</el-col>
</el-row>
</div>
<div class="cert-block">
<div class="cert-block__title">内河船舶安全与环保证书</div>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="总吨" prop="grossTonnage">
<el-input-number
v-model="shipForm.grossTonnage"
:min="0"
:precision="2"
:controls="false"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="净吨" prop="netTonnage">
<el-input-number
v-model="shipForm.netTonnage"
:min="0"
:precision="2"
:controls="false"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="船检登记号" prop="shipInspectionNo">
<el-input v-model="shipForm.shipInspectionNo" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="船舶类型" prop="shipType">
<el-select v-model="shipForm.shipType" placeholder="请选择船舶类型" clearable>
<el-option label="散货船" value="散货船" />
<el-option label="集装箱船" value="集装箱船" />
<el-option label="杂货船" value="杂货船" />
<el-option label="油船" value="油船" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</div>
<div class="cert-block">
<div class="cert-block__title">船舶国籍证书</div>
<el-row :gutter="18">
<el-col :span="16">
<el-form-item label="证书有效期自" prop="nationalityCertStartDate">
<div class="date-range-inline">
<el-date-picker
v-model="shipForm.nationalityCertStartDate"
type="date"
value-format="YYYY-MM-DD"
/>
<span></span>
<el-date-picker
v-model="shipForm.nationalityCertEndDate"
type="date"
value-format="YYYY-MM-DD"
@change="validateField('nationalityCertStartDate')"
/>
</div>
</el-form-item>
</el-col>
</el-row>
</div>
<div class="cert-block">
<div class="cert-block__title">内河船舶最低安全配员证书</div>
<el-row :gutter="18">
<el-col :span="16">
<el-form-item label="证书有效期自" prop="safeManningCertStartDate">
<div class="date-range-inline">
<el-date-picker
v-model="shipForm.safeManningCertStartDate"
type="date"
value-format="YYYY-MM-DD"
/>
<span></span>
<el-date-picker
v-model="shipForm.safeManningCertEndDate"
type="date"
value-format="YYYY-MM-DD"
@change="validateField('safeManningCertStartDate')"
/>
</div>
</el-form-item>
</el-col>
</el-row>
</div>
<div class="cert-block">
<div class="cert-block__title">光船租赁登记证明书</div>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="起租日期" prop="leaseStartDate">
<el-date-picker
v-model="shipForm.leaseStartDate"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="终止日期" prop="leaseEndDate">
<el-date-picker
v-model="shipForm.leaseEndDate"
type="date"
value-format="YYYY-MM-DD"
@change="validateField('leaseStartDate')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="船舶承租人" prop="shipLessee">
<el-input v-model="shipForm.shipLessee" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="cert-block">
<div class="cert-block__title">船舶营业运输证书</div>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="证书编号" prop="businessTransportCertNo">
<el-input v-model="shipForm.businessTransportCertNo" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发证日期" prop="businessTransportCertIssueDate">
<el-date-picker
v-model="shipForm.businessTransportCertIssueDate"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="有效期至" prop="businessTransportCertEndDate">
<el-date-picker
v-model="shipForm.businessTransportCertEndDate"
type="date"
value-format="YYYY-MM-DD"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="船舶经营人" prop="shipOperator">
<el-input v-model="shipForm.shipOperator" maxlength="50" />
</el-form-item>
</el-col>
</el-row>
</div>
<el-row class="cert-upload-grid" :gutter="18">
<el-col :span="8">
<image-upload-field
label="船舶所有权证书"
prop="ownershipCertImage"
:value="shipForm.ownershipCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
@success="url => handleShipCertificateUploadSuccess('ownershipCertImage', url)"
/>
</el-col>
<el-col :span="8">
<image-upload-field
label="内河船舶安全与环保证书"
prop="safetyCertImage"
:value="shipForm.safetyCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
@success="url => handleShipCertificateUploadSuccess('safetyCertImage', url)"
/>
</el-col>
<el-col :span="8">
<image-upload-field
label="船舶国籍证书"
prop="nationalityCertImage"
:value="shipForm.nationalityCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
@success="url => handleShipCertificateUploadSuccess('nationalityCertImage', url)"
/>
</el-col>
<el-col :span="8">
<image-upload-field
label="内河船舶最低安全配员证书"
prop="safeManningCertImage"
:value="shipForm.safeManningCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
@success="url => handleShipCertificateUploadSuccess('safeManningCertImage', url)"
/>
</el-col>
<el-col :span="8">
<div class="cert-upload-spacer"></div>
</el-col>
<el-col :span="8">
<image-upload-field
label="光船租赁登记证书"
prop="leaseContractImage"
:value="shipForm.leaseContractImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
@success="url => handleShipCertificateUploadSuccess('leaseContractImage', url)"
/>
</el-col>
<el-col :span="8">
<image-upload-field
label="船舶营业运输证"
prop="businessTransportCertImage"
:value="shipForm.businessTransportCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
@success="url => handleShipCertificateUploadSuccess('businessTransportCertImage', url)"
/>
</el-col>
</el-row>
</section-card>
</el-form>
<template #footer>
<el-button @click="shipBox = false">取消</el-button>
<el-button type="primary" v-if="!readonly" :loading="submitLoading" @click="handleSubmit"
>确定</el-button
>
</template>
</el-dialog>
</basic-container>
</template>
<script>
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import { ElLoading } from 'element-plus';
import { InfoFilled } from '@element-plus/icons-vue';
import { option } from '@/option/transportCapacity/transport-ship';
import { getDeptTree } from '@/api/system/dept';
import {
getList,
getDetail,
submit,
remove,
changeStatus,
getExpiryStat,
recognizeBaiduOcr,
} from '@/api/transportCapacity/transport-ship';
import { exportBlob } from '@/api/common';
import { getToken } from '@/utils/auth';
import { getUploadHeaders } from '@/utils/upload';
import { downloadXls } from '@/utils/util';
import ImageUploadField from '@/components/image-upload-field/main.vue';
import ShipLedger from './components/ship-ledger.vue';
const emptyForm = () => ({
id: '',
organizationName: '',
shipName: '',
shipIdentifierNo: '',
keelLayingDate: '',
buildCompletionDate: '',
totalLength: undefined,
shipWidth: undefined,
moldedDepth: undefined,
maxShipHeight: undefined,
lightDraft: undefined,
fullLoadDraft: undefined,
navigationArea: 'A级',
ownershipRegistrationNo: '',
initialRegistrationNo: '',
shipOwner: '',
ownershipAcquisitionDate: '',
shipInspectionNo: '',
shipType: '',
grossTonnage: undefined,
netTonnage: undefined,
nationalityCertStartDate: '',
nationalityCertEndDate: '',
nationalityCertLongTerm: 0,
nationalityCertImage: '',
ownershipCertImage: '',
safetyCertImage: '',
safeManningCertStartDate: '',
safeManningCertEndDate: '',
safeManningCertLongTerm: 0,
safeManningCertImage: '',
leaseStartDate: '',
shipLessee: '',
businessTransportCertEndDate: '',
businessTransportCertLongTerm: 0,
businessTransportCertImage: '',
businessTransportCertNo: '',
businessTransportCertIssueDate: '',
shipOperator: '',
leaseEndDate: '',
leaseLongTerm: 0,
leaseContractImage: '',
remark: '',
status: 1,
});
export default {
components: { ImageUploadField, InfoFilled, ShipLedger },
data() {
return {
option,
form: {},
query: { expireStatus: '' },
searchForm: {},
loading: true,
submitLoading: false,
data: [],
ids: '',
selectionList: [],
page: { pageSize: 10, currentPage: 1, total: 0 },
expiryStat: { total: 0, within30: 0, expired: 0 },
shipBox: false,
shipLedgerBox: false,
ledgerShip: {},
readonly: false,
shipForm: emptyForm(),
uploadHeaders: getUploadHeaders(),
organizationOptions: [],
formRules: {
organizationName: [{ required: true, message: '请选择船舶所属组织', trigger: 'change' }],
shipName: [
{ required: true, message: '请输入船名', trigger: 'blur' },
{ max: 20, message: '船名不能超过20个字符', trigger: 'blur' },
],
keelLayingDate: [{ validator: this.validateKeelCompletionDate, trigger: 'change' }],
shipIdentifierNo: [{ required: true, message: '请输入船舶识别号', trigger: 'blur' }],
totalLength: [{ validator: this.validateNonNegativeField, trigger: 'change' }],
shipWidth: [{ validator: this.validateNonNegativeField, trigger: 'change' }],
moldedDepth: [{ validator: this.validateNonNegativeField, trigger: 'change' }],
maxShipHeight: [{ validator: this.validateNonNegativeField, trigger: 'change' }],
lightDraft: [{ validator: this.validateNonNegativeField, trigger: 'change' }],
fullLoadDraft: [{ validator: this.validateNonNegativeField, trigger: 'change' }],
navigationArea: [{ required: true, message: '请选择航区', trigger: 'change' }],
ownershipRegistrationNo: [{ required: true, message: '请输入登记号码', trigger: 'blur' }],
initialRegistrationNo: [{ required: true, message: '请输入初次登记号码', trigger: 'blur' }],
shipOwner: [{ max: 50, message: '船舶所有人不能超过50字', trigger: 'blur' }],
grossTonnage: [{ required: true, message: '请输入总吨', trigger: 'change' }],
netTonnage: [{ required: true, message: '请输入净吨', trigger: 'change' }],
shipInspectionNo: [{ required: true, message: '请输入船检登记号', trigger: 'blur' }],
shipType: [{ required: true, message: '请选择船舶类型', trigger: 'change' }],
nationalityCertStartDate: [
{ validator: this.validateNationalityCertDate, trigger: 'change' },
],
safeManningCertStartDate: [
{ validator: this.validateSafeManningCertDate, trigger: 'change' },
],
leaseStartDate: [{ validator: this.validateLeaseDate, trigger: 'change' }],
businessTransportCertNo: [{ required: true, message: '请输入证书编号', trigger: 'blur' }],
businessTransportCertIssueDate: [
{ required: true, message: '请选择发证日期', trigger: 'change' },
{ validator: this.validateBusinessTransportCertDate, trigger: 'change' },
],
businessTransportCertEndDate: [
{ required: true, message: '请选择有效期至', trigger: 'change' },
{ validator: this.validateBusinessTransportCertDate, trigger: 'change' },
],
shipLessee: [{ max: 50, message: '船舶承租人不能超过50字', trigger: 'blur' }],
shipOperator: [{ max: 50, message: '船舶经营人不能超过50字', trigger: 'blur' }],
remark: [{ max: 200, message: '备注最多200字', trigger: 'blur' }],
},
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
isAdmin() {
const authority = this.userInfo.authority || '';
return authority.includes('admin');
},
permissionList() {
return {
addBtn: this.hasPermission('transport_ship_add'),
viewBtn: this.hasPermission('transport_ship_view'),
delBtn: this.hasPermission('transport_ship_delete'),
editBtn: this.hasPermission('transport_ship_edit'),
};
},
dialogTitle() {
if (this.readonly) return '船舶综合台账';
return this.shipForm.id ? '修改船舶' : '新增船舶';
},
expiryTagOptions() {
return [
{ label: '全部', value: '', statKey: 'total' },
{ label: '30天内到期', value: 'within30', statKey: 'within30' },
{ label: '已到期', value: 'expired', statKey: 'expired' },
];
},
},
created() {
this.initDeptTree();
},
methods: {
hasPermission(code) {
return this.isAdmin || this.permission?.[code] === true;
},
initDeptTree() {
getDeptTree(this.userInfo.tenantId).then(res => {
this.organizationOptions = this.flattenDeptOptions(res.data.data || []);
const column = this.findColumn(this.option.column, 'organizationName');
column.dicData = this.organizationOptions;
if (this.shipBox && !this.shipForm.id && !this.shipForm.organizationName) {
this.shipForm.organizationName = this.getCurrentOrganizationName();
}
});
},
flattenDeptOptions(tree = [], level = 0) {
const result = [];
tree.forEach(item => {
const rawLabel = item.title || item.deptName || item.name;
result.push({
label: `${' '.repeat(level)}${rawLabel}`,
value: rawLabel,
rawLabel,
id: item.id || item.value,
});
if (item.children && item.children.length) {
result.push(...this.flattenDeptOptions(item.children, level + 1));
}
});
return result;
},
getCurrentOrganizationName() {
const currentDeptId = this.userInfo.deptId || this.userInfo.dept_id;
const currentDept = this.organizationOptions.find(
item => String(item.id) === String(currentDeptId)
);
return currentDept?.rawLabel || this.userInfo.deptName || this.userInfo.dept_name || '';
},
validateField(prop) {
this.$refs.shipForm?.validateField(prop);
},
normalizeNonNegativeDecimal(prop) {
const value = String(this.shipForm[prop] ?? '').replace(/[^\d.]/g, '');
const [integerPart = '', ...decimalParts] = value.split('.');
const decimalPart = decimalParts.join('').slice(0, 2);
this.shipForm[prop] = decimalParts.length ? `${integerPart}.${decimalPart}` : integerPart;
},
validateNonNegativeField(rule, value, callback) {
if (value !== undefined && value !== null && value < 0) {
callback(new Error('数值不能小于0'));
return;
}
callback();
},
validateKeelCompletionDate(rule, value, callback) {
if (
this.shipForm.keelLayingDate &&
this.shipForm.buildCompletionDate &&
this.$dayjs(this.shipForm.buildCompletionDate).isBefore(this.shipForm.keelLayingDate, 'day')
) {
callback(new Error('建造完工日期不能早于安放龙骨日期'));
return;
}
callback();
},
validateNationalityCertDate(rule, value, callback) {
this.validateDateRangeByProp(
'nationalityCertStartDate',
'nationalityCertEndDate',
'nationalityCertLongTerm',
'请选择船舶国籍证书有效期',
callback
);
},
validateSafeManningCertDate(rule, value, callback) {
this.validateDateRangeByProp(
'safeManningCertStartDate',
'safeManningCertEndDate',
'safeManningCertLongTerm',
'请选择最低安全配员证书有效期',
callback
);
},
validateLeaseDate(rule, value, callback) {
this.validateOptionalDateRange(
'leaseStartDate',
'leaseEndDate',
'终止日期不能早于起租日期',
callback
);
},
validateBusinessTransportCertDate(rule, value, callback) {
this.validateOptionalDateRange(
'businessTransportCertIssueDate',
'businessTransportCertEndDate',
'有效期至不能早于发证日期',
callback
);
},
validateDateRangeByProp(startProp, endProp, longTermProp, emptyMessage, callback) {
if (
this.shipForm[longTermProp] === 1 &&
!this.shipForm[startProp] &&
!this.shipForm[endProp]
) {
callback();
return;
}
if (!this.shipForm[startProp] || !this.shipForm[endProp]) {
callback(new Error(emptyMessage));
return;
}
this.validateOptionalDateRange(startProp, endProp, '结束日期不能早于开始日期', callback);
},
validateOptionalDateRange(startProp, endProp, message, callback) {
if (
this.shipForm[startProp] &&
this.shipForm[endProp] &&
this.$dayjs(this.shipForm[endProp]).isBefore(this.shipForm[startProp], 'day')
) {
callback(new Error(message));
return;
}
callback();
},
openShip(row, readonly = false) {
this.readonly = readonly;
if (!row?.id) {
this.shipForm = emptyForm();
this.shipForm.organizationName = this.getCurrentOrganizationName();
this.shipBox = true;
return;
}
getDetail(row.id).then(res => {
this.shipForm = { ...emptyForm(), ...(res.data.data || {}) };
this.shipBox = true;
});
},
openShipLedger(row) {
this.ledgerShip = { ...row };
this.shipLedgerBox = true;
},
resetShip() {
this.shipForm = emptyForm();
this.readonly = false;
this.submitLoading = false;
this.$refs.shipForm?.clearValidate();
},
setImage(prop, url) {
this.shipForm[prop] = url;
this.$refs.shipForm?.validateField(prop);
},
handleShipCertificateUploadSuccess(prop, url) {
this.setImage(prop, url);
const documentNameMap = {
ownershipCertImage: '船舶所有权证书',
safetyCertImage: '内河船舶安全与环保证书',
nationalityCertImage: '船舶国籍证书',
safeManningCertImage: '内河船舶最低安全配员证书',
leaseContractImage: '光船租赁登记证书',
businessTransportCertImage: '船舶营业运输证',
};
this.recognizeBaiduOcr(url, documentNameMap[prop], data => {
this.applyShipCertificateRecognition(prop, data);
});
},
recognizeBaiduOcr(url, documentName, applyRecognition) {
if (!url) {
this.$message.warning(`${documentName}图片上传成功,未获取到图片地址,无法自动识别`);
return;
}
const loading = ElLoading.service({
lock: true,
text: `${documentName}识别中`,
background: 'rgba(255, 255, 255, 0.7)',
});
recognizeBaiduOcr(url, 'general')
.then(res => {
applyRecognition(res.data.data?.result || {});
this.$message.success(`${documentName}识别完成`);
})
.catch(() => {
this.$message.warning(`${documentName}图片上传成功,自动识别失败,请手动填写证书信息`);
})
.finally(() => {
loading.close();
});
},
applyShipCertificateRecognition(prop, data = {}) {
const words = this.getBaiduOcrWords(data);
switch (prop) {
case 'ownershipCertImage':
this.fillShipCertificateText(words, 'ownershipRegistrationNo', ['登记号码', '登记号']);
this.fillShipCertificateText(words, 'initialRegistrationNo', ['初次登记号码', '初始登记号码']);
this.fillShipCertificateText(words, 'shipOwner', ['船舶所有人', '所有人']);
this.fillShipCertificateText(words, 'shipIdentifierNo', ['船舶识别号', '船舶识别号码']);
this.fillShipCertificateDate(words, 'ownershipAcquisitionDate', ['取得所有权日期', '取得日期']);
break;
case 'safetyCertImage':
this.fillShipCertificateNumber(words, 'grossTonnage', ['总吨', '总吨位']);
this.fillShipCertificateNumber(words, 'netTonnage', ['净吨', '净吨位']);
this.fillShipCertificateText(words, 'shipInspectionNo', ['船检登记号', '船检证书号']);
this.fillShipCertificateText(words, 'shipType', ['船舶类型']);
break;
case 'nationalityCertImage':
this.fillShipCertificateDateRange(
words,
'nationalityCertStartDate',
'nationalityCertEndDate',
'nationalityCertLongTerm'
);
break;
case 'safeManningCertImage':
this.fillShipCertificateDateRange(
words,
'safeManningCertStartDate',
'safeManningCertEndDate',
'safeManningCertLongTerm'
);
break;
case 'leaseContractImage':
this.fillShipCertificateDate(words, 'leaseStartDate', ['起租日期', '租赁开始日期']);
this.fillShipCertificateDate(words, 'leaseEndDate', ['终止日期', '租赁终止日期']);
this.fillShipCertificateText(words, 'shipLessee', ['船舶承租人', '承租人']);
break;
case 'businessTransportCertImage':
this.fillShipCertificateText(words, 'businessTransportCertNo', ['证书编号', '运输证号']);
this.fillShipCertificateDate(words, 'businessTransportCertIssueDate', ['发证日期']);
this.fillShipCertificateDate(words, 'businessTransportCertEndDate', ['有效期至', '有效期限']);
this.fillShipCertificateText(words, 'shipOperator', ['船舶经营人', '经营人']);
break;
default:
break;
}
},
getBaiduOcrWords(data = {}) {
const wordsResult = data.words_result || data.wordsResult || {};
if (Array.isArray(wordsResult)) {
return wordsResult.map(item => item.words || item.value || '').filter(Boolean);
}
return Object.entries(wordsResult)
.flatMap(([key, value]) => [key, typeof value === 'object' ? value.words || value.value || '' : value])
.filter(Boolean);
},
getShipCertificateOcrValue(words = [], labels = []) {
const normalizedWords = words.map(word => String(word || '').trim()).filter(Boolean);
for (const label of labels) {
const labelIndex = normalizedWords.findIndex(word =>
new RegExp(`^${label}\\s*[:]?$`).test(word)
);
if (labelIndex >= 0 && normalizedWords[labelIndex + 1]) {
return normalizedWords[labelIndex + 1];
}
const valueLine = normalizedWords.find(word =>
new RegExp(`${label}\\s*[:]?\\s*(.+)$`).test(word)
);
if (valueLine) {
return valueLine.replace(new RegExp(`^.*${label}\\s*[:]?\\s*`), '').trim();
}
}
return '';
},
fillShipCertificateText(words, prop, labels) {
const value = this.getShipCertificateOcrValue(words, labels);
if (value) this.shipForm[prop] = value.replace(/\s/g, '');
},
fillShipCertificateNumber(words, prop, labels) {
const value = this.getShipCertificateOcrValue(words, labels);
const number = Number(String(value || '').replace(/[^\d.]/g, ''));
if (!Number.isNaN(number) && value) this.shipForm[prop] = number;
},
fillShipCertificateDate(words, prop, labels) {
const value = this.getShipCertificateOcrValue(words, labels);
const date = this.getShipCertificateDates(value)[0];
if (date) this.shipForm[prop] = date;
},
fillShipCertificateDateRange(words, startProp, endProp, longTermProp) {
const text =
this.getShipCertificateOcrValue(words, ['证书有效期', '有效期限', '有效期至', '有效期']) ||
words.join(' ');
if (/(长期|永久)/.test(text)) {
this.shipForm[longTermProp] = 1;
this.shipForm[startProp] = '';
this.shipForm[endProp] = '';
return;
}
const dateList = this.getShipCertificateDates(text);
if (dateList[0] && dateList[1]) {
this.shipForm[startProp] = dateList[0];
this.shipForm[endProp] = dateList[dateList.length - 1];
this.shipForm[longTermProp] = 0;
}
},
getShipCertificateDates(value = '') {
return (String(value || '').match(/\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/g) || []).map(
date => {
const [, year, month, day] = date.match(/(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/) || [];
return year ? `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}` : '';
}
);
},
handleSubmit() {
this.$refs.shipForm.validate(valid => {
if (!valid) return;
this.submitLoading = true;
submit(this.shipForm)
.then(() => {
this.$message.success('操作成功');
this.shipBox = false;
this.onLoad(this.page);
})
.finally(() => {
this.submitLoading = false;
});
});
},
handleStatus(row) {
const status = row.status === 1 ? 2 : 1;
const text = status === 1 ? '启用' : '停用';
this.$confirm(`是否${text}该船舶?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
changeStatus(row.id, status).then(() => {
this.$message.success('操作成功');
this.onLoad(this.page);
});
});
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('是否删除选中的船舶数据?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
remove(this.ids).then(() => {
this.$message.success('操作成功');
this.onLoad(this.page);
});
});
},
handleExpireChange(expireStatus) {
this.query.expireStatus = expireStatus;
this.page.currentPage = 1;
this.onLoad(this.page);
},
refreshStat() {
getExpiryStat(this.buildQuery(false)).then(res => {
this.expiryStat = {
total: res.data.data?.total || 0,
within30: res.data.data?.within30 || 0,
expired: res.data.data?.expired || 0,
};
});
},
searchReset() {
this.searchForm = {};
this.query = { expireStatus: this.query.expireStatus };
this.onLoad(this.page);
},
searchChange(params, done) {
this.searchForm = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
selectionChange(list) {
this.selectionList = list;
this.ids = list.map(item => item.id).join(',');
},
selectionClear() {
this.selectionList = [];
this.ids = '';
this.$refs.crud?.toggleSelection();
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.searchForm);
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, this.buildQuery(true, params))
.then(res => {
const data = res.data.data;
this.page.total = data.total;
this.data = data.records;
this.selectionClear();
this.refreshStat();
})
.finally(() => {
this.loading = false;
});
},
buildQuery(includeExpire = true, params = {}) {
const query = { ...params, ...this.searchForm };
if (includeExpire && this.query.expireStatus) query.expireStatus = this.query.expireStatus;
return query;
},
handleExport() {
this.$confirm('是否导出船舶数据?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
NProgress.start();
exportBlob(
'/blade-transport/transport-ship/export-transport-ship',
this.buildExportParams(),
{ feedback: true }
)
.then(res => {
downloadXls(res.data, `船舶管理${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`);
})
.finally(() => {
NProgress.done();
});
});
},
buildExportParams() {
return { ...this.buildQuery(true), ids: this.ids, [this.website.tokenHeader]: getToken() };
},
formatEndDate(date, longTerm) {
if (longTerm === 1) return '长期有效';
return date || '-';
},
getDateClass(row, dateProp, longTermProp) {
if (row[longTermProp] === 1 || !row[dateProp]) return '';
const today = this.$dayjs();
const endDate = this.$dayjs(row[dateProp]);
if (endDate.isBefore(today, 'day')) return 'date-danger';
if (endDate.diff(today, 'day') <= 30) return 'date-warning';
return '';
},
getCertificateExpiryClass(row, dateProp, longTermProp) {
if (row[longTermProp] === 1 || !row[dateProp]) return '';
const endDate = this.$dayjs(row[dateProp]);
return endDate.isValid() && endDate.diff(this.$dayjs(), 'day') <= 30 ? 'date-danger' : '';
},
},
};
</script>
<style lang="scss" scoped>
.transport-ship-page {
&__expiry-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
order: -1;
margin-right: 12px;
min-width: 0;
:deep(.el-check-tag) {
margin: 0;
white-space: nowrap;
}
}
:deep(.el-table th .cell),
:deep(.el-table td .cell) {
white-space: nowrap;
}
}
.ship-form {
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper),
:deep(.el-select),
:deep(.el-input),
:deep(.el-input-number) {
width: 100%;
}
// label 固定宽度:可容纳 6 个汉字,超出自动换行
:deep(.el-form-item__label) {
white-space: normal;
line-height: 20px;
}
}
.date-range-inline {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.date-range-inline :deep(.el-date-editor) {
flex: 1;
min-width: 0;
}
.navigation-area-field {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.navigation-area-field :deep(.el-select) {
flex: 1;
min-width: 0;
}
.navigation-info-icon {
color: #909399;
font-size: 18px;
cursor: help;
flex-shrink: 0;
}
.cert-block {
padding: 16px 0 8px;
border-top: 1px solid #e5e6eb;
}
.cert-block__title {
margin-bottom: 12px;
color: #303133;
font-weight: 600;
line-height: 20px;
}
.cert-upload-grid {
padding-top: 8px;
}
.cert-upload-spacer {
height: 1px;
}
.ship-upload-item {
margin-bottom: 14px;
}
.ocr-tip {
min-height: 114px;
padding: 18px 20px;
background: #ffd84d;
color: #333;
font-size: 16px;
line-height: 1.45;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
// 大尺寸证件照:按身份证 1.586:1 比例缩放(约真实尺寸 75%,240×151),左对齐显示
:deep(.ship-uploader) {
display: block;
}
:deep(.ship-uploader .el-upload),
:deep(.ship-uploader.el-upload) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 170px;
border: 1px dashed #c0c4cc;
background: #fafafa;
overflow: hidden;
}
:deep(.ship-uploader--large .el-upload),
:deep(.ship-uploader--large.el-upload) {
width: 100%;
height: 151px;
}
:deep(.ship-uploader--large) {
display: block;
width: 240px;
margin: 0;
}
:deep(.ship-uploader__image) {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
:deep(.ship-uploader__empty) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #606266;
}
:deep(.ship-navigation-tooltip) {
padding: 8px 10px;
}
.navigation-cargo-table {
border-collapse: collapse;
font-size: 12px;
}
.navigation-cargo-table th,
.navigation-cargo-table td {
border: 1px solid #909399;
padding: 6px 10px;
text-align: center;
white-space: nowrap;
}
.navigation-cargo-table th {
font-weight: 600;
}
.date-warning {
color: #f56c6c;
font-weight: 600;
}
.date-danger {
color: #d40000;
font-weight: 600;
}
</style>