194 lines
17 KiB
Vue
194 lines
17 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||
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 { confirmTripHandover, previewTripHandover, listTripHandovers, releaseHandoverResource, handoverNumber, handoverVersion,
|
||
type HandoverCommand, type HandoverPreview, type HandoverRecord, type HandoverRelease, type HandoverSelection } from '../api/tripHandovers'
|
||
import type { TripDetail } from '../tripTypes'
|
||
import type { VehicleDetail } from '../types'
|
||
import type { DriverDetail } from '../driverTypes'
|
||
|
||
const props = defineProps<{ detail: TripDetail }>()
|
||
const emit = defineEmits<{ saved: [] }>()
|
||
const access = useAccessPreviewStore()
|
||
const readable = computed(() => access.can('platform:fleet:trip:list'))
|
||
const writable = computed(() => readable.value && access.can('platform:fleet:trip:handover'))
|
||
const releasable = computed(() => readable.value && access.can('platform:fleet:trip:release'))
|
||
const rows = ref<HandoverRecord[]>([]), page = ref(1), size = ref(10), total = ref(0), loading = ref(false), busy = ref(false)
|
||
const error = ref<ApiErrorDetails | null>(null), commandError = ref<ApiErrorDetails | null>(null)
|
||
const preview = ref<HandoverPreview | null>(null), selected = ref<HandoverRecord | null>(null)
|
||
const pending = ref<HandoverCommand | null>(null), pendingRelease = ref<{ number: string; command: HandoverRelease } | null>(null)
|
||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
|
||
const vehicleLoading = ref(false), driverLoading = ref(false)
|
||
const form = reactive({ vehicleId: '', driverId: '', handedOverAt: '', expectedDriverEndAt: '', location: '', reason: '' })
|
||
const ending = ref<'VEHICLE' | 'DRIVER' | null>(null)
|
||
const endForm = reactive({ actualEndAt: '', location: '', vehicleStatus: 'IN_SERVICE' })
|
||
let scope = 0, query = 0, vehicleQuery = 0, driverQuery = 0, previewQuery = 0
|
||
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'handoverNo', prop: 'handoverNo', label: '交接编号', minWidth: 220 },
|
||
{ key: 'time', label: '实际交接时间', minWidth: 180, slotName: 'time' },
|
||
{ key: 'vehicles', label: '交接前后车辆', minWidth: 200, slotName: 'vehicles' },
|
||
{ key: 'drivers', label: '交接前后司机', minWidth: 180, slotName: 'drivers' },
|
||
{ key: 'location', label: '交接地点', minWidth: 200, slotName: 'location' },
|
||
]
|
||
function canEnd(row: HandoverRecord, kind: 'VEHICLE' | 'DRIVER') {
|
||
const vehicle = kind === 'VEHICLE', before = row.snapshot.previous, after = row.snapshot.replacement
|
||
return releasable.value && (vehicle ? before.vehicleId !== after.vehicleId && !row.vehicleRelease && before.vehicleId !== props.detail.trip.vehicleId
|
||
: before.driverId !== after.driverId && !row.driverRelease && before.driverId !== props.detail.trip.driverId)
|
||
}
|
||
function actions(row: HandoverRecord): RowActionItem[] {
|
||
return [{ key: 'detail', label: '查看交接' }, ...(canEnd(row, 'VEHICLE') ? [{ key: 'VEHICLE', label: '结束原车辆任务' }] : []),
|
||
...(canEnd(row, 'DRIVER') ? [{ key: 'DRIVER', label: '结束原司机任务' }] : [])]
|
||
}
|
||
function show(row: HandoverRecord, action: string) {
|
||
if (busy.value || pending.value || pendingRelease.value) return
|
||
selected.value = row; ending.value = action === 'VEHICLE' || action === 'DRIVER' ? action : null
|
||
endForm.actualEndAt = new Date().toISOString(); endForm.location = ''; endForm.vehicleStatus = 'IN_SERVICE'; commandError.value = null
|
||
}
|
||
async function load() {
|
||
const token = ++query
|
||
if (!readable.value) { rows.value = []; total.value = 0; return }
|
||
loading.value = true; error.value = null
|
||
try { const result = await listTripHandovers(props.detail.trip.tripNo, page.value, size.value); if (token === query) { rows.value = result.records; total.value = result.total } }
|
||
catch (cause) { if (token === query) error.value = getApiErrorDetails(cause, '交接记录加载失败') }
|
||
finally { if (token === query) loading.value = false }
|
||
}
|
||
watch(() => props.detail.trip.tripNo, () => {
|
||
scope++; query++; previewQuery++; vehicleQuery++; driverQuery++; pending.value = null; pendingRelease.value = null
|
||
busy.value = false; preview.value = null; selected.value = null; ending.value = null; commandError.value = null
|
||
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
|
||
Object.assign(form, { vehicleId: props.detail.trip.vehicleId || '', driverId: props.detail.trip.driverId || '', handedOverAt: new Date().toISOString(), expectedDriverEndAt: props.detail.trip.expectedDriverEndAt, location: '', reason: '' })
|
||
page.value = 1
|
||
}, { immediate: true })
|
||
watch([() => props.detail.trip.tripNo, page, size, readable], load, { immediate: true })
|
||
watch([() => props.detail.trip.recordVersion, () => form.vehicleId, () => form.driverId, () => form.handedOverAt, () => form.expectedDriverEndAt], () => { previewQuery++; preview.value = null })
|
||
async function searchVehicles(keyword = '') {
|
||
const token = ++vehicleQuery; vehicleLoading.value = true
|
||
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (token === vehicleQuery) vehicles.value = result.records }
|
||
catch (cause) { if (token === vehicleQuery) commandError.value = getApiErrorDetails(cause, '车辆候选读取失败') }
|
||
finally { if (token === vehicleQuery) vehicleLoading.value = false }
|
||
}
|
||
async function searchDrivers(keyword = '') {
|
||
const token = ++driverQuery; driverLoading.value = true
|
||
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.expectedDriverEndAt }); if (token === driverQuery) drivers.value = result.records }
|
||
catch (cause) { if (token === driverQuery) commandError.value = getApiErrorDetails(cause, '司机候选读取失败') }
|
||
finally { if (token === driverQuery) driverLoading.value = false }
|
||
}
|
||
function selection(): HandoverSelection {
|
||
return { recordVersion: props.detail.trip.recordVersion, vehicleId: form.vehicleId, driverId: form.driverId,
|
||
handedOverAt: new Date(form.handedOverAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString() }
|
||
}
|
||
async function inspect() {
|
||
if (busy.value || pending.value || !writable.value) return
|
||
if (!form.vehicleId || !form.driverId || !form.handedOverAt || !form.expectedDriverEndAt) { ElMessage.warning('请选择接替资源并填写交接及预计任务结束时间'); return }
|
||
const token = ++previewQuery, generation = scope; busy.value = true; commandError.value = null; preview.value = null
|
||
try { const result = await previewTripHandover(props.detail.trip.tripNo, selection()); if (token === previewQuery && generation === scope) preview.value = result }
|
||
catch (cause) { if (generation === scope) commandError.value = getApiErrorDetails(cause, '交接核对未通过') }
|
||
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
|
||
}
|
||
async function confirm() {
|
||
if (busy.value || !writable.value || (!pending.value && !preview.value)) return
|
||
if (!pending.value && !form.location.trim()) { ElMessage.warning('请填写实际交接地点'); return }
|
||
const generation = scope; busy.value = true; commandError.value = null
|
||
try {
|
||
pending.value ??= { selection: selection(), cargoHash: preview.value!.cargoHash, location: form.location.trim(), reason: form.reason.trim() || undefined, idempotencyKey: crypto.randomUUID() }
|
||
const result = await confirmTripHandover(props.detail.trip.tripNo, pending.value)
|
||
if (generation !== scope) return
|
||
pending.value = null; preview.value = null; selected.value = result; emit('saved'); ElMessage.success('交接已记录,请分别核对原资源任务结束')
|
||
await load()
|
||
} catch (cause) { if (generation === scope) { if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pending.value = null; preview.value = null } commandError.value = getApiErrorDetails(cause, '交接未确认,请核对或重试') } }
|
||
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
|
||
}
|
||
async function release() {
|
||
if (busy.value || !releasable.value || (!pendingRelease.value && (!selected.value || !ending.value))) return
|
||
if (!pendingRelease.value && (!endForm.actualEndAt || !endForm.location.trim())) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
|
||
const generation = scope; busy.value = true; commandError.value = null
|
||
try {
|
||
pendingRelease.value ??= { number: selected.value!.handoverNo, command: { recordVersion: handoverVersion(selected.value!.recordVersion), resourceKind: ending.value!, actualEndAt: new Date(endForm.actualEndAt).toISOString(), location: endForm.location.trim(), vehicleStatus: ending.value === 'VEHICLE' ? endForm.vehicleStatus : undefined, idempotencyKey: crypto.randomUUID() } }
|
||
const result = await releaseHandoverResource(props.detail.trip.tripNo, pendingRelease.value.number, pendingRelease.value.command)
|
||
if (generation !== scope) return
|
||
pendingRelease.value = null; selected.value = result; ending.value = null; emit('saved'); ElMessage.success('原资源任务已结束'); await load()
|
||
} catch (cause) {
|
||
if (generation === scope) {
|
||
commandError.value = getApiErrorDetails(cause, '任务结束未确认,请核对或重试')
|
||
|
||
}
|
||
}
|
||
finally { pending.value = null; pendingRelease.value = null; if (generation === scope) busy.value = false }
|
||
}
|
||
onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driverQuery++ })
|
||
</script>
|
||
|
||
<template>
|
||
<section aria-label="在途交接" class="handover-panel">
|
||
<p v-if="!readable">暂无车次交接查看权限。</p>
|
||
<template v-else>
|
||
<p>交接后原车辆和司机分别结束实际任务。已经签收或完成退回的货物保留原运输记录。</p>
|
||
<LoadErrorState v-if="error" title="交接记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<div v-if="writable && (detail.trip.status === 'IN_TRANSIT' || pending)">
|
||
|
||
<el-form label-position="top" :disabled="busy || !!pending || !!pendingRelease" class="density-form handover-form">
|
||
<el-form-item label="接替车辆" required><el-select v-model="form.vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" @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" :label="vehicle.plateNumber" />
|
||
</el-select></el-form-item>
|
||
<el-form-item label="接替司机" required><el-select v-model="form.driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" @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" :label="`${driver.details.name} · ${driver.details.phone}`" />
|
||
</el-select></el-form-item>
|
||
<el-form-item label="实际交接时间" required><el-date-picker v-model="form.handedOverAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||
<el-form-item label="实际交接地点" required><el-input v-model="form.location" maxlength="500" placeholder="请输入实际交接地点" /></el-form-item>
|
||
<el-form-item label="交接说明(选填)"><el-input v-model="form.reason" maxlength="1000" placeholder="如车辆故障或司机替班(选填)" /></el-form-item>
|
||
</el-form>
|
||
<el-button :loading="busy" :disabled="!!pending || !!pendingRelease" @click="inspect">核对交接货物</el-button>
|
||
<el-button :disabled="busy || !!pending || !!pendingRelease" @click="emit('saved')">刷新车次</el-button>
|
||
<template v-if="preview">
|
||
<p v-if="preview.driverQualification.expiringSoon">驾驶证将在30天内到期,当前预计任务结束仍在有效期内。</p>
|
||
<p>本次交接 {{ preview.cargo.remainingPalletIds.length }} 个实托,占 {{ handoverNumber(preview.cargo.occupied.palletSpaces) }} 托位、{{ handoverNumber(preview.cargo.occupied.weightKg) }} kg、{{ handoverNumber(preview.cargo.occupied.volumeCbm) }} m³。</p>
|
||
<table class="density-table"><thead><tr><th>客户排车单</th><th>交接箱数</th></tr></thead><tbody><tr v-for="(cargo, index) in preview.cargo.remainingCargo" :key="index"><td>{{ cargo.dispatchNo }}</td><td>{{ cargo.packages }}</td></tr></tbody></table>
|
||
</template>
|
||
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ '确认实际交接' }}</el-button>
|
||
</div>
|
||
<div v-if="commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></div>
|
||
<div class="workbench-table"><DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
|
||
<template #actions="{ row }"><RowActions :actions="actions(row as HandoverRecord)" @action="show(row as HandoverRecord, $event)" /></template>
|
||
<template #time="{ row }">{{ time(row.snapshot.handedOverAt) }}</template>
|
||
<template #vehicles="{ row }">{{ row.snapshot.previous.vehiclePlate || '—' }} → {{ row.snapshot.replacement.vehiclePlate || '—' }}</template>
|
||
<template #drivers="{ row }">{{ row.snapshot.previous.driverName || '—' }} → {{ row.snapshot.replacement.driverName || '—' }}</template>
|
||
<template #location="{ row }">{{ row.snapshot.location }}</template>
|
||
</DataTable></div>
|
||
<div v-if="selected" class="handover-detail">
|
||
<h4>交接记录 {{ selected.handoverNo }}</h4>
|
||
<p>{{ selected.snapshot.reason }} · {{ selected.snapshot.location }}</p>
|
||
<p>原车辆任务:{{ selected.snapshot.previous.vehicleId === selected.snapshot.replacement.vehicleId ? '车辆继续执行' : selected.vehicleRelease ? `已结束 · ${time(selected.vehicleRelease.actualEndAt)} · ${selected.vehicleRelease.location}` : '尚未结束' }}</p>
|
||
<p>原司机任务:{{ selected.snapshot.previous.driverId === selected.snapshot.replacement.driverId ? '司机继续执行' : selected.driverRelease ? `已结束 · ${time(selected.driverRelease.actualEndAt)} · ${selected.driverRelease.location}` : '尚未结束' }}</p>
|
||
<p>交接前已完成派送:{{ selected.snapshot.cargo.completedDeliveries.map(row => row.deliveryNo).join('、') || '—' }}</p>
|
||
<template v-if="ending || pendingRelease">
|
||
|
||
<el-form label-position="top" class="density-form handover-form" :disabled="busy || !!pendingRelease">
|
||
<el-form-item label="实际任务结束时间" required><el-date-picker v-model="endForm.actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||
<el-form-item label="实际任务结束地点" required><el-input v-model="endForm.location" maxlength="500" placeholder="请输入原车辆或司机实际结束任务的地点" /></el-form-item>
|
||
<el-form-item v-if="ending === 'VEHICLE'" label="车辆实际状态" required><el-select v-model="endForm.vehicleStatus"><el-option value="IN_SERVICE" label="在役" /><el-option value="MAINTENANCE" label="维修中" /><el-option value="INACTIVE" label="停用" /><el-option value="RETURNED" label="已归还" /></el-select></el-form-item>
|
||
</el-form>
|
||
<el-button v-if="releasable" type="primary" :loading="busy" @click="release">{{ '确认原资源任务结束' }}</el-button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
<style scoped>
|
||
.handover-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
|
||
.handover-form :deep(.el-select){width:100%}.handover-detail{margin-top:var(--admin-space-6)}
|
||
</style>
|