179 lines
18 KiB
Vue
179 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import '@/styles/adminWorkbench.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 admin-workbench">
|
||
<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"><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><template v-else><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><ol><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></template></div>
|
||
</section>
|
||
|
||
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
|
||
<div class="document-table-wrap">
|
||
<div class="workbench-evidence"><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>
|
||
</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(--workbench-muted); font-size: var(--admin-font-caption); }
|
||
.overview-section p { margin: 5px 0; color: var(--workbench-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(--workbench-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(--workbench-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--workbench-border)}.exception-history li time,.exception-history li span{color:var(--workbench-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)!important}
|
||
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--workbench-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(--workbench-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(--workbench-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--workbench-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(--workbench-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(--workbench-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(--workbench-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(--workbench-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(--workbench-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>
|