2026-10-03 12:43:25 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-10-03 12:43:25 +08:00
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 , '交接核对未通过' ) }
2026-10-22 23:10:09 +08:00
finally { pending . value = null ; pendingRelease . value = null ; if ( generation === scope ) busy . value = false }
2026-10-03 12:43:25 +08:00
}
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 , '交接未确认,请核对或重试' ) } }
2026-10-22 23:10:09 +08:00
finally { pending . value = null ; pendingRelease . value = null ; if ( generation === scope ) busy . value = false }
2026-10-03 12:43:25 +08:00
}
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 , '任务结束未确认,请核对或重试' )
2026-10-22 23:10:09 +08:00
2026-10-03 12:43:25 +08:00
}
}
2026-10-22 23:10:09 +08:00
finally { pending . value = null ; pendingRelease . value = null ; if ( generation === scope ) busy . value = false }
2026-10-03 12:43:25 +08:00
}
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)" >
2026-10-22 23:10:09 +08:00
2026-10-03 12:43:25 +08:00
< 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 >
2026-10-22 23:10:09 +08:00
< el-button v-if = "preview || pending" type="primary" :loading="busy" @click="confirm" >{{ ' 确认实际交接 ' }} </ el -button >
2026-10-03 12:43:25 +08:00
</ div >
< div v-if = "commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></ div >
2026-10-20 23:34:11 +08:00
< 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 =" 暂无在途交接记录 " >
2026-10-03 12:43:25 +08:00
< 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 >
2026-10-20 23:34:11 +08:00
</ DataTable ></ div >
2026-10-03 12:43:25 +08:00
< 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" >
2026-10-22 23:10:09 +08:00
2026-10-03 12:43:25 +08:00
< 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 >
2026-10-22 23:10:09 +08:00
< el-button v-if = "releasable" type="primary" :loading="busy" @click="release" >{{ ' 确认原资源任务结束 ' }} </ el -button >
2026-10-03 12:43:25 +08:00
</ template >
</ div >
</ template >
</ section >
</ template >
< style scoped >
. handover - form { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); 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 >