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-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 ])))
const downloadableTypes = new Set ([ 'DELIVERY_ORDER' , 'PICKUP_ORDER' ])
2026-08-26 11:10:48 +08:00
const customsTypes = new Set ([ 'DESTINATION_CLEARANCE' , 'CUSTOMS_DECLARATION' ])
function canUploadDocument ( documentType : string ) {
return downloadableTypes . has ( documentType ) ? props . canManageDocuments : customsTypes . has ( documentType ) && props . canManageCustoms
}
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-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" >
< section class = "overview-section" aria -label =" 货柜异常状态 " >
< header >< h3 > 异常状态 </ h3 >< el-button v-if = "canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview . exception . active ? '取消异常' : '标记异常' }}</ el-button ></ header >
< el-alert v-if = "overview.exception.active" type="error" :closable="false" show -icon >< template # title > 货柜已标记异常 </ template >< p >{{ overview . exception . reason || '未填写异常原因' }}</ p >< small >{{ overview . exception . markedByName || '操作人信息缺失' }} · {{ formatTime ( overview . exception . markedAt ) }}</ small ></ el-alert >
2026-08-26 11:10:48 +08:00
< p v-else class = "compact-empty" > 当前无货柜异常 </ p >
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 11:10:48 +08:00
< div class = "document-grid compact-document-list" >
2026-08-13 11:57:44 +08:00
< article v-for = "item in documentTypes" :key="item.type" class="document-item" >
2026-08-26 12:46:46 +08:00
< div >< strong >{{ item . label }}</ strong >< span >{{ documentsByType . get ( item . type ) ? . fileName || '未上传' }}</ span >< small >{{ documentStatusText ( item . type ) }}</ small ></ div >
< div class = "document-actions" >< el-button v-if = "documentsByType.get(item.type) && downloadableTypes.has(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)" 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 ></ div >
2026-08-13 11:57:44 +08:00
</ article >
</ 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 >
< section class = "overview-section" aria -label =" 异常处理历史 " >
< header >< h3 > 异常处理历史 </ h3 >< span >{{ overview . exceptionHistory . length }} 条 </ span ></ header >
< el-empty v-if = "overview.exceptionHistory.length === 0" description="暂无异常处理历史" :image-size="64" />
< el-timeline v-else >< el-timeline-item v-for = "history in overview.exceptionHistory" :key="history.id" :timestamp="formatTime(history.occurredAt)" placement="top" >< strong >{{ eventLabel ( history . action ) }}</ strong >< p >{{ history . reason }}</ p >< small >{{ history . operatorName || '操作人信息缺失' }}</ small ></ el-timeline-item ></ el-timeline >
</ section >
</ div >
</ template >
< style scoped >
2026-08-26 11:10:48 +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 }. overview - section : nth - child ( 5 ){ order : 5 }
. 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 ; }
. document - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 10 px ; }
2026-08-26 11:10:48 +08:00
. document - item { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 10 px ; padding : 8 px 10 px ; border - top : 1 px solid var ( -- cc - border ); }
. document - item : nth - child ( - n + 2 ){ border - top : 0 }. compact - document - list { column - gap : 18 px ; row - gap : 0 }
2026-08-13 11:57:44 +08:00
. document - item > div : first - child { display : grid ; min - width : 0 ; gap : 4 px ; }
. document - item span { overflow : hidden ; color : var ( -- cc - muted ); font - size : 13 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. document - actions { display : flex ; flex - shrink : 0 ; gap : 6 px ; }
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 }}
@ media ( max - width : 720 px ) { . document - grid { grid - template - columns : 1 fr ; }. document - item : nth - child ( 2 ){ border - top : 1 px solid var ( -- cc - border )} . 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
@ media ( max - width : 420 px ) { . document - item { align - items : stretch ; flex - direction : column ; } . document - actions : deep (. el - button ) { flex : 1 ; } }
</ style >