2026-08-21 11:20:09 +08:00
< script setup lang = "ts" >
2026-08-26 15:48:34 +08:00
import { computed , onMounted , reactive , ref } from 'vue'
2026-08-25 20:57:51 +08:00
import { Eye , Truck } from '@lucide/vue'
import { ElMessage } from 'element-plus'
2026-08-21 18:16:42 +08:00
import { useRouter } from 'vue-router'
2026-08-21 11:20:09 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-25 20:57:51 +08:00
import { listDispatches , transferDispatchToDelivery , type DispatchSummaryDto } from '../api/dispatches'
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-21 11:20:09 +08:00
import type { DispatchListItem } from '../types'
2026-08-22 12:24:15 +08:00
import { formatOverseasDateTime } from '../lastMilePresentation'
2026-08-21 11:20:09 +08:00
2026-08-21 18:16:42 +08:00
const router = useRouter ()
2026-08-21 11:20:09 +08:00
const rows = ref < DispatchListItem [] >([])
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const page = ref ( 1 )
2026-08-22 12:24:15 +08:00
const pageSize = ref ( 10 )
2026-08-21 11:20:09 +08:00
const total = ref ( 0 )
const filters = ref < QueryModel >({ dispatchNo : '' , vehiclePlate : '' , status : '' , fbaAppointmentNo : '' })
2026-08-26 15:48:34 +08:00
const transferOpen = ref ( false ); const transferRow = ref < DispatchListItem | null >( null ); const transferSaving = ref ( false )
const transferForm = reactive ({ plannedDeliveryAt : '' , deliveryMethod : '' , waybillNo : '' , carrierName : '' , pickupMethod : '' , loadingType : '' , vehicleType : '' , vehicleSize : '' , driverName : '' , driverPhone : '' , vehiclePlate : '' , appointmentAccount : '' , sellerName : '' , deliveryFee : undefined as number | undefined , loadingFee : undefined as number | undefined , appointmentFee : undefined as number | undefined , remark : '' })
const deliveryMethodOptions = [
{ label : 'FBA自提' , value : 'FBA_PICKUP' }, { label : '客户自提' , value : 'CUSTOMER_PICKUP' },
{ label : '卡派打托' , value : 'TRUCK_PALLET' }, { label : '卡派地板' , value : 'TRUCK_FLOOR' },
{ label : '快递' , value : 'EXPRESS' }, { label : '外配' , value : 'OUTSOURCED' },
{ label : '专车直送' , value : 'DEDICATED_TRUCK' }, { label : '零担派送' , value : 'LTL' },
]
const pickupMethodOptions = [
{ label : '一提一卸' , value : 'ONE_PICKUP_ONE_DROP' }, { label : '一提多卸' , value : 'ONE_PICKUP_MULTI_DROP' },
{ label : '多提一卸' , value : 'MULTI_PICKUP_ONE_DROP' }, { label : '多提多卸' , value : 'MULTI_PICKUP_MULTI_DROP' },
{ label : '仓库自提' , value : 'WAREHOUSE_PICKUP' },
]
const loadingTypeOptions = [{ label : '托盘' , value : 'PALLET' }, { label : '地板' , value : 'FLOOR' }, { label : '混装' , value : 'MIXED' }]
const vehicleTypeOptions = [ '厢式货车' , '平板车' , '冷藏车' , '集装箱卡车' , '尾板车' ]. map (( label ) => ({ label , value : label }))
const vehicleSizeOptions = [ '53尺大车(30托)' , '26尺中车(14托)' , '16尺小车(8托)' , 'Cargo Van( 4托)' ]. map (( label ) => ({ label , value : label }))
const appointmentAccountOptions = [
{ label : '4号账号(美西)' , value : '4' }, { label : '8号账号(美东)' , value : '8' },
{ label : '10号账号(美中)' , value : '10' }, { label : '12号账号(加拿大)' , value : '12' }, { label : '买约' , value : 'BUY_APPOINTMENT' },
]
const isBuyAppointment = computed (() => transferForm . appointmentAccount === 'BUY_APPOINTMENT' )
const needsLoadingFee = computed (() => [ 'FLOOR' , 'MIXED' ]. includes ( transferForm . loadingType ))
2026-08-21 11:20:09 +08:00
const statusOptions = [
{ label : '待发车' , value : 'PENDING_DEPARTURE' },
{ label : '拣货中' , value : 'PICKING' },
{ label : '待发车(拣货完成)' , value : 'READY_TO_DEPART' },
{ label : '待发车(部分完成)' , value : 'PARTIAL_READY_TO_DEPART' },
{ label : '已发车' , value : 'DEPARTED' },
{ label : '已发车(部分完成)' , value : 'PARTIAL_DEPARTED' },
{ label : '已取消' , value : 'CANCELLED' },
]
const fields = computed < QueryField [] >(() => [
{ key : 'dispatchNo' , type : 'input' , label : '排车单号' , placeholder : '请输入排车单号' },
{ key : 'vehiclePlate' , type : 'input' , label : '车牌号' , placeholder : '请输入车牌号' },
{ key : 'status' , type : 'select' , label : '车辆状态' , placeholder : '全部状态' , options : statusOptions },
{ key : 'fbaAppointmentNo' , type : 'input' , label : 'FBA预约号' , placeholder : '请输入FBA预约号' },
])
const columns : DataTableColumn [] = [
2026-08-21 18:16:42 +08:00
{ key : 'actions' , label : '操作' , width : 110 , fixed : 'left' , slotName : 'actions' },
2026-08-21 11:20:09 +08:00
{ key : 'dispatchNo' , prop : 'dispatchNo' , label : '排车单号' , minWidth : 170 , fixed : 'left' },
{ key : 'vehiclePlate' , prop : 'vehiclePlate' , label : '车牌号' , minWidth : 130 },
{ key : 'driverName' , prop : 'driverName' , label : '司机' , minWidth : 120 },
{ key : 'driverPhone' , prop : 'driverPhone' , label : '联系电话' , minWidth : 140 },
{ key : 'carrierName' , prop : 'carrierName' , label : '托车公司名称' , minWidth : 160 },
{ key : 'orderNos' , prop : 'orderNos' , label : '关联订单' , minWidth : 220 },
{ key : 'route' , prop : 'route' , label : '路线' , minWidth : 180 },
{ key : 'overseasWarehouse' , prop : 'overseasWarehouse' , label : '海外仓' , minWidth : 160 },
{ key : 'departAt' , prop : 'departAt' , label : '发车时间' , minWidth : 170 },
{ key : 'fbaAppointment' , prop : 'fbaAppointment' , label : 'FBA预约' , minWidth : 180 },
{ key : 'vehicleAppointment' , prop : 'vehicleAppointment' , label : '车辆预约' , minWidth : 130 },
{ key : 'estimatedArrivalAt' , prop : 'estimatedArrivalAt' , label : '预计到达时间' , minWidth : 170 },
{ key : 'palletCount' , prop : 'palletCount' , label : '托盘数' , width : 90 , align : 'right' },
{ key : 'packageCount' , prop : 'packageCount' , label : '箱数' , width : 80 , align : 'right' },
{ key : 'status' , prop : 'status' , label : '车辆状态' , width : 150 , slotName : 'status' },
{ key : 'createdAt' , prop : 'createdAt' , label : '创建时间' , minWidth : 170 },
]
function text ( value : unknown ) {
const normalized = String ( value ?? '' ). trim ()
return normalized || '—'
}
function number ( value : unknown ) {
const normalized = Number ( value )
return Number . isFinite ( normalized ) ? normalized : 0
}
function statusLabel ( value : unknown ) {
const status = String ( value || '' )
return statusOptions . find (( option ) => option . value === status ) ? . label || text ( value )
}
function appointmentLabel ( numberValue : unknown , statusValue : unknown ) {
const appointmentNo = text ( numberValue )
const appointmentStatus = text ( statusValue )
if ( appointmentNo === '—' ) return appointmentStatus
return appointmentStatus === '—' ? appointmentNo : ` ${ appointmentNo } · ${ appointmentStatus } `
}
function toListItem ( dto : DispatchSummaryDto ) : DispatchListItem {
return {
id : String ( dto . id ),
dispatchNo : text ( dto . dispatchNo ),
vehiclePlate : text ( dto . vehiclePlate ),
driverName : text ( dto . driverName ),
driverPhone : text ( dto . driverPhone ),
carrierName : text ( dto . carrierName ),
orderNos : text ( dto . orderNos ),
route : text ( dto . route ),
overseasWarehouse : text ( dto . overseasWarehouseName || dto . overseasWarehouseId ),
departAt : text ( dto . departAt ),
fbaAppointment : appointmentLabel ( dto . fbaAppointmentNo , dto . fbaAppointmentStatus ),
vehicleAppointment : text ( dto . vehicleAppointmentStatus ),
estimatedArrivalAt : text ( dto . estimatedArrivalAt ),
status : statusLabel ( dto . status ),
palletCount : number ( dto . palletCount ),
packageCount : number ( dto . packageCount ),
2026-08-22 12:24:15 +08:00
createdAt : formatOverseasDateTime ( dto . createdAt ),
2026-08-21 11:20:09 +08:00
}
}
function dispatchError ( error : unknown ) {
const details = getApiErrorDetails ( error , '排车列表加载失败,请重试' )
return details . code === 'HTTP_404'
? { ... details , message : '排车接口未启用或不存在,请确认海外尾程排车接口已发布' }
: details
}
async function load ( model : QueryModel = filters . value ) {
Object . assign ( filters . value , model )
loading . value = true
loadError . value = null
try {
const result = await listDispatches ({
page : page . value ,
size : pageSize . value ,
dispatchNo : String ( filters . value . dispatchNo || '' ). trim () || undefined ,
vehiclePlate : String ( filters . value . vehiclePlate || '' ). trim () || undefined ,
status : String ( filters . value . status || '' ). trim () || undefined ,
fbaAppointmentNo : String ( filters . value . fbaAppointmentNo || '' ). trim () || undefined ,
})
rows . value = result . records . map ( toListItem )
total . value = result . total
} catch ( error ) {
rows . value = []
total . value = 0
loadError . value = dispatchError ( error )
} finally {
loading . value = false
}
}
function search ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
function reset ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
2026-08-21 18:16:42 +08:00
function openDetail ( row : DispatchListItem ) { void router . push ({ name : 'overseas-dispatch-detail' , params : { dispatchNo : row . dispatchNo } }) }
2026-08-26 15:48:34 +08:00
function openTransfer ( row : DispatchListItem ) { transferRow . value = row ; Object . assign ( transferForm , { plannedDeliveryAt : '' , deliveryMethod : '' , waybillNo : '' , carrierName : row . carrierName === '—' ? '' : row . carrierName , pickupMethod : '' , loadingType : '' , vehicleType : '' , vehicleSize : '' , driverName : row . driverName === '—' ? '' : row . driverName , driverPhone : row . driverPhone === '—' ? '' : row . driverPhone , vehiclePlate : row . vehiclePlate === '—' ? '' : row . vehiclePlate , appointmentAccount : '' , sellerName : '' , deliveryFee : undefined , loadingFee : undefined , appointmentFee : undefined , remark : '' }); transferOpen . value = true }
async function submitTransfer () {
if ( ! transferRow . value ) return
if ( ! transferForm . plannedDeliveryAt || ! transferForm . deliveryMethod || ! transferForm . pickupMethod || ! transferForm . loadingType ) { ElMessage . warning ( '请填写预计派送时间、派送方式、提货方式和装车类型' ); return }
if ( transferForm . deliveryFee == null ) { ElMessage . warning ( '请填写派送费用' ); return }
if ( needsLoadingFee . value && transferForm . loadingFee == null ) { ElMessage . warning ( '当前装车类型需要填写装车费' ); return }
if ( isBuyAppointment . value && ( ! transferForm . sellerName . trim () || transferForm . appointmentFee == null )) { ElMessage . warning ( '买约时请填写卖家姓名和买约费用' ); return }
const feeInputs = [[ 'DELIVERY_FEE' , transferForm . deliveryFee , '派送费用' ], [ 'LOADING_FEE' , needsLoadingFee . value ? transferForm . loadingFee : undefined , '装车费' ], [ 'APPOINTMENT_FEE' , isBuyAppointment . value ? transferForm . appointmentFee : undefined , '买约费用' ]] as const
const fees = feeInputs . filter (([, amount ]) => amount != null ). map (([ feeType , amount , remark ]) => ({ feeType , currency : 'USD' , amount : Number ( amount ), remark }))
transferSaving . value = true
try { await transferDispatchToDelivery ( transferRow . value . dispatchNo , { idempotencyKey : `web-transfer- ${ transferRow . value . dispatchNo } - ${ Date . now () } ` , plannedDeliveryAt : transferForm . plannedDeliveryAt , deliveryMethod : transferForm . deliveryMethod , waybillNo : transferForm . waybillNo . trim () || undefined , carrierName : transferForm . carrierName . trim () || undefined , pickupMethod : transferForm . pickupMethod , loadingType : transferForm . loadingType , vehicleType : transferForm . vehicleType || undefined , vehicleSize : transferForm . vehicleSize || undefined , driverName : transferForm . driverName . trim () || undefined , driverPhone : transferForm . driverPhone . trim () || undefined , vehiclePlate : transferForm . vehiclePlate . trim () || undefined , appointmentAccount : transferForm . appointmentAccount || undefined , sellerName : isBuyAppointment . value ? transferForm . sellerName . trim () : undefined , remark : transferForm . remark . trim () || undefined , fees }); transferOpen . value = false ; ElMessage . success ( '已生成出库单和派送单' ) } catch ( error ) { ElMessage . error ( getApiErrorDetails ( error , '转派送失败' ). message ) } finally { transferSaving . value = false }
}
2026-08-21 11:20:09 +08:00
onMounted ( load )
</ script >
< template >
< AdminPage >< ListPageLayout aria -label =" 海外仓排车列表 " >
< template # toolbar >< QueryToolbar v-model = "filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" @search="search" @reset="reset" /></ template >
< template v-if = "loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState ></ template >
< section v-if = "!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()" >
2026-08-25 20:57:51 +08:00
< template # actions = "{ row }" >< el-button size = "small" plain type = "primary" @click ="openDetail(row as DispatchListItem)" >< Eye :size = "15" /> 查看 </ el-button >< el-button size = "small" type = "success" plain @click ="openTransfer(row as DispatchListItem)" >< Truck :size = "15" /> 转派送 </ el-button ></ template >
2026-08-21 11:20:09 +08:00
< template # status = "{ row }" >< StatusBadge : status = "String((row as DispatchListItem).status || '—')" /></ template >
< template # pagination -left > 共 {{ total }} 条排车记录 </ template >
</ DataTable ></ section >
2026-08-26 15:48:34 +08:00
</ ListPageLayout ></ AdminPage >< AppDialog v-model = "transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD) </h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></ el-form ></ AppDialog >
2026-08-21 11:20:09 +08:00
</ template >
2026-08-26 15:48:34 +08:00
< style scoped >. transfer - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 0 16 px }. transfer - grid : deep (. el - select ),. transfer - grid : deep (. el - date - editor ),. transfer - grid : deep (. el - input - number ){ width : 100 % } h3 { margin : 4 px 0 12 px } @ media ( max - width : 900 px ){. transfer - grid { grid - template - columns : 1 fr }}</ style >