2026-08-21 11:20:09 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-10-19 17:56:31 +08:00
import { computed , onBeforeUnmount , onMounted , ref } from 'vue'
2026-10-15 17:11:03 +08:00
import { useRoute , useRouter } from 'vue-router'
2026-10-19 17:56:31 +08:00
import { ElMessage } from 'element-plus'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { TransportRouteDialog , routeLocation , type RouteLocation } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
2026-08-21 11:20:09 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-10-19 17:56:31 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-21 11:20:09 +08:00
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-09-18 14:58:40 +08:00
import RowActions , { type RowActionItem } from '@/shared/components/RowActions.vue'
2026-10-19 17:56:31 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getDispatch , getDispatchBolPdf , listDispatches } from '../api/dispatches'
import { dispatchStages , reviewLabels , stageLabel , type BeforeLoadingSection , type DispatchAction , type DispatchListRow , type DispatchStage , type DispatchStatistics , type DispatchStatus , type ReviewStatus } from '../dispatchContracts'
2026-10-21 14:30:33 +08:00
import { canDispatchAction , dispatchActionLabels , dispatchActionPermissions , dispatchRejectionReason } from '../dispatchPresentation'
2026-10-19 17:56:31 +08:00
import { formatOverseasDateTime as time } from '../lastMilePresentation'
2026-10-20 21:33:46 +08:00
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
2026-10-21 15:20:24 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
2026-10-20 21:33:46 +08:00
import { transportLabels , type TransportType } from '../api/transportTasks'
2026-10-19 17:56:31 +08:00
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
2026-08-21 11:20:09 +08:00
2026-10-19 17:56:31 +08:00
const route = useRoute (), router = useRouter (), access = useAccessPreviewStore ()
const auth = useAuthStore (), recovery = useDispatchCommandRecovery (() => auth . user ? . id || '' )
const rows = ref < DispatchListRow [] >([]), statistics = ref < DispatchStatistics | null >( null )
const loading = ref ( false ), loadError = ref < ApiErrorDetails | null >( null ), mutationError = ref < ApiErrorDetails | null >( null )
2026-10-20 14:25:01 +08:00
const rejectionError = ref < ApiErrorDetails | null >( null )
2026-10-20 21:33:46 +08:00
const transportOpen = ref ( false ), transportType = ref < TransportType >( 'PICKUP' )
2026-10-21 15:20:24 +08:00
const transportEditOpen = ref ( false ), transportEditNo = ref ( '' )
2026-10-20 21:33:46 +08:00
function createTransport ( type : TransportType ) { transportType . value = type ; transportOpen . value = true }
2026-08-21 11:20:09 +08:00
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 )
2026-10-19 17:56:31 +08:00
const stage = ref < DispatchStage >( 'BEFORE_LOADING' ), section = ref < BeforeLoadingSection >( 'VEHICLE_INFO' )
2026-10-21 14:30:33 +08:00
const model = ref < QueryModel >({ dispatchNo : String ( route . query . dispatchNo || '' ), vehiclePlate : '' , status : 'ACTIVE' })
2026-10-19 17:56:31 +08:00
const operation = ref < 'ROUTE' | undefined > ()
const action = ref < DispatchAction >( 'EDIT' ), actionNo = ref ( '' ), actionOpen = ref ( false ), createOpen = ref ( false ), bolLoadingNo = ref ( '' )
const routeOpen = ref ( false ), routeOrigin = ref < RouteLocation >( routeLocation ( '' )), routeDestinations = ref < RouteLocation [] >([])
const sections : Array < { value : BeforeLoadingSection ; label : string } > = [{ value : 'VEHICLE_INFO' , label : '车辆信息' }, { value : 'DISPATCH_REVIEW' , label : '调度审核' }, { value : 'LOADING_REVIEW' , label : '理货员审核' }]
const showSections = computed (() => stage . value === 'BEFORE_LOADING' && model . value . status === 'ACTIVE' )
2026-08-21 11:20:09 +08:00
const fields = computed < QueryField [] >(() => [
2026-10-19 17:56:31 +08:00
{ key : 'dispatchNo' , label : '排车单号' , type : 'input' , width : 180 , placeholder : '请输入排车单号' },
{ key : 'vehiclePlate' , label : '车牌号' , type : 'input' , width : 150 , placeholder : '请输入车牌号' },
{ key : 'status' , label : '单据范围' , type : 'select' , width : 140 , options : [{ value : 'ACTIVE' , label : '有效排车' }, { value : 'CANCELLED' , label : '已取消' }, { value : 'ALL' , label : '全部单据' }] },
2026-08-21 11:20:09 +08:00
])
2026-10-19 17:56:31 +08:00
const columns = computed < DataTableColumn [] >(() => [
2026-10-21 14:30:33 +08:00
{ key : 'actions' , label : '操作' , width : 220 , fixed : 'left' , slotName : 'actions' },
2026-10-20 21:33:46 +08:00
{ key : 'taskType' , label : '排车类型' , width : 115 , slotName : 'taskType' },
2026-10-19 17:56:31 +08:00
{ key : 'dispatchNo' , prop : 'dispatchNo' , label : '排车单号' , minWidth : 190 },
{ key : 'vehicle' , label : '车牌号' , minWidth : 130 , slotName : 'vehicle' }, { key : 'driver' , label : '司机/联系电话' , minWidth : 170 , slotName : 'driver' },
{ key : 'warehouseName' , prop : 'warehouseName' , label : '起运仓' , minWidth : 160 },
2026-10-21 14:30:33 +08:00
...( showSections . value && section . value === 'VEHICLE_INFO' ? [{ key : 'returnReason' , label : '退回原因' , minWidth : 240 , slotName : 'returnReason' }] : []),
2026-10-19 17:56:31 +08:00
...( showSections . value && section . value !== 'VEHICLE_INFO' ? [{ key : 'review' , label : section . value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核' , minWidth : 210 , slotName : 'review' }] : []),
{ key : 'counts' , label : '托数/箱数/已拣' , minWidth : 160 , slotName : 'counts' }, { key : 'planned' , label : '计划发车' , minWidth : 165 , slotName : 'planned' },
{ key : 'departure' , label : '实际发车' , minWidth : 165 , slotName : 'departure' }, { key : 'stage' , label : '作业阶段' , minWidth : 110 , slotName : 'stage' },
2026-10-21 14:30:33 +08:00
...( stage . value === 'DELIVERING' ? [{ key : 'routeMap' , label : '路线' , width : 100 , slotName : 'routeMap' }] : []),
2026-10-19 17:56:31 +08:00
])
const preferenceKey = computed (() => `overseas-dispatches- ${ stage . value } - ${ section . value } ` )
let sequence = 0
async function load () {
const current = ++ sequence
loading . value = true ; loadError . value = null
2026-08-21 11:20:09 +08:00
try {
2026-10-19 17:56:31 +08:00
const result = await listDispatches ({ page : page . value , size : pageSize . value , stage : stage . value , status : String ( model . value . status || 'ACTIVE' ) as DispatchStatus | 'ALL' , dispatchNo : String ( model . value . dispatchNo || '' ). trim () || undefined , vehiclePlate : String ( model . value . vehiclePlate || '' ). trim () || undefined ,
2026-10-21 14:30:33 +08:00
...( showSections . value ? { beforeLoadingSection : section . value } : {}) })
2026-10-19 17:56:31 +08:00
if ( current !== sequence ) return
rows . value = result . records ; total . value = result . total ; statistics . value = result . statistics
} catch ( cause ) { if ( current === sequence ) { rows . value = []; total . value = 0 ; statistics . value = null ; loadError . value = getApiErrorDetails ( cause , '排车列表加载失败,请重试' ) } }
finally { if ( current === sequence ) loading . value = false }
2026-08-21 11:20:09 +08:00
}
2026-10-19 17:56:31 +08:00
function search ( value : QueryModel ) { model . value = value ; page . value = 1 ; void load () }
2026-10-21 14:30:33 +08:00
function switchStage ( value : DispatchStage ) { stage . value = value ; section . value = 'VEHICLE_INFO' ; page . value = 1 ; void load () }
function switchSection ( value : BeforeLoadingSection ) { section . value = value ; page . value = 1 ; void load () }
2026-10-19 17:56:31 +08:00
function openDetail ( row : DispatchListRow ) { void router . push ({ name : 'overseas-dispatch-detail' , params : { dispatchNo : row . dispatchNo } }) }
2026-10-21 14:30:33 +08:00
const rowActionOrder : DispatchAction [] = [ 'EDIT' , 'SUBMIT_REVIEW' , 'DISPATCH_REVIEW' , 'LOADING_REVIEW' , 'RETURN_TO_REVIEW' , 'LOADING_COMPLETE' , 'CANCEL' ]
2026-10-19 17:56:31 +08:00
function rowActions ( row : DispatchListRow ) : RowActionItem [] {
2026-10-21 15:10:54 +08:00
if ( showSections . value && section . value !== 'VEHICLE_INFO' ) {
const review = section . value
return [...( canDispatchAction ( row . actions , review , access . can ) ? [{ key : review , label : '审核' , ariaLabel : dispatchActionLabels [ review ], priority : 600 }] : []), { key : 'detail' , label : '详情' , priority : 500 }]
}
2026-10-21 15:20:24 +08:00
if ( row . taskType === 'LOCAL_TRUCK' ) return [{ key : 'detail' , label : '运输操作' , priority : 500 }]
2026-10-21 14:30:33 +08:00
const available = rowActionOrder . filter ( value => canDispatchAction ( row . actions , value , access . can ))
2026-10-21 15:10:54 +08:00
const primaryAction = available . find ( value => value === 'EDIT' ) ?? available [ 0 ]
2026-10-21 14:30:33 +08:00
return [... available . map (( value , index ) => ({
key : value ,
2026-10-21 15:10:54 +08:00
label : dispatchActionLabels [ value ],
2026-10-21 14:30:33 +08:00
ariaLabel : dispatchActionLabels [ value ],
priority : value === primaryAction ? 600 : 400 - index * 10 ,
tone : value === 'CANCEL' ? 'danger' as const : undefined ,
2026-10-21 15:20:24 +08:00
})), { key : 'detail' , label : '详情' , priority : 500 }, ...( row . taskType === 'PICKUP' ? [] : [...( canDispatchAction ( row . actions , 'EDIT' , access . can ) ? [{ key : 'route-order' , label : '调整卸货顺序' , priority : 190 }] : []), { key : 'bol' , label : '下载BOL' , priority : 100 , loading : bolLoadingNo . value === row . dispatchNo }])]
2026-09-13 21:10:05 +08:00
}
2026-10-19 17:56:31 +08:00
function handleAction ( value : string , row : DispatchListRow ) {
if ( value === 'detail' ) return openDetail ( row )
2026-10-21 15:10:54 +08:00
if ( showSections . value && section . value !== 'VEHICLE_INFO' && value !== section . value ) return
2026-10-19 17:56:31 +08:00
if ( value === 'bol' ) return void openBol ( row )
2026-10-20 14:25:01 +08:00
rejectionError . value = null
2026-10-19 17:56:31 +08:00
if ( value === 'route-order' ) { if ( ! canDispatchAction ( row . actions , 'EDIT' , access . can )) return ; operation . value = 'ROUTE' ; action . value = 'EDIT' ; actionNo . value = row . dispatchNo ; actionOpen . value = true ; return }
operation . value = undefined
2026-10-21 13:22:42 +08:00
if ( value === 'ADD_CARGO' || value === 'REMOVE_CARGO' ) return
2026-10-19 17:56:31 +08:00
const requested = value as DispatchAction
if ( ! canDispatchAction ( row . actions , requested , access . can )) return
2026-10-21 15:20:24 +08:00
if ( row . taskType === 'PICKUP' && requested === 'EDIT' ) { transportEditNo . value = row . dispatchNo ; transportEditOpen . value = true ; return }
2026-10-19 17:56:31 +08:00
action . value = requested ; actionNo . value = row . dispatchNo ; actionOpen . value = true
2026-09-13 21:10:05 +08:00
}
2026-10-20 14:25:01 +08:00
async function handleRejection ( details : ApiErrorDetails ) { rejectionError . value = details ; await load () }
async function handleSaved () { rejectionError . value = null ; await load () }
2026-10-19 17:56:31 +08:00
function recoverPending () {
const command = recovery . pending . value
if ( ! command ) return
2026-10-21 13:22:42 +08:00
if ([ 'CREATE' , 'ADD_CARGO' , 'REMOVE_CARGO' ]. includes ( command . action )) { createOpen . value = true ; return }
2026-10-19 17:56:31 +08:00
operation . value = command . operation ; action . value = command . action ; actionNo . value = command . dispatchNo ; actionOpen . value = true
2026-09-13 21:10:05 +08:00
}
2026-10-19 17:56:31 +08:00
async function openBol ( row : DispatchListRow ) {
2026-09-10 21:22:06 +08:00
if ( bolLoadingNo . value ) return
2026-10-19 17:56:31 +08:00
const windowHandle = window . open ( '' , '_blank' )
if ( ! windowHandle ) { ElMessage . warning ( '请允许本站打开BOL预览窗口' ); return }
2026-09-10 21:22:06 +08:00
bolLoadingNo . value = row . dispatchNo
2026-10-19 17:56:31 +08:00
try { const pdf = await getDispatchBolPdf ( row . dispatchNo ); const url = URL . createObjectURL ( pdf ); windowHandle . location . href = url ; window . setTimeout (() => URL . revokeObjectURL ( url ), 60 _000 ) }
catch ( cause ) { windowHandle . close (); mutationError . value = getApiErrorDetails ( cause , 'BOL读取失败' ) }
finally { bolLoadingNo . value = '' }
2026-09-10 21:22:06 +08:00
}
2026-10-19 17:56:31 +08:00
async function openRoute ( row : DispatchListRow ) {
try { const data = await getDispatch ( row . dispatchNo ); if ( data . dispatchNo !== row . dispatchNo ) throw new Error ( '路线排车号不一致' ); routeOrigin . value = { kind : 'warehouse' , label : row . warehouseName , warehouseId : row . warehouseId }; routeDestinations . value = [... new Set ( data . orderDetails . map ( item => item . destinationFbaWarehouse ). filter (( code ) : code is string => Boolean ( code )))]. map ( code => ({ kind : 'warehouse' , label : code , warehouseCode : code })); routeOpen . value = true }
catch ( cause ) { mutationError . value = getApiErrorDetails ( cause , '运输路线读取失败' ) }
2026-09-13 21:10:05 +08:00
}
2026-08-21 11:20:09 +08:00
onMounted ( load )
2026-10-19 17:56:31 +08:00
onBeforeUnmount (() => { sequence ++ })
2026-08-21 11:20:09 +08:00
</ script >
< template >
2026-10-20 23:34:11 +08:00
< AdminPage class = "admin-density admin-workbench" >< ListPageLayout aria -label =" 海外仓排车列表 " >
2026-10-19 17:56:31 +08:00
< OperationErrorToast :details = "mutationError" />
2026-10-20 14:25:01 +08:00
< LoadErrorState v-if = "rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></ template ></ LoadErrorState >
< p v-if = "recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending" >{{ recovery.pending.value.result ? ' 刷新页面资料 ' : ' 核对原请求 ' }} </ el -button ></ p >
2026-10-21 13:22:42 +08:00
< div class = "workbench-table is-page-surface" >< DataTable v -model:page = "page" v -model:page-size = "pageSize" appearance = "integrated" :data = "rows" :columns = "columns" :loading = "loading" :total = "total" :preference-key = "preferenceKey" row -key =" dispatchNo " empty -text =" 暂无排车记录 " @ update :page = "load()" @ update : page -size =" page = 1 ; load ()" >
2026-10-20 23:34:11 +08:00
< template # toolbar -start >< el-button v-if = "appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)" >{{ item.label }} < span >{{ statistics ? . stageCounts [ item . value ] ?? '—' }}</ span ></ button ></ div ></ template >
2026-10-21 13:22:42 +08:00
< template # toolbar -end >< el-button v-if = "access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true" > 智能排车 </ el -button ></ template >
2026-10-20 23:34:11 +08:00
< template # filters >< div class = "integrated-query-group" >< div v-if = "showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></ div ></ template >
2026-09-30 14:01:58 +08:00
< template v-if = "loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></ template ></ LoadErrorState ></ template >
2026-10-21 14:30:33 +08:00
< template # actions = "{ row }" >< RowActions text -actions : actions = "rowActions(row as DispatchListRow)" :max-visible = "2" : more -label ="` 更多操作 · $ { row.dispatchNo } `" @action ="handleAction($event, row as DispatchListRow)" />< p v-if = "!row.actions" > 操作资格未返回 , 请刷新核对 </ p ></ template >
2026-10-20 21:33:46 +08:00
< template # taskType = "{ row }" >{{ transportLabels [( row . taskType || 'DELIVERY' ) as keyof typeof transportLabels ] }}</ template >
2026-10-19 17:56:31 +08:00
< template # vehicle = "{ row }" >{{ row . resources . vehicle ? . plate || '—' }}</ template >< template # driver = "{ row }" >{{ row . resources . driver ? . name || '—' }}< br >{{ row . resources . driver ? . phone || '—' }}</ template >
< template # review = "{ row }" >< template v-for = "review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason" >{{ review.invalidatedReason }}</ div >< div >{{ time ( review . reviewedAt ) }}</ div ></ template ></ template >
2026-10-21 14:30:33 +08:00
< template # returnReason = "{ row }" >{{ dispatchRejectionReason ( row . reviews ) || '—' }}</ template >
2026-10-19 17:56:31 +08:00
< template # counts = "{ row }" >{{ row . counts . palletCount }} 托 / {{ row . counts . packageCount }} 箱 / 已拣 {{ row . counts . pickedPackageCount }} 箱 </ template >< template # planned = "{ row }" >{{ time ( row . plannedDepartureAt ) }}</ template >< template # departure = "{ row }" >{{ time ( row . departedAt ) }}</ template >< template # stage = "{ row }" >{{ row . status === 'CANCELLED' ? '已取消' : stageLabel ( row . stage ) }}</ template >< template # routeMap = "{ row }" >< el-button link @click ="openRoute(row as DispatchListRow)" > 查看路线 </ el -button ></ template >
2026-10-20 23:34:11 +08:00
</ DataTable ></ div >
2026-10-19 17:56:31 +08:00
</ ListPageLayout ></ AdminPage >
2026-10-20 21:33:46 +08:00
< TransportTaskCreateDialog v-model = "transportOpen" :type="transportType" @created="load" />
2026-10-21 15:20:24 +08:00
< AppDialog v-model = "transportEditOpen" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @saved="load" /></ AppDialog >
2026-10-19 17:56:31 +08:00
< SmartDispatchDialog v-model = "createOpen" :orders="[]" @created="load" />
2026-10-20 14:25:01 +08:00
< DispatchWorkflowDialog v-model = "actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
2026-10-19 17:56:31 +08:00
< TransportRouteDialog v-model = "routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
2026-08-21 11:20:09 +08:00
</ template >