2026-08-21 18:16:42 +08:00
< script setup lang = "ts" >
import { computed , onMounted , ref } from 'vue'
2026-08-31 10:58:12 +08:00
import { ElMessage } from 'element-plus'
2026-08-21 18:16:42 +08:00
import { useRoute , useRouter } from 'vue-router'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-31 10:58:12 +08:00
import { completeDispatchPda , getDispatch , getDispatchPdaTask , pickDispatchPda , pushDispatchPda , type DispatchItemDto , type DispatchPdaItemDto , type DispatchPdaTaskDto , type DispatchSummaryDto } from '../api/dispatches'
2026-08-21 18:16:42 +08:00
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime } from '../lastMilePresentation'
interface DetailField { label : string ; value : string | number }
interface RelatedOrderRow {
orderNo : string
weight : string
palletCount : number
fbaWarehouse : string
volume : number
containerNo : string
}
const route = useRoute ()
const router = useRouter ()
const dispatchNo = computed (() => String ( route . params . dispatchNo || '' ))
const detail = ref < DispatchSummaryDto | null >( null )
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
2026-08-31 10:58:12 +08:00
const pdaTask = ref < DispatchPdaTaskDto | null >( null )
const pdaLoading = ref ( false )
const pdaMutating = ref ( false )
const pdaError = ref < ApiErrorDetails | null >( null )
const pdaPushKey = ref ( '' )
const pdaReason = ref ( '' )
const pickForm = ref ({ itemId : 0 , positionId : 0 , packages : 1 , productQuantity : 1 , idempotencyKey : '' })
2026-08-21 18:16:42 +08:00
const dispatchStatusLabels : Record < string , string > = {
PENDING_DEPARTURE : '待发车' ,
PICKING : '拣货中' ,
READY_TO_DEPART : '待发车(拣货完成)' ,
PARTIAL_READY_TO_DEPART : '待发车(部分完成)' ,
DEPARTED : '已发车' ,
PARTIAL_DEPARTED : '已发车(部分完成)' ,
CANCELLED : '已取消' ,
}
const appointmentStatusLabels : Record < string , string > = {
PENDING : '待预约' ,
BOOKED : '已预约' ,
RESERVED : '已预约' ,
CONFIRMED : '已确认' ,
CANCELLED : '已取消' ,
}
function text ( value : unknown ) { return String ( value ?? '' ). trim () || '—' }
function dispatchStatus ( value : unknown ) {
const code = String ( value ?? '' ). trim ()
return dispatchStatusLabels [ code ] || text ( code )
}
function appointmentStatus ( value : unknown ) {
const code = String ( value ?? '' ). trim ()
return appointmentStatusLabels [ code ] || text ( code )
}
function orderReferences ( item : DispatchItemDto ) {
const references = String ( item . orderRefs || '' ). split ( /[,, ;; \s]+/ ). map (( value ) => value . trim ()). filter ( Boolean )
return references . length ? references : [ text ( item . orderNo )]
}
const vehicleFields = computed < DetailField [] >(() => [
{ label : '车牌号' , value : text ( detail . value ? . vehiclePlate ) },
{ label : '司机' , value : text ( detail . value ? . driverName ) },
{ label : '联系电话' , value : text ( detail . value ? . driverPhone ) },
{ label : '拖车公司名称' , value : text ( detail . value ? . carrierName ) },
{ label : '车辆状态' , value : dispatchStatus ( detail . value ? . status ) },
])
const dispatchFields = computed < DetailField [] >(() => [
{ label : '路线' , value : text ( detail . value ? . route ) },
{ label : '发车时间' , value : formatOverseasDateTime ( detail . value ? . departAt ) },
{ label : '预计到达时间' , value : formatOverseasDateTime ( detail . value ? . estimatedArrivalAt ) },
{ label : 'FBA预约' , value : appointmentStatus ( detail . value ? . fbaAppointmentStatus ) },
{ label : '车辆预约' , value : appointmentStatus ( detail . value ? . vehicleAppointmentStatus ) },
])
const documentFields = computed < DetailField [] >(() => [
{ label : '排车单号' , value : text ( detail . value ? . dispatchNo || dispatchNo . value ) },
{ label : '关联订单' , value : text ( detail . value ? . orderNos ) },
{ label : '创建时间' , value : formatOverseasDateTime ( detail . value ? . createdAt ) },
{ label : '更新时间' , value : '—' },
{ label : '备注' , value : '—' },
...( detail . value ? . partialCompletionReason
? [{ label : '部分完成原因' , value : text ( detail . value . partialCompletionReason ) }]
: []),
])
const relatedOrders = computed < RelatedOrderRow [] >(() => {
const grouped = new Map < string , { pallets : Set < string >; destinations : Set < string >; volume : number } > ()
for ( const item of detail . value ? . items || []) {
for ( const orderNo of orderReferences ( item )) {
const current = grouped . get ( orderNo ) || { pallets : new Set < string >(), destinations : new Set < string >(), volume : 0 }
current . pallets . add ( String ( item . warehousePalletId || item . planPalletId || item . id ))
const destination = text ( item . fbaWarehouseCode || item . destinationWarehouseCode || item . destinationWarehouseName )
if ( destination !== '—' ) current . destinations . add ( destination )
current . volume += Number ( item . volumeCbm || 0 )
grouped . set ( orderNo , current )
}
}
return [... grouped . entries ()]. map (([ orderNo , value ]) => ({
orderNo ,
weight : '—' ,
palletCount : value . pallets . size ,
fbaWarehouse : [... value . destinations ]. join ( '、' ) || '—' ,
volume : Number ( value . volume . toFixed ( 3 )),
containerNo : '—' ,
}))
})
const orderColumns : DataTableColumn [] = [
{ key : 'orderNo' , prop : 'orderNo' , label : '订单号' , minWidth : 210 },
2026-08-25 10:34:27 +08:00
{ key : 'weight' , prop : 'weight' , label : '重量(lb)' , minWidth : 130 , align : 'right' },
2026-08-21 18:16:42 +08:00
{ key : 'palletCount' , prop : 'palletCount' , label : '托数' , minWidth : 100 , align : 'right' },
{ key : 'fbaWarehouse' , prop : 'fbaWarehouse' , label : '目的FBA仓' , minWidth : 150 },
{ key : 'volume' , prop : 'volume' , label : '体积(m³)' , minWidth : 130 , align : 'right' },
{ key : 'containerNo' , prop : 'containerNo' , label : '集装箱号' , minWidth : 170 },
]
async function load () {
if ( ! dispatchNo . value ) {
loadError . value = { message : '详情缺少排车单号,请从排车列表重新进入' }
return
}
loading . value = true
loadError . value = null
try { detail . value = await getDispatch ( dispatchNo . value ) }
catch ( error ) { loadError . value = getApiErrorDetails ( error , '排车详情加载失败,请重试' ) }
finally { loading . value = false }
}
function back () { void router . push ({ name : 'overseas-dispatches' }) }
2026-08-31 10:58:12 +08:00
function makeKey ( prefix : string ) { return ` ${ prefix } - ${ dispatchNo . value } - ${ Date . now () } - ${ Math . random (). toString ( 16 ). slice ( 2 ) } ` }
function selectPdaItem ( item : DispatchPdaItemDto ) {
const position = item . positions ? . find (( value ) => value . availablePackages > 0 ) || item . positions ? .[ 0 ]
pickForm . value = { itemId : item . dispatchItemId , positionId : Number ( position ? . id || 0 ), packages : 1 , productQuantity : 1 , idempotencyKey : makeKey ( 'web-pick' ) }
}
function selectPdaRow ( item : unknown ) { selectPdaItem ( item as DispatchPdaItemDto ) }
async function loadPdaTask () {
if ( ! dispatchNo . value ) return
pdaLoading . value = true ; pdaError . value = null
try { pdaTask . value = await getDispatchPdaTask ( dispatchNo . value ); if ( ! pickForm . value . itemId && pdaTask . value . items ? .[ 0 ]) selectPdaItem ( pdaTask . value . items [ 0 ]) }
catch ( error ) { pdaError . value = getApiErrorDetails ( error , 'PDA任务加载失败,请先启动拣货任务' ) }
finally { pdaLoading . value = false }
}
async function startPda () {
pdaMutating . value = true ; pdaError . value = null
try { pdaTask . value = await pushDispatchPda ( dispatchNo . value , { idempotencyKey : pdaPushKey . value . trim () || makeKey ( 'web-push' ) }); pdaPushKey . value = '' ; if ( pdaTask . value . items ? .[ 0 ]) selectPdaItem ( pdaTask . value . items [ 0 ]); ElMessage . success ( 'PDA拣货任务已启动' ) }
catch ( error ) { pdaError . value = getApiErrorDetails ( error , '启动PDA任务失败' ) }
finally { pdaMutating . value = false }
}
async function submitPick () {
const form = pickForm . value
if ( ! form . itemId || ! form . positionId || form . packages < 1 ) { ElMessage . warning ( '请选择拣货明细和有效库位、数量' ); return }
pdaMutating . value = true ; pdaError . value = null
try { await pickDispatchPda ( dispatchNo . value , form . itemId , { positionId : form . positionId , packages : form . packages , productQuantity : form . productQuantity || form . packages , idempotencyKey : form . idempotencyKey . trim () || makeKey ( 'web-pick' ) }); ElMessage . success ( '拣货成功,库存已扣减' ); await loadPdaTask () }
catch ( error ) { pdaError . value = getApiErrorDetails ( error , '拣货失败,请检查库位库存' ) }
finally { pdaMutating . value = false }
}
async function completePda () {
pdaMutating . value = true ; pdaError . value = null
try { await completeDispatchPda ( dispatchNo . value , { idempotencyKey : makeKey ( 'web-complete' ), reason : pdaReason . value . trim () || '正常完成拣货' }); ElMessage . success ( '拣货任务已完成' ); await loadPdaTask (); await load () }
catch ( error ) { pdaError . value = getApiErrorDetails ( error , '完成拣货失败' ) }
finally { pdaMutating . value = false }
}
2026-08-21 18:16:42 +08:00
onMounted ( load )
</ script >
< template >
< OverseasDetailShell title = "排车详情" reference -label =" 排车单号 " : reference -value =" detail ? .dispatchNo || dispatchNo " back -label =" 返回排车列表 " domain = "排车详情" :pending = "false" : status -text =" detail ? dispatchStatus ( detail.status ) : ' 加载中 '" @back ="back" >
< LoadErrorState v-if = "loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState >
< div v-else v-loading = "loading" class="dispatch-detail" :aria-busy="loading" >
< div class = "summary-grid" >
< section class = "detail-section" aria -labelledby =" dispatch -vehicle-heading " >< h2 id = "dispatch-vehicle-heading" > 车辆信息 </ h2 >< dl class = "detail-fields" >< div v-for = "field in vehicleFields" :key="field.label" >< dt >{{ field . label }}</ dt >< dd >{{ field . value }}</ dd ></ div ></ dl ></ section >
< section class = "detail-section" aria -labelledby =" dispatch -info-heading " >< h2 id = "dispatch-info-heading" > 排车信息 </ h2 >< dl class = "detail-fields" >< div v-for = "field in dispatchFields" :key="field.label" >< dt >{{ field . label }}</ dt >< dd >{{ field . value }}</ dd ></ div ></ dl ></ section >
< section class = "detail-section document-section" aria -labelledby =" dispatch -document-heading " >< h2 id = "dispatch-document-heading" > 单据信息 </ h2 >< dl class = "detail-fields" >< div v-for = "field in documentFields" :key="field.label" >< dt >{{ field . label }}</ dt >< dd >{{ field . value }}</ dd ></ div ></ dl ></ section >
</ div >
< section class = "detail-section order-section" aria -labelledby =" dispatch -orders-heading " >< h2 id = "dispatch-orders-heading" > 关联订单明细 </ h2 >< DataTable :data = "relatedOrders" :columns = "orderColumns" column -settings-mode =" hide " row -key =" orderNo " empty -text =" 暂无关联订单明细 " /></ section >
2026-08-31 10:58:12 +08:00
< section class = "detail-section pda-section" aria -labelledby =" dispatch -pda-heading " >
< div class = "pda-header" >< h2 id = "dispatch-pda-heading" > PDA拣货与库存扣减 </ h2 >< div class = "pda-actions" >< el-input v-model = "pdaPushKey" placeholder="启动幂等键(可选)" clearable /><el-button type="primary" :loading="pdaMutating" @click="startPda">启动拣货</el-button><el-button :loading="pdaLoading" @click="loadPdaTask" > 刷新任务 </ el -button ></ div ></ div >
< el-alert v-if = "pdaError" :title="pdaError.message" type="error" :closable="false" class="pda-alert" />
< div v-if = "pdaTask" v-loading="pdaLoading" class="pda-body" >
< div class = "pda-meta" > 任务号 : {{ pdaTask . taskNo }} 状态 : < strong >{{ dispatchStatus ( pdaTask . status ) }}</ strong > 仓库 : {{ text ( pdaTask . warehouseId ) }}</ div >
< el-table : data = "pdaTask.items || []" size = "small" border @ row -click =" selectPdaItem " >
2026-08-31 14:42:28 +08:00
< el-table-column label = "托盘" prop = "palletNo" min -width =" 140 " />< el-table-column label = "转仓提示" min -width =" 150 " >< template # default = "{ row }" >< el-tag v-if = "row.warehouseRouteLabel" type="warning" effect="plain">{{ row.warehouseRouteLabel }}</el-tag><span v-else>{{ text(row.fbaWarehouseCode || row.destinationWarehouseCode) }}</span></template></el-table-column><el-table-column label="计划箱数" prop="scheduledPackages" width="100" /><el-table-column label="已拣箱数" prop="pickedPackages" width="100" />
2026-08-31 10:58:12 +08:00
< el-table-column label = "库位(可用)" min -width =" 220 " >< template # default = "{ row }" >< el-button v-for = "position in row.positions || []" :key="position.id" link type="primary" @click.stop="selectPdaRow(row); pickForm.positionId = Number(position.id)" >{{ position.id }} ( {{ position.availablePackages }} ) </el -button ></ template ></ el-table-column >
</ el-table >
< div class = "pick-form" >< span class = "pick-label" > 拣货明细 : {{ pickForm . itemId || '未选择' }}</ span >< el-input-number v-model = "pickForm.positionId" :min="1" controls-position="right" placeholder="库位ID" /><el-input-number v-model="pickForm.packages" :min="1" controls-position="right" placeholder="箱数" @change="(value) => { pickForm.productQuantity = Number(value || 1) }" /><el-input v-model="pickForm.idempotencyKey" placeholder="拣货幂等键" /><el-button type="success" :loading="pdaMutating" @click="submitPick" > 拣货并扣减库存 </ el -button ></ div >
< div class = "pda-complete" >< el-input v-model = "pdaReason" placeholder="完成原因(可选)" /><el-button type="warning" :loading="pdaMutating" @click="completePda" > 完成拣货 </ el -button ></ div >
</ div >
< el-empty v-else description = "尚未加载PDA任务,请点击启动拣货" />
</ section >
2026-08-21 18:16:42 +08:00
</ div >
</ OverseasDetailShell >
</ template >
< style scoped >
. dispatch - detail { min - width : 0 ; min - height : 260 px ; }
. summary - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); border : 1 px solid var ( -- cc - border ); border - bottom : 0 ; background : # fff ; }
. detail - section { min - width : 0 ; padding : 18 px ; }
. summary - grid > . detail - section { border - right : 1 px solid var ( -- cc - border ); border - bottom : 1 px solid var ( -- cc - border ); }
. summary - grid > . detail - section : nth - child ( 2 ) { border - right : 0 ; }
. document - section { grid - column : 1 / 2 ; }
. detail - section h2 { margin : 0 0 14 px ; color : var ( -- el - color - primary ); font - size : 16 px ; }
. detail - fields { margin : 0 ; border - top : 1 px solid var ( -- cc - border ); }
. detail - fields > div { display : grid ; min - height : 48 px ; grid - template - columns : 180 px minmax ( 0 , 1 fr ); border - bottom : 1 px solid var ( -- cc - border ); }
. detail - fields dt , . detail - fields dd { display : flex ; min - width : 0 ; align - items : center ; padding : 9 px 12 px ; }
. detail - fields dt { color : var ( -- cc - muted ); }
. detail - fields dd { margin : 0 ; color : var ( -- cc - text ); font - weight : 600 ; overflow - wrap : anywhere ; }
. order - section { overflow : hidden ; border : 1 px solid var ( -- cc - border ); border - top : 0 ; background : # fff ; }
. order - section h2 { margin - right : 4 px ; margin - left : 4 px ; }
2026-08-31 10:58:12 +08:00
. pda - section { border : 1 px solid var ( -- cc - border ); border - top : 0 ; background : # fff ; }
. pda - header { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; flex - wrap : wrap ; }
. pda - header h2 { margin - bottom : 0 ; }
. pda - actions , . pick - form , . pda - complete { display : flex ; align - items : center ; gap : 8 px ; flex - wrap : wrap ; }
. pda - actions : deep (. el - input ) { width : 230 px ; }
. pda - alert { margin : 12 px 0 ; }
. pda - meta { margin : 12 px 0 ; color : var ( -- cc - muted ); }
. pick - form { margin - top : 14 px ; padding - top : 14 px ; border - top : 1 px solid var ( -- cc - border ); }
. pick - form : deep (. el - input ), . pda - complete : deep (. el - input ) { width : 220 px ; }
. pick - label { color : var ( -- cc - muted ); }
. pda - complete { justify - content : flex - end ; margin - top : 12 px ; }
2026-08-21 18:16:42 +08:00
@ media ( max - width : 960 px ) {
. summary - grid { grid - template - columns : 1 fr ; }
. summary - grid > . detail - section , . summary - grid > . detail - section : nth - child ( 2 ) { border - right : 0 ; }
. document - section { grid - column : auto ; }
. detail - fields > div { grid - template - columns : 132 px minmax ( 0 , 1 fr ); }
2026-08-31 10:58:12 +08:00
. pda - actions { width : 100 % ; }
2026-08-21 18:16:42 +08:00
}
</ style >