Files
shanxia2.0/web-admin/src/views/pl/vehicle/index.vue
T
2026-07-12 17:55:11 +08:00

1139 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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>
<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>