2026-08-13 11:57:44 +08:00
< script setup lang = "ts" >
2026-08-26 11:10:48 +08:00
import { computed , onBeforeUnmount , reactive , watch } from 'vue'
2026-08-26 12:46:46 +08:00
import { Check , Download , FileUp , TriangleAlert } from '@lucide/vue'
2026-08-26 11:10:48 +08:00
import { getApiErrorDetails } from '@/shared/api/http'
2026-08-13 11:57:44 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
import type { ContainerOverview , ContainerOverviewDocument , ContainerPdaPhoto } from '../types'
2026-08-26 11:10:48 +08:00
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
2026-08-13 11:57:44 +08:00
const props = defineProps < {
overview : ContainerOverview
canManageDocuments : boolean
2026-08-26 11:10:48 +08:00
canManageCustoms : boolean
2026-08-26 13:00:51 +08:00
canDownloadCustoms ?: boolean
2026-08-13 11:57:44 +08:00
canManageException : boolean
documentBusy ?: boolean
2026-08-26 12:46:46 +08:00
customsCompleting ?: boolean
2026-08-13 11:57:44 +08:00
} > ()
const emit = defineEmits < {
uploadDocument : [ documentType : string ]
downloadDocument : [ document : ContainerOverviewDocument ]
2026-08-26 12:46:46 +08:00
completeCustoms : [ document : ContainerOverviewDocument ]
2026-08-13 11:57:44 +08:00
manageException : []
} > ()
const documentTypes = [
{ type : 'DESTINATION_CLEARANCE' , label : '目的港清关资料' },
2026-08-26 11:10:48 +08:00
{ type : 'CUSTOMS_DECLARATION' , label : '国内出口报关附件' },
2026-08-13 11:57:44 +08:00
{ type : 'DELIVERY_ORDER' , label : 'DO 资料' },
{ type : 'PICKUP_ORDER' , label : 'PU 资料' },
] as const
const documentsByType = computed (() => new Map ( props . overview . documents . map (( item ) => [ item . documentType , item ])))
2026-08-26 13:00:51 +08:00
const containerDownloadableTypes = new Set ([ 'DELIVERY_ORDER' , 'PICKUP_ORDER' ])
2026-08-26 11:10:48 +08:00
const customsTypes = new Set ([ 'DESTINATION_CLEARANCE' , 'CUSTOMS_DECLARATION' ])
2026-08-26 13:00:51 +08:00
function canDownloadDocument ( documentType : string ) {
2026-08-26 13:06:00 +08:00
return containerDownloadableTypes . has ( documentType )
|| ( documentType === 'CUSTOMS_DECLARATION' && props . canDownloadCustoms )
|| ( documentType === 'DESTINATION_CLEARANCE' && props . canManageCustoms )
2026-08-26 13:00:51 +08:00
}
2026-08-26 11:10:48 +08:00
function canUploadDocument ( documentType : string ) {
2026-08-26 13:00:51 +08:00
return containerDownloadableTypes . has ( documentType ) ? props . canManageDocuments : customsTypes . has ( documentType ) && props . canManageCustoms
}
function hasDocumentAction ( documentType : string ) {
const document = documentsByType . value . get ( documentType )
return Boolean (( document && canDownloadDocument ( documentType )) || ( canUploadDocument ( documentType ) && ! document ? . completed ))
2026-08-26 11:10:48 +08:00
}
2026-08-26 12:46:46 +08:00
function documentStatusText ( documentType : string ) {
const document = documentsByType . value . get ( documentType )
if ( documentType !== 'CUSTOMS_DECLARATION' ) return document ? formatTime ( document . uploadedAt ) : '等待上传'
if ( ! document ) return '第一步上传附件,第二步确认报关完成'
return document . completed ? '已确认报关完成' : '附件已上传,待确认报关完成'
}
2026-08-26 13:00:51 +08:00
function documentStatusLabel ( documentType : string ) {
const document = documentsByType . value . get ( documentType )
if ( ! document ) return '未上传'
return document . completed ? '已完成' : '已上传'
}
function documentStatusType ( documentType : string ) {
const document = documentsByType . value . get ( documentType )
if ( ! document ) return 'info'
return document . completed ? 'success' : 'primary'
}
2026-08-13 11:57:44 +08:00
const pdaProgress = computed (() => {
const pda = props . overview . pda
if ( ! pda || pda . itemCount <= 0 ) return 0
return Math . min ( 100 , Math . round (( pda . completedItemCount / pda . itemCount ) * 100 ))
})
const activePhotos = computed (() => ( props . overview . pda ? . photos || []). filter (( item ) => item . status === 'ACTIVE' && item . downloadPath ))
2026-08-26 11:10:48 +08:00
const photoStates = reactive < Record < string , { loading : boolean ; url : string | null ; error : string | null }> > ({})
let photoGeneration = 0
2026-08-14 14:27:03 +08:00
const workflowStatusLabels : Record < string , string > = {
LOADING : '作业中' , AWAITING_REVIEW : '待人工复核' , COMPLETED : '已完成' ,
}
const evidenceTypeLabels : Record < string , string > = {
PHOTO_STEP_EMPTY_CONTAINER : '空柜照片' , PHOTO_STEP_HALF_LOADED : '半柜照片' , PHOTO_STEP_FULL_CONTAINER : '满柜照片' ,
EMPTY_CONTAINER : '空柜照片' , HALF_LOADED : '半柜照片' , FULL_CONTAINER : '满柜照片' , SEAL : '封条照片' ,
}
function workflowStatusLabel ( workflowStatus : string | null | undefined , taskStatus : string ) { return workflowStatusLabels [ workflowStatus || taskStatus ] || '作业状态待同步' }
function evidenceTypeLabel ( value : string ) { return evidenceTypeLabels [ value ] || '装柜照片' }
2026-08-13 11:57:44 +08:00
function formatTime ( value : string | null | undefined ) {
return value ? . replace ( 'T' , ' ' ). slice ( 0 , 16 ) || '—'
}
function eventLabel ( value : string ) {
return ({
MARK : '标记异常' , CANCEL : '取消异常' , TRANSPORT_UPDATE : '更新运输信息' ,
2026-08-26 11:10:48 +08:00
PDA_CLAIM : '领取 PDA 任务' , PDA_BATCH : '提交拣货结果' , PDA_COMPLETE_LOADING : '完成装柜' ,
2026-08-13 11:57:44 +08:00
} as Record < string , string >)[ value ] || value || '状态更新'
}
2026-08-26 11:10:48 +08:00
function eventSummary ( entry : ContainerOverview [ 'timeline' ][ number ]) {
if ( entry . eventType === 'PDA_CLAIM' ) return 'PDA 作业任务已领取'
if ( entry . eventType === 'PDA_BATCH' ) return '拣货结果已提交'
if ( entry . eventType === 'PDA_COMPLETE_LOADING' ) return '装柜作业已完成'
const detail = String ( entry . detail || '' ). trim ()
return ! detail || detail . length > 120 || /(?:operation|idempot|claim-|pda-submit|complete-)/i . test ( detail ) ? '操作已记录' : detail
}
function revokePhotoUrls () {
Object . values ( photoStates ). forEach (( state ) => { if ( state . url ) URL . revokeObjectURL ( state . url ) })
Object . keys ( photoStates ). forEach (( key ) => delete photoStates [ key ])
}
async function loadPhotoThumbnails () {
const generation = ++ photoGeneration
revokePhotoUrls ()
await Promise . all ( activePhotos . value . map ( async ( photo ) => {
photoStates [ photo . evidenceId ] = { loading : true , url : null , error : null }
try {
const blob = await downloadContainerPdaPhoto ( photo . downloadPath )
if ( generation !== photoGeneration ) return
photoStates [ photo . evidenceId ] = { loading : false , url : URL . createObjectURL ( blob ), error : null }
} catch ( cause ) {
if ( generation !== photoGeneration ) return
photoStates [ photo . evidenceId ] = { loading : false , url : null , error : getApiErrorDetails ( cause , '照片加载失败' ). message }
}
}))
}
function openPhoto ( photo : ContainerPdaPhoto ) {
const url = photoStates [ photo . evidenceId ] ? . url
if ( url ) window . open ( url , '_blank' , 'noopener,noreferrer' )
}
watch (() => activePhotos . value . map (( photo ) => ` ${ photo . evidenceId } : ${ photo . downloadPath } ` ). join ( '|' ), () => void loadPhotoThumbnails (), { immediate : true })
onBeforeUnmount (() => { photoGeneration += 1 ; revokePhotoUrls () })
2026-08-13 11:57:44 +08:00
</ script >
< template >
< div class = "overview-panel" >
2026-08-26 13:48:55 +08:00
< section class = "overview-section exception-management" aria -label =" 异常管理 " >
< header class = "exception-management__header" >< div >< h3 > 异常管理 </ h3 >< p v-if = "overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview . exception . active ? '取消异常' : '标记异常' }}</ el-button ></ div ></ header >
< div class = "exception-history" >< div class = "exception-history__head" >< span > 时间 </ span >< span > 动作 </ span >< span > 操作人 </ span >< span > 原因 </ span ></ div >< p v-if = "overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason" >{{ history.reason }}</ span ></ li ></ ol ></ div >
2026-08-13 11:57:44 +08:00
</ section >
< section class = "overview-section" aria -label =" 货柜业务资料 " >
< header >< h3 > 业务资料 </ h3 >< span >{{ overview . documents . length }} 份 </ span ></ header >
2026-08-26 13:00:51 +08:00
< div class = "document-table-wrap" >
< table class = "document-table" >
< thead >< tr >< th class = "document-table__actions" > 操作 </ th >< th > 资料类型 </ th >< th > 文件名 </ th >< th > 上传人 </ th >< th > 上传时间 </ th >< th > 状态 </ th ></ tr ></ thead >
< tbody >< tr v-for = "item in documentTypes" :key="item.type" >
< td class = "document-table__actions" >< div class = "document-actions" >< el-button v-if = "documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><el-button v-if="item.type === 'CUSTOMS_DECLARATION' && canManageCustoms && documentsByType.get(item.type) && !documentsByType.get(item.type)?.completed" size="small" type="primary" :loading="customsCompleting" @click="emit('completeCustoms', documentsByType.get(item.type)!)"><Check :size="15" />确认报关完成</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action" > — </ span ></ div ></ td >
< td >< strong >{{ item . label }}</ strong >< small v-if = "item.type === 'CUSTOMS_DECLARATION'" >{{ documentStatusText ( item.type ) }}</ small ></ td >
< td >< span class = "document-file-name" : title = "documentsByType.get(item.type)?.fileName || undefined" >{{ documentsByType . get ( item . type ) ? . fileName || '—' }}</ span ></ td >
< td >{{ documentsByType . get ( item . type ) ? . uploadedByName || '—' }}</ td >
< td >{{ documentsByType . get ( item . type ) ? formatTime ( documentsByType . get ( item . type ) ? . uploadedAt ) : '—' }}</ td >
< td >< el-tag size = "small" :type = "documentStatusType(item.type)" >{{ documentStatusLabel ( item . type ) }}</ el-tag ></ td >
</ tr ></ tbody >
</ table >
2026-08-13 11:57:44 +08:00
</ div >
</ section >
< section class = "overview-section" aria -label =" PDA作业摘要 " >
2026-08-14 14:27:03 +08:00
< header >< h3 > PDA 作业进度 </ h3 >< StatusBadge v-if = "overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></ header >
2026-08-13 11:57:44 +08:00
< el-empty v-if = "!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
2026-08-26 11:10:48 +08:00
< template v-else >< div class = "pda-summary" >< el-progress :percentage = "pdaProgress" : status = "overview.pda.unresolvedItemCount ? 'exception' : undefined" />< div class = "metric-grid" >< div >< span > 任务明细 </ span >< strong >{{ overview . pda . itemCount }}</ strong ></ div >< div >< span > 已处理 </ span >< strong >{{ overview . pda . completedItemCount }}</ strong ></ div >< div >< span > 未解决 </ span >< strong >{{ overview . pda . unresolvedItemCount }}</ strong ></ div >< div >< span > 有效照片 </ span >< strong >{{ overview . pda . photoCounts . reduce (( sum , item ) => sum + item . count , 0 ) }}</ strong ></ div ></ div ></ div >< el-alert v-if = "workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join('; ')" /></ template >
2026-08-13 11:57:44 +08:00
</ section >
< section class = "overview-section" aria -label =" 运输轨迹和操作日志 " >
< header >< h3 > 运输轨迹与操作日志 </ h3 >< span >{{ overview . timeline . length }} 条 </ span ></ header >
< el-empty v-if = "overview.timeline.length === 0" description="暂无轨迹或操作记录" :image-size="64" />
2026-08-26 11:10:48 +08:00
< ol v-else class = "compact-timeline" >< li v-for = "(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`" >< time >{{ formatTime ( entry . occurredAt ) }}</ time >< strong >{{ eventLabel ( entry . eventType ) }}</ strong >< span >{{ entry . operatorName || '系统' }}</ span >< small >{{ eventSummary ( entry ) }}</ small ></ li ></ ol >
2026-08-13 11:57:44 +08:00
</ section >
</ div >
</ template >
< style scoped >
2026-08-26 13:48:55 +08:00
. overview - panel { display : grid ; gap : 12 px ; }. overview - section : nth - child ( 3 ){ order : 1 }. overview - section : nth - child ( 2 ){ order : 2 }. overview - section : nth - child ( 1 ){ order : 3 }. overview - section : nth - child ( 4 ){ order : 4 }
2026-08-26 11:10:48 +08:00
. overview - section { min - width : 0 ; padding : 12 px 16 px ; border : 1 px solid var ( -- cc - border ); background : # fff ; }
2026-08-13 11:57:44 +08:00
. overview - section > header { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - bottom : 14 px ; }
. overview - section h3 { margin : 0 ; font - size : 15 px ; }
. overview - section header > span , . overview - section small { color : var ( -- cc - muted ); font - size : 12 px ; }
. overview - section p { margin : 5 px 0 ; color : var ( -- cc - muted ); font - size : 13 px ; }
2026-08-26 13:48:55 +08:00
. exception - management { padding - top : 10 px ; padding - bottom : 10 px }. exception - management__header { margin - bottom : 7 px ! important }. exception - management__header > div : first - child { display : flex ; min - width : 0 ; align - items : center ; gap : 14 px }. exception - current { display : flex ; min - width : 0 ; align - items : center ; gap : 9 px ; margin : 0 ! important }. exception - current strong ,. exception - current span { overflow : hidden ; max - width : 360 px ; text - overflow : ellipsis ; white - space : nowrap }. exception - current strong { color : var ( -- el - color - danger )}. exception - current span { font - size : 12 px }. exception - management__actions { display : flex ; align - items : center ; gap : 8 px }. exception - history { overflow - x : auto ; border - top : 1 px solid var ( -- cc - border ); font - size : 12 px }. exception - history__head ,. exception - history li { display : grid ; grid - template - columns : 145 px 120 px 130 px minmax ( 220 px , 1 fr ); gap : 8 px ; align - items : center ; min - width : 680 px ; padding : 6 px 4 px }. exception - history__head { color : var ( -- cc - muted ); font - weight : 650 }. exception - history ol { margin : 0 ; padding : 0 ; list - style : none }. exception - history li { border - top : 1 px solid var ( -- cc - border )}. exception - history li time ,. exception - history li span { overflow : hidden ; color : var ( -- cc - muted ); text - overflow : ellipsis ; white - space : nowrap }. exception - history__empty { margin : 0 ! important ; padding : 7 px 4 px ; border - top : 1 px solid var ( -- cc - border ); font - size : 12 px ! important }
2026-08-26 13:00:51 +08:00
. document - table - wrap { width : 100 % ; overflow - x : auto ; border : 1 px solid var ( -- cc - border )}. document - table { width : 100 % ; min - width : 940 px ; border - collapse : collapse ; table - layout : fixed ; font - size : 12 px }. document - table th ,. document - table td { height : 40 px ; padding : 5 px 10 px ; border - bottom : 1 px solid var ( -- cc - border ); background : # fff ; text - align : left ; vertical - align : middle }. document - table tbody tr : last - child td { border - bottom : 0 }. document - table th { background : var ( -- cc - bg - subtle , # f7f8fa ); color : var ( -- cc - muted ); font - weight : 650 }. document - table th : nth - child ( 1 ){ width : 260 px }. document - table th : nth - child ( 2 ){ width : 180 px }. document - table th : nth - child ( 3 ){ width : 210 px }. document - table th : nth - child ( 4 ){ width : 120 px }. document - table th : nth - child ( 5 ){ width : 150 px }. document - table th : nth - child ( 6 ){ width : 90 px }. document - table__actions { position : sticky ; left : 0 ; z - index : 1 ; box - shadow : 1 px 0 0 var ( -- cc - border )}. document - table th . document - table__actions { z - index : 2 }. document - table td strong { font - size : 13 px }. document - table td small { display : block ; overflow : hidden ; max - width : 170 px ; margin - top : 2 px ; text - overflow : ellipsis ; white - space : nowrap }. document - file - name { display : block ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap }. document - actions { display : flex ; align - items : center ; gap : 5 px ; white - space : nowrap }. document - actions : deep (. el - button ){ margin - left : 0 }. no - action { color : var ( -- cc - muted )}
2026-08-26 11:10:48 +08:00
. pda - summary { display : grid ; grid - template - columns : minmax ( 220 px , 1 fr ) minmax ( 0 , 2 fr ); align - items : center ; gap : 16 px ; margin - bottom : 10 px }. metric - grid { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : 6 px ; margin : 0 ; }
. metric - grid > div { display : grid ; gap : 2 px ; padding : 6 px 9 px ; background : var ( -- cc - bg - subtle , # f7f8fa ); }
2026-08-13 11:57:44 +08:00
. metric - grid span { color : var ( -- cc - muted ); font - size : 12 px ; }
. metric - grid strong { font - size : 18 px ; }
2026-08-26 11:10:48 +08:00
. photo - gallery { display : grid ; grid - template - columns : repeat ( 5 , minmax ( 0 , 1 fr )); gap : 8 px ; margin : 10 px 0 }. photo - thumb { display : grid ; min - width : 0 ; overflow : hidden ; padding : 0 ; border : 1 px solid var ( -- cc - border ); background : # fff ; color : inherit ; text - align : left ; cursor : pointer }. photo - thumb : disabled { cursor : default }. photo - thumb img ,. photo - placeholder { width : 100 % ; aspect - ratio : 4 / 3 ; object - fit : cover ; background : var ( -- cc - bg - subtle , # f7f8fa )}. photo - placeholder { display : grid ; place - items : center ; padding : 8 px ; color : var ( -- cc - muted ); font - size : 12 px }. photo - placeholder -- error { color : var ( -- el - color - danger )}. photo - caption { display : flex ; align - items : center ; justify - content : space - between ; gap : 6 px ; padding : 6 px 8 px }. photo - caption small { white - space : nowrap }. compact - empty { margin : 6 px 0 ; color : var ( -- cc - muted ); font - size : 12 px }. compact - timeline { display : grid ; margin : 0 ; padding : 0 ; list - style : none }. compact - timeline li { display : grid ; grid - template - columns : 130 px 130 px 110 px minmax ( 0 , 1 fr ); gap : 8 px ; padding : 6 px 0 ; border - top : 1 px solid var ( -- cc - border ); font - size : 12 px }. compact - timeline li : first - child { border - top : 0 }. compact - timeline time ,. compact - timeline span ,. compact - timeline small { overflow : hidden ; color : var ( -- cc - muted ); text - overflow : ellipsis ; white - space : nowrap }
@ media ( max - width : 1200 px ){. photo - gallery { grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ))}. pda - summary { grid - template - columns : 1 fr }. compact - timeline li { grid - template - columns : 120 px 120 px minmax ( 0 , 1 fr )}. compact - timeline small { grid - column : 2 /- 1 }}
2026-08-26 13:48:55 +08:00
@ media ( max - width : 720 px ) { . exception - management__header { align - items : flex - start ; flex - direction : column }. exception - management__header > div : first - child { align - items : flex - start ; flex - direction : column ; gap : 3 px }. exception - current { align - items : flex - start ; flex - direction : column ; gap : 2 px }. metric - grid { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); }. photo - gallery { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ))}. compact - timeline li { grid - template - columns : 1 fr 1 fr }. compact - timeline span ,. compact - timeline small { grid - column : auto ; white - space : normal } }
2026-08-13 11:57:44 +08:00
</ style >