Files
admin-web/src/modules/container-loading/components/ContainerOverviewPanel.vue
T
2026-10-09 11:11:20 +08:00

178 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
const props = defineProps<{
overview: ContainerOverview
canManageDocuments: boolean
canManageCustoms: boolean
canManageException: boolean
documentBusy?: boolean
pickupCodeBusy?: boolean
deliveryOrderBlockedReason?: string | null
sections?: Array<'exception' | 'documents' | 'pda'>
}>()
const emit = defineEmits<{
uploadDocument: [documentType: string]
downloadDocument: [document: ContainerOverviewDocument]
savePickupCode: [pickupCode: string]
manageException: []
}>()
const documentTypes = [
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
] as const
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
const containerDownloadableTypes = new Set(['DELIVERY_ORDER'])
const pickupCodeInput = ref('')
watch(() => props.overview.container.pickupCode, (value) => { pickupCodeInput.value = value || '' }, { immediate: true })
const pickupCodeValid = computed(() => /^[0-9]{1,64}$/.test(pickupCodeInput.value))
function canDownloadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType)
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
}
function canUploadDocument(documentType: string) {
if (documentType === 'DELIVERY_ORDER' && props.deliveryOrderBlockedReason) return false
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms
}
function hasDocumentAction(documentType: string) {
const document = documentsByType.value.get(documentType)
return Boolean((document && canDownloadDocument(documentType)) || (canUploadDocument(documentType) && !document?.completed))
}
function documentStatusText(documentType: string) {
const document = documentsByType.value.get(documentType)
return document ? formatTime(document.uploadedAt) : '等待上传'
}
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'
}
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 visibleSections = computed(() => new Set(props.sections || ['exception', 'documents', 'pda']))
const activePhotos = computed(() => visibleSections.value.has('pda')
? (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath)
: [])
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
let photoGeneration = 0
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] || '装柜照片' }
function formatTime(value: string | null | undefined) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function eventLabel(value: string) {
return ({
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜',
} as Record<string, string>)[value] || value || '状态更新'
}
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() })
</script>
<template>
<div class="overview-panel admin-density">
<section v-if="visibleSections.has('exception')" 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>
</section>
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
<div class="document-table-wrap">
<table class="document-table density-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 density-actions"><el-button class="density-action" v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)">下载</el-button><el-button class="density-action" v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)">{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong></td>
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span><small v-if="item.type === 'DELIVERY_ORDER' && deliveryOrderBlockedReason" class="document-blocked-reason">{{ deliveryOrderBlockedReason }}</small></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><tr class="pickup-code-row">
<td class="document-table__actions"><el-button data-tone="primary" class="density-action" v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td>
<td><strong>PU 资料</strong></td>
<td><el-input v-model="pickupCodeInput" :disabled="!canManageDocuments" maxlength="64" inputmode="numeric" placeholder="请输入数字 PU 码" aria-label="PU码" @input="pickupCodeInput = pickupCodeInput.replace(/\D/g, '')" /></td>
<td>{{ overview.container.pickupCodeUpdatedByName || '—' }}</td>
<td>{{ formatTime(overview.container.pickupCodeUpdatedAt) }}</td>
<td><el-tag size="small" :type="overview.container.pickupCode ? 'success' : 'info'">{{ overview.container.pickupCode ? '已填写' : '未填写' }}</el-tag></td>
</tr></tbody>
</table>
</div>
</section>
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div><div><dt>任务明细</dt><dd>{{ overview.pda.itemCount }}</dd></div><div><dt>已处理</dt><dd>{{ overview.pda.completedItemCount }}</dd></div><div><dt>未解决</dt><dd>{{ overview.pda.unresolvedItemCount }}</dd></div><div><dt>有效照片</dt><dd>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</dd></div></dl><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>
</section>
</div>
</template>
<style scoped>
.overview-panel { display: grid; gap: var(--admin-space-5); }
.overview-section { min-width: 0; background: var(--admin-surface); border: 0; padding: 0; }
.overview-section--documents{padding:0;border:0}
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.overview-section h3 { margin: 0; font-size: var(--admin-font-section); }
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.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:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{color:var(--cc-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.overview-panel .pda-summary { grid-template-columns: minmax(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
.pda-progress dd { width: 100%; }
.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;height:112px;object-fit:contain;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:grid;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.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: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr 1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
</style>