@@ -10,7 +10,13 @@ import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierSc
import { getPortSchedule } from '../api/platformPortSchedules'
import { getPortSchedule } from '../api/platformPortSchedules'
import type { CarrierScheduleExceptionFact , CarrierScheduleManualNodeRequest , CarrierScheduleNodeFact , CarrierScheduleQueryDetail , CarrierScheduleQueryView , CarrierScheduleStandardNode , PortScheduleView } from '../types'
import type { CarrierScheduleExceptionFact , CarrierScheduleManualNodeRequest , CarrierScheduleNodeFact , CarrierScheduleQueryDetail , CarrierScheduleQueryView , CarrierScheduleStandardNode , PortScheduleView } from '../types'
type WarehouseTimelineEntry = { eventType : string ; detail ? : string | null ; operatorName ? : string | null ; occurredAt ? : string | null }
type WarehouseTimelineEntry = {
id ? : string | null ; eventId ? : string | null ; idempotencyKey ? : string | null
eventType : string ; action ? : string | null ; fromStatus ? : string | null ; toStatus ? : string | null
detail ? : string | null ; summary ? : string | null ; changeSummary ? : string | null ; reason ? : string | null
changedFields ? : unknown ; fieldChanges ? : unknown ; changes ? : unknown
operatorName ? : string | null ; occurredAt ? : string | null
}
type FulfillmentEvent = { key : string ; time : string ; type : '仓内操作' | '确认离仓' | '船运节点' | '人工补录' | '异常' ; title : string ; summary : string ; source : string ; actual : boolean }
type FulfillmentEvent = { key : string ; time : string ; type : '仓内操作' | '确认离仓' | '船运节点' | '人工补录' | '异常' ; title : string ; summary : string ; source : string ; actual : boolean }
const props = defineProps < { oceanBookingId ? : string | null ; containerBookingId ? : string | null ; portScheduleId ? : string | null ; warehouseTimeline ? : WarehouseTimelineEntry [ ] } > ( )
const props = defineProps < { oceanBookingId ? : string | null ; containerBookingId ? : string | null ; portScheduleId ? : string | null ; warehouseTimeline ? : WarehouseTimelineEntry [ ] } > ( )
const access = useAccessPreviewStore ( )
const access = useAccessPreviewStore ( )
@@ -86,17 +92,78 @@ function exceptionLocation(item: CarrierScheduleExceptionFact) { return [item.lo
function nodeTimeText ( item : CarrierScheduleNodeFact ) { return item . actualAt ? ` 实际 ${ formatTime ( item . actualAt ) } ` : item . estimatedAt ? ` 预计 ${ formatTime ( item . estimatedAt ) } ` : ` 观测 ${ formatTime ( item . observedAt ) } ` }
function nodeTimeText ( item : CarrierScheduleNodeFact ) { return item . actualAt ? ` 实际 ${ formatTime ( item . actualAt ) } ` : item . estimatedAt ? ` 预计 ${ formatTime ( item . estimatedAt ) } ` : ` 观测 ${ formatTime ( item . observedAt ) } ` }
function exceptionText ( value : CarrierScheduleExceptionFact [ 'exceptionCode' ] ) { return ( { CUSTOMS _HOLD : '海关扣留' , TERMINAL _HOLD : '码头扣留' , DOCUMENT _HOLD : '单证扣留' , UNKNOWN _HOLD : '其他扣留' } as Record < string , string > ) [ value ] || value }
function exceptionText ( value : CarrierScheduleExceptionFact [ 'exceptionCode' ] ) { return ( { CUSTOMS _HOLD : '海关扣留' , TERMINAL _HOLD : '码头扣留' , DOCUMENT _HOLD : '单证扣留' , UNKNOWN _HOLD : '其他扣留' } as Record < string , string > ) [ value ] || value }
function historyText ( action : string ) { return ( { AUTO _CREATE : '国内出仓自动建任务' , CREATE : '人工建立查询任务' , NODE _OBSERVED : '记录标准节点' , PROVIDER _QUERY : '船司查询' , MANUAL _NODE : '人工补录标准节点' , STOP : '停止查询' } as Record < string , string > ) [ action ] || action }
function historyText ( action : string ) { return ( { AUTO _CREATE : '国内出仓自动建任务' , CREATE : '人工建立查询任务' , NODE _OBSERVED : '记录标准节点' , PROVIDER _QUERY : '船司查询' , MANUAL _NODE : '人工补录标准节点' , STOP : '停止查询' } as Record < string , string > ) [ action ] || action }
function warehouseEventText ( value : string ) { return ( { TRANSPORT _UPDATE : '更新运输信息' , PDA _CLAIM : '领取 PDA 任务' , PDA _BATCH : '提交拣货结果' , PDA _COMPLETE _LOADING : '完成装柜' } as Record < string , string > ) [ value ] || value || '状态更新' }
function warehouseEventText ( value : string ) { return ( { STATUS _UPDATE : '状态更新' , PUSH _PRELOAD : '推送预装清单' , CONFIRM _PRELOAD : '确认预装清单' , TRANSPORT _UPDATE : '更新运输信息' , PDA _CLAIM : '领取 PDA 任务' , PDA _BATCH : '提交拣货结果' , PDA _COMPLETE _LOADING : '完成装柜' } as Record < string , string > ) [ value ] || value || '状态更新' }
function nonBlank ( value : unknown ) { return typeof value === 'string' ? value . trim ( ) : '' }
function hasStructuredChanges ( value : unknown ) {
if ( Array . isArray ( value ) ) return value . length > 0
if ( value && typeof value === 'object' ) return Object . keys ( value as Record < string , unknown > ) . length > 0
return Boolean ( nonBlank ( value ) )
}
function statusLabel ( value ? : string | null ) {
return ( { WAIT _LOADING : '待装柜' , EDITING : '预装柜中' , LOADING : '拣货中' , COMPLETED : '装柜完成' } as Record < string , string > ) [ value || '' ] || nonBlank ( value )
}
function isStatusEcho ( detail : string , fromStatus ? : string | null , toStatus ? : string | null ) {
const from = nonBlank ( fromStatus ) ; const to = nonBlank ( toStatus )
if ( ! from || ! to ) return false
const compact = detail . replace ( /\s+/g , '' )
return [ [ from , to ] , [ statusLabel ( from ) , statusLabel ( to ) ] ] . some ( ( [ left , right ] ) =>
[ ` ${ left } → ${ right } ` , ` ${ left } -> ${ right } ` ] . includes ( compact ) )
}
function warehouseAction ( entry : WarehouseTimelineEntry ) { return nonBlank ( entry . action ) || nonBlank ( entry . eventType ) }
function hasEffectiveWarehouseChange ( entry : WarehouseTimelineEntry ) {
if ( hasStructuredChanges ( entry . changedFields ) || hasStructuredChanges ( entry . fieldChanges ) || hasStructuredChanges ( entry . changes ) ) return true
if ( [ entry . summary , entry . changeSummary , entry . reason ] . some ( ( value ) => nonBlank ( value ) ) ) return true
const detail = nonBlank ( entry . detail )
return Boolean ( detail && ! isStatusEcho ( detail , entry . fromStatus , entry . toStatus ) )
}
function isMeaningfulWarehouseAction ( entry : WarehouseTimelineEntry ) {
return [ entry . action , entry . eventType ] . some ( ( value ) => / ^ ( ? : PUSH _PRELOAD | PDA ( ? : _ | $ ) | CONFIRM ( ? : _ | $ ) ) / . test ( nonBlank ( value ) ) )
}
function isNoopWarehouseStatus ( entry : WarehouseTimelineEntry ) {
if ( isMeaningfulWarehouseAction ( entry ) ) return false
return warehouseAction ( entry ) === 'STATUS_UPDATE'
&& nonBlank ( entry . fromStatus ) !== ''
&& nonBlank ( entry . fromStatus ) === nonBlank ( entry . toStatus )
&& ! hasEffectiveWarehouseChange ( entry )
}
function eventIdentity ( entry : WarehouseTimelineEntry ) { return nonBlank ( entry . eventId ) || nonBlank ( entry . idempotencyKey ) }
function eventSignature ( entry : WarehouseTimelineEntry ) {
return JSON . stringify ( [ warehouseAction ( entry ) , entry . fromStatus || null , entry . toStatus || null , entry . summary || null , entry . changeSummary || null , entry . reason || null , entry . detail || null , entry . changedFields || null , entry . fieldChanges || null , entry . changes || null ] )
}
function sameExplicitEvent ( left : WarehouseTimelineEntry , right : WarehouseTimelineEntry ) {
const sameId = nonBlank ( left . eventId ) && nonBlank ( left . eventId ) === nonBlank ( right . eventId )
const sameKey = nonBlank ( left . idempotencyKey ) && nonBlank ( left . idempotencyKey ) === nonBlank ( right . idempotencyKey )
return Boolean ( ( sameId || sameKey ) && eventSignature ( left ) === eventSignature ( right ) )
}
const filteredWarehouseTimeline = computed ( ( ) => {
const sorted = ( props . warehouseTimeline || [ ] ) . map ( ( entry , index ) => ( { entry , index } ) )
. filter ( ( { entry } ) => entry . occurredAt && ! [ 'MARK' , 'CANCEL' ] . includes ( warehouseAction ( entry ) ) && ! isNoopWarehouseStatus ( entry ) )
. sort ( ( left , right ) => {
const time = Date . parse ( right . entry . occurredAt || '' ) - Date . parse ( left . entry . occurredAt || '' )
return time || left . index - right . index
} )
const result : WarehouseTimelineEntry [ ] = [ ]
for ( const { entry } of sorted ) {
const previous = result [ result . length - 1 ]
if ( previous && sameExplicitEvent ( previous , entry ) ) continue
result . push ( entry )
}
return result
} )
function warehouseEventSummary ( entry : WarehouseTimelineEntry ) {
function warehouseEventSummary ( entry : WarehouseTimelineEntry ) {
const value = String ( entry . detail || '' ) . trim ( )
const value = nonBlank ( entry . summary ) || nonBlank ( entry . changeSummary ) || nonBlank ( entry . reason ) || nonBlank ( entry . detail )
return ! value || value . length > 120 || /(?:operation|idempot|claim-|pda-submit|complete-)/i . test ( value ) ? '操作已记录' : value
if ( value && value . length <= 120 && ! /(?:operation|idempot|claim-|pda-submit|complete-)/i . test ( value ) ) return value
const from = nonBlank ( entry . fromStatus ) ; const to = nonBlank ( entry . toStatus )
if ( from && to && from !== to ) return ` ${ statusLabel ( from ) } → ${ statusLabel ( to ) } `
if ( hasStructuredChanges ( entry . changedFields ) || hasStructuredChanges ( entry . fieldChanges ) || hasStructuredChanges ( entry . changes ) ) return '业务字段已更新'
return '操作已记录'
}
}
const fulfillmentEvents = computed < FulfillmentEvent [ ] > ( ( ) => {
const fulfillmentEvents = computed < FulfillmentEvent [ ] > ( ( ) => {
const values : FulfillmentEvent [ ] = [ ]
const values : FulfillmentEvent [ ] = [ ]
for ( const [ index , item ] of ( props . warehouseTimeline || [ ] ) . entries ( ) ) {
for ( const [ index , item ] of filteredWarehouseTimeline . value . entries ( ) ) {
if ( ! item . occurredAt || [ 'MARK' , 'CANCEL' ] . includes ( item . eventType ) ) continue
const action = warehouseAction ( item )
const departure = /OUTBOUND|DISPATCH|DEPARTED_WAREHOUSE/ . test ( item . eventType )
const departure = /OUTBOUND|DISPATCH|DEPARTED_WAREHOUSE/ . test ( action )
values . push ( { key : ` warehouse- ${ item . eventType } - ${ item . occurredAt } - ${ index } ` , time : item . occurredAt , type : departure ? '确认离仓' : '仓内操作' , title : warehouseEventText ( item . eventType ) , summary : warehouseEventSummary ( item ) , source : item . operatorName || '系统' , actual : true } )
values . push ( { key : ` warehouse- ${ eventIdentity ( item ) || ` ${ action } - ${ item . occurredAt } - ${ index } ` } ` , time : item . occurredAt ! , type : departure ? '确认离仓' : '仓内操作' , title : warehouseEventText ( action ) , summary : warehouseEventSummary ( item ) , source : item . operatorName || '系统' , actual : true } )
}
}
const nodeKeys = new Set < string > ( )
const nodeKeys = new Set < string > ( )
for ( const item of primaryNodeFacts . value ) {
for ( const item of primaryNodeFacts . value ) {
@@ -179,7 +246,7 @@ watch(() => manualForm.actualAt, (actualAt) => {
< template >
< template >
< section class = "carrier-query" aria -label = " 货柜履约轨迹 " >
< section class = "carrier-query" aria -label = " 货柜履约轨迹 " >
< header > < div > < h2 > 货柜履约轨迹 < / h2 > < p > 统一展示仓内操作 、 确认离仓与船运节点 ; 预计节点不会推进实际进度 。 < / p > < / div > < div class = "header-actions" > < el-button v-if = "query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light" > {{ statusText }} < / el -tag > < / div > < / header >
< header > < div > < h2 > 货柜履约轨迹 < / h2 > < p > 统一展示仓内操作 、 确认离仓与船运节点 ; 预计节点不会推进实际进度 。 < / p > < / div > < div class = "header-actions" > < span class = "history-count" > 仓内 { { filteredWarehouseTimeline . length } } 条 · 共 { { fulfillmentEvents . length } } 条 < / span > < el-button v-if = "query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light" > {{ statusText }} < / el -tag > < / div > < / header >
< LoadErrorState v-if = "error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" / > < / template > < / LoadErrorState >
< LoadErrorState v-if = "error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" / > < / template > < / LoadErrorState >
< div v-else-if = "loading" class="loading-state"><el-skeleton :rows="1" animated / > < / div >
< div v-else-if = "loading" class="loading-state"><el-skeleton :rows="1" animated / > < / div >
< template v-else >
< template v-else >
@@ -209,5 +276,6 @@ watch(() => manualForm.actualAt, (actualAt) => {
< / template >
< / template >
< style scoped >
< style scoped >
. history - count { color : var ( -- cc - muted ) ; font - size : 12 px ; white - space : nowrap }
. carrier - query { display : grid ; gap : 9 px ; padding - top : 2 px } . carrier - query > header { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 12 px } . header - actions { display : flex ; align - items : center ; gap : 8 px } . carrier - query h2 { margin : 0 ; font - size : 15 px } . carrier - query header p { margin : 3 px 0 0 ; color : var ( -- cc - muted ) ; font - size : 12 px } . loading - state { padding : 6 px 0 } . latest - result { display : flex ; flex - wrap : wrap ; align - items : center ; gap : 6 px 16 px ; padding : 7 px 10 px ; border - left : 3 px solid var ( -- el - color - primary ) ; background : var ( -- el - color - primary - light - 9 ) ; font - size : 12 px } . fulfillment - timeline { display : grid ; margin : 0 ; padding : 0 ; list - style : none } . fulfillment - timeline li { display : grid ; grid - template - columns : 130 px 86 px 150 px minmax ( 180 px , 1 fr ) minmax ( 120 px , .8 fr ) ; align - items : center ; gap : 7 px ; padding : 6 px 2 px ; border - top : 1 px solid var ( -- cc - border ) ; font - size : 12 px } . fulfillment - timeline time , . fulfillment - timeline span , . fulfillment - timeline small { overflow : hidden ; color : var ( -- cc - muted ) ; text - overflow : ellipsis ; white - space : nowrap } . tracking - prompt { margin : 0 ; padding : 7 px 10 px ; border - left : 3 px solid var ( -- el - color - info ) ; background : var ( -- cc - bg - subtle , # f7f8fa ) ; color : var ( -- cc - muted ) ; font - size : 12 px } . empty - line { margin : 0 ; color : var ( -- cc - muted ) ; font - size : 12 px } . technical - details { border - top : 1 px solid var ( -- cc - border ) ; font - size : 12 px } . technical - details summary { padding : 7 px 2 px ; color : var ( -- cc - primary ) ; cursor : pointer } . query - facts { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 8 px 16 px ; margin : 4 px 0 10 px } . query - facts > div { min - width : 0 } . query - facts dt { color : var ( -- cc - muted ) ; font - size : 11 px } . query - facts dd { margin : 2 px 0 0 ; font - size : 12 px } . break - value { overflow - wrap : anywhere } . technical - details h3 { margin : 8 px 0 5 px ; font - size : 12 px } . technical - history { margin : 0 ; padding : 0 ; list - style : none } . technical - history li { display : grid ; grid - template - columns : 130 px 150 px minmax ( 0 , 1 fr ) ; gap : 8 px ; padding : 5 px 0 ; border - top : 1 px solid var ( -- cc - border ) } . technical - history small { color : var ( -- cc - muted ) } . manual - form { display : grid ; gap : 2 px ; margin - top : 12 px } . manual - form - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } @ media ( max - width : 1200 px ) { . query - facts { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) } . fulfillment - timeline li { grid - template - columns : 120 px 82 px 140 px minmax ( 0 , 1 fr ) } . fulfillment - timeline small { grid - column : 4 } } @ media ( max - width : 720 px ) { . carrier - query > header { align - items : flex - start ; flex - direction : column } . query - facts , . manual - form - grid { grid - template - columns : 1 fr } . fulfillment - timeline li { grid - template - columns : 90 px minmax ( 0 , 1 fr ) } . fulfillment - timeline strong , . fulfillment - timeline span , . fulfillment - timeline small { grid - column : 2 } . technical - history li { grid - template - columns : 1 fr } }
. carrier - query { display : grid ; gap : 9 px ; padding - top : 2 px } . carrier - query > header { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 12 px } . header - actions { display : flex ; align - items : center ; gap : 8 px } . carrier - query h2 { margin : 0 ; font - size : 15 px } . carrier - query header p { margin : 3 px 0 0 ; color : var ( -- cc - muted ) ; font - size : 12 px } . loading - state { padding : 6 px 0 } . latest - result { display : flex ; flex - wrap : wrap ; align - items : center ; gap : 6 px 16 px ; padding : 7 px 10 px ; border - left : 3 px solid var ( -- el - color - primary ) ; background : var ( -- el - color - primary - light - 9 ) ; font - size : 12 px } . fulfillment - timeline { display : grid ; margin : 0 ; padding : 0 ; list - style : none } . fulfillment - timeline li { display : grid ; grid - template - columns : 130 px 86 px 150 px minmax ( 180 px , 1 fr ) minmax ( 120 px , .8 fr ) ; align - items : center ; gap : 7 px ; padding : 6 px 2 px ; border - top : 1 px solid var ( -- cc - border ) ; font - size : 12 px } . fulfillment - timeline time , . fulfillment - timeline span , . fulfillment - timeline small { overflow : hidden ; color : var ( -- cc - muted ) ; text - overflow : ellipsis ; white - space : nowrap } . tracking - prompt { margin : 0 ; padding : 7 px 10 px ; border - left : 3 px solid var ( -- el - color - info ) ; background : var ( -- cc - bg - subtle , # f7f8fa ) ; color : var ( -- cc - muted ) ; font - size : 12 px } . empty - line { margin : 0 ; color : var ( -- cc - muted ) ; font - size : 12 px } . technical - details { border - top : 1 px solid var ( -- cc - border ) ; font - size : 12 px } . technical - details summary { padding : 7 px 2 px ; color : var ( -- cc - primary ) ; cursor : pointer } . query - facts { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 8 px 16 px ; margin : 4 px 0 10 px } . query - facts > div { min - width : 0 } . query - facts dt { color : var ( -- cc - muted ) ; font - size : 11 px } . query - facts dd { margin : 2 px 0 0 ; font - size : 12 px } . break - value { overflow - wrap : anywhere } . technical - details h3 { margin : 8 px 0 5 px ; font - size : 12 px } . technical - history { margin : 0 ; padding : 0 ; list - style : none } . technical - history li { display : grid ; grid - template - columns : 130 px 150 px minmax ( 0 , 1 fr ) ; gap : 8 px ; padding : 5 px 0 ; border - top : 1 px solid var ( -- cc - border ) } . technical - history small { color : var ( -- cc - muted ) } . manual - form { display : grid ; gap : 2 px ; margin - top : 12 px } . manual - form - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } @ media ( max - width : 1200 px ) { . query - facts { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) } . fulfillment - timeline li { grid - template - columns : 120 px 82 px 140 px minmax ( 0 , 1 fr ) } . fulfillment - timeline small { grid - column : 4 } } @ media ( max - width : 720 px ) { . carrier - query > header { align - items : flex - start ; flex - direction : column } . query - facts , . manual - form - grid { grid - template - columns : 1 fr } . fulfillment - timeline li { grid - template - columns : 90 px minmax ( 0 , 1 fr ) } . fulfillment - timeline strong , . fulfillment - timeline span , . fulfillment - timeline small { grid - column : 2 } . technical - history li { grid - template - columns : 1 fr } }
< / style >
< / style >