Files
tms-erp-web/src/views/transportCapacity/vehicle.vue
T
2026-10-25 19:46:42 +08:00

1660 lines
56 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-vehicle-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_vehicle_add')"
@click="openVehicle()"
>新增
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="hasPermission('transport_vehicle_export')"
@click="handleExport"
>导出
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="hasPermission('transport_vehicle_delete')"
@click="handleDelete"
>批量删除
</el-button>
</template>
<template #header>
<div class="transport-vehicle-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 #plateNo="{ row }">
<el-button type="primary" link @click="openVehicle(row, true)">
{{ row.plateNo }}
</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 #boundDriver="{ row }">
<span>{{ row.boundDriver || '-' }}</span>
</template>
<template #certificationStatus="{ row }">
<el-tag :type="getCertificationStatus(row).type" class="status-text">
{{ getCertificationStatus(row).label }}
</el-tag>
</template>
<template #drivingLicenseEndDate="{ row }">
<span :class="getDateClass(row, 'drivingLicenseEndDate', 'drivingLicenseLongTerm')">
{{ formatEndDate(row.drivingLicenseEndDate, row.drivingLicenseLongTerm) }}
</span>
</template>
<template #roadTransportCertEndDate="{ row }">
<span :class="getDateClass(row, 'roadTransportCertEndDate', 'roadTransportCertLongTerm')">
{{ formatEndDate(row.roadTransportCertEndDate, row.roadTransportCertLongTerm) }}
</span>
</template>
<template #annualReviewEndDate="{ row }">
<span :class="getDateClass(row, 'annualReviewEndDate', 'annualReviewLongTerm')">
{{ formatEndDate(row.annualReviewEndDate, row.annualReviewLongTerm) }}
</span>
</template>
<template #menu="{ row }">
<el-link
type="primary"
v-if="hasPermission('transport_vehicle_view')"
@click="openVehicle(row, true)"
>
查看
</el-link>
<el-link
type="primary"
v-if="hasPermission('transport_vehicle_edit')"
@click="openVehicle(row)"
>
修改
</el-link>
<el-link type="primary" @click="openVehicleLedger(row)">综合台账</el-link>
<el-link
type="primary"
v-if="hasPermission('transport_vehicle_status')"
@click="handleStatus(row)"
>
{{ row.status === 1 ? '停用' : '启用' }}
</el-link>
</template>
</avue-crud>
<vehicle-ledger v-model="vehicleLedgerBox" :vehicle="ledgerVehicle" />
<el-dialog
:title="dialogTitle"
append-to-body
v-model="vehicleBox"
width="92%"
top="4vh"
class="vehicle-dialog"
@closed="resetVehicle"
>
<el-form
ref="vehicleForm"
:model="vehicleForm"
:rules="formRules"
label-position="right"
label-width="calc(4em + 24px)"
:validate-on-rule-change="false"
:disabled="readonly"
class="vehicle-form archive-form"
>
<section-card title="基础车辆信息">
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="车牌号" prop="plateNo">
<el-input
v-model="vehicleForm.plateNo"
maxlength="8"
show-word-limit
placeholder="请输入"
@input="handlePlateNoInput"
@blur="validatePlateFormat"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="车辆类型" prop="vehicleType">
<el-select
v-model="vehicleForm.vehicleType"
filterable
allow-create
default-first-option
placeholder="请选择"
>
<el-option
v-for="item in vehicleTypeOptions"
: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="ownerName">
<el-input
v-model="vehicleForm.ownerName"
maxlength="100"
placeholder="行驶证登记的单位名称"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="车辆识别代号" prop="vin">
<el-input
v-model="vehicleForm.vin"
maxlength="17"
placeholder="选填,17位车架号"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="外廓尺寸">
<div class="dimension-group">
<div class="dimension-group__item">
<span class="dimension-group__label">长</span>
<el-input
v-model="vehicleForm.outerLength"
inputmode="numeric"
placeholder="请输入"
@input="vehicleForm.outerLength = digitsOnly($event)"
>
<template #suffix>mm</template>
</el-input>
</div>
<div class="dimension-group__item">
<span class="dimension-group__label">宽</span>
<el-input
v-model="vehicleForm.outerWidth"
inputmode="numeric"
placeholder="请输入"
@input="vehicleForm.outerWidth = digitsOnly($event)"
>
<template #suffix>mm</template>
</el-input>
</div>
<div class="dimension-group__item">
<span class="dimension-group__label">高</span>
<el-input
v-model="vehicleForm.outerHeight"
inputmode="numeric"
placeholder="请输入"
@input="vehicleForm.outerHeight = digitsOnly($event)"
>
<template #suffix>mm</template>
</el-input>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="核定载质量" prop="approvedLoadKg">
<el-input
v-model="vehicleForm.approvedLoadKg"
inputmode="numeric"
placeholder="请输入"
@input="vehicleForm.approvedLoadKg = digitsOnly($event)"
>
<template #suffix>KG</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="准牵引总质量" prop="tractionMassKg">
<el-input
v-model="vehicleForm.tractionMassKg"
inputmode="numeric"
placeholder="请输入"
@input="vehicleForm.tractionMassKg = digitsOnly($event)"
>
<template #suffix>KG</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="业务关系" prop="businessRelation">
<el-select v-model="vehicleForm.businessRelation" placeholder="请选择">
<el-option
v-for="item in businessRelationOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="能源类型" prop="energyType">
<el-select
v-model="vehicleForm.energyType"
filterable
allow-create
default-first-option
placeholder="请选择"
>
<el-option
v-for="item in energyTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="强制报废日期" prop="compulsoryScrapDate">
<div class="date-with-flag">
<el-date-picker
v-model="vehicleForm.compulsoryScrapDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
:disabled="vehicleForm.compulsoryScrapLongTerm === 1"
/>
<el-checkbox
v-model="vehicleForm.compulsoryScrapLongTerm"
:true-label="1"
:false-label="0"
@change="handleLongTermChange('compulsoryScrapDate', 'compulsoryScrapLongTerm')"
>
长期有效
</el-checkbox>
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="海关备案号" prop="customsRecordNo">
<el-input
v-model="vehicleForm.customsRecordNo"
maxlength="50"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<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="useDepartment">
<el-cascader
v-model="useDepartmentCascaderValue"
:options="organizationTreeOptions"
:props="organizationCascaderProps"
filterable
clearable
placeholder="请选择"
/>
</el-form-item>
</el-col>
</el-row>
</section-card>
<section-card title="车辆资质图片">
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="行驶证有效期" prop="drivingLicenseEndDate">
<div class="date-with-flag">
<el-date-picker
v-model="vehicleForm.drivingLicenseEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
:disabled="vehicleForm.drivingLicenseLongTerm === 1"
/>
<el-checkbox
v-model="vehicleForm.drivingLicenseLongTerm"
:true-label="1"
:false-label="0"
@change="handleLongTermChange('drivingLicenseEndDate', 'drivingLicenseLongTerm')"
>
长期有效
</el-checkbox>
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="道路运输证号" prop="roadTransportCertNo">
<el-input
v-model="vehicleForm.roadTransportCertNo"
maxlength="50"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="道路运输证有效期" prop="roadTransportCertEndDate">
<div class="date-with-flag">
<el-date-picker
v-model="vehicleForm.roadTransportCertEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
:disabled="vehicleForm.roadTransportCertLongTerm === 1"
/>
<el-checkbox
v-model="vehicleForm.roadTransportCertLongTerm"
:true-label="1"
:false-label="0"
@change="handleLongTermChange('roadTransportCertEndDate', 'roadTransportCertLongTerm')"
>
长期有效
</el-checkbox>
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="道路运输年审有效期" prop="annualReviewEndDate">
<div class="date-with-flag">
<el-date-picker
v-model="vehicleForm.annualReviewEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
:disabled="vehicleForm.annualReviewLongTerm === 1"
/>
<el-checkbox
v-model="vehicleForm.annualReviewLongTerm"
:true-label="1"
:false-label="0"
@change="handleLongTermChange('annualReviewEndDate', 'annualReviewLongTerm')"
>
长期有效
</el-checkbox>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="机动车登记编号" prop="registrationNo">
<el-input
v-model="vehicleForm.registrationNo"
maxlength="50"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="机动车登记日期" prop="registrationDate">
<el-date-picker
v-model="vehicleForm.registrationDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="行驶证档案编号" prop="drivingLicenseNo">
<el-input
v-model="vehicleForm.drivingLicenseNo"
maxlength="50"
placeholder="请输入"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<image-upload-field
label="行驶证主页正面"
prop="drivingLicenseImage"
:value="vehicleForm.drivingLicenseImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="vehicle"
@success="
(url, file) =>
handleVehicleCertificateUploadSuccess('drivingLicenseImage', url, file)
"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="行驶证主页反面"
prop="drivingLicenseMainBack"
:value="vehicleForm.drivingLicenseMainBack"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="vehicle"
@success="
(url, file) =>
handleVehicleCertificateUploadSuccess('drivingLicenseMainBack', url, file)
"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="行驶证副页正面"
prop="drivingLicenseViceFront"
:value="vehicleForm.drivingLicenseViceFront"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="vehicle"
@success="
(url, file) =>
handleVehicleCertificateUploadSuccess('drivingLicenseViceFront', url, file)
"
/>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<image-upload-field
label="行驶证副页反面"
prop="drivingLicenseViceBack"
:value="vehicleForm.drivingLicenseViceBack"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="vehicle"
@success="
(url, file) =>
handleVehicleCertificateUploadSuccess('drivingLicenseViceBack', url, file)
"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="道路运输证图片"
prop="roadTransportCertImage"
:value="vehicleForm.roadTransportCertImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="vehicle"
@success="url => handleVehicleCertificateUploadSuccess('roadTransportCertImage', url)"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="机动车登记本"
prop="registrationImage"
:value="vehicleForm.registrationImage"
:readonly="readonly"
:headers="uploadHeaders"
large
class-prefix="vehicle"
@success="
(url, file) =>
handleVehicleCertificateUploadSuccess('registrationImage', url, file)
"
/>
</el-col>
</el-row>
</section-card>
</el-form>
<template #footer>
<el-button @click="vehicleBox = 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 { option } from '@/option/transportCapacity/transport-vehicle';
import {
getList,
getDetail,
submit,
remove,
changeStatus,
getExpiryStat,
recognitionTransportCertificates,
recognizeBaiduOcr,
} from '@/api/transportCapacity/transport-vehicle';
import { getDeptTree } from '@/api/system/dept';
import organizationSearch from '@/mixins/organization-search';
import { exportBlob } from '@/api/common';
import { getToken, isAdministrator } from '@/utils/auth';
import { certStatusTag } from '@/utils/cert-audit';
import { getUploadHeaders } from '@/utils/upload';
import { downloadXls } from '@/utils/util';
import ImageUploadField from '@/components/image-upload-field/main.vue';
import VehicleLedger from './components/vehicle-ledger.vue';
const emptyForm = () => ({
id: '',
organizationName: '',
useDepartment: '',
plateNo: '',
ownerName: '',
plateColor: '',
vehicleType: '',
vin: '',
outerLength: undefined,
outerWidth: undefined,
outerHeight: undefined,
approvedLoadKg: undefined,
tractionMassKg: undefined,
businessRelation: '自有',
energyType: '',
compulsoryScrapDate: '',
compulsoryScrapLongTerm: 0,
customsRecordNo: '',
drivingLicenseNo: '',
drivingLicenseStartDate: '',
drivingLicenseEndDate: '',
drivingLicenseLongTerm: 0,
drivingLicenseImage: '',
drivingLicenseMainBack: '',
drivingLicenseViceFront: '',
drivingLicenseViceBack: '',
roadTransportCertNo: '',
roadTransportCertStartDate: '',
roadTransportCertEndDate: '',
roadTransportCertLongTerm: 0,
roadTransportCertImage: '',
annualReviewEndDate: '',
annualReviewLongTerm: 0,
registrationNo: '',
registrationDate: '',
registrationImage: '',
status: 1,
});
export default {
components: {
ImageUploadField,
VehicleLedger,
},
mixins: [organizationSearch],
data() {
return {
option,
form: {},
query: {
expireStatus: '',
},
searchForm: {
businessRelation: '',
},
loading: true,
submitLoading: false,
data: [],
ids: '',
selectionList: [],
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
expiryStat: {
total: 0,
within30: 0,
expired: 0,
},
vehicleBox: false,
vehicleLedgerBox: false,
ledgerVehicle: {},
readonly: false,
vehicleForm: emptyForm(),
vehicleCertificateUploads: {},
uploadHeaders: getUploadHeaders(),
organizationOptions: [],
vehicleTypeOptions: [
{ label: '高栏车', value: '高栏' },
{ label: '低栏车', value: '低栏' },
{ label: '平板车', value: '平板' },
{ label: '厢式车', value: '厢式' },
{ label: '拖挂车', value: '拖挂' },
{ label: '半挂车', value: '半挂' },
],
businessRelationOptions: ['自有', '管理挂靠', '业务合作'],
energyTypeOptions: ['柴油', '汽油', '新能源', '天然气'],
baseRules: {
// 后端已允许所属组织为空,这里不做必填校验
useDepartment: [{ required: true, message: '请选择使用部门', trigger: 'change' }],
plateNo: [
{ required: true, message: '请输入车牌号', trigger: 'change' },
{ validator: this.validatePlateNo, trigger: 'change' },
],
vehicleType: [{ required: true, message: '请选择车辆类型', trigger: 'change' }],
ownerName: [{ required: true, message: '请输入所有人', trigger: 'blur' }],
vin: [{ validator: this.validateVin, trigger: 'blur' }],
approvedLoadKg: [{ required: true, message: '请输入核定载质量', trigger: 'blur' }],
businessRelation: [{ required: true, message: '请选择业务关系', trigger: 'change' }],
energyType: [{ required: true, message: '请选择能源类型', trigger: 'change' }],
drivingLicenseNo: [{ required: true, message: '请输入行驶证档案编号', trigger: 'blur' }],
roadTransportCertNo: [{ required: true, message: '请输入道路运输证号', trigger: 'blur' }],
roadTransportCertEndDate: [
{ validator: this.validateRoadTransportCertEndDate, trigger: 'change' },
],
annualReviewEndDate: [{ validator: this.validateAnnualReviewEndDate, trigger: 'change' }],
registrationNo: [{ required: true, message: '请输入机动车登记编号', trigger: 'blur' }],
},
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
// 行驶证有效期:用 required 规则渲染必填星号;勾选「长期有效」后免填,星号同步消失
formRules() {
return {
...this.baseRules,
drivingLicenseEndDate:
this.vehicleForm.drivingLicenseLongTerm === 1
? []
: [{ required: true, message: '请选择行驶证有效期', trigger: 'change' }],
};
},
// 所属组织级联:表单存组织名称,级联控件用 id 路径,两者在此双向转换
organizationCascaderValue: {
get() {
return this.resolveOrgPath(this.vehicleForm.organizationName);
},
set(path) {
this.vehicleForm.organizationName = this.resolveOrgName(path);
},
},
// 使用部门级联:与所属组织同一套部门树
useDepartmentCascaderValue: {
get() {
return this.resolveOrgPath(this.vehicleForm.useDepartment);
},
set(path) {
this.vehicleForm.useDepartment = this.resolveOrgName(path);
},
},
isAdmin() {
const authority = this.userInfo.authority || '';
return isAdministrator(this.userInfo);
},
permissionList() {
return {
addBtn: this.hasPermission('transport_vehicle_add'),
viewBtn: this.hasPermission('transport_vehicle_view'),
delBtn: this.hasPermission('transport_vehicle_delete'),
editBtn: this.hasPermission('transport_vehicle_edit'),
};
},
dialogTitle() {
if (this.readonly) {
return '查看车辆';
}
return this.vehicleForm.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.vehicleBox && !this.vehicleForm.id && !this.vehicleForm.organizationName) {
this.vehicleForm.organizationName = this.getCurrentOrganizationName();
}
if (this.vehicleBox && !this.vehicleForm.useDepartment) {
this.vehicleForm.useDepartment = 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 || '';
},
digitsOnly(value = '') {
return String(value).replace(/\D/g, '');
},
validatePlateNo(rule, value, callback) {
const plateNo = String(value || '')
.trim()
.toUpperCase();
if (!plateNo) {
callback(new Error('请输入车牌号'));
return;
}
if (!/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼港澳]{1}[A-Z][A-Z0-9挂学警港澳]{5,6}$/.test(plateNo)) {
callback(new Error('请输入标准车牌号(如:京A12345)'));
return;
}
if (this.vehicleForm.plateNo !== plateNo) {
this.vehicleForm.plateNo = plateNo;
}
callback();
},
// 车辆识别代号(车架号)为选填:留空放行,填了就必须是 17 位
validateVin(rule, value, callback) {
const vin = String(value || '')
.trim()
.toUpperCase();
if (!vin) {
callback();
return;
}
if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(vin)) {
callback(new Error('车架号必须是17位字母数字(不含 I、O、Q)'));
return;
}
if (this.vehicleForm.vin !== vin) {
this.vehicleForm.vin = vin;
}
callback();
},
validateAnnualReviewEndDate(rule, value, callback) {
if (this.vehicleForm.annualReviewLongTerm !== 1 && !value) {
callback(new Error('请选择道路运输年审有效期'));
return;
}
callback();
},
// 长期有效勾选:勾选时清空对应日期(不可编辑由 date-picker :disabled 控制),取消勾选后可重新填报
handleLongTermChange(dateProp, longTermProp) {
if (this.vehicleForm[longTermProp] === 1) {
this.vehicleForm[dateProp] = '';
}
this.$refs.vehicleForm?.validateField(dateProp);
},
validateRoadTransportCertEndDate(rule, value, callback) {
if (this.vehicleForm.roadTransportCertLongTerm === 1 && !value) {
callback();
return;
}
if (!value) {
callback(new Error('请选择道路运输证有效期'));
return;
}
callback();
},
openVehicle(row, readonly = false) {
this.readonly = readonly;
if (!row?.id) {
this.vehicleForm = emptyForm();
this.vehicleForm.organizationName = this.getCurrentOrganizationName();
this.vehicleForm.useDepartment = this.getCurrentOrganizationName();
this.syncPlateNo(false);
this.vehicleBox = true;
return;
}
getDetail(row.id).then(res => {
this.vehicleForm = {
...emptyForm(),
...(res.data.data || {}),
};
this.normalizeOptionalNumberFields(this.vehicleForm);
if (!this.vehicleForm.useDepartment) {
this.vehicleForm.useDepartment = this.getCurrentOrganizationName();
}
this.splitPlateNo();
this.syncPlateNo(false);
this.vehicleBox = true;
});
},
openVehicleLedger(row) {
this.ledgerVehicle = { ...row };
this.vehicleLedgerBox = true;
},
resetVehicle() {
this.vehicleForm = emptyForm();
this.vehicleCertificateUploads = {};
this.readonly = false;
this.submitLoading = false;
this.$refs.vehicleForm?.clearValidate();
},
setImage(prop, url) {
this.vehicleForm[prop] = url;
this.$refs.vehicleForm?.validateField(prop);
},
handleVehicleCertificateUploadSuccess(prop, url) {
this.setImage(prop, url);
this.vehicleCertificateUploads[prop] = url;
if (prop.startsWith('drivingLicense')) {
if (prop.endsWith('Back')) {
this.recognizeBaiduVehicleOcr(url, 'general', '', '行驶证', data => {
this.applyBaiduVehicleLicenseGeneralRecognition(data);
});
return;
}
const side = prop === 'drivingLicenseViceFront' ? 'back' : 'front';
this.recognizeBaiduVehicleOcr(url, 'vehicle_license', side, '行驶证', data => {
this.applyBaiduVehicleLicenseRecognition(data);
});
return;
}
if (prop === 'roadTransportCertImage') {
this.recognizeBaiduVehicleOcr(
url,
'road_transport_certificate',
'',
'道路运输证',
data => {
this.applyBaiduRoadTransportCertificateRecognition(data);
}
);
return;
}
this.recognizeVehicleCertificates();
},
recognizeBaiduVehicleOcr(url, type, side, 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, type, side)
.then(res => {
applyRecognition(res.data.data?.result || {});
this.$message.success(`${documentName}识别完成`);
})
.catch(() => {
this.$message.warning(`${documentName}图片上传成功,自动识别失败,请手动填写车辆资质信息`);
})
.finally(() => {
loading.close();
});
},
recognizeVehicleCertificates() {
const objectKeys = [
this.vehicleCertificateUploads.registrationImage || this.vehicleForm.registrationImage,
].filter(Boolean);
if (!objectKeys.length) {
this.$message.warning('车辆资质图片上传成功,未获取到图片地址,无法自动识别');
return;
}
const loading = ElLoading.service({
lock: true,
text: '车辆资质识别中',
background: 'rgba(255, 255, 255, 0.7)',
});
recognitionTransportCertificates(objectKeys)
.then(res => {
this.applyVehicleCertificateRecognition(res.data.data || {});
this.$message.success('车辆资质识别完成');
})
.catch(() => {
this.$message.warning('车辆资质图片上传成功,自动识别失败,请手动填写车辆资质信息');
})
.finally(() => {
loading.close();
});
},
applyBaiduVehicleLicenseRecognition(data = {}) {
const plateNo = this.getBaiduOcrValue(data, ['号牌号码', '车牌号码']);
const vehicleType = this.getBaiduOcrValue(data, ['车辆类型']);
const drivingLicenseNo = this.getBaiduOcrValue(data, ['档案编号']);
const inspectionValidity = this.getBaiduOcrValue(data, ['检验有效期至', '检验有效期', '检验记录']);
const registrationDate = this.getBaiduOcrValue(data, ['注册日期']);
const registrationNo = this.getBaiduOcrValue(data, ['车辆识别代号', '车架号']);
const approvedLoadKg = this.getBaiduOcrValue(data, ['核定载质量']);
const outerDimensions = this.getBaiduOcrValue(data, ['外廓尺寸']);
if (plateNo) {
this.vehicleForm.plateNo = String(plateNo).replace(/\s/g, '').toUpperCase();
this.splitPlateNo();
this.syncPlateNo(false);
}
if (vehicleType) {
this.vehicleForm.vehicleType = this.normalizeVehicleType(vehicleType);
}
if (drivingLicenseNo) {
this.vehicleForm.drivingLicenseNo = String(drivingLicenseNo).replace(/\s/g, '');
}
this.applyDrivingLicenseValidity(inspectionValidity);
if (registrationDate) {
this.vehicleForm.registrationDate = this.normalizeDate(registrationDate, 'first');
}
if (registrationNo) {
this.vehicleForm.registrationNo = String(registrationNo).replace(/\s/g, '');
}
if (approvedLoadKg) {
this.vehicleForm.approvedLoadKg = this.normalizeVehicleNumber(approvedLoadKg);
}
this.applyOuterDimensions(outerDimensions);
this.$nextTick(() => {
[
'plateNo',
'vehicleType',
'drivingLicenseNo',
'drivingLicenseEndDate',
'registrationDate',
'registrationNo',
'approvedLoadKg',
'outerLength',
'outerWidth',
'outerHeight',
].forEach(prop => {
this.$refs.vehicleForm?.validateField(prop);
});
});
},
applyBaiduVehicleLicenseGeneralRecognition(data = {}) {
const words = this.getBaiduOcrWords(data);
const getValue = labels => this.getBaiduGeneralOcrValue(words, labels);
this.applyBaiduVehicleLicenseRecognition({
words_result: {
号牌号码: { words: getValue(['号牌号码', '车牌号码']) },
车辆类型: { words: getValue(['车辆类型']) },
档案编号: { words: getValue(['档案编号']) },
检验有效期至: { words: getValue(['检验有效期至', '检验有效期', '检验记录']) },
注册日期: { words: getValue(['注册日期']) },
车辆识别代号: { words: getValue(['车辆识别代号', '车架号']) },
核定载质量: { words: getValue(['核定载质量']) },
外廓尺寸: { words: getValue(['外廓尺寸']) },
},
});
},
normalizeVehicleNumber(value = '') {
const number = String(value || '').replace(/,/g, '').match(/-?\d+(?:\.\d+)?/);
return number ? number[0] : '';
},
applyOuterDimensions(value = '') {
const dimensions = String(value || '').match(/\d+(?:\.\d+)?/g) || [];
if (dimensions[0]) this.vehicleForm.outerLength = dimensions[0];
if (dimensions[1]) this.vehicleForm.outerWidth = dimensions[1];
if (dimensions[2]) this.vehicleForm.outerHeight = dimensions[2];
},
applyBaiduRoadTransportCertificateRecognition(data = {}) {
const certificateNo = this.getBaiduOcrValue(data, ['道路运输证号', '证号']);
const validity = this.getBaiduOcrValue(data, ['有效期至', '有效期限', '有效期']);
if (certificateNo) {
this.vehicleForm.roadTransportCertNo = String(certificateNo).replace(/\s/g, '');
}
this.applyRoadTransportCertificateValidity(validity);
this.$nextTick(() => {
['roadTransportCertNo', 'roadTransportCertEndDate'].forEach(prop => {
this.$refs.vehicleForm?.validateField(prop);
});
});
},
applyRoadTransportCertificateValidity(validity = '') {
const value = String(validity || '');
if (!value) return;
if (/(长期|永久)/.test(value)) {
this.vehicleForm.roadTransportCertLongTerm = 1;
this.vehicleForm.roadTransportCertEndDate = '';
return;
}
const endDate = this.normalizeDate(value, 'last');
if (endDate) {
this.vehicleForm.roadTransportCertEndDate = endDate;
this.vehicleForm.roadTransportCertLongTerm = 0;
}
},
applyDrivingLicenseValidity(validity = '') {
const value = String(validity || '');
if (!value) return;
if (/(长期|永久)/.test(value)) {
this.vehicleForm.drivingLicenseLongTerm = 1;
this.vehicleForm.drivingLicenseEndDate = '';
return;
}
const endDate = this.normalizeDate(value, 'last');
if (endDate) {
this.vehicleForm.drivingLicenseEndDate = endDate;
this.vehicleForm.drivingLicenseLongTerm = 0;
}
},
getBaiduOcrValue(data = {}, keys = []) {
const wordsResult = data.words_result || data.wordsResult || {};
const normalizedKeys = keys.map(key => String(key).toLowerCase());
if (Array.isArray(wordsResult)) {
const wordItem = wordsResult.find(item =>
normalizedKeys.includes(String(item.key || item.name || '').toLowerCase())
);
return wordItem?.words || wordItem?.word || wordItem?.value || '';
}
const wordEntry = Object.entries(wordsResult).find(([key]) =>
normalizedKeys.includes(String(key).toLowerCase())
);
if (!wordEntry) return '';
const value = wordEntry[1];
if (Array.isArray(value)) {
const item = value.find(entry => entry?.words || entry?.word || entry?.value);
return item?.words || item?.word || item?.value || '';
}
return typeof value === 'object' ? value.words || value.word || value.value || '' : value || '';
},
getBaiduOcrWords(data = {}) {
const wordsResult = data.words_result || data.wordsResult || {};
if (Array.isArray(wordsResult)) {
return wordsResult.map(item => item.words || item.word || item.value || '').filter(Boolean);
}
return Object.values(wordsResult)
.map(item => {
if (Array.isArray(item)) {
const entry = item.find(value => value?.words || value?.word || value?.value);
return entry?.words || entry?.word || entry?.value || '';
}
return typeof item === 'object' ? item.words || item.word || item.value || '' : item || '';
})
.filter(Boolean);
},
getBaiduGeneralOcrValue(words = [], labels = []) {
const normalizedWords = words.map(word => String(word || '').trim()).filter(Boolean);
for (const label of labels) {
const labelIndex = normalizedWords.findIndex(word => word.includes(label));
if (labelIndex < 0) continue;
const inlineValue = normalizedWords[labelIndex]
.replace(new RegExp(`^.*${label}\\s*[::]?`), '')
.trim();
if (inlineValue) return inlineValue;
if (normalizedWords[labelIndex + 1]) return normalizedWords[labelIndex + 1];
}
return '';
},
applyVehicleCertificateRecognition(data = {}) {
const plateNo = data.drivingPlateNo || data.vehicleLicensePlateNo || '';
const vehicleType = data.vehicleLicenseVehicleType || data.vehicleType || '';
const drivingLicenseNo =
data.vehicleLicenseFileNo || data.drivingLicenseNo || data.fileNo || '';
const drivingLicenseEndDate =
data.vehicleLicenseInspectionValidDate ||
data.drivingLicenseEndDate ||
data.vehicleLicenseValidDateEnd ||
'';
const registrationDate =
data.vehicleLicenseRegisterDate || data.registrationDate || data.dateRegister || '';
const registrationNo = data.registrationNo || data.vehicleLicenseNo || '';
if (plateNo) {
this.vehicleForm.plateNo = plateNo;
this.splitPlateNo();
this.syncPlateNo(false);
}
if (vehicleType) {
this.vehicleForm.vehicleType = this.normalizeVehicleType(vehicleType);
}
if (drivingLicenseNo) {
this.vehicleForm.drivingLicenseNo = drivingLicenseNo;
}
if (drivingLicenseEndDate) {
this.vehicleForm.drivingLicenseEndDate = this.normalizeDate(drivingLicenseEndDate, 'last');
this.vehicleForm.drivingLicenseLongTerm = 0;
}
if (registrationDate) {
this.vehicleForm.registrationDate = this.normalizeDate(registrationDate, 'first');
}
if (registrationNo) {
this.vehicleForm.registrationNo = registrationNo;
}
this.$nextTick(() => {
[
'plateNo',
'vehicleType',
'drivingLicenseNo',
'drivingLicenseEndDate',
'registrationDate',
'registrationNo',
].forEach(prop => {
this.$refs.vehicleForm?.validateField(prop);
});
});
},
/**
* OCR 日期常为区间(如 2022-08-17至2024-01-05)、无分隔符(如 20260317)、
* 年月日/点分(2030年05月20日、2018.3.9)或任意非数字分隔。
* 对齐司机侧 normalizeBirthday / 后端 OcrFieldSupport:年与月、月与日之间允许任意非数字。
* prefer=first 取登记类起始日,prefer=last 取有效期截止日。
* 返回值统一为 YYYY-MM-DD,供 el-date-picker 展示与后端 LocalDate 反序列化。
*/
normalizeDate(value = '', prefer = 'first') {
const dateValue = String(value || '').trim();
if (!dateValue) return '';
if (/长期|永久/.test(dateValue)) return '';
const candidates = [];
const loosePattern = /(\d{4})\D+(\d{1,2})\D+(\d{1,2})/g;
let match = loosePattern.exec(dateValue);
while (match) {
const normalized = this.toValidDateText(match[1], match[2], match[3]);
if (normalized) {
candidates.push(normalized);
}
match = loosePattern.exec(dateValue);
}
if (!candidates.length) {
const compact = dateValue.replace(/\D/g, '');
if (compact.length === 8) {
const normalized = this.toValidDateText(
compact.slice(0, 4),
compact.slice(4, 6),
compact.slice(6, 8)
);
if (normalized) {
candidates.push(normalized);
}
}
}
if (!candidates.length) return '';
return prefer === 'last' ? candidates[candidates.length - 1] : candidates[0];
},
toValidDateText(yearText, monthText, dayText) {
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return '';
}
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return '';
}
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
},
normalizeVehicleDateFields(form = {}) {
const dateFields = [
['compulsoryScrapDate', 'first'],
['drivingLicenseStartDate', 'first'],
['drivingLicenseEndDate', 'last'],
['roadTransportCertStartDate', 'first'],
['roadTransportCertEndDate', 'last'],
['annualReviewEndDate', 'last'],
['registrationDate', 'first'],
];
dateFields.forEach(([prop, prefer]) => {
if (form[prop] === undefined || form[prop] === null || form[prop] === '') {
form[prop] = null;
return;
}
const normalized = this.normalizeDate(form[prop], prefer);
// 无法识别或空串统一置 null,避免把 "" / "20260317" 原样提交给 LocalDate
form[prop] = normalized || null;
});
return form;
},
normalizeVehicleType(value = '') {
const vehicleType = String(value || '').trim();
const option = this.vehicleTypeOptions.find(
item => item.value === vehicleType || item.label === vehicleType
);
return option ? option.value : vehicleType;
},
handlePlateNoInput(value) {
this.vehicleForm.plateNo = String(value || '')
.replace(/\s/g, '')
.toUpperCase();
},
validatePlateFormat() {
this.$refs.vehicleForm?.validateField('plateNo');
},
syncPlateNo(validate = true) {
const plateNo = String(this.vehicleForm.plateNo || '')
.replace(/\s/g, '')
.toUpperCase();
this.vehicleForm.plateNo = plateNo;
if (validate) {
this.$refs.vehicleForm?.validateField('plateNo');
}
},
splitPlateNo() {
// 车牌号已统一为单一 input,无需拆分
},
handleSubmit() {
this.syncPlateNo();
this.normalizeVehicleDateFields(this.vehicleForm);
this.$refs.vehicleForm.validate(valid => {
if (!valid) {
return;
}
this.submitLoading = true;
const payload = this.normalizeVehicleDateFields({ ...this.vehicleForm });
this.normalizeOptionalNumberFields(payload, null);
submit(payload)
.then(() => {
this.$message.success('操作成功');
this.vehicleBox = false;
this.onLoad(this.page);
})
.finally(() => {
this.submitLoading = false;
});
});
},
/**
* 可选数字字段:空串 / -1(BladeX null 占位)归一为 emptyValue,避免查看时显示 -1
*/
normalizeOptionalNumberFields(form = {}, emptyValue = undefined) {
['outerLength', 'outerWidth', 'outerHeight', 'tractionMassKg'].forEach(key => {
const value = form[key];
if (
value === undefined ||
value === null ||
value === '' ||
Number(value) === -1 ||
String(value) === '-1'
) {
form[key] = emptyValue;
}
});
return form;
},
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 = {
businessRelation: '',
};
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-vehicle/export-transport-vehicle',
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 '';
},
// 认证状态展示的是「最新一次提交的状态」缓存,不代表该车当前是否已认证
// (是否已认证由生效快照指针决定,即曾审核通过即永久保持)。
// 证书是否临近到期另有列表顶部的到期标签与日期列标色展示,不在这里推断。
getCertificationStatus(row) {
return certStatusTag(row.certificationStatus);
},
},
};
</script>
<style lang="scss" scoped>
.transport-vehicle-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;
}
}
// 编辑弹窗:灰底 + section-card 白卡分组
.vehicle-form {
:deep(.el-form-item__label) {
height: auto;
padding-right: 12px;
white-space: normal !important;
word-break: break-all;
overflow-wrap: anywhere;
line-height: 18px;
text-align: right;
}
:deep(.el-form-item) {
align-items: center;
}
: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;
}
:deep(.el-form-item__label) {
flex: 0 0 calc(4em + 24px) !important;
width: calc(4em + 24px) !important;
}
}
.vehicle-form {
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper),
:deep(.el-select),
:deep(.el-input),
:deep(.el-input-number),
:deep(.el-cascader) {
width: 100%;
}
.dimension-group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: center;
width: 100%;
}
.dimension-group__item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.dimension-group__item :deep(.el-input) {
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
.dimension-group__label {
flex-shrink: 0;
color: #303133;
}
.date-with-flag {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.date-with-flag :deep(.el-date-editor) {
flex: 1 1 auto;
min-width: 0;
width: 100%;
}
.date-with-flag :deep(.el-checkbox) {
flex-shrink: 0;
margin-right: 0;
}
.date-with-flag :deep(.el-checkbox__label) {
padding-left: 4px;
}
.date-range {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
}
// 证件照上传:宽度与同列表单项(输入框)对齐,高度按身份证 1.586:1 比例自适应
:deep(.vehicle-uploader) {
display: block;
width: 100%;
}
:deep(.vehicle-uploader .el-upload),
:deep(.vehicle-uploader.el-upload) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 170px;
border: 1px dashed #c0c4cc;
background: #fafafa;
overflow: hidden;
}
:deep(.vehicle-uploader--large) {
display: block;
width: 100%;
margin: 0;
}
:deep(.vehicle-uploader--large .el-upload),
:deep(.vehicle-uploader--large.el-upload) {
width: 100%;
height: auto;
aspect-ratio: 1.586 / 1;
}
:deep(.vehicle-uploader__image) {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
:deep(.vehicle-uploader__empty) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #606266;
}
.date-warning {
color: #d40000;
font-weight: 600;
}
.date-danger {
color: #d40000;
font-weight: 600;
}
.el-form-item {
margin-bottom: 24px !important;
}
</style>