Files
tms-erp-web/src/views/transportCapacity/ship.vue
T
gxwebsoft 1625fb292d fix(styles): 优化多页面表单及弹窗样式统一性和功能
- 全站业务状态标签文字化,el-tag.status-text 转为纯文本,统一字号、行高和基线对齐
- 临时额度详情弹窗加宽 label 列宽至 180px,避免标签折成三行
- 年检记录费用字段将单位“元”移至标签内,清理无效 append 配置和 template 代码
- 维修记录里程字段单位动态显示于标签,移除输入框右侧单位后缀
- 全站 Avue CRUD 弹窗标题自动添加页面全称,支持手动覆盖和自定义
- 合同管理独立表单页顶部补充“新增合同管理”标题,统一与其它表单页面样式
- 结算-正式结算页表格整合到“应付/应收”两个标签页内,复用组件并调整操作列宽度至320px
- 业务相关弹窗和组件统一支持 dialogCustomClass 传递及样式适配
- 取消部分页面样式中冗余或注释代码,调整组件内按钮布局和权限判断函数逻辑
- 新增全局 mixin 以设置 CRUD 弹窗标题,主入口 main.js 注册相关初始化函数
2026-08-25 15:18:44 +08:00

1369 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'" class="status-text">
{{ 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="8">
<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="8">
<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="8">
<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="8">
<el-form-item label="总长(m" prop="totalLength">
<el-input
v-model="shipForm.totalLength"
@input="normalizeNonNegativeDecimal('totalLength')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="船宽(m" prop="shipWidth">
<el-input
v-model="shipForm.shipWidth"
@input="normalizeNonNegativeDecimal('shipWidth')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="型深(m" prop="moldedDepth">
<el-input
v-model="shipForm.moldedDepth"
@input="normalizeNonNegativeDecimal('moldedDepth')"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="8">
<el-form-item label="最大船高(m" prop="maxShipHeight">
<el-input
v-model="shipForm.maxShipHeight"
@input="normalizeNonNegativeDecimal('maxShipHeight')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="空载吃水(t" prop="lightDraft">
<el-input
v-model="shipForm.lightDraft"
@input="normalizeNonNegativeDecimal('lightDraft')"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="满载吃水(t" prop="fullLoadDraft">
<el-input
v-model="shipForm.fullLoadDraft"
@input="normalizeNonNegativeDecimal('fullLoadDraft')"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="8">
<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;
}
.el-form-item {
margin-bottom: 14px !important;
}
</style>