1139 lines
35 KiB
Vue
1139 lines
35 KiB
Vue
<!-- 全部车辆管理 -->
|
||
<template>
|
||
<div class="page-container">
|
||
<section class="panel">
|
||
<div class="panel-top">
|
||
<h2 class="title-bar-heading">车辆列表</h2>
|
||
</div>
|
||
|
||
<!-- 筛选区 -->
|
||
<div class="filter-block">
|
||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||
<div class="filter-grid">
|
||
<el-form-item label="车牌号">
|
||
<el-input
|
||
v-model="searchForm.q"
|
||
placeholder="搜索车牌号"
|
||
clearable
|
||
@keyup.enter="handleSearch"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="车辆类型">
|
||
<el-select v-model="searchForm.vehicleType" placeholder="全部" clearable>
|
||
<el-option label="自有" value="self" />
|
||
<el-option label="承运商" value="carrier" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="承运商">
|
||
<el-select v-model="searchForm.carrierId" placeholder="全部" clearable filterable>
|
||
<el-option
|
||
v-for="c in carrierList"
|
||
:key="c.id"
|
||
:label="c.carrierName"
|
||
:value="c.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="车辆状态">
|
||
<el-select v-model="searchForm.vehicleStatus" placeholder="全部" clearable>
|
||
<el-option label="可用" value="available" />
|
||
<el-option label="维修中" value="maintaining" />
|
||
<el-option label="报废" value="scrapped" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="filter-actions">
|
||
<div class="filter-actions-end">
|
||
<el-button class="btn-ghost" :loading="loading" @click="handleReset">
|
||
<el-icon><RefreshLeft /></el-icon>重置
|
||
</el-button>
|
||
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch">
|
||
<el-icon><Search /></el-icon>搜索
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
</div>
|
||
|
||
<!-- 工具栏 -->
|
||
<div class="list-toolbar">
|
||
<div class="list-toolbar-row">
|
||
<div class="list-toolbar-selected">
|
||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||
</div>
|
||
<ListQuickSearchInput
|
||
v-model="quickSearchKeyword"
|
||
placeholder="车牌号"
|
||
@search="handleQuickSearch"
|
||
@clear="handleQuickSearchClear"
|
||
/>
|
||
<el-button class="btn-main" type="primary" @click="handleAdd">
|
||
<el-icon><Plus /></el-icon>新增车辆
|
||
</el-button>
|
||
<el-button
|
||
class="btn-ghost"
|
||
:disabled="selectedRows.length !== 1"
|
||
@click="handleToolbarEdit"
|
||
>
|
||
<el-icon><Edit /></el-icon>编辑
|
||
</el-button>
|
||
<el-button
|
||
class="btn-ghost"
|
||
type="danger"
|
||
:disabled="selectedRows.length !== 1 || !canDeleteByCreator(selectedRows[0])"
|
||
:title="selectedRows.length === 1 && !canDeleteByCreator(selectedRows[0]) ? CREATOR_DELETE_TIP : ''"
|
||
@click="handleToolbarDelete"
|
||
>
|
||
<el-icon><Delete /></el-icon>删除
|
||
</el-button>
|
||
|
||
<ListToolbarEndActions
|
||
:loading="loading"
|
||
:column-defs="listColumnDefs"
|
||
v-model:visible-columns="visibleColumns"
|
||
v-model:check-all="checkAll"
|
||
:is-indeterminate="isIndeterminate"
|
||
@refresh="fetchData"
|
||
@reset-columns="resetToDefault"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="list-body list-wrap">
|
||
<el-table
|
||
class="list-table"
|
||
:data="vehicleList"
|
||
v-loading="loading"
|
||
stripe
|
||
row-key="id"
|
||
@selection-change="handleSelectionChange"
|
||
@row-dblclick="handleView"
|
||
>
|
||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||
<el-table-column
|
||
type="index"
|
||
label="序号"
|
||
min-width="68"
|
||
align="center"
|
||
header-align="center"
|
||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||
/>
|
||
<el-table-column
|
||
prop="licensePlate"
|
||
label="车牌号"
|
||
min-width="340"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<div class="vehicle-plate-cell">
|
||
<el-link type="primary" @click="handleView(row)">
|
||
{{ formatPlVehiclePlateDisplay(row.licensePlate, row.trailerNo) }}
|
||
</el-link>
|
||
<PlVehicleCertBadges
|
||
:vehicle-type="row.vehicleType === 'carrier' ? 'carrier' : 'self'"
|
||
:cert-list="row.certList"
|
||
:trailer-no="row.trailerNo"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="车辆类型" min-width="100" align="center" header-align="center" v-if="isColumnVisible('vehicleType')">
|
||
<template #default="{ row }">
|
||
<el-tag :type="getVehicleTypeTagType(row.vehicleType)" size="small">
|
||
{{ getVehicleTypeLabel(row.vehicleType) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="车辆状态" min-width="100" align="center" header-align="center" v-if="isColumnVisible('vehicleStatus')">
|
||
<template #default="{ row }">
|
||
<el-tag :type="getStatusTagType(row.vehicleStatus)" size="small">
|
||
{{ getStatusLabel(row.vehicleStatus) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
v-if="isColumnVisible('loadCapacity')"
|
||
prop="loadCapacity"
|
||
label="载重(吨)"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
v-if="isColumnVisible('carrierName')"
|
||
prop="carrierName"
|
||
label="承运商"
|
||
min-width="140"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
v-if="isColumnVisible('personnelSummary')"
|
||
prop="personnelSummary"
|
||
label="关联人员"
|
||
min-width="160"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
{{ normalizePlPersonnelSummaryDisplay(row.personnelSummary) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
v-if="isColumnVisible('remark')"
|
||
prop="remark"
|
||
label="备注"
|
||
min-width="160"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
</el-table>
|
||
|
||
<div class="pagination list-pager-bar">
|
||
<el-pagination
|
||
v-model:current-page="pagination.page"
|
||
v-model:page-size="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
@size-change="handleSizeChange"
|
||
@current-change="handlePageChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 新增/编辑弹窗 -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="dialogTitle"
|
||
width="1360px"
|
||
align-center
|
||
class="dialog-form-layout"
|
||
@close="handleDialogClose"
|
||
>
|
||
<el-form ref="formRef" :model="formData" :rules="formRules" label-position="right" label-width="100px" class="pl-dialog-form">
|
||
<div class="form-grid-3">
|
||
<el-form-item label="车牌号" prop="licensePlate">
|
||
<el-input v-model="formData.licensePlate" placeholder="请输入车牌号" maxlength="20" />
|
||
</el-form-item>
|
||
<el-form-item label="挂车号" prop="trailerNo">
|
||
<el-input v-model="formData.trailerNo" placeholder="请输入挂车号(选填)" maxlength="20" clearable />
|
||
</el-form-item>
|
||
<el-form-item label="车辆类型" prop="vehicleType">
|
||
<el-select v-model="formData.vehicleType" placeholder="请选择">
|
||
<el-option label="自有" value="self" />
|
||
<el-option label="承运商" value="carrier" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="载重(吨)" prop="loadCapacity">
|
||
<el-input-number
|
||
v-model="formData.loadCapacity"
|
||
:precision="2"
|
||
:min="0"
|
||
placeholder="请输入载重"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="承运商" prop="carrierId">
|
||
<el-select v-model="formData.carrierId" placeholder="请选择承运商" filterable>
|
||
<el-option
|
||
v-for="c in carrierList"
|
||
:key="c.id"
|
||
:label="c.carrierName"
|
||
:value="c.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="车辆状态" prop="vehicleStatus">
|
||
<el-select v-model="formData.vehicleStatus" placeholder="请选择">
|
||
<el-option label="可用" value="available" />
|
||
<el-option label="维修中" value="maintaining" />
|
||
<el-option label="报废" value="scrapped" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="备注" prop="remark">
|
||
<el-input
|
||
v-model="formData.remark"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="请输入备注"
|
||
maxlength="512"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<!-- 关联人员子表 -->
|
||
<div class="sub-table-section">
|
||
<div class="sub-table-header">
|
||
<span class="sub-table-title">关联人员</span>
|
||
<el-button type="primary" size="small" @click="handleAddPersonnel">
|
||
<el-icon><Plus /></el-icon>添加人员
|
||
</el-button>
|
||
</div>
|
||
<el-table :data="personnelList" stripe class="list-table sub-table">
|
||
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
|
||
<el-table-column label="姓名" min-width="160">
|
||
<template #default="{ row }">
|
||
<el-select
|
||
v-model="row.personnelId"
|
||
placeholder="请选择人员"
|
||
filterable
|
||
clearable
|
||
@change="(id: string | undefined) => handlePersonnelSelect(row, id)"
|
||
>
|
||
<el-option
|
||
v-for="p in getPersonnelOptionsForRow(row)"
|
||
:key="p.id"
|
||
:label="formatPersonnelLabel(p)"
|
||
:value="p.id"
|
||
/>
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="手机号" min-width="120">
|
||
<template #default="{ row }">
|
||
<span>{{ row.phoneNo || '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="年龄" min-width="80" align="center">
|
||
<template #default="{ row }">
|
||
<span>{{ row.age ?? '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="人员类型" min-width="100" align="center">
|
||
<template #default="{ row, $index }">
|
||
<el-select
|
||
v-model="row.role"
|
||
placeholder="类型*"
|
||
@change="handleRoleChange(row, $index)"
|
||
>
|
||
<el-option label="司机" value="driver" />
|
||
<el-option label="押运员" value="escort" />
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="备注" min-width="100">
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.remark" placeholder="备注" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="60" align="center">
|
||
<template #default="{ $index }">
|
||
<el-button link type="danger" @click="handleRemovePersonnel($index)">
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<!-- 车辆证书(主车/挂车 Tab) -->
|
||
<PlVehicleCertPartTabs
|
||
ref="certFormListRef"
|
||
v-model="certList"
|
||
:license-plate="formData.licensePlate"
|
||
:trailer-no="formData.trailerNo"
|
||
title="车辆证书"
|
||
upload-folder="pl/vehicle-cert"
|
||
:cert-name-options="certNameOptions"
|
||
layout="table"
|
||
:enforce-self-vehicle-required="formData.vehicleType === 'self'"
|
||
:enforce-carrier-vehicle-required="formData.vehicleType === 'carrier'"
|
||
/>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="dialogVisible = false">
|
||
<el-icon><Close /></el-icon>取消
|
||
</el-button>
|
||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||
<el-icon><Check /></el-icon>确定
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<SystemViewDialog
|
||
v-model:visible="viewVisible"
|
||
:title="viewDialogTitle"
|
||
:icon="Van"
|
||
:meta="viewHeroMeta"
|
||
:sections="viewSections"
|
||
:loading="viewLoading"
|
||
width="1040px"
|
||
modal-class="order-module-dialog-mask"
|
||
append-to-body
|
||
>
|
||
<template v-if="viewDetail" #hero-actions>
|
||
<el-button class="btn-main" type="primary" @click="handleEditFromView">
|
||
<el-icon><Edit /></el-icon>编辑
|
||
</el-button>
|
||
<el-button
|
||
class="btn-ghost"
|
||
type="danger"
|
||
:disabled="!canDeleteByCreator(viewDetail)"
|
||
:title="canDeleteByCreator(viewDetail) ? '' : CREATOR_DELETE_TIP"
|
||
@click="handleDeleteFromView"
|
||
>
|
||
<el-icon><Delete /></el-icon>删除
|
||
</el-button>
|
||
</template>
|
||
<template #vehicleDetailTabs>
|
||
<PlVehicleCertPersonnelTabs
|
||
:cert-list="viewDetail?.certList || []"
|
||
:license-plate="viewDetail?.licensePlate"
|
||
:trailer-no="viewDetail?.trailerNo"
|
||
:vehicle-id="viewDetail?.id"
|
||
:personnel-list="viewPersonnelList"
|
||
:vehicle-cert-check-type="viewVehicleCertCheckType"
|
||
:check-linked-personnel-certs="
|
||
viewDetail?.vehicleType === 'self' || viewDetail?.vehicleType === 'carrier'
|
||
"
|
||
cert-layout="table"
|
||
/>
|
||
</template>
|
||
</SystemViewDialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
|
||
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
|
||
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
|
||
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search, Van } from '@element-plus/icons-vue'
|
||
import {
|
||
vehicleApi,
|
||
carrierApi,
|
||
personnelApi,
|
||
type Vehicle,
|
||
type VehicleDetail,
|
||
type VehiclePersonnelRow,
|
||
type CertRow,
|
||
type Carrier,
|
||
type Personnel
|
||
} from '@/api/pl'
|
||
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
|
||
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
|
||
import PlVehicleCertBadges from '@/components/business/pl-vehicle-cert-badges/index.vue'
|
||
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
|
||
import SystemViewDialog, {
|
||
type ViewSection
|
||
} from '@/components/business/system-view-dialog/index.vue'
|
||
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
|
||
import { pickBxList, pickBxPageResult } from '@/utils/http/bx'
|
||
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
|
||
import { formatDateTime } from '@/utils/format'
|
||
import { formatPlVehiclePlateDisplay, normalizePlPersonnelSummaryDisplay } from '@/utils/pl-display'
|
||
import {
|
||
collectCarrierVehicleRequiredCertErrors,
|
||
collectSelfVehicleRequiredCertErrors,
|
||
ensureVehicleCertSlots,
|
||
formatRequiredCertErrors
|
||
} from '@/utils/pl-cert-slots'
|
||
import {
|
||
PL_VEHICLE_CERT_NAME_OPTIONS,
|
||
getVehicleCertMissingSuffix,
|
||
type PlVehicleCertCheckType
|
||
} from '@/utils/pl-vehicle-cert'
|
||
import {
|
||
mapPersonnelTypeToVehicleRole,
|
||
validatePersonnelTypeRoleCompat,
|
||
validatePersonnelAffiliationVehicleCompat
|
||
} from '@/utils/pl-personnel-cert'
|
||
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
|
||
|
||
type CertListItem = CertRow & { _key?: string }
|
||
type CertFormListRef = InstanceType<typeof PlVehicleCertPartTabs>
|
||
|
||
const loading = ref(false)
|
||
const quickSearchKeyword = ref('')
|
||
const submitLoading = ref(false)
|
||
const dialogLoading = ref(false)
|
||
const vehicleList = ref<Vehicle[]>([])
|
||
const carrierList = ref<Carrier[]>([])
|
||
|
||
const listColumnDefs = [
|
||
{ prop: 'vehicleType', label: '车辆类型' },
|
||
{ prop: 'vehicleStatus', label: '车辆状态' },
|
||
{ prop: 'loadCapacity', label: '载重(吨)' },
|
||
{ prop: 'carrierName', label: '承运商' },
|
||
{ prop: 'personnelSummary', label: '关联人员' },
|
||
{ prop: 'remark', label: '备注', defaultVisible: false }
|
||
]
|
||
|
||
const {
|
||
visibleColumns,
|
||
checkAll,
|
||
isIndeterminate,
|
||
isColumnVisible,
|
||
resetToDefault
|
||
} = usePersistedListColumns('pl-vehicle-list-columns', listColumnDefs)
|
||
|
||
const personnelOptions = ref<Personnel[]>([])
|
||
const selectedRows = ref<Vehicle[]>([])
|
||
const dialogVisible = ref(false)
|
||
const dialogTitle = ref('')
|
||
const formRef = ref()
|
||
const isEdit = ref(false)
|
||
|
||
const personnelList = ref<VehiclePersonnelRow[]>([])
|
||
const certList = ref<CertListItem[]>([])
|
||
const certFormListRef = ref<CertFormListRef>()
|
||
const viewVisible = ref(false)
|
||
const viewLoading = ref(false)
|
||
const viewDetail = ref<VehicleDetail | null>(null)
|
||
const viewPersonnelList = ref<VehiclePersonnelViewRow[]>([])
|
||
|
||
const certNameOptions = PL_VEHICLE_CERT_NAME_OPTIONS
|
||
|
||
const searchForm = reactive({
|
||
q: '',
|
||
vehicleType: '',
|
||
carrierId: '',
|
||
vehicleStatus: ''
|
||
})
|
||
|
||
const pagination = reactive({
|
||
page: 1,
|
||
pageSize: DEFAULT_LIST_PAGE_SIZE,
|
||
total: 0
|
||
})
|
||
|
||
const formData = reactive({
|
||
id: '',
|
||
licensePlate: '',
|
||
trailerNo: '',
|
||
vehicleType: 'self',
|
||
carrierId: '',
|
||
carrierName: '',
|
||
loadCapacity: null as number | null,
|
||
vehicleStatus: 'available',
|
||
remark: ''
|
||
})
|
||
|
||
const formRules = {
|
||
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }],
|
||
vehicleType: [{ required: true, message: '请选择车辆类型', trigger: 'change' }],
|
||
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }]
|
||
}
|
||
|
||
function getStatusTagType(status: string) {
|
||
const map: Record<string, string> = {
|
||
available: 'success',
|
||
maintaining: 'warning',
|
||
scrapped: 'danger'
|
||
}
|
||
return map[status] || 'info'
|
||
}
|
||
|
||
function getStatusLabel(status: string) {
|
||
const map: Record<string, string> = {
|
||
available: '可用',
|
||
maintaining: '维修中',
|
||
scrapped: '报废'
|
||
}
|
||
return map[status] || status
|
||
}
|
||
|
||
function getVehicleTypeLabel(type: string) {
|
||
return type === 'self' ? '自有' : type === 'carrier' ? '承运商' : type
|
||
}
|
||
|
||
function getVehicleTypeTagType(type: string) {
|
||
return type === 'self' ? 'success' : 'warning'
|
||
}
|
||
|
||
const viewHeroMeta = computed(() => {
|
||
if (!viewDetail.value) return ''
|
||
const parts = [
|
||
getVehicleTypeLabel(viewDetail.value.vehicleType),
|
||
getStatusLabel(viewDetail.value.vehicleStatus),
|
||
viewDetail.value.carrierName || undefined
|
||
].filter(Boolean)
|
||
return parts.join(' · ')
|
||
})
|
||
|
||
const viewVehicleCertCheckType = computed<PlVehicleCertCheckType | undefined>(() => {
|
||
const type = viewDetail.value?.vehicleType
|
||
return type === 'self' || type === 'carrier' ? type : undefined
|
||
})
|
||
|
||
const viewDialogTitle = computed(() => {
|
||
const plate = viewDetail.value?.licensePlate || '车辆详情'
|
||
const checkType = viewVehicleCertCheckType.value
|
||
if (!viewDetail.value || !checkType) return plate
|
||
return (
|
||
plate +
|
||
getVehicleCertMissingSuffix({
|
||
vehicleType: checkType,
|
||
certList: viewDetail.value.certList,
|
||
trailerNo: viewDetail.value.trailerNo
|
||
})
|
||
)
|
||
})
|
||
|
||
const viewSections = computed<ViewSection[]>(() => {
|
||
const d = viewDetail.value
|
||
if (!d) return []
|
||
|
||
return [
|
||
{
|
||
title: '车辆信息',
|
||
fields: [
|
||
{ label: '车牌号', value: d.licensePlate },
|
||
{ label: '挂车号', value: d.trailerNo || '—' },
|
||
{
|
||
label: '车辆类型',
|
||
value: getVehicleTypeLabel(d.vehicleType),
|
||
tagType: getVehicleTypeTagType(d.vehicleType)
|
||
},
|
||
{ label: '承运商', value: d.carrierName || '—' },
|
||
{ label: '载重(吨)', value: d.loadCapacity ?? '—' },
|
||
{
|
||
label: '车辆状态',
|
||
value: getStatusLabel(d.vehicleStatus),
|
||
tagType: getStatusTagType(d.vehicleStatus)
|
||
},
|
||
{
|
||
label: '关联人员摘要',
|
||
value: normalizePlPersonnelSummaryDisplay(d.personnelSummary) || '—',
|
||
span: true
|
||
},
|
||
{ label: '备注', value: d.remark || '—', span: true },
|
||
{ label: '创建时间', value: formatDateTime(d.createTime) }
|
||
],
|
||
slot: 'vehicleDetailTabs'
|
||
}
|
||
]
|
||
})
|
||
|
||
async function loadViewPersonnelCerts(personnelList: VehiclePersonnelRow[]) {
|
||
const rows = await Promise.all(
|
||
personnelList.map(async person => {
|
||
if (!person.personnelId) {
|
||
return { ...person, certList: [] } as VehiclePersonnelViewRow
|
||
}
|
||
try {
|
||
const res = await personnelApi.get(person.personnelId)
|
||
const detail = ((res.data ?? res) || {}) as { certList?: VehiclePersonnelViewRow['certList'] }
|
||
return {
|
||
...person,
|
||
certList: detail.certList || []
|
||
} as VehiclePersonnelViewRow
|
||
} catch {
|
||
return { ...person, certList: [] } as VehiclePersonnelViewRow
|
||
}
|
||
})
|
||
)
|
||
viewPersonnelList.value = rows
|
||
}
|
||
|
||
function createEmptyPersonnel(): VehiclePersonnelRow {
|
||
return {
|
||
personnelId: undefined,
|
||
personnelName: '',
|
||
phoneNo: '',
|
||
age: null,
|
||
role: 'driver',
|
||
isDefault: 0,
|
||
remark: ''
|
||
}
|
||
}
|
||
|
||
function formatPersonnelLabel(p: Personnel) {
|
||
return p.phoneNo ? `${p.personnelName}(${p.phoneNo})` : p.personnelName
|
||
}
|
||
|
||
function getPersonnelOptionsForRow(row: VehiclePersonnelRow) {
|
||
const selectedIds = new Set(
|
||
personnelList.value
|
||
.filter(item => item !== row && item.personnelId)
|
||
.map(item => item.personnelId as string)
|
||
)
|
||
const options = personnelOptions.value.filter(
|
||
p => !selectedIds.has(p.id) || p.id === row.personnelId
|
||
)
|
||
if (
|
||
row.personnelId &&
|
||
!options.some(p => p.id === row.personnelId) &&
|
||
row.personnelName
|
||
) {
|
||
return [
|
||
{
|
||
id: row.personnelId,
|
||
personnelName: row.personnelName,
|
||
phoneNo: row.phoneNo,
|
||
age: row.age ?? undefined,
|
||
status: '0',
|
||
createTime: ''
|
||
},
|
||
...options
|
||
]
|
||
}
|
||
return options
|
||
}
|
||
|
||
function handlePersonnelSelect(row: VehiclePersonnelRow, personnelId?: string) {
|
||
if (!personnelId) {
|
||
row.personnelId = undefined
|
||
row.personnelName = ''
|
||
row.phoneNo = ''
|
||
row.age = null
|
||
row.role = ''
|
||
return
|
||
}
|
||
const person = personnelOptions.value.find(p => p.id === personnelId)
|
||
if (!person) return
|
||
row.personnelId = person.id
|
||
row.personnelName = person.personnelName
|
||
row.phoneNo = person.phoneNo || ''
|
||
row.age = person.age ?? null
|
||
const mappedRole = mapPersonnelTypeToVehicleRole(person.personnelType)
|
||
if (mappedRole) {
|
||
row.role = mappedRole
|
||
handleRoleChange(row, 0)
|
||
}
|
||
}
|
||
|
||
function resolvePersonnelRows(rows: VehiclePersonnelRow[]) {
|
||
return rows.map(row => {
|
||
if (row.personnelId) {
|
||
handlePersonnelSelect(row, row.personnelId)
|
||
return row
|
||
}
|
||
const matched = personnelOptions.value.find(
|
||
p =>
|
||
p.personnelName === row.personnelName &&
|
||
(!row.phoneNo || !p.phoneNo || p.phoneNo === row.phoneNo)
|
||
)
|
||
if (matched) {
|
||
handlePersonnelSelect(row, matched.id)
|
||
}
|
||
return row
|
||
})
|
||
}
|
||
|
||
async function loadPersonnelOptions() {
|
||
const affiliation =
|
||
formData.vehicleType === 'carrier' ? 'carrier' : formData.vehicleType === 'self' ? 'self' : undefined
|
||
const res = await personnelApi.list({
|
||
status: '1',
|
||
pageSize: 100,
|
||
personnelAffiliation: affiliation
|
||
})
|
||
personnelOptions.value = pickBxList<Personnel>(res)
|
||
}
|
||
|
||
async function loadCarrierOptions() {
|
||
const res = await carrierApi.list({ page: 1, pageSize: 100 })
|
||
carrierList.value = pickBxList<Carrier>(res)
|
||
}
|
||
|
||
function handleAddPersonnel() {
|
||
const row = createEmptyPersonnel()
|
||
if (!personnelList.value.some(p => p.role === 'driver' && p.isDefault === 1)) {
|
||
row.isDefault = 1
|
||
}
|
||
personnelList.value.push(row)
|
||
}
|
||
|
||
function handleRemovePersonnel(index: number) {
|
||
const wasDefault = personnelList.value[index]?.isDefault === 1
|
||
personnelList.value.splice(index, 1)
|
||
if (wasDefault) {
|
||
const firstDriver = personnelList.value.find(p => p.role === 'driver')
|
||
if (firstDriver) firstDriver.isDefault = 1
|
||
}
|
||
}
|
||
|
||
function handleRoleChange(row: VehiclePersonnelRow, _index: number) {
|
||
if (row.role !== 'driver') {
|
||
row.isDefault = 0
|
||
if (!personnelList.value.some(p => p.role === 'driver' && p.isDefault === 1)) {
|
||
const firstDriver = personnelList.value.find(p => p.role === 'driver')
|
||
if (firstDriver) firstDriver.isDefault = 1
|
||
}
|
||
} else if (!personnelList.value.some(p => p.role === 'driver' && p.isDefault === 1)) {
|
||
row.isDefault = 1
|
||
}
|
||
}
|
||
|
||
function resetSubTables() {
|
||
personnelList.value = []
|
||
certList.value = []
|
||
certFormListRef.value?.clearUploadRefs()
|
||
}
|
||
|
||
function validateSubTables(): boolean {
|
||
for (const row of personnelList.value) {
|
||
if (!row.personnelId) {
|
||
ElMessage.warning('请选择关联人员')
|
||
return false
|
||
}
|
||
if (!row.role) {
|
||
ElMessage.warning('请选择人员类型')
|
||
return false
|
||
}
|
||
if (formData.vehicleType === 'self' || formData.vehicleType === 'carrier') {
|
||
const person = personnelOptions.value.find(p => p.id === row.personnelId)
|
||
if (
|
||
!validatePersonnelTypeRoleCompat(person?.personnelType, row.role, message =>
|
||
ElMessage.warning(message)
|
||
)
|
||
) {
|
||
return false
|
||
}
|
||
if (
|
||
!validatePersonnelAffiliationVehicleCompat(
|
||
person?.personnelAffiliation,
|
||
formData.vehicleType,
|
||
message => ElMessage.warning(message)
|
||
)
|
||
) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
if (formData.vehicleType === 'self') {
|
||
const certErrors = collectSelfVehicleRequiredCertErrors(certList.value, formData.trailerNo)
|
||
if (certErrors.length) {
|
||
ElMessage.warning(formatRequiredCertErrors(certErrors))
|
||
return false
|
||
}
|
||
} else if (formData.vehicleType === 'carrier') {
|
||
const certErrors = collectCarrierVehicleRequiredCertErrors(certList.value, formData.trailerNo)
|
||
if (certErrors.length) {
|
||
ElMessage.warning(formatRequiredCertErrors(certErrors))
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function fetchData() {
|
||
loading.value = true
|
||
try {
|
||
const res = await vehicleApi.list({
|
||
q: quickSearchKeyword.value.trim() || searchForm.q || undefined,
|
||
vehicleType: searchForm.vehicleType || undefined,
|
||
carrierId: searchForm.carrierId || undefined,
|
||
vehicleStatus: searchForm.vehicleStatus || undefined,
|
||
page: pagination.page,
|
||
pageSize: pagination.pageSize
|
||
})
|
||
const { list, total } = pickBxPageResult<Vehicle>(res)
|
||
vehicleList.value = list
|
||
pagination.total = total
|
||
const idSet = new Set(vehicleList.value.map(item => item.id))
|
||
selectedRows.value = selectedRows.value.filter(item => idSet.has(item.id))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
quickSearchKeyword.value = ''
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleReset() {
|
||
searchForm.q = ''
|
||
searchForm.vehicleType = ''
|
||
searchForm.carrierId = ''
|
||
searchForm.vehicleStatus = ''
|
||
quickSearchKeyword.value = ''
|
||
selectedRows.value = []
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleQuickSearch() {
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleQuickSearchClear() {
|
||
quickSearchKeyword.value = ''
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handlePageChange() {
|
||
fetchData()
|
||
}
|
||
|
||
function handleSizeChange() {
|
||
pagination.page = 1
|
||
fetchData()
|
||
}
|
||
|
||
function handleSelectionChange(rows: Vehicle[]) {
|
||
selectedRows.value = rows
|
||
}
|
||
|
||
function handleToolbarEdit() {
|
||
if (selectedRows.value.length !== 1) {
|
||
ElMessage.warning('请选择一条车辆记录')
|
||
return
|
||
}
|
||
handleEdit(selectedRows.value[0])
|
||
}
|
||
|
||
function handleToolbarDelete() {
|
||
if (selectedRows.value.length !== 1) {
|
||
ElMessage.warning('请选择一条车辆记录')
|
||
return
|
||
}
|
||
handleDelete(selectedRows.value[0])
|
||
}
|
||
|
||
function handleEditFromView() {
|
||
if (!viewDetail.value) return
|
||
viewVisible.value = false
|
||
handleEdit(viewDetail.value)
|
||
}
|
||
|
||
async function handleDeleteFromView() {
|
||
if (!viewDetail.value) return
|
||
await handleDelete(viewDetail.value)
|
||
}
|
||
|
||
function handleAdd() {
|
||
dialogTitle.value = '新增车辆'
|
||
isEdit.value = false
|
||
Object.assign(formData, {
|
||
id: '',
|
||
licensePlate: '',
|
||
trailerNo: '',
|
||
vehicleType: 'self',
|
||
carrierId: '',
|
||
carrierName: '',
|
||
loadCapacity: null,
|
||
vehicleStatus: 'available',
|
||
remark: ''
|
||
})
|
||
resetSubTables()
|
||
certList.value = ensureVehicleCertSlots([], formData.trailerNo, formData.vehicleType as PlVehicleCertCheckType)
|
||
loadPersonnelOptions()
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleView(row: Vehicle) {
|
||
viewVisible.value = true
|
||
viewLoading.value = true
|
||
viewDetail.value = null
|
||
viewPersonnelList.value = []
|
||
try {
|
||
const res = await vehicleApi.get(row.id)
|
||
const detail = ((res.data ?? res) || null) as VehicleDetail | null
|
||
viewDetail.value = detail
|
||
await loadViewPersonnelCerts(detail?.personnelList || [])
|
||
} catch {
|
||
viewVisible.value = false
|
||
ElMessage.error('加载车辆详情失败')
|
||
} finally {
|
||
viewLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleEdit(row: Vehicle) {
|
||
dialogTitle.value = '编辑车辆'
|
||
isEdit.value = true
|
||
dialogLoading.value = true
|
||
dialogVisible.value = true
|
||
try {
|
||
await loadPersonnelOptions()
|
||
const res = await vehicleApi.get(row.id)
|
||
const detail = ((res.data ?? res) || {}) as VehicleDetail
|
||
Object.assign(formData, {
|
||
id: detail.id,
|
||
licensePlate: detail.licensePlate,
|
||
trailerNo: detail.trailerNo || '',
|
||
vehicleType: detail.vehicleType,
|
||
carrierId: detail.carrierId,
|
||
carrierName: detail.carrierName,
|
||
loadCapacity: detail.loadCapacity ?? null,
|
||
vehicleStatus: detail.vehicleStatus,
|
||
remark: detail.remark
|
||
})
|
||
personnelList.value = resolvePersonnelRows(
|
||
(detail.personnelList || []).map(p => ({
|
||
...p,
|
||
age: p.age ?? null
|
||
}))
|
||
)
|
||
const mappedCerts = (detail.certList || []).map(c => ({
|
||
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
|
||
certId: c.id || c.certId,
|
||
vehiclePart: c.vehiclePart,
|
||
certName: c.certName,
|
||
certNo: c.certNo,
|
||
expireDate: c.expireDate,
|
||
remindDays: c.remindDays ?? 30,
|
||
attachmentUrls: c.attachmentUrls || ''
|
||
}))
|
||
certList.value =
|
||
detail.vehicleType === 'self' || detail.vehicleType === 'carrier'
|
||
? ensureVehicleCertSlots(
|
||
mappedCerts,
|
||
formData.trailerNo,
|
||
detail.vehicleType as PlVehicleCertCheckType
|
||
)
|
||
: mappedCerts
|
||
certFormListRef.value?.refreshRequiredSlots?.()
|
||
} finally {
|
||
dialogLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
const valid = await formRef.value.validate().catch(() => false)
|
||
if (!valid || !validateSubTables()) return
|
||
if (certFormListRef.value?.hasUploading?.()) {
|
||
ElMessage.warning('证书附件上传中,请稍候')
|
||
return
|
||
}
|
||
|
||
if (formData.carrierId) {
|
||
const carrier = carrierList.value.find(c => c.id === formData.carrierId)
|
||
formData.carrierName = carrier?.carrierName || ''
|
||
} else {
|
||
formData.carrierName = ''
|
||
}
|
||
|
||
submitLoading.value = true
|
||
try {
|
||
const payload = {
|
||
licensePlate: formData.licensePlate,
|
||
trailerNo: formData.trailerNo || undefined,
|
||
vehicleType: formData.vehicleType,
|
||
carrierId: formData.carrierId || undefined,
|
||
carrierName: formData.carrierName || undefined,
|
||
loadCapacity: formData.loadCapacity,
|
||
vehicleStatus: formData.vehicleStatus,
|
||
remark: formData.remark,
|
||
personnelList: personnelList.value.map(p => ({
|
||
personnelId: p.personnelId,
|
||
personnelName: p.personnelName.trim(),
|
||
phoneNo: p.phoneNo || undefined,
|
||
age: p.age ?? undefined,
|
||
role: p.role,
|
||
isDefault: p.role === 'driver' ? (p.isDefault ?? 0) : 0,
|
||
remark: p.remark || undefined
|
||
})),
|
||
certList: certList.value.map(c => ({
|
||
certId: c.certId,
|
||
vehiclePart: c.vehiclePart,
|
||
certName: c.certName.trim(),
|
||
certNo: c.certNo || undefined,
|
||
expireDate: c.expireDate || undefined,
|
||
remindDays: c.remindDays ?? 30,
|
||
attachmentUrls: c.attachmentUrls || undefined
|
||
}))
|
||
}
|
||
|
||
if (isEdit.value) {
|
||
await vehicleApi.update(formData.id, payload)
|
||
} else {
|
||
await vehicleApi.create(payload)
|
||
}
|
||
ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
|
||
dialogVisible.value = false
|
||
fetchData()
|
||
} finally {
|
||
submitLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleDialogClose() {
|
||
formRef.value?.resetFields()
|
||
resetSubTables()
|
||
}
|
||
|
||
async function handleDelete(row: Vehicle) {
|
||
if (!canDeleteByCreator(row)) {
|
||
ElMessage.warning(CREATOR_DELETE_TIP)
|
||
return
|
||
}
|
||
await ElMessageBox.confirm('确定删除该车辆吗?', '提示', { type: 'warning' })
|
||
await vehicleApi.delete(row.id)
|
||
ElMessage.success('删除成功')
|
||
if (viewDetail.value?.id === row.id) {
|
||
viewVisible.value = false
|
||
viewDetail.value = null
|
||
}
|
||
fetchData()
|
||
}
|
||
|
||
watch(
|
||
() => formData.vehicleType,
|
||
async type => {
|
||
if (!dialogVisible.value) return
|
||
await loadPersonnelOptions()
|
||
if (type === 'self' || type === 'carrier') {
|
||
certList.value = ensureVehicleCertSlots(
|
||
certList.value,
|
||
formData.trailerNo,
|
||
type as PlVehicleCertCheckType
|
||
)
|
||
certFormListRef.value?.refreshRequiredSlots?.()
|
||
}
|
||
}
|
||
)
|
||
|
||
useDbInstantSearch(
|
||
() => ({
|
||
q: searchForm.q,
|
||
vehicleType: searchForm.vehicleType,
|
||
carrierId: searchForm.carrierId,
|
||
vehicleStatus: searchForm.vehicleStatus
|
||
}),
|
||
handleSearch
|
||
)
|
||
|
||
onMounted(() => {
|
||
loadCarrierOptions()
|
||
fetchData()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.sub-table-section {
|
||
margin-top: 16px;
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.sub-table-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.sub-table-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text, #1e293b);
|
||
}
|
||
|
||
.sub-table {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.vehicle-plate-cell {
|
||
display: inline-flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-wrap: nowrap;
|
||
gap: 6px;
|
||
}
|
||
</style>
|