Files
tms-erp-web/src/views/transportCapacity/ship.vue
T
gxwebsoft f3021d4bae style(ship): 调整航区字段布局及上传器宽度
- 将航区选择控件从宽度12调整为宽度6的列中展示
- 保留航区选择及提示信息功能不变,位置更紧凑
- 调整上传组件宽度从240px增加到248px以适配新布局
2026-09-09 11:20:52 +08:00

1408 lines
49 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 #header>
<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="6">
<el-form-item label="船舶所属组织" prop="organizationName">
<el-cascader
v-model="organizationCascaderValue"
:options="organizationTreeOptions"
:props="organizationCascaderProps"
filterable
clearable
placeholder="请选择 船舶所属组织"
/>
</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-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>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="总长(m" prop="totalLength">
<el-input
v-model="shipForm.totalLength"
@input="normalizeNonNegativeDecimal('totalLength')"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="船宽(m" prop="shipWidth">
<el-input
v-model="shipForm.shipWidth"
@input="normalizeNonNegativeDecimal('shipWidth')"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="型深(m" prop="moldedDepth">
<el-input
v-model="shipForm.moldedDepth"
@input="normalizeNonNegativeDecimal('moldedDepth')"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="最大船高(m" prop="maxShipHeight">
<el-input
v-model="shipForm.maxShipHeight"
@input="normalizeNonNegativeDecimal('maxShipHeight')"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="空载吃水(t" prop="lightDraft">
<el-input
v-model="shipForm.lightDraft"
@input="normalizeNonNegativeDecimal('lightDraft')"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="满载吃水(t" prop="fullLoadDraft">
<el-input
v-model="shipForm.fullLoadDraft"
@input="normalizeNonNegativeDecimal('fullLoadDraft')"
/>
</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="6">
<el-form-item label="登记号码" prop="ownershipRegistrationNo">
<el-input v-model="shipForm.ownershipRegistrationNo" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="初次登记号码" prop="initialRegistrationNo">
<el-input v-model="shipForm.initialRegistrationNo" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="船舶所有人" prop="shipOwner">
<el-input v-model="shipForm.shipOwner" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<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-row>
<el-row :gutter="18">
<el-col :span="6">
<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>
<el-row :gutter="18">
<el-col :span="8">
<image-upload-field
label="船舶所有权证书"
prop="ownershipCertImage"
:value="shipForm.ownershipCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
class="ship-upload-item"
@success="url => handleShipCertificateUploadSuccess('ownershipCertImage', url)"
/>
</el-col>
</el-row>
</div>
<div class="cert-block">
<div class="cert-block__title">内河船舶安全与环保证书</div>
<el-row :gutter="18">
<el-col :span="6">
<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="6">
<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="6">
<el-form-item label="船检登记号" prop="shipInspectionNo">
<el-input v-model="shipForm.shipInspectionNo" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="6">
<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>
<el-row :gutter="18">
<el-col :span="8">
<image-upload-field
label="内河船舶安全与环保证书"
prop="safetyCertImage"
:value="shipForm.safetyCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
class="ship-upload-item"
@success="url => handleShipCertificateUploadSuccess('safetyCertImage', url)"
/>
</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>
<el-row :gutter="18">
<el-col :span="8">
<image-upload-field
label="船舶国籍证书"
prop="nationalityCertImage"
:value="shipForm.nationalityCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
class="ship-upload-item"
@success="url => handleShipCertificateUploadSuccess('nationalityCertImage', url)"
/>
</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>
<el-row :gutter="18">
<el-col :span="8">
<image-upload-field
label="内河船舶最低安全配员证书"
prop="safeManningCertImage"
:value="shipForm.safeManningCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
class="ship-upload-item"
@success="url => handleShipCertificateUploadSuccess('safeManningCertImage', url)"
/>
</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>
<el-row :gutter="18">
<el-col :span="8">
<image-upload-field
label="光船租赁登记证书"
prop="leaseContractImage"
:value="shipForm.leaseContractImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
class="ship-upload-item"
@success="url => handleShipCertificateUploadSuccess('leaseContractImage', url)"
/>
</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>
<el-row :gutter="18">
<el-col :span="8">
<image-upload-field
label="船舶营业运输证"
prop="businessTransportCertImage"
:value="shipForm.businessTransportCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="ship"
class="ship-upload-item"
@success="url => handleShipCertificateUploadSuccess('businessTransportCertImage', url)"
/>
</el-col>
</el-row>
</div>
</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 organizationSearch from '@/mixins/organization-search';
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 },
mixins: [organizationSearch],
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']),
// 船舶所属组织级联:表单存组织名称,级联控件用 id 路径,两者在此双向转换
organizationCascaderValue: {
get() {
return this.resolveOrgPath(this.shipForm.organizationName);
},
set(path) {
this.shipForm.organizationName = this.resolveOrgName(path);
},
},
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();
this.loadOrganizationOptions();
},
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) {
// 所属组织级联返回的是 id 路径,转成组织名称传给后端
this.normalizeOrganizationSearch(params);
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;
margin-top: 8px;
margin-bottom: 16px;
:deep(.el-check-tag) {
margin: 0;
white-space: nowrap;
}
}
:deep(.el-table th .cell),
:deep(.el-table td .cell) {
white-space: nowrap;
}
}
.ship-form {
// 对齐 vehicle/customer-archive 的 .archive-form 风格:
// label 固定 calc(6em + 24px) 容纳 6 汉字 + 长 label 自动折行;
// 控件统一 250px + max-width: 100%,保证 cascader / input / select / date-picker 等混排时宽度一致。
// 修复前:第一行 span=6+6+12 布局下 el-cascader (默认 inline-block) 与 el-input 实际宽度差很大,
// 加上 label-width="100" 在长 label 上被撑开,导致 3 个控件视觉宽度比不一致。
:deep(.el-form-item__label) {
flex: 0 0 calc(6em + 24px) !important;
width: calc(6em + 24px) !important;
height: auto;
white-space: normal !important;
word-break: break-all;
overflow-wrap: anywhere;
line-height: 18px;
}
// label 与控件垂直居中(避免长 label 折行时视觉错位)
:deep(.el-form-item) {
align-items: center;
}
// 必填星号 * 与首行文字垂直居中(多行 label 时 :before 与文字行盒高度不一致的修复)
:deep(.el-form-item.is-required .el-form-item__label) {
align-items: flex-start;
}
:deep(.el-form-item.is-required .el-form-item__label::before) {
display: inline-block;
height: 18px;
line-height: 18px;
margin-right: 4px;
}
// 所有控件统一 250pxcol 宽不足时由 max-width: 100% 兜底收缩)
:deep(.el-form-item__content > .el-input),
:deep(.el-form-item__content > .el-input-number),
:deep(.el-form-item__content > .el-select),
:deep(.el-form-item__content > .el-cascader),
:deep(.el-form-item__content > .el-date-editor) {
width: 250px;
max-width: 100%;
}
}
.date-range-inline {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.date-range-inline :deep(.el-date-editor) {
flex: 1;
min-width: 0;
max-width: 250px;
}
.navigation-area-field {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.navigation-area-field :deep(.el-select) {
flex: 1;
min-width: 0;
max-width: 250px;
}
.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;
}
.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: 248px;
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>