Files
tms-erp-web/src/views/transportCapacity/driver.vue
T

2078 lines
72 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="driver-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('driver_add')"
@click="openDriver()"
>新增
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="hasPermission('driver_export')"
@click="handleExport"
>导出
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="hasPermission('driver_delete')"
@click="handleDelete"
>批量删除
</el-button>
</template>
<template #header>
<div class="driver-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 #driverName="{ row }">
<el-link type="primary" @click="openDriver(row, true)">{{ row.driverName }}</el-link>
</template>
<template #idCardNo="{ row }">
<span>{{ row.idCardNo || '-' }}</span>
</template>
<template #organizationName="{ row }">
<span
v-if="isBlankOrganizationName(row.organizationName)"
class="organization-name--empty"
>未分配组织</span>
<span v-else>{{ String(row.organizationName || '').replace(/^[\s\u3000]+/, '') }}</span>
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'primary' : 'info'" class="status-text">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
<template #drivingLicenseEndDate="{ row }">
<span
:class="{
'driver-page__date-expiring': isDateExpiring(
row,
'drivingLicenseEndDate',
'drivingLicenseLongTerm'
),
}"
>
{{ formatEndDate(row.drivingLicenseEndDate, row.drivingLicenseLongTerm) }}
</span>
</template>
<template #qualificationEndDate="{ row }">
<span
:class="{
'driver-page__date-expiring': isDateExpiring(
row,
'qualificationEndDate',
'qualificationLongTerm'
),
}"
>
{{ formatEndDate(row.qualificationEndDate, row.qualificationLongTerm) }}
</span>
</template>
<template #menu="{ row }">
<el-link type="primary" v-if="hasPermission('driver_view')" @click="openDriver(row, true)">
查看
</el-link>
<el-link type="primary" v-if="hasPermission('driver_edit')" @click="openDriver(row)">
修改
</el-link>
<el-link type="primary" v-if="hasPermission('driver_status')" @click="handleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-link>
</template>
</avue-crud>
<el-dialog
:title="dialogTitle"
append-to-body
v-model="driverBox"
width="90%"
top="4vh"
class="driver-dialog"
@closed="resetDriver"
>
<el-form
ref="driverForm"
:model="driverForm"
:rules="formRules"
label-position="right"
label-width="calc(4em + 24px)"
:disabled="readonly"
class="driver-form archive-form"
>
<section-card title="基础身份信息">
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="司机姓名" prop="driverName">
<el-input
v-model="driverForm.driverName"
maxlength="10"
show-word-limit
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="身份证号" prop="idCardNo">
<el-input
v-model="driverForm.idCardNo"
maxlength="18"
show-word-limit
placeholder="请输入"
@blur="handleIdCardBlur"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="出生年月" prop="birthday">
<el-date-picker
v-model="driverForm.birthday"
type="date"
value-format="YYYY-MM-DD"
placeholder="请选择出生年月"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="性别" prop="gender">
<el-select
v-model="driverForm.gender"
clearable
placeholder="请选择"
:validate-event="submitAttempted"
>
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="民族" prop="nation">
<el-select v-model="driverForm.nation" clearable filterable placeholder="请选择">
<el-option
v-for="item in nationOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="学历" prop="education">
<el-select
v-model="driverForm.education"
clearable
filterable
placeholder="请选择"
>
<el-option
v-for="item in educationOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="住址" prop="addressRegion">
<!-- 只读直接显示库里那串区划名称:级联要先按名称反解 path,区划改名后反解不出来,
同一串文字就会在灰字 placeholder 与黑字标签之间摇摆 -->
<el-input v-if="readonly" :model-value="driverForm.addressRegion || '—'" readonly />
<el-cascader
v-else
ref="addressRegionCascader"
v-model="driverForm.addressRegionPath"
:options="regionOptions"
:props="regionCascaderProps"
:placeholder="driverForm.addressRegion || '请选择省/市/区'"
clearable
filterable
@change="handleAddressRegionChange"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="详细地址" prop="address">
<el-input
v-model="driverForm.address"
maxlength="200"
show-word-limit
placeholder="请输入详细地址(街道、门牌号)"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="驾驶车辆" prop="drivingVehicle">
<el-input
v-model="driverForm.drivingVehicle"
maxlength="30"
clearable
readonly
placeholder="请选择车辆"
:disabled="readonly"
@clear="driverForm.drivingVehicle = ''"
@click="!readonly && openVehicleSelector()"
>
<template #append>
<el-tooltip content="选择车辆" placement="top">
<el-button
:icon="List"
:disabled="readonly"
aria-label="选择车辆"
@click="openVehicleSelector"
/>
</el-tooltip>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="岗位" prop="postList">
<el-checkbox-group v-model="driverForm.postList">
<el-checkbox label="司机" />
<el-checkbox label="押运员" />
<el-checkbox label="装卸管理员" />
</el-checkbox-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<image-upload-field
label="身份证正面照"
prop="idCardFront"
:value="driverForm.idCardFront"
:readonly="readonly"
:headers="uploadHeaders"
large
@success="url => handleIdCardUploadSuccess('idCardFront', url)"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="身份证反面照"
prop="idCardBack"
:value="driverForm.idCardBack"
:readonly="readonly"
:headers="uploadHeaders"
large
@success="url => handleIdCardUploadSuccess('idCardBack', url)"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="大头照"
prop="headPhoto"
:value="driverForm.headPhoto"
:readonly="readonly"
:headers="uploadHeaders"
large
@success="url => setImage('headPhoto', url)"
/>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="身份证有效期" prop="idCardValidEnd">
<div class="date-range">
<el-date-picker
v-model="driverForm.idCardValidStart"
type="date"
value-format="YYYY-MM-DD"
placeholder="起"
:disabled="driverForm.idCardLongTerm === 1"
@change="validateFormField('idCardValidEnd')"
/>
<el-date-picker
v-model="driverForm.idCardValidEnd"
type="date"
value-format="YYYY-MM-DD"
placeholder="止"
:disabled="driverForm.idCardLongTerm === 1"
@change="validateFormField('idCardValidEnd')"
/>
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="长期有效" prop="idCardLongTerm">
<el-checkbox
v-model="driverForm.idCardLongTerm"
:true-label="1"
:false-label="0"
@change="handleIdCardLongTermChange"
>
是
</el-checkbox>
</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="drivingType">
<el-select
v-model="driverForm.drivingType"
clearable
filterable
allow-create
default-first-option
placeholder="请选择"
:validate-event="submitAttempted"
>
<el-option
v-for="item in drivingTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="档案编号" prop="drivingLicenseNo">
<el-input
v-model="driverForm.drivingLicenseNo"
maxlength="50"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="有效期" prop="drivingLicenseEndDate">
<div class="date-range">
<el-date-picker
v-model="driverForm.drivingLicenseStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="起"
:disabled="driverForm.drivingLicenseLongTerm === 1"
@change="validateFormField('drivingLicenseEndDate')"
/>
<el-date-picker
v-model="driverForm.drivingLicenseEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="止"
:disabled="driverForm.drivingLicenseLongTerm === 1"
@change="validateFormField('drivingLicenseEndDate')"
/>
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="长期有效" prop="drivingLicenseLongTerm">
<el-checkbox
v-model="driverForm.drivingLicenseLongTerm"
:true-label="1"
:false-label="0"
@change="validateFormField('drivingLicenseEndDate')"
>
是
</el-checkbox>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<image-upload-field
label="驾驶证主页"
prop="drivingLicenseFront"
:value="driverForm.drivingLicenseFront"
:readonly="readonly"
:headers="uploadHeaders"
large
@success="
(url, file) => handleDrivingLicenseUploadSuccess('drivingLicenseFront', url, file)
"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="驾驶证副页"
prop="drivingLicenseBack"
:value="driverForm.drivingLicenseBack"
:readonly="readonly"
:headers="uploadHeaders"
large
@success="
(url, file) => handleDrivingLicenseUploadSuccess('drivingLicenseBack', url, file)
"
/>
</el-col>
</el-row>
</section-card>
<section-card title="从业资格证信息">
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="从业资格认证类型" prop="qualificationType">
<el-select
v-model="driverForm.qualificationType"
clearable
filterable
placeholder="请选择"
:validate-event="submitAttempted"
>
<el-option
v-for="item in qualificationTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="资格证号" prop="qualificationNo">
<el-input
v-model="driverForm.qualificationNo"
maxlength="50"
placeholder="默认身份证号"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="有效期" prop="qualificationEndDate">
<div class="date-range">
<el-date-picker
v-model="driverForm.qualificationStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="起"
:disabled="driverForm.qualificationLongTerm === 1"
@change="validateFormField('qualificationEndDate')"
/>
<el-date-picker
v-model="driverForm.qualificationEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="止"
:disabled="driverForm.qualificationLongTerm === 1"
@change="validateFormField('qualificationEndDate')"
/>
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="长期有效" prop="qualificationLongTerm">
<el-checkbox
v-model="driverForm.qualificationLongTerm"
:true-label="1"
:false-label="0"
@change="validateFormField('qualificationEndDate')"
>
是
</el-checkbox>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="18">
<el-col :span="6">
<image-upload-field
label="封面页"
prop="qualificationFront"
:value="driverForm.qualificationFront"
:readonly="readonly"
:headers="uploadHeaders"
large
@success="url => handleQualificationUploadSuccess('qualificationFront', url)"
/>
</el-col>
<el-col :span="6">
<image-upload-field
label="内容页"
prop="qualificationBack"
:value="driverForm.qualificationBack"
:readonly="readonly"
:headers="uploadHeaders"
large
@success="url => handleQualificationUploadSuccess('qualificationBack', url)"
/>
</el-col>
</el-row>
</section-card>
<section-card title="司机联系信息">
<el-row :gutter="18">
<el-col :span="6">
<el-form-item label="司机类型" prop="driverType">
<el-select v-model="driverForm.driverType" filterable placeholder="请选择">
<el-option label="自有" value="自有" />
<el-option label="外协" value="外协" />
<el-option label="承运管理员" value="承运管理员" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="手机号" prop="mobile">
<el-input
v-model="driverForm.mobile"
maxlength="20"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="紧急联系人姓名" prop="emergencyContactName">
<el-input
v-model="driverForm.emergencyContactName"
maxlength="20"
placeholder="请输入"
@input="driverForm.emergencyContactName = removeDigits($event)"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="紧急联系人手机号" prop="emergencyContactMobile">
<el-input
v-model="driverForm.emergencyContactMobile"
maxlength="20"
placeholder="请输入"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="与联系人关系" prop="contactRelation">
<el-select
v-model="driverForm.contactRelation"
clearable
filterable
allow-create
default-first-option
placeholder="请选择"
>
<el-option
v-for="item in relationOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</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-row>
</section-card>
</el-form>
<template #footer>
<el-button @click="driverBox = false">取消</el-button>
<el-button type="primary" v-if="!readonly" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="vehicleSelectorVisible"
title="选择车辆"
append-to-body
width="92%"
top="4vh"
class="vehicle-selector-dialog"
@closed="resetVehicleSelector"
>
<avue-crud
v-if="vehicleSelectorVisible"
ref="vehicleSelectorCrud"
v-model="vehicleSelectorForm"
v-model:page="vehicleSelectorPage"
:option="vehicleSelectorOption"
:table-loading="vehicleSelectorLoading"
:data="vehicleSelectorData"
@search-change="vehicleSelectorSearchChange"
@search-reset="vehicleSelectorSearchReset"
@current-change="vehicleSelectorCurrentChange"
@size-change="vehicleSelectorSizeChange"
@refresh-change="vehicleSelectorRefreshChange"
@on-load="loadVehicleSelector"
>
<template #plateNo="{ row }">
<span>{{ row.plateNo || '-' }}</span>
</template>
<template #organizationName="{ row }">
<span
v-if="isBlankOrganizationName(row.organizationName)"
class="organization-name--empty"
>未分配组织</span>
<span v-else>{{ String(row.organizationName || '').replace(/^[\s\u3000]+/, '') }}</span>
</template>
<template #boundDriver="{ row }">
<span>{{ row.boundDriver || '-' }}</span>
</template>
<template #certificationStatus="{ row }">
<el-tag :type="getVehicleCertificationStatus(row).type" class="status-text">
{{ getVehicleCertificationStatus(row).label }}
</el-tag>
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'primary' : 'info'" class="status-text">
{{ row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
<template #drivingLicenseEndDate="{ row }">
{{ formatEndDate(row.drivingLicenseEndDate, row.drivingLicenseLongTerm) }}
</template>
<template #roadTransportCertEndDate="{ row }">
{{ formatEndDate(row.roadTransportCertEndDate, row.roadTransportCertLongTerm) }}
</template>
<template #annualReviewEndDate="{ row }">
{{ formatEndDate(row.annualReviewEndDate, row.annualReviewLongTerm) }}
</template>
<template #menu="{ row }">
<el-link type="primary" @click="selectDrivingVehicle(row)">选择</el-link>
</template>
</avue-crud>
</el-dialog>
</basic-container>
</template>
<script>
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import { ElLoading } from 'element-plus';
import { List } from '@element-plus/icons-vue';
import { option } from '@/option/transportCapacity/driver';
import { option as transportVehicleOption } from '@/option/transportCapacity/transport-vehicle';
import {
getList,
getDetail,
submit,
remove,
changeStatus,
getExpiryStat,
recognitionIDCard,
recognitionTransportCertificates,
recognizeBaiduOcr,
} from '@/api/transportCapacity/driver';
import { getList as getVehicleList } from '@/api/transportCapacity/transport-vehicle';
import { getDeptTree } from '@/api/system/dept';
import organizationSearch from '@/mixins/organization-search';
import { getDictionary } from '@/api/system/dictbiz';
import { getLazyTree } from '@/api/base/region';
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';
const vehicleSelectorOption = {
...transportVehicleOption,
height: 430,
selection: false,
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
columnBtn: false,
menuWidth: 90,
menuFixed: 'right',
column: transportVehicleOption.column.map(column => ({
...column,
dicData: Array.isArray(column.dicData)
? column.dicData.map(item => ({ ...item }))
: column.dicData,
})),
};
const emptyForm = () => ({
id: '',
driverName: '',
idCardNo: '',
birthday: '',
gender: '',
nation: '',
education: '',
addressRegionPath: [],
addressRegion: '',
address: '',
drivingVehicle: '',
postList: ['司机'],
posts: '',
idCardFront: '',
idCardBack: '',
idCardValidStart: '',
idCardValidEnd: '',
idCardLongTerm: 0,
headPhoto: '',
drivingType: '',
drivingLicenseNo: '',
drivingLicenseStartDate: '',
drivingLicenseEndDate: '',
drivingLicenseLongTerm: 0,
drivingLicenseFront: '',
drivingLicenseBack: '',
qualificationType: '',
qualificationNo: '',
qualificationStartDate: '',
qualificationEndDate: '',
qualificationLongTerm: 0,
qualificationFront: '',
qualificationBack: '',
driverType: '自有',
mobile: '',
contactRelation: '',
organizationName: '',
emergencyContactName: '',
emergencyContactMobile: '',
status: 1,
});
export default {
components: {
ImageUploadField,
},
mixins: [organizationSearch],
data() {
return {
option,
List,
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,
},
driverBox: false,
// 搜索栏组织级联追加「未分配组织」一项(哨兵值见 organization-search mixin)
enableUnassignedOrgFilter: true,
vehicleSelectorVisible: false,
vehicleSelectorLoading: false,
vehicleSelectorOption,
vehicleSelectorForm: {},
vehicleSelectorData: [],
vehicleSelectorSearchForm: {},
vehicleSelectorPage: {
pageSize: 10,
currentPage: 1,
total: 0,
},
readonly: false,
submitAttempted: false,
driverForm: emptyForm(),
drivingLicenseUploads: {},
uploadHeaders: getUploadHeaders(),
nationOptions: [
'汉族',
'蒙古族',
'回族',
'藏族',
'维吾尔族',
'苗族',
'彝族',
'壮族',
'布依族',
'朝鲜族',
'满族',
'侗族',
'瑶族',
'白族',
'土家族',
'哈尼族',
'哈萨克族',
'傣族',
'黎族',
'傈僳族',
'佤族',
'畲族',
'高山族',
'拉祜族',
'水族',
'东乡族',
'纳西族',
'景颇族',
'柯尔克孜族',
'土族',
'达斡尔族',
'仫佬族',
'羌族',
'布朗族',
'撒拉族',
'毛南族',
'仡佬族',
'锡伯族',
'阿昌族',
'普米族',
'塔吉克族',
'怒族',
'乌孜别克族',
'俄罗斯族',
'鄂温克族',
'德昂族',
'保安族',
'裕固族',
'京族',
'塔塔尔族',
'独龙族',
'鄂伦春族',
'赫哲族',
'门巴族',
'珞巴族',
'基诺族',
],
educationOptions: ['初中', '高中', '中专', '大专', '本科', '硕士及以上'],
drivingTypeOptions: ['A1', 'A2', 'A3', 'B1', 'B2', 'C1', 'C2'],
qualificationTypeOptions: [],
relationOptions: ['父母', '配偶', '子女', '兄弟姐妹', '朋友', '同事', '其他'],
organizationOptions: [],
regionOptions: [],
formRules: {
driverName: [{ required: true, message: '请输入司机姓名', trigger: 'blur' }],
idCardNo: [{ required: true, message: '请输入身份证号', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
postList: [
{
type: 'array',
required: true,
min: 1,
message: '请至少选择一个岗位',
trigger: 'change',
},
],
drivingType: [{ required: true, message: '请选择准驾车型', trigger: 'change' }],
drivingLicenseNo: [{ required: true, message: '请输入档案编号', trigger: 'blur' }],
drivingLicenseEndDate: [
{ required: true, validator: this.validateDrivingLicenseEndDate, trigger: 'change' },
],
qualificationType: [
{ required: true, message: '请选择从业资格认证类型', trigger: 'change' },
],
qualificationNo: [{ required: true, message: '请输入资格证号', trigger: 'blur' }],
qualificationEndDate: [
{ required: true, validator: this.validateQualificationEndDate, trigger: 'change' },
],
driverType: [{ required: true, message: '请选择司机类型', trigger: 'change' }],
mobile: [{ required: true, message: '请输入手机号', trigger: 'blur' }],
emergencyContactName: [
{ required: true, message: '请输入紧急联系人姓名', trigger: 'blur' },
{ validator: this.validateEmergencyContactName, trigger: 'blur' },
],
emergencyContactMobile: [
{ required: true, message: '请输入紧急联系人手机号', trigger: 'blur' },
],
// 后端已允许所属组织为空,这里不做必填校验
idCardFront: [{ required: true, message: '请上传身份证正面照', trigger: 'change' }],
idCardBack: [{ required: true, message: '请上传身份证反面照', trigger: 'change' }],
drivingLicenseFront: [{ required: true, message: '请上传驾驶证主页', trigger: 'change' }],
drivingLicenseBack: [{ required: true, message: '请上传驾驶证副页', trigger: 'change' }],
},
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
// 所属组织级联:表单存组织名称,级联控件用 id 路径,两者在此双向转换
organizationCascaderValue: {
get() {
return this.resolveOrgPath(this.driverForm.organizationName);
},
set(path) {
this.driverForm.organizationName = this.resolveOrgName(path);
},
},
isAdmin() {
const authority = this.userInfo.authority || '';
return isAdministrator(this.userInfo);
},
permissionList() {
return {
addBtn: this.hasPermission('driver_add'),
viewBtn: this.hasPermission('driver_view'),
delBtn: this.hasPermission('driver_delete'),
editBtn: this.hasPermission('driver_edit'),
};
},
dialogTitle() {
if (this.readonly) {
return '查看司机';
}
return this.driverForm.id ? '修改司机' : '新增司机';
},
regionCascaderProps() {
return {
value: 'id',
label: 'title',
children: 'children',
emitPath: true,
};
},
expiryTagOptions() {
return [
{ label: '全部', value: '', statKey: 'total' },
{ label: '30天内到期', value: 'within30', statKey: 'within30' },
{ label: '已到期', value: 'expired', statKey: 'expired' },
];
},
},
created() {
this.initDeptTree();
this.loadOrganizationOptions();
this.initRegionOptions();
this.initQualificationTypeOptions();
},
methods: {
hasPermission(code) {
return this.isAdmin || this.permission?.[code] === true;
},
initDeptTree() {
getDeptTree(this.userInfo.tenantId).then(res => {
this.organizationOptions = this.formatDeptOptions(res.data.data || []);
const column = this.findColumn(this.option.column, 'organizationName');
column.dicData = this.organizationOptions;
const vehicleColumn = this.findColumn(
this.vehicleSelectorOption.column,
'organizationName'
);
vehicleColumn.dicData = this.organizationOptions;
if (this.driverBox && !this.driverForm.id && !this.driverForm.organizationName) {
this.driverForm.organizationName = this.getCurrentOrganizationName();
}
});
},
initQualificationTypeOptions() {
getDictionary({ code: 'type_of_cert' }).then(res => {
this.qualificationTypeOptions = (res.data.data || [])
.map(item => item.dictValue)
.filter(Boolean);
const matchedType = this.matchQualificationType(this.driverForm.qualificationType);
if (matchedType) {
this.driverForm.qualificationType = matchedType;
}
});
},
formatDeptOptions(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.formatDeptOptions(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 || '';
},
initRegionOptions() {
getLazyTree().then(res => {
const regions = this.buildRegionTree(res.data.data || []);
this.regionOptions = this.extractChinaRegionOptions(regions);
this.syncAddressRegionPath();
});
},
buildRegionTree(regions = []) {
const flatRegions = [];
const collectRegions = list => {
(list || []).forEach(item => {
flatRegions.push({
...item,
children: undefined,
});
if (item.children?.length) collectRegions(item.children);
});
};
collectRegions(regions);
const nodeMap = new Map();
flatRegions.forEach(item => {
const id = item.id === undefined || item.id === null ? item.value : item.id;
if (id === undefined || id === null) return;
nodeMap.set(String(id), {
...item,
id: String(id),
parentId:
item.parentId === undefined || item.parentId === null ? '' : String(item.parentId),
children: [],
});
});
const rootNodes = [];
nodeMap.forEach(node => {
const parent = nodeMap.get(node.parentId);
if (parent && parent !== node) {
parent.children.push(node);
} else {
rootNodes.push(node);
}
});
return rootNodes.map(item => this.normalizeRegionTreeNode(item));
},
normalizeRegionTreeNode(region) {
const children = (region.children || []).map(item => this.normalizeRegionTreeNode(item));
return {
...region,
children: children.length ? children : undefined,
leaf: !children.length,
};
},
isChinaRegion(region = {}) {
const title = String(region.title || region.name || '').trim();
return title === '中国' || title === '中华人民共和国';
},
extractChinaRegionOptions(regions) {
const china = (regions || []).find(item => this.isChinaRegion(item));
if (china?.children?.length) return china.children;
if (regions.length === 1 && regions[0].children?.length) return regions[0].children;
return regions;
},
findRegionOption(options, value) {
for (const item of options || []) {
if (String(item.id) === String(value)) return item;
const child = this.findRegionOption(item.children, value);
if (child) return child;
}
return null;
},
findRegionPathByName(options = [], addressRegion = '', parents = []) {
const target = String(addressRegion || '').replace(/\s+/g, '');
if (!target) return [];
for (const item of options || []) {
const nextParents = [...parents, item];
const nextName = nextParents.map(region => region.title || region.name || '').join('');
if (nextName === target) {
return nextParents.map(region => region.id);
}
const childPath = this.findRegionPathByName(item.children, target, nextParents);
if (childPath.length) return childPath;
}
return [];
},
getAddressRegionLabels(value) {
const nodes = this.$refs.addressRegionCascader?.getCheckedNodes?.() || [];
if (nodes.length && nodes[0].pathLabels?.length) {
return nodes[0].pathLabels;
}
return (value || [])
.map(code => this.findRegionOption(this.regionOptions, code)?.title)
.filter(Boolean);
},
handleAddressRegionChange(value) {
if (!value || !value.length) {
this.driverForm.addressRegion = '';
this.$refs.driverForm?.validateField('addressRegion');
return;
}
this.$nextTick(() => {
this.driverForm.addressRegion = this.getAddressRegionLabels(value).join('');
this.$refs.driverForm?.validateField('addressRegion');
});
},
syncAddressRegionPath() {
if (!this.driverForm.addressRegion || this.driverForm.addressRegionPath?.length) return;
const path = this.findRegionPathByName(this.regionOptions, this.driverForm.addressRegion);
if (path.length) {
this.driverForm.addressRegionPath = path;
}
},
validateDrivingLicenseEndDate(rule, value, callback) {
// 勾选长期有效:起止均已禁用,不再校验
if (this.driverForm.drivingLicenseLongTerm === 1) {
callback();
return;
}
if (!this.driverForm.drivingLicenseStartDate) {
callback(new Error('请选择驾驶证有效期起'));
return;
}
if (!value) {
callback(new Error('请选择驾驶证有效期止'));
return;
}
callback();
},
validateQualificationEndDate(rule, value, callback) {
// 勾选长期有效:起止均已禁用,不再校验
if (this.driverForm.qualificationLongTerm === 1) {
callback();
return;
}
if (!this.driverForm.qualificationStartDate) {
callback(new Error('请选择从业资格证有效期起'));
return;
}
if (!value) {
callback(new Error('请选择从业资格证有效期止'));
return;
}
callback();
},
validateEmergencyContactName(rule, value, callback) {
if (/\d/.test(value || '')) {
callback(new Error('紧急联系人姓名不能包含数字'));
return;
}
callback();
},
removeDigits(value = '') {
return String(value).replace(/\d/g, '');
},
/**
* 身份证号失焦后补出生年月与性别,与小程序 person.vue 的 onIdCardNoChange 同口径:
* 只在字段还空着时补——用户手改过的值不被他随后修改身份证号的动作冲掉。
* 不回写空值:18 位不合法时清空生日会把已选日期当垃圾丢掉。
* OCR 识别结果仍直接覆盖(见 applyIdCardRecognition),那是「重新采一次证件」,与手输联动不是一回事。
*/
handleIdCardBlur() {
const idCardNo = String(this.driverForm.idCardNo || '').toUpperCase();
this.driverForm.idCardNo = idCardNo;
const idCardInfo = this.parseIdCardInfo(idCardNo);
if (!this.driverForm.birthday && idCardInfo.birthday) {
this.driverForm.birthday = idCardInfo.birthday;
}
if (!this.driverForm.gender && idCardInfo.gender) {
this.driverForm.gender = idCardInfo.gender;
}
if (this.submitAttempted) {
this.$nextTick(() => {
this.$refs.driverForm?.validateField(['idCardNo', 'birthday', 'gender']);
});
}
},
openDriver(row, readonly = false) {
this.readonly = readonly;
this.submitAttempted = false;
if (!row?.id) {
this.driverForm = emptyForm();
this.driverForm.organizationName = this.getCurrentOrganizationName();
this.driverBox = true;
return;
}
getDetail(row.id).then(res => {
const detail = res.data.data || {};
this.driverForm = {
...emptyForm(),
...detail,
addressRegionPath: Array.isArray(detail.addressRegionPath)
? detail.addressRegionPath
: [],
postList: detail.posts ? detail.posts.split(',').filter(Boolean) : [],
};
this.syncAddressRegionPath();
this.driverBox = true;
});
},
resetDriver() {
this.driverForm = emptyForm();
this.drivingLicenseUploads = {};
this.readonly = false;
this.submitAttempted = false;
this.submitLoading = false;
this.$refs.driverForm?.clearValidate();
},
openVehicleSelector() {
if (this.readonly) return;
this.vehicleSelectorPage.currentPage = 1;
this.vehicleSelectorVisible = true;
},
resetVehicleSelector() {
this.vehicleSelectorForm = {};
this.vehicleSelectorData = [];
this.vehicleSelectorSearchForm = {};
this.vehicleSelectorPage = {
pageSize: 10,
currentPage: 1,
total: 0,
};
},
normalizeVehicleSelectorQuery(params = {}) {
const query = {
...this.vehicleSelectorSearchForm,
...params,
};
if (Array.isArray(query.organizationName)) {
query.organizationName = query.organizationName[query.organizationName.length - 1] || '';
}
return query;
},
loadVehicleSelector(page = this.vehicleSelectorPage, params = {}) {
this.vehicleSelectorLoading = true;
getVehicleList(page.currentPage, page.pageSize, this.normalizeVehicleSelectorQuery(params))
.then(res => {
const data = res.data.data || {};
this.vehicleSelectorData = data.records || [];
this.vehicleSelectorPage.total = data.total || 0;
})
.finally(() => {
this.vehicleSelectorLoading = false;
});
},
vehicleSelectorSearchChange(params, done) {
this.vehicleSelectorSearchForm = params;
this.vehicleSelectorPage.currentPage = 1;
this.loadVehicleSelector(this.vehicleSelectorPage, params);
done();
},
vehicleSelectorSearchReset() {
this.vehicleSelectorSearchForm = {};
this.vehicleSelectorPage.currentPage = 1;
this.loadVehicleSelector();
},
vehicleSelectorCurrentChange(currentPage) {
this.vehicleSelectorPage.currentPage = currentPage;
},
vehicleSelectorSizeChange(pageSize) {
this.vehicleSelectorPage.pageSize = pageSize;
},
vehicleSelectorRefreshChange() {
this.loadVehicleSelector();
},
selectDrivingVehicle(row) {
this.driverForm.drivingVehicle = row.plateNo || '';
this.vehicleSelectorVisible = false;
},
// 车辆认证状态展示的是「最新一次提交的状态」缓存,与审核页使用同一套状态词表
getVehicleCertificationStatus(row) {
return certStatusTag(row.certificationStatus);
},
setImage(prop, url) {
this.driverForm[prop] = url;
this.$refs.driverForm?.validateField(prop);
},
handleIdCardUploadSuccess(prop, url) {
this.setImage(prop, url);
this.recognizeIdCard(url, prop);
},
handleIdCardLongTermChange() {
if (this.driverForm.idCardLongTerm === 1) {
this.driverForm.idCardValidEnd = '';
}
this.validateFormField('idCardValidEnd');
},
handleDrivingLicenseUploadSuccess(prop, url) {
this.setImage(prop, url);
this.drivingLicenseUploads[prop] = url;
this.recognizeBaiduOcr(
url,
'driving_license',
prop === 'drivingLicenseBack' ? 'back' : 'front',
'驾驶证',
data => {
this.applyDrivingLicenseRecognition(data);
}
);
},
handleQualificationUploadSuccess(prop, url) {
this.setImage(prop, url);
this.recognizeBaiduOcr(url, 'general', '', '从业资格证', data => {
this.applyQualificationRecognition(data);
});
},
recognizeBaiduOcr(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();
});
},
recognizeIdCard(url = '', prop = 'idCardFront') {
if (!url) {
this.$message.warning('身份证图片上传成功,未获取到图片地址,无法自动识别');
return;
}
const loading = ElLoading.service({
lock: true,
text: '身份证识别中',
background: 'rgba(255, 255, 255, 0.7)',
});
recognitionIDCard(url)
.then(res => {
const data = res.data.data || {};
this.applyIdCardRecognition(data, prop);
this.$message.success('身份证识别完成');
})
.catch(() => {
this.$message.warning('身份证图片上传成功,自动识别失败,请手动填写身份信息');
})
.finally(() => {
loading.close();
});
},
recognizeDrivingLicense() {
const certificates = this.buildDrivingLicenseRecognitionCertificates();
if (!certificates.length) {
this.$message.warning('驾驶证图片上传成功,未获取到图片地址,无法自动识别');
return;
}
const loading = ElLoading.service({
lock: true,
text: '驾驶证识别中',
background: 'rgba(255, 255, 255, 0.7)',
});
recognitionTransportCertificates(certificates)
.then(res => {
const data = res.data.data || {};
this.applyDrivingLicenseRecognition(data);
this.$message.success('驾驶证识别完成');
})
.catch(() => {
this.$message.warning('驾驶证图片上传成功,自动识别失败,请手动填写驾驶证信息');
})
.finally(() => {
loading.close();
});
},
buildDrivingLicenseRecognitionCertificates() {
return ['drivingLicenseFront', 'drivingLicenseBack']
.map(prop => {
const url = this.drivingLicenseUploads[prop] || this.driverForm[prop];
if (!url) return null;
return {
driverLicenseUrl: url,
};
})
.filter(Boolean);
},
applyDrivingLicenseRecognition(data = {}) {
const driverName =
data.driverLicenseName || data.name || this.getOcrValue(data, ['name', '姓名']);
const idCardNo = String(
data.driverLicenseIdNo ||
data.idNo ||
data.idCardNo ||
this.getOcrValue(data, ['ID_no', 'idNo', 'idCardNo', '证号'])
).toUpperCase();
const drivingType =
data.driverLicenseType ||
data.drivingType ||
data.driverClass ||
this.getOcrValue(data, ['class', '准驾车型']);
const drivingLicenseNo =
data.driverLicenseFileNo ||
data.drivingLicenseNo ||
data.fileNo ||
this.getOcrValue(data, ['file_no', '档案编号']);
const validDateStart =
data.driverLicenseValidDateStart ||
data.drivingLicenseStartDate ||
this.getOcrValue(data, ['date_vaild_start', '有效期限(起始时间)', '有效起始日期']);
const validDateEnd =
data.driverLicenseValidDateEnd ||
data.drivingLicenseEndDate ||
this.getOcrValue(data, ['date_vaild_end', '有效期限(终止时间)', '有效期限(终⽌时间)', '有效截止日期']);
if (driverName && !this.driverForm.driverName) {
this.driverForm.driverName = driverName;
}
if (idCardNo && !this.driverForm.idCardNo) {
this.driverForm.idCardNo = idCardNo;
}
if (idCardNo && !this.driverForm.qualificationNo) {
this.driverForm.qualificationNo = idCardNo;
}
if (drivingType) {
this.driverForm.drivingType = drivingType;
}
if (drivingLicenseNo) {
this.driverForm.drivingLicenseNo = drivingLicenseNo;
}
if (validDateStart) {
this.driverForm.drivingLicenseStartDate = this.normalizeBirthday(validDateStart);
}
if (validDateEnd) {
this.driverForm.drivingLicenseEndDate = this.normalizeBirthday(validDateEnd);
this.driverForm.drivingLicenseLongTerm = 0;
}
this.applyDrivingLicenseValidity(data);
this.$nextTick(() => {
[
'driverName',
'idCardNo',
'drivingType',
'drivingLicenseNo',
'drivingLicenseEndDate',
'qualificationNo',
].forEach(prop => {
this.$refs.driverForm?.validateField(prop);
});
});
},
applyDrivingLicenseValidity(data = {}) {
const validity = this.getOcrValue(data, ['有效期限', '有效期']);
if (!validity) return;
const dateList = validity.match(/\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/g) || [];
if (!this.driverForm.drivingLicenseStartDate && dateList[0]) {
this.driverForm.drivingLicenseStartDate = this.normalizeBirthday(dateList[0]);
}
if (/(长期|永久)/.test(validity)) {
this.driverForm.drivingLicenseLongTerm = 1;
this.driverForm.drivingLicenseEndDate = '';
} else if (!this.driverForm.drivingLicenseEndDate && dateList[1]) {
this.driverForm.drivingLicenseEndDate = this.normalizeBirthday(dateList[1]);
this.driverForm.drivingLicenseLongTerm = 0;
}
},
applyIdCardRecognition(data = {}, prop = 'idCardFront') {
// 正反面都走同一 ShenFenZheng 接口;反面结果可能也带姓名/民族等字段,
// 反面只补空、不覆盖正面已填值,避免国徽页误识别冲掉正面信息。
const fromBack = prop === 'idCardBack';
const canWrite = (current, next) => !!next && (!fromBack || !current);
const driverName = data.name || data.driverName || this.getOcrValue(data, ['name', '姓名']);
const idCardNo = String(
data.idNo ||
data.idCardNo ||
data.idCard ||
this.getOcrValue(data, ['idNo', 'idCardNo', 'number', '公民身份号码', '身份证号']) ||
''
)
.trim()
.toUpperCase();
const ocrGender = data.gender || this.getOcrValue(data, ['gender', '性别']);
const ocrNation =
data.nation ||
data.ethnicGroup ||
this.getOcrValue(data, ['ethnic_group', 'nation', '民族']);
const ocrAddress = data.address || this.getOcrValue(data, ['address', '住址', '地址']);
const ocrBirthday =
data.birthday || this.getOcrValue(data, ['birthday', '出生', '出生年月', '出生日期']);
if (canWrite(this.driverForm.driverName, driverName)) {
this.driverForm.driverName = driverName;
}
if (canWrite(this.driverForm.gender, ocrGender)) {
this.driverForm.gender = ocrGender;
}
if (canWrite(this.driverForm.nation, ocrNation)) {
this.driverForm.nation = this.normalizeNation(ocrNation);
}
if (canWrite(this.driverForm.address, ocrAddress)) {
this.driverForm.address = String(ocrAddress).trim();
}
if (ocrBirthday) {
const birthday = this.normalizeBirthday(ocrBirthday);
if (canWrite(this.driverForm.birthday, birthday)) {
this.driverForm.birthday = birthday;
}
}
if (idCardNo && canWrite(this.driverForm.idCardNo, idCardNo)) {
this.driverForm.idCardNo = idCardNo;
if (!this.driverForm.qualificationNo) {
this.driverForm.qualificationNo = idCardNo;
}
const idCardInfo = this.parseIdCardInfo(idCardNo);
if (!this.driverForm.birthday && idCardInfo.birthday) {
this.driverForm.birthday = idCardInfo.birthday;
}
if (!this.driverForm.gender && idCardInfo.gender) {
this.driverForm.gender = idCardInfo.gender;
}
}
// 有效期:正反面都可回填;反面通常才有,识别到就覆盖
this.applyIdCardValidity(data);
this.$nextTick(() => {
[
'driverName',
'idCardNo',
'birthday',
'gender',
'nation',
'qualificationNo',
'address',
'idCardValidEnd',
].forEach(field => {
this.$refs.driverForm?.validateField(field);
});
});
},
applyIdCardValidity(data = {}) {
const validStart =
data.validDateStart ||
data.idCardValidStart ||
this.getOcrValue(data, ['签发日期', '有效起始日期', '有效期限起', '身份证_签发日期']);
const validEndRaw =
data.validDateEnd ||
data.idCardValidEnd ||
this.getOcrValue(data, [
'失效日期',
'有效期限',
'有效期限止',
'身份证_有效期限',
'身份证_失效日期',
]);
const longTerm = data.longTerm ?? data.idCardLongTerm;
const validEndText = String(validEndRaw || '').trim();
if (longTerm === 1 || longTerm === '1' || /长期|永久/.test(validEndText)) {
this.driverForm.idCardLongTerm = 1;
this.driverForm.idCardValidEnd = '';
} else if (validEndText) {
const endDate = this.normalizeBirthday(validEndText);
if (endDate) {
this.driverForm.idCardValidEnd = endDate;
this.driverForm.idCardLongTerm = 0;
}
}
if (validStart) {
const startDate = this.normalizeBirthday(validStart);
if (startDate) {
this.driverForm.idCardValidStart = startDate;
}
}
},
getOcrValue(data = {}, keys = []) {
const keyInfos = data.keyInfos || data.keyInfo || data.ocrKeyInfos || [];
const list = Array.isArray(keyInfos) ? keyInfos : [];
const item = list.find(info => {
const key = String(info.key || '').toLowerCase();
const description = String(info.description || '');
return keys.some(value => key === String(value).toLowerCase() || description === value);
});
if (item) return item.value || '';
const wordsResult = data.words_result || data.wordsResult || {};
const normalizedKeys = keys.map(key => String(key).toLowerCase());
if (Array.isArray(wordsResult)) {
const wordItem = wordsResult.find(info =>
normalizedKeys.includes(String(info.key || info.name || '').toLowerCase())
);
return wordItem?.words || wordItem?.value || '';
}
const wordEntry = Object.entries(wordsResult).find(([key]) =>
normalizedKeys.includes(String(key).toLowerCase())
);
if (!wordEntry) return '';
const value = wordEntry[1];
return typeof value === 'object' ? value.words || value.value || '' : value || '';
},
applyQualificationRecognition(data = {}) {
const qualificationType = this.getQualificationType(data);
const qualificationNo = this.getQualificationNo(data);
const matchedType = this.matchQualificationType(qualificationType);
if (matchedType) {
this.driverForm.qualificationType = matchedType;
} else if (qualificationType && !this.qualificationTypeOptions.length) {
this.driverForm.qualificationType = qualificationType;
}
if (qualificationNo && !this.driverForm.qualificationNo) {
this.driverForm.qualificationNo = String(qualificationNo).replace(/\s/g, '');
}
const words = this.getBaiduOcrWords(data).join(' ');
const dateList = words.match(/\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/g) || [];
if (/(长期|永久有效)/.test(words)) {
this.driverForm.qualificationLongTerm = 1;
this.driverForm.qualificationEndDate = '';
} else if (dateList.length) {
this.driverForm.qualificationEndDate = this.normalizeBirthday(dateList[dateList.length - 1]);
this.driverForm.qualificationLongTerm = 0;
}
this.$nextTick(() => {
['qualificationType', 'qualificationNo', 'qualificationEndDate'].forEach(prop => {
this.$refs.driverForm?.validateField(prop);
});
});
},
getQualificationType(data = {}) {
const fieldValue = this.getOcrValue(data, ['从业资格类别', '从业资格类型']);
if (fieldValue) return String(fieldValue).trim();
const words = this.getBaiduOcrWords(data);
const labelIndex = words.findIndex(word => /从业资格类别|从业资格类型/.test(word));
if (labelIndex < 0) return '';
const firstValue = words[labelIndex].replace(/^.*?(?:从业资格类别|从业资格类型)\s*[::]?/, '').trim();
const values = firstValue ? [firstValue] : [];
for (let index = labelIndex + 1; index < words.length; index += 1) {
const word = String(words[index] || '').trim();
if (!word || /^(有效起始日期|有效期限|核发机关|继续教育信息|诚信考核信息)/.test(word)) {
break;
}
values.push(word);
}
return values.join('').replace(/[,,;;]+$/, '').trim();
},
matchQualificationType(value = '') {
const text = String(value || '').replace(/\s/g, '');
if (!text) return '';
const options = this.qualificationTypeOptions || [];
const normalizedOptions = options.map(option => ({
value: option,
text: String(option).replace(/\s/g, ''),
}));
const exact = normalizedOptions.find(option => option.text === text);
if (exact) return exact.value;
const candidates = text.split(/[,,;;]/).filter(Boolean);
const matched = normalizedOptions.find(option =>
candidates.some(candidate => candidate.includes(option.text) || option.text.includes(candidate))
);
return matched?.value || '';
},
getQualificationNo(data = {}) {
const fieldValue = this.getOcrValue(data, ['从业资格证号', '资格证号', '证书编号']);
if (fieldValue) return fieldValue;
const words = this.getBaiduOcrWords(data);
const labelIndex = words.findIndex(word => /^(?:(?:从业)?资格证号?|证书编号)[::]?$/.test(word));
if (labelIndex >= 0 && words[labelIndex + 1]) {
return words[labelIndex + 1];
}
const certificateLine = words.find(word => /(?:从业)?资格证号?|证书编号/.test(word));
const match = certificateLine?.match(/(?:(?:从业)?资格证号?|证书编号)\s*[::]?\s*([A-Z0-9-]{6,})/i);
return match?.[1] || '';
},
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.values(wordsResult)
.map(item => (typeof item === 'object' ? item.words || item.value || '' : item || ''))
.filter(Boolean);
},
normalizeNation(value = '') {
const nation = String(value || '').trim();
if (!nation) return '';
if (this.nationOptions.includes(nation)) return nation;
const fullNation = `${nation}族`;
return this.nationOptions.includes(fullNation) ? fullNation : nation;
},
/**
* OCR / 手工录入日期归一化:对齐车辆侧与后端 FlexibleLocalDateDeserializer。
* 支持 yyyyMMdd、yyyy-MM-dd、2018.3.9、2030年05月20日及任意非数字分隔;无法识别返回空串。
*/
normalizeBirthday(value = '') {
const birthday = String(value || '').trim();
if (!birthday) return '';
if (/长期|永久/.test(birthday)) return '';
const looseMatch = birthday.match(/(\d{4})\D+(\d{1,2})\D+(\d{1,2})/);
if (looseMatch) {
return this.toValidDateText(looseMatch[1], looseMatch[2], looseMatch[3]);
}
const compact = birthday.replace(/\D/g, '');
if (compact.length === 8) {
return this.toValidDateText(compact.slice(0, 4), compact.slice(4, 6), compact.slice(6, 8));
}
return '';
},
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')}`;
},
parseIdCardInfo(idCardNo = '') {
if (!/^\d{17}[\dX]$/.test(idCardNo)) {
return {};
}
const birthday = `${idCardNo.slice(6, 10)}-${idCardNo.slice(10, 12)}-${idCardNo.slice(
12,
14
)}`;
const date = new Date(`${birthday}T00:00:00`);
const validDate =
!Number.isNaN(date.getTime()) &&
date.getFullYear() === Number(idCardNo.slice(6, 10)) &&
date.getMonth() + 1 === Number(idCardNo.slice(10, 12)) &&
date.getDate() === Number(idCardNo.slice(12, 14));
return {
birthday: validDate ? birthday : '',
gender: Number(idCardNo.slice(16, 17)) % 2 === 1 ? '男' : '女',
};
},
validateFormField(prop) {
this.$refs.driverForm?.validateField(prop);
},
handleSubmit() {
this.submitAttempted = true;
this.$refs.driverForm.validate(valid => {
if (!valid) {
return;
}
const row = {
...this.driverForm,
birthday: this.normalizeBirthday(this.driverForm.birthday),
idCardValidStart: this.normalizeBirthday(this.driverForm.idCardValidStart),
idCardValidEnd: this.normalizeBirthday(this.driverForm.idCardValidEnd),
drivingLicenseStartDate: this.normalizeBirthday(this.driverForm.drivingLicenseStartDate),
drivingLicenseEndDate: this.normalizeBirthday(this.driverForm.drivingLicenseEndDate),
qualificationStartDate: this.normalizeBirthday(this.driverForm.qualificationStartDate),
qualificationEndDate: this.normalizeBirthday(this.driverForm.qualificationEndDate),
qualificationNo: this.driverForm.qualificationNo || this.driverForm.idCardNo,
posts: this.driverForm.postList.join(','),
};
delete row.addressRegionPath;
this.submitLoading = true;
submit(row)
.then(() => {
this.$message.success('操作成功');
this.driverBox = 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 (Array.isArray(query.organizationName)) {
query.organizationName = query.organizationName[query.organizationName.length - 1] || '';
}
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/driver/export-driver', 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 || '-';
},
isDateExpiring(row, dateProp, longTermProp) {
if (row[longTermProp] === 1 || !row[dateProp]) {
return false;
}
const today = this.$dayjs();
const endDate = this.$dayjs(row[dateProp]);
return endDate.isValid() && endDate.diff(today, 'day') <= 30;
},
},
};
</script>
<style lang="scss" scoped>
.driver-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;
}
}
.driver-form {
:deep(.el-form-item__label) {
flex: 0 0 calc(4em + 24px) !important;
width: calc(4em + 24px) !important;
height: auto;
white-space: normal !important;
word-break: break-all;
overflow-wrap: anywhere;
line-height: 18px;
}
: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-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper),
:deep(.el-cascader),
:deep(.el-select),
:deep(.el-input) {
width: 100%;
}
.date-range {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
width: 100%;
}
.date-range :deep(.el-date-editor) {
width: 100%;
min-width: 0;
}
}
// 大尺寸证件照:按身份证 1.586:1 比例缩放(约真实尺寸 75%,240×151),左对齐显示
:deep(.driver-uploader) {
display: block;
}
:deep(.driver-uploader .el-upload),
:deep(.driver-uploader.el-upload) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 170px;
border: 1px dashed #c0c4cc;
background: #fafafa;
overflow: hidden;
}
// 大尺寸模式:跟随列宽自适应(最大 240px,约 1.586:1 证件比例),
// 保证不同分辨率 / 系统缩放下都与上方输入框左右对齐
:deep(.driver-uploader--large) {
display: block;
width: 100%;
max-width: 240px;
flex: none;
margin: 0;
}
:deep(.driver-uploader--large .el-upload),
:deep(.driver-uploader--large.el-upload) {
width: 100%;
height: 151px;
}
:deep(.driver-uploader__image) {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
:deep(.driver-uploader__empty) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #606266;
}
// 弹窗内可滚动(弹窗 body 灰底已由全局 .el-dialog__body 规则生效)
.driver-dialog {
:deep(.el-dialog__body) {
max-height: 72vh;
overflow: auto;
}
}
// 空组织记录在列表里的占位文案
.organization-name--empty {
color: #909399;
}
.driver-page__date-expiring {
color: #d40000;
font-weight: 600;
}
.el-form-item {
margin-bottom: 24px !important;
}
</style>