2026-08-10 15:36:53 +08:00
< script setup lang = "ts" >
import { computed } from 'vue'
2026-08-18 16:39:44 +08:00
import { ArrowLeft , Check , FileUp , Pencil } from '@lucide/vue'
2026-08-10 15:36:53 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
2026-08-10 15:36:53 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-12 16:31:46 +08:00
import ContainerFeesPanel from './ContainerFeesPanel.vue'
2026-08-11 19:35:28 +08:00
import { loadingListStatusLabel } from '../status'
2026-08-10 15:36:53 +08:00
import type { ApiErrorDetails } from '@/shared/api/http'
2026-08-17 20:01:51 +08:00
import type { LoadingDetail , LoadingItem , LoadingListMode , LoadingListSummary , PreloadSummary } from '../api/platformLoadingLists'
2026-08-10 15:36:53 +08:00
const props = defineProps < {
mode : LoadingListMode
loading : boolean
detail : LoadingDetail | null
error : ApiErrorDetails | null
canEdit : boolean
2026-08-15 17:04:00 +08:00
confirmSaving ?: boolean
2026-08-13 11:57:44 +08:00
canManageCustoms ?: boolean
customsSaving ?: boolean
2026-08-10 15:36:53 +08:00
} > ()
2026-08-18 16:39:44 +08:00
const emit = defineEmits < { back : []; retry : []; edit : []; editWarehouseRemark : [ item : LoadingItem ]; confirmPreload : []; attachCustoms : []; completeCustoms : [] } > ()
2026-08-10 15:36:53 +08:00
2026-08-18 16:39:44 +08:00
const actionColumn : DataTableColumn = { key : 'actions' , label : '操作' , fixed : 'left' , width : 106 , slotName : 'actions' , hideable : false , movable : false }
const baseItemColumns : DataTableColumn [] = [
2026-08-18 16:09:43 +08:00
{ key : 'sequence' , prop : 'loadSequence' , label : '装柜顺序' , width : 92 },
2026-08-18 16:39:44 +08:00
{ key : 'inboundCode' , prop : 'inboundCode' , label : '入仓码' , minWidth : 170 , className : 'identifier-column' , showOverflowTooltip : true },
{ key : 'orderNo' , prop : 'orderNo' , label : '订单号' , minWidth : 170 , className : 'identifier-column' , showOverflowTooltip : true },
{ key : 'containerNo' , prop : 'containerNo' , label : '集装箱号' , minWidth : 165 , className : 'identifier-column' , showOverflowTooltip : true , formatter : ( row : LoadingItem ) => row . containerNo || '—' },
{ key : 'skuCode' , prop : 'skuCode' , label : 'SKU' , minWidth : 150 , className : 'identifier-column' , showOverflowTooltip : true },
{ key : 'destinationWarehouse' , prop : 'destinationWarehouse' , label : '目的仓' , minWidth : 150 , className : 'identifier-column' , showOverflowTooltip : true },
2026-08-17 19:52:04 +08:00
{ key : 'customerName' , prop : 'customerName' , label : '客户' , minWidth : 150 },
{ key : 'chineseName' , prop : 'chineseName' , label : '中文品名' , minWidth : 160 },
{ key : 'englishName' , prop : 'englishName' , label : '英文品名' , minWidth : 170 },
{ key : 'netWeightKg' , label : '净重(kg)' , width : 110 , align : 'right' , slotName : 'netWeight' },
2026-08-10 15:36:53 +08:00
{ key : 'packageCount' , prop : 'packageCount' , label : '箱数' , width : 86 , align : 'right' },
{ key : 'palletCount' , prop : 'palletCount' , label : '托盘数' , width : 86 , align : 'right' },
{ key : 'volumeCbm' , label : '体积(m³)' , width : 110 , align : 'right' , slotName : 'volume' },
2026-08-17 19:52:04 +08:00
{ key : 'grossWeightKg' , label : '毛重(kg)' , width : 110 , align : 'right' , slotName : 'grossWeight' },
{ key : 'dimensionalWeightKg' , label : '材积重(kg)' , width : 115 , align : 'right' , slotName : 'dimensionalWeight' },
{ key : 'productQuantity' , prop : 'productQuantity' , label : '产品数' , width : 96 , align : 'right' },
{ key : 'unitsPerPackage' , prop : 'unitsPerPackage' , label : '每箱产品数' , width : 110 , align : 'right' },
2026-08-10 15:36:53 +08:00
{ key : 'fbaTrackingNo' , prop : 'fbaTrackingNo' , label : 'FBA 追踪编码' , minWidth : 155 },
{ key : 'shipmentNo' , prop : 'shipmentNo' , label : '货件号' , minWidth : 140 },
{ key : 'shippingMark' , prop : 'shippingMark' , label : '唛头' , minWidth : 130 },
2026-08-18 16:09:43 +08:00
{ key : 'customerRemark' , prop : 'customerRemark' , label : '备注(客户)' , minWidth : 190 , slotName : 'customerRemark' },
{ key : 'internalRemark' , prop : 'internalRemark' , label : '备注(内部)' , minWidth : 190 , slotName : 'internalRemark' },
{ key : 'warehouseRemark' , prop : 'warehouseRemark' , label : '备注(仓库)' , minWidth : 210 , slotName : 'warehouseRemark' },
2026-08-13 11:57:44 +08:00
{ key : 'customsDocument' , label : '报关资料' , minWidth : 170 , slotName : 'customsDocument' },
2026-08-10 15:36:53 +08:00
{ key : 'frozenAt' , label : '冻结时间' , minWidth : 150 , slotName : 'frozenAt' },
]
const summary = computed < LoadingListSummary | null >(() => props . detail ? . summary || null )
const preloadSummary = computed (() => props . mode === 'preload' ? summary . value as PreloadSummary | null : null )
2026-08-18 16:39:44 +08:00
const canEditItemRemark = computed (() => props . mode === 'preload' && props . canEdit && [ 'EDITING' , 'LOADING' ]. includes ( summary . value ? . status || '' ))
const itemColumns = computed (() => canEditItemRemark . value ? [ actionColumn , ... baseItemColumns ] : baseItemColumns )
2026-08-15 17:04:00 +08:00
const blockingReasonLabels : Record < string , string > = {
PRELOAD_NOT_PUSHED : '尚未推送预装清单' ,
LOADING_PLAN_NOT_EDITING : '装柜计划已不在可确认的编辑状态' ,
CONTAINER_NO_MISSING : '尚未录入集装箱号' ,
PRELOAD_LINK_MISSING : '装柜明细尚未完整关联入库预装票据' ,
OCEAN_BOOKING_NOT_LINKED : '尚未关联真实订舱' ,
OCEAN_BOOKING_NOT_RELEASED : '关联订舱尚未放舱' ,
PDA_TASK_ALREADY_CREATED : 'PDA 任务已创建,无需重复确认' ,
PRELOAD_ALREADY_CONFIRMED : '整份预装计划已经确认' ,
}
const confirmationStatusLabels : Record < string , string > = {
PENDING : '待确认' , PARTIALLY_CONFIRMED : '部分票据已确认' , CONFIRMED : '已确认' ,
}
const oceanBookingStatusLabels : Record < string , string > = {
CONFIRMED : '已确认' , CHANGE_PENDING : '改船处理中' , RELEASED : '已放舱' , CANCELLED : '已取消' ,
}
const pdaTaskStatusLabels : Record < string , string > = {
PENDING : '待领取' , CLAIMED : '已领取' , LOADING : '作业中' , AWAITING_REVIEW : '待人工复核' , COMPLETED : '已完成' ,
}
const blockingReasonText = computed (() => ( preloadSummary . value ? . readiness . blockingReasons || [])
. map (( reason ) => blockingReasonLabels [ reason ] || '存在未满足的确认条件' )
. join ( '; ' ))
2026-08-12 16:31:46 +08:00
const feeCustomers = computed (() => {
const customers = new Map < string , string | null >()
for ( const item of props . detail ? . items || []) if ( item . accountId ) customers . set ( item . accountId , item . customerName )
return [... customers ]. map (([ accountId , customerName ]) => ({ accountId , customerName }))
})
2026-08-10 15:36:53 +08:00
const title = computed (() => {
if ( ! summary . value ) return props . mode === 'preload' ? '预装清单详情' : '装柜清单详情'
const number = props . mode === 'preload'
? ( summary . value as PreloadSummary ). preloadNo
: 'loadingListNo' in summary . value ? summary . value . loadingListNo : ''
return ` ${ props . mode === 'preload' ? '预装清单' : '装柜清单' } · ${ number } `
})
function actionLabel ( action : string ) {
return ({
CREATE : '创建计划' , ASSIGN : '分配货物' , ADJUST : '调整货物' , DELETE : '移除货物' ,
RECOMMEND : '智能排柜' , PUSH_PRELOAD : '推送预装' , UPDATE_TRANSPORT : '修改运输信息' ,
2026-08-15 17:04:00 +08:00
CONFIRM_PRELOAD : '确认预装' ,
2026-08-11 11:52:30 +08:00
START_LOADING : '开始拣货' , COMPLETE : '完成装柜' ,
2026-08-12 09:25:55 +08:00
} as Record < string , string >)[ action ] || '状态更新'
2026-08-10 15:36:53 +08:00
}
function formatDateTime ( value ?: string | null ) {
return value ? value . replace ( 'T' , ' ' ). slice ( 0 , 16 ) : '—'
}
function formatNumber ( value ?: number | null , digits = 3 ) {
if ( value === null || value === undefined || ! Number . isFinite ( Number ( value ))) return '—'
return Number ( value ). toLocaleString ( 'zh-CN' , { maximumFractionDigits : digits })
}
</ script >
< template >
2026-08-12 16:31:46 +08:00
< section v-loading = "loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading" >
2026-08-18 16:39:44 +08:00
< section class = "detail-overview surface-panel" aria -label =" 清单概览 " >
< header class = "detail-page-header" >
< el-button link type = "primary" class = "detail-page-header__back" @click ="emit('back')" >< ArrowLeft :size = "16" /> 返回列表 </ el-button >
< div class = "detail-page-header__identity" >
< h1 >{{ title }}</ h1 >
< p >{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细、报关资料和柜次费用' }}</ p >
</ div >
< div v-if = "detail && summary" class="detail-page-header__actions" >
< template v-if = "canEditItemRemark" >
< el-button type = "primary" @click ="emit('edit')" >< Pencil :size = "16" />{{ preloadSummary ? . containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</ el-button >
< el-button type = "success" :loading = "confirmSaving" :disabled = "!preloadSummary?.readiness.canConfirm" @click ="emit('confirmPreload')" >< Check :size = "16" /> 确认预装 </ el-button >
</ template >
< template v-else-if = "mode === 'confirmed'" >
< el-button v-if = "canManageCustoms && !detail.customsDocument?.completedAt" type="primary" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail . customsDocument ? '更换报关资料' : '上传报关资料' }}</ el-button >
< el-button v-if = "canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" /> 完成报关 </ el-button >
< StatusBadge v-if = "detail.customsDocument?.completedAt" status="报关完成" />
</ template >
</ div >
</ header >
< section v-if = "detail && summary" class="detail-summary" aria-label="清单基本信息" >
2026-08-15 17:04:00 +08:00
< div >< span > 柜次号 </ span >< strong >{{ summary . cabinetNo || '—' }}</ strong ></ div >
< div >< span > 集装箱号 </ span >< strong >{{ summary . containerNo || '待录入' }}</ strong ></ div >
2026-08-10 15:36:53 +08:00
< div >< span > 仓库 </ span >< strong >{{ summary . warehouseName || '—' }}</ strong ></ div >
2026-08-11 19:35:28 +08:00
< div >< span > 状态 </ span >< StatusBadge :status = "loadingListStatusLabel(summary.status)" /></ div >
2026-08-10 15:36:53 +08:00
< template v-if = "preloadSummary" >
< div >< span > 船名 / 航次 </ span >< strong >{{ preloadSummary . vesselName || '—' }} / {{ preloadSummary . voyageNo || '—' }}</ strong ></ div >
< div >< span > 箱型 </ span >< strong >{{ preloadSummary . containerType || '—' }}</ strong ></ div >
2026-08-15 17:04:00 +08:00
< div >< span > 预装确认 </ span >< StatusBadge : status = "confirmationStatusLabels[preloadSummary.readiness.confirmationStatus] || '未知状态'" /></ div >
< div >< span > 订舱状态 </ span >< strong >{{ preloadSummary . readiness . oceanBookingStatus ? ( oceanBookingStatusLabels [ preloadSummary . readiness . oceanBookingStatus ] || '未知状态' ) : '未关联' }}</ strong ></ div >
< div >< span > PDA 状态 </ span >< strong >{{ preloadSummary . readiness . pdaTaskStatus ? ( pdaTaskStatusLabels [ preloadSummary . readiness . pdaTaskStatus ] || '未知状态' ) : ( preloadSummary . readiness . readyForPda ? '可下发' : '未就绪' ) }}</ strong ></ div >
2026-08-12 10:59:49 +08:00
< div class = "detail-summary__wide" >< span > 客户 </ span >< strong >{{ preloadSummary . customers . map (( item ) => item . customerName || '客户信息缺失' ). join ( '、' ) || '客户信息缺失' }}</ strong ></ div >
2026-08-10 15:36:53 +08:00
</ template >
< div >< span > 创建时间 </ span >< strong >{{ formatDateTime ( summary . createdAt ) }}</ strong ></ div >
</ section >
2026-08-18 16:39:44 +08:00
</ section >
< LoadErrorState v-if = "error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')" >
< template # details >< ApiErrorMeta :details = "error" /></ template >
</ LoadErrorState >
2026-08-10 15:36:53 +08:00
2026-08-18 16:39:44 +08:00
< div v-else-if = "detail && summary" class="detail-layout" >
< DetailPageLayout :ariaLabel = "title" >
2026-08-15 17:04:00 +08:00
< el-alert v-if = "preloadSummary?.readiness.confirmationStatus === 'CONFIRMED'" type="success" :closable="false" show-icon title="整份预装计划已确认" :description="preloadSummary.readiness.pdaTaskStatus ? `PDA 任务状态:${pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态'}` : 'PDA 前置条件已满足,等待服务端生成任务。'" />
< el-alert v-else-if = "preloadSummary && !preloadSummary.readiness.canConfirm" type="warning" :closable="false" show-icon title="当前暂不能确认预装" :description="blockingReasonText || '请刷新详情获取最新确认条件。'" />
2026-08-10 15:36:53 +08:00
< section class = "detail-section surface-panel" >
< DataTable
:data = "detail.items"
:columns = "itemColumns"
2026-08-12 18:16:44 +08:00
stripe
2026-08-10 15:36:53 +08:00
preference -key =" loading -list-detail-items "
row -key =" itemId "
empty -text =" 暂无装柜货物明细 "
aria -label =" 装柜货物明细 "
>
2026-08-18 16:39:44 +08:00
< template # toolbar -start >< div class = "detail-section__heading" >< div >< h2 > 装柜货物明细 < span >{{ detail . items . length }} 条 </ span ></ h2 >< p > 仓库备注将随装柜完成资料下发海外仓 </ p ></ div ></ div ></ template >
2026-08-18 16:09:43 +08:00
< template # actions = "{ row }" >
2026-08-18 16:39:44 +08:00
< el-button size = "small" type = "primary" plain : aria -label ="` 编辑 $ { row.inboundCode || row.orderNo } 的仓库备注 `" @click ="emit('editWarehouseRemark', row as LoadingItem)" >< Pencil :size = "14" /> 编辑备注 </ el-button >
2026-08-18 16:09:43 +08:00
</ template >
2026-08-10 15:36:53 +08:00
< template # grossWeight = "{ row }" >{{ formatNumber ( row . grossWeightKg ) }}</ template >
< template # netWeight = "{ row }" >{{ formatNumber ( row . netWeightKg ) }}</ template >
< template # volume = "{ row }" >{{ formatNumber ( row . volumeCbm ) }}</ template >
2026-08-17 19:52:04 +08:00
< template # dimensionalWeight = "{ row }" >{{ formatNumber ( row . dimensionalWeightKg ) }}</ template >
2026-08-18 16:09:43 +08:00
< template # customerRemark = "{ row }" >< div class = "remark-cell" >< span >{{ row . customerRemark || '—' }}</ span >< small > 来源 : 客户下单 </ small ></ div ></ template >
< template # internalRemark = "{ row }" >< div class = "remark-cell" >< span >{{ row . internalRemark || '—' }}</ span >< small > 来源 : 订单内部备注 </ small ></ div ></ template >
< template # warehouseRemark = "{ row }" >< div class = "remark-cell" >< span >{{ row . warehouseRemark || '—' }}</ span >< small >{{ mode === 'preload' ? '由仓库维护' : '装柜完成时冻结' }}</ small ></ div ></ template >
2026-08-10 15:36:53 +08:00
< template # frozenAt = "{ row }" >{{ formatDateTime ( row . frozenAt ) }}</ template >
2026-08-13 11:57:44 +08:00
< template # customsDocument >< span class = "document-cell" >{{ detail . customsDocument ? . fileName || '未上传' }}</ span ></ template >
2026-08-10 15:36:53 +08:00
</ DataTable >
</ section >
2026-08-12 17:29:35 +08:00
< ContainerFeesPanel :plan-id = "summary.planId" :container-booking-id = "summary.containerBookingId" :customers = "feeCustomers" />
2026-08-12 16:31:46 +08:00
2026-08-10 15:36:53 +08:00
< section class = "history-section surface-panel" aria -label =" 操作历史 " >
< header >< h2 > 操作历史 </ h2 >< span >{{ detail . histories . length }} 条 </ span ></ header >
< el-empty v-if = "detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
< el-timeline v-else >
< el-timeline-item v-for = "history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top" >
< div class = "history-entry" >
< strong >{{ actionLabel ( history . actionType ) }}</ strong >
2026-08-11 19:35:28 +08:00
< span v-if = "history.fromStatus || history.toStatus" >{{ loadingListStatusLabel ( history.fromStatus ) }} → {{ loadingListStatusLabel ( history.toStatus ) }}</ span >
2026-08-12 10:59:49 +08:00
< p >{{ history . operatorName || '操作人信息缺失' }}< template v-if = "history.operationReason" > · {{ history.operationReason }}</ template ></ p >
2026-08-10 15:36:53 +08:00
</ div >
</ el -timeline-item >
</ el-timeline >
</ section >
2026-08-10 16:24:54 +08:00
</ DetailPageLayout >
2026-08-10 15:36:53 +08:00
</ div >
2026-08-12 16:31:46 +08:00
</ section >
2026-08-10 15:36:53 +08:00
</ template >
< style scoped >
2026-08-12 16:31:46 +08:00
. loading - detail - content , . detail - layout { display : grid ; min - width : 0 ; gap : 16 px ; }
2026-08-18 16:39:44 +08:00
. detail - overview { overflow : hidden ; }
. detail - page - header { display : grid ; grid - template - columns : auto minmax ( 0 , 1 fr ) auto ; align - items : center ; gap : 16 px ; padding : 14 px 16 px ; }
. detail - page - header__back { margin - right : 2 px ; }
. detail - page - header__identity { min - width : 0 ; }
. detail - page - header__identity h1 { margin : 0 ; color : var ( -- cc - text ); font - size : 18 px ; line - height : 1.35 ; }
. detail - page - header__identity p { margin : 3 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; }
. detail - page - header__actions { display : flex ; align - items : center ; justify - content : flex - end ; gap : 8 px ; }
. detail - page - header__actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
. detail - summary { display : grid ; grid - template - columns : repeat ( 5 , minmax ( 0 , 1 fr )); overflow : hidden ; border - top : 1 px solid var ( -- cc - border ); }
. detail - summary > div { display : flex ; min - width : 0 ; min - height : 68 px ; flex - direction : column ; justify - content : center ; gap : 5 px ; padding : 11 px 14 px ; border - right : 1 px solid var ( -- cc - border ); border - bottom : 1 px solid var ( -- cc - border ); background : # fff ; }
. detail - summary > div : nth - child ( 5 n ) { border - right : 0 ; }
2026-08-10 15:36:53 +08:00
. detail - summary span { color : var ( -- cc - muted ); font - size : 12 px ; }
2026-08-18 16:39:44 +08:00
. detail - summary strong { overflow : hidden ; color : var ( -- cc - text ); font - size : 14 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. detail - summary : deep (. status - badge ) { width : fit - content ; }
2026-08-10 15:36:53 +08:00
. detail - summary small { color : var ( -- cc - muted ); font - size : 11 px ; }
. detail - summary__wide { grid - column : span 2 ; }
2026-08-18 16:39:44 +08:00
. detail - layout : deep (. detail - page - layout__content ) { display : grid ; min - width : 0 ; gap : 16 px ; }
2026-08-10 15:36:53 +08:00
. detail - section , . history - section { min - width : 0 ; overflow : hidden ; border : 1 px solid var ( -- cc - border ); background : # fff ; }
2026-08-18 16:39:44 +08:00
. detail - section__heading h2 { margin : 0 ; font - size : 15 px ; }
. detail - section__heading h2 span { margin - left : 6 px ; color : var ( -- cc - muted ); font - size : 12 px ; font - weight : 400 ; }
. detail - section__heading p { margin : 2 px 0 0 ; color : var ( -- cc - muted ); font - size : 11 px ; line - height : 1.35 ; }
. detail - section : deep (. data - table__toolbar ) { min - height : 52 px ; padding : 7 px 12 px ; }
2026-08-10 15:36:53 +08:00
. detail - section : deep (. el - table ) { width : 100 % ; }
2026-08-18 16:39:44 +08:00
. detail - section : deep (. el - table th . el - table__cell ) { padding : 7 px 0 ; }
. detail - section : deep (. el - table td . el - table__cell ) { padding : 6 px 0 ; }
. detail - section : deep (. identifier - column . cell ) { overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
2026-08-10 15:36:53 +08:00
. detail - section small { display : block ; margin - top : 3 px ; color : var ( -- cc - muted ); }
2026-08-18 16:09:43 +08:00
. remark - cell { min - width : 0 ; line - height : 1.45 ; overflow - wrap : anywhere ; }
2026-08-18 16:39:44 +08:00
. history - section { padding : 14 px 16 px ; }
2026-08-10 15:36:53 +08:00
. history - section header { display : flex ; align - items : baseline ; gap : 10 px ; margin - bottom : 18 px ; }
. history - section h2 { margin : 0 ; font - size : 16 px ; }
. history - section header span { color : var ( -- cc - muted ); font - size : 12 px ; }
. history - entry { display : flex ; flex - wrap : wrap ; align - items : center ; gap : 8 px ; }
. history - entry > span { color : var ( -- cc - muted ); font - size : 12 px ; }
. history - entry p { width : 100 % ; margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; }
</ style >