2026-10-10 08:42:03 +08:00
< script setup lang = "ts" >
2026-10-10 15:33:10 +08:00
import { onBeforeUnmount , onMounted , ref , computed } from 'vue'
2026-10-10 08:42:03 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlanningContainers , resolvePlanningContainer , type PlanningContainer , type ContainerFollowupContext } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
2026-10-10 15:33:10 +08:00
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
import { listImpacts , refreshImpacts , impactStatus } from './impactApi'
2026-10-10 08:42:03 +08:00
2026-10-10 15:33:10 +08:00
const impactReady = ref ( false ), impactError = ref < ApiErrorDetails | null >( null )
const impactId = ref < string | null >( null ), refreshing = ref ( false ), onlyChanges = ref ( false )
2026-10-10 08:42:03 +08:00
const access = useAccessPreviewStore ()
const query = ref < QueryModel >({ keyword : '' })
const fields : QueryField [] = [{ key : 'keyword' , type : 'input' , label : '整柜' , placeholder : '柜号或入仓码' , width : 300 }]
const rows = ref < PlanningContainer [] >([]), page = ref ( 1 ), size = ref ( 20 ), total = ref ( 0 )
const loading = ref ( false ), opening = ref ( false )
const error = ref < ApiErrorDetails | null >( null ), contextError = ref < ApiErrorDetails | null >( null )
const context = ref < ContainerFollowupContext | null >( null )
2026-10-10 15:33:10 +08:00
const changedCount = computed (() => rows . value . filter ( row => row . impact ? . status === 'OPEN' ). length )
const displayedRows = computed (() => onlyChanges . value ? rows . value . filter ( row => row . impact ? . status === 'OPEN' ) : rows . value )
async function refreshForecasts () {
refreshing . value = true ; contextError . value = null
try { await refreshImpacts ({ page : page . value , size : size . value , keyword : String ( query . value . keyword || '' ). trim () || undefined }); await load () }
catch ( cause ) { contextError . value = getApiErrorDetails ( cause , '船期核对未完成,原记录保留' ) }
finally { refreshing . value = false }
}
2026-10-10 08:42:03 +08:00
let request = 0 , contextRequest = 0 , controller : AbortController | undefined
const columns : DataTableColumn [] = [
2026-10-10 15:33:10 +08:00
{ key : 'actions' , label : '操作' , slotName : 'actions' , width : 200 , fixed : 'left' },
2026-10-10 08:42:03 +08:00
{ prop : 'containerNo' , label : '集装箱号' , minWidth : 170 },
{ prop : 'orderCount' , label : '美国范围票数' , width : 120 },
{ prop : 'customerCount' , label : '客户数' , width : 90 },
{ key : 'packages' , label : '预报箱数' , slotName : 'packages' , width : 130 },
{ key : 'arrival' , label : '来源预计到港' , slotName : 'arrival' , minWidth : 200 },
{ key : 'receiving' , label : '接仓点及跟进' , slotName : 'receiving' , minWidth : 280 , singleLine : false },
2026-10-10 15:33:10 +08:00
{ key : 'impact' , label : '船期跟进' , slotName : 'impact' , minWidth : 160 },
{ key : 'owner' , label : '负责人' , slotName : 'owner' , minWidth : 110 },
2026-10-10 08:42:03 +08:00
{ key : 'scope' , label : '范围核对' , slotName : 'scope' , minWidth : 200 },
]
async function load ( reset = false ) {
if ( reset ) page . value = 1
const current = ++ request
controller ? . abort (); controller = new AbortController ()
2026-10-10 15:33:10 +08:00
loading . value = true ; error . value = null ; impactReady . value = false ; impactError . value = null
2026-10-10 08:42:03 +08:00
try {
2026-10-10 15:33:10 +08:00
const params = { page : page . value , size : size . value , keyword : String ( query . value . keyword || '' ). trim () || undefined }
const [ cargo , impacts ] = await Promise . allSettled ([ listPlanningContainers ( params , controller . signal ), listImpacts ( params , controller . signal )])
2026-10-10 08:42:03 +08:00
if ( current !== request ) return
2026-10-10 15:33:10 +08:00
if ( cargo . status === 'rejected' ) throw cargo . reason
rows . value = cargo . value . records ; total . value = cargo . value . total
if ( impacts . status === 'fulfilled' ) {
const states = new Map ( impacts . value . records . map ( row => [ row . container . containerBookingId , row . impact ]))
rows . value = rows . value . map ( row => ({ ... row , impact : states . get ( row . containerBookingId ) || null }))
impactReady . value = true
} else { onlyChanges . value = false ; impactError . value = getApiErrorDetails ( impacts . reason , '船期跟进暂不可用' ) }
2026-10-10 08:42:03 +08:00
} catch ( cause ) { if ( current === request ) { error . value = getApiErrorDetails ( cause , '整柜货源加载失败' ); rows . value = []; total . value = 0 } }
finally { if ( current === request ) loading . value = false }
}
async function follow ( row : PlanningContainer , warehouseId ?: string ) {
const current = ++ contextRequest
contextError . value = null ; opening . value = true
try {
const result = await resolvePlanningContainer ( row . containerBookingId , warehouseId )
if ( current === contextRequest ) context . value = result
} catch ( cause ) { if ( current === contextRequest ) contextError . value = getApiErrorDetails ( cause , '整柜范围核对失败' ) }
finally { if ( current === contextRequest ) opening . value = false }
}
const displayTime = ( value : string | null ) => value ? . replace ( 'T' , ' ' ) || '—'
onMounted (() => load ())
onBeforeUnmount (() => { ++ request ; ++ contextRequest ; controller ? . abort () })
</ script >
< template >
2026-10-10 15:33:10 +08:00
< DataTable v -model:page = "page" v -model:page-size = "size" appearance = "integrated" :columns = "columns" :data = "displayedRows" :total = "total" : loading = "loading || refreshing" :results-visible = "!error" row -key =" containerBookingId " @ update :page = "load()" @ update :page-size = "load(true)" >
< template # filters >< QueryToolbar v-model = "query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)" >
< template # actions >
< el-button v-if = "impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges" > 本页待处理 {{ changedCount }} </ el -button >
< el-button v-if = "impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts" > 核对船期 </ el -button >
</ template >
</ QueryToolbar ></ template >
2026-10-10 08:42:03 +08:00
< template # notices >
2026-10-10 15:33:10 +08:00
< LoadErrorState v-if = "error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></ details ></ template ></ LoadErrorState >
< LoadErrorState v-if = "contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></ details ></ template ></ LoadErrorState >
< details v-if = "impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></ details >
2026-10-10 08:42:03 +08:00
</ template >
2026-10-10 15:33:10 +08:00
< template # actions = "{ row }" >< el-button class = "density-action" size = "small" :disabled = "opening" @click ="follow(row as PlanningContainer)" > 提柜跟进 </ el -button >< el-button v-if = "row.impact" link type="primary" @click="impactId = row.containerBookingId" >{{ row.impact.status === ' OPEN ' ? ' 处理船期变化 ' : ' 核对记录 ' }} </ el -button ></ template >
2026-10-10 08:42:03 +08:00
< template # packages = "{ row }" >{{ row . packages ?? '待核实' }}</ template >
< template # arrival = "{ row }" >{{ displayTime ( row . earliestArrivalAt ) }}< span v-if = "row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt" > 至 {{ displayTime ( row.latestArrivalAt ) }}</ span ></ template >
< template # receiving = "{ row }" >< div v-for = "warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)" > 接仓跟进 </ el -button ></ div ></ template >
2026-10-10 15:33:10 +08:00
< template # impact = "{ row }" >{{ row . impact ? impactStatus [ row . impact . status ] : ( impactReady ? '未核对' : '暂不可用' ) }}</ template >
< template # owner = "{ row }" >{{ row . impact ? . ownerName || '未认领' }}</ template >
2026-10-10 08:42:03 +08:00
< template # scope = "{ row }" >{{ row . incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</ template >
</ DataTable >
2026-10-10 15:33:10 +08:00
< AppointmentImpactDialog v-if = "impactId" :container-id="impactId" :can-write="access.can('platform:appointment:followup')" @close="impactId = null" @saved="load()" />
2026-10-10 08:42:03 +08:00
< AppointmentFollowupDialog v-if = "context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
</ template >
< style scoped >
2026-10-10 15:33:10 +08:00
. container - help { font - size : var ( -- admin - font - caption ); color : var ( -- el - text - color - secondary ); }
. container - help summary { cursor : pointer ; }
2026-10-10 08:42:03 +08:00
. receiving - row { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 3 ); }
</ style >