Files
admin-web/src/modules/overseas-master-data/components/TripAssignmentPanel.vue
T
2026-10-19 10:23:38 +08:00

98 lines
7.8 KiB
Vue

<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import { assignTrip, getTripResourceOccupancy, type TripResourceOccupancy } from '../api/trips'
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
import type { VehicleDetail } from '../types'
import type { TripDetail } from '../tripTypes'
const props = defineProps<{ detail: TripDetail }>()
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const vehicleOccupancy = ref<Record<string, TripResourceOccupancy>>({}), driverOccupancy = ref<Record<string, TripResourceOccupancy>>({})
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
let vehicleRequest = 0, driverRequest = 0, generation = 0
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
/** 本车占用可保留,其他车次占用显示具体业务号并禁止选择。 */
function occupancyLabel(row: TripResourceOccupancy | undefined): string {
if (!row) return ' · 占用待查询'
if (!row.available) return ` · 被车次 ${row.occupiedTripNo} 占用`
return row.occupiedTripNo ? ' · 当前车次' : ' · 空闲'
}
watch(() => props.detail.trip, trip => {
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
vehicleOccupancy.value = {}; driverOccupancy.value = {}
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
}, { immediate: true })
/** 下拉搜索先批量核对占用,失败不把未知状态的资源当成空闲。 */
function searchVehicles(keyword = '') {
clearTimeout(vehicleTimer); const request = ++vehicleRequest
vehicles.value = []; vehicleOccupancy.value = {}; vehicleLoading.value = true
vehicleTimer = setTimeout(async () => {
try {
const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' })
if (request !== vehicleRequest) return
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'VEHICLE', result.records.map(row => row.id))
if (request === vehicleRequest) { vehicleOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); vehicles.value = result.records }
}
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
finally { if (request === vehicleRequest) vehicleLoading.value = false }
}, 250)
}
/** 证照合格与资源空闲分别展示,不能用可派车资格代替未占用。 */
function searchDrivers(keyword = '') {
clearTimeout(driverTimer); const request = ++driverRequest
drivers.value = []; driverOccupancy.value = {}; driverLoading.value = true
driverTimer = setTimeout(async () => {
try {
const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt })
if (request !== driverRequest) return
const occupancy = await getTripResourceOccupancy(props.detail.trip.tripNo, 'DRIVER', result.records.map(row => row.id))
if (request === driverRequest) { driverOccupancy.value = Object.fromEntries(occupancy.map(row => [row.resourceId, row])); drivers.value = result.records }
}
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
finally { if (request === driverRequest) driverLoading.value = false }
}, 250)
}
/** 候选阶段挡住已知占用,保存仍由后端锁定复验以防同时被其他人分配。 */
async function save() {
if (busy.value) return
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
if (vehicleLoading.value || driverLoading.value) { ElMessage.warning('请等待车辆和司机占用查询完成'); return }
if (vehicleOccupancy.value[vehicleId.value]?.available === false || driverOccupancy.value[driverId.value]?.available === false) { ElMessage.warning('所选车辆或司机已被其他车次占用,请重新选择'); return }
const current = generation; busy.value = true; error.value = null
try {
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
if (current === generation) { emit('saved', result); ElMessage.success(props.detail.trip.tripPurpose === 'PICKUP' ? '车辆和司机已分配,可安排取货发车' : '车辆和司机已分配,请重新核对装载') }
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer) })
</script>
<template>
<div>
<LoadErrorState v-if="error" title="分配未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :disabled="vehicleOccupancy[vehicle.id]?.available !== true" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位${occupancyLabel(vehicleOccupancy[vehicle.id])}`" />
</el-select></el-form-item>
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :disabled="driverOccupancy[driver.id]?.available !== true || !driver.qualification.canDispatch" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}${occupancyLabel(driverOccupancy[driver.id])}`" />
</el-select></el-form-item>
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
</el-form>
</div>
</template>
<style scoped>.assignment-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.assignment-form :deep(.el-select){width:100%}</style>