优化
This commit is contained in:
@@ -1,43 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Download, Eye, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
|
||||
import { Download, FileUp, 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
|
||||
photoBusyId?: string | null
|
||||
photoError?: ApiErrorDetails | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploadDocument: [documentType: string]
|
||||
downloadDocument: [document: ContainerOverviewDocument]
|
||||
manageException: []
|
||||
previewPhoto: [photo: ContainerPdaPhoto]
|
||||
}>()
|
||||
|
||||
const documentTypes = [
|
||||
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
|
||||
{ type: 'CUSTOMS_DECLARATION', label: '报关资料' },
|
||||
{ type: 'CUSTOMS_DECLARATION', label: '国内出口报关附件' },
|
||||
{ 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'])
|
||||
const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION'])
|
||||
function canUploadDocument(documentType: string) {
|
||||
return downloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms
|
||||
}
|
||||
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))
|
||||
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: '已完成',
|
||||
}
|
||||
@@ -55,8 +58,41 @@ function formatTime(value: string | null | undefined) {
|
||||
function eventLabel(value: string) {
|
||||
return ({
|
||||
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
|
||||
PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜',
|
||||
} as Record<string, string>)[value] || value || '状态更新'
|
||||
}
|
||||
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() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -64,15 +100,15 @@ function eventLabel(value: string) {
|
||||
<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>
|
||||
<el-empty v-else description="当前无货柜异常" :image-size="64" />
|
||||
<p v-else class="compact-empty">当前无货柜异常</p>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="货柜业务资料">
|
||||
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
|
||||
<div class="document-grid">
|
||||
<div class="document-grid compact-document-list">
|
||||
<article v-for="item in documentTypes" :key="item.type" class="document-item">
|
||||
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small v-if="documentsByType.get(item.type)">{{ documentsByType.get(item.type)?.uploadedByName || '上传人信息缺失' }} · {{ formatTime(documentsByType.get(item.type)?.uploadedAt) }}</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="canManageDocuments && downloadableTypes.has(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
|
||||
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '等待上传' }}</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></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -80,13 +116,13 @@ function eventLabel(value: string) {
|
||||
<section 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><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><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 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 v-if="overview.pda.photoCounts.length" class="photo-summary"><span v-for="item in overview.pda.photoCounts" :key="item.evidenceType">{{ evidenceTypeLabel(item.evidenceType) }}:{{ item.count }}</span></div><div v-if="activePhotos.length" class="photo-list"><article v-for="photo in activePhotos" :key="photo.evidenceId"><div><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><span>{{ photo.fileName }}</span><small>{{ formatTime(photo.uploadedAt) }}</small></div><el-button size="small" :loading="photoBusyId === photo.evidenceId" :aria-label="`查看装柜照片 ${photo.fileName}`" @click="emit('previewPhoto', photo)"><Eye :size="15" />查看</el-button></article></div><el-empty v-else description="暂无可查看的有效照片" :image-size="48" /><LoadErrorState v-if="photoError" compact title="照片加载失败" :message="photoError.message" :show-retry="false"><template #details><ApiErrorMeta :details="photoError" /></template></LoadErrorState><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
<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>
|
||||
</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" />
|
||||
<el-timeline v-else><el-timeline-item v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`" :timestamp="formatTime(entry.occurredAt)" placement="top"><strong>{{ eventLabel(entry.eventType) }}</strong><p>{{ entry.detail || '无补充说明' }}</p><small>{{ entry.operatorName || '系统' }}</small></el-timeline-item></el-timeline>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="异常处理历史">
|
||||
@@ -98,23 +134,24 @@ function eventLabel(value: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; gap: 16px; margin-top: 16px; }
|
||||
.overview-section { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-panel { display: grid; gap: 12px; }.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: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.overview-section h3 { margin: 0; font-size: 15px; }
|
||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
|
||||
.document-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--cc-border); }
|
||||
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-top: 1px solid var(--cc-border); }
|
||||
.document-item:nth-child(-n+2){border-top:0}.compact-document-list{column-gap:18px;row-gap:0}
|
||||
.document-item > div:first-child { display: grid; min-width: 0; gap: 4px; }
|
||||
.document-item span { overflow: hidden; color: var(--cc-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.document-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0; }
|
||||
.metric-grid > div { display: grid; gap: 4px; padding: 10px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
|
||||
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
||||
.metric-grid strong { font-size: 18px; }
|
||||
.photo-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }.photo-summary span { padding: 4px 8px; border: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.photo-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 12px; }.photo-list article { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:8px; padding:10px; border:1px solid var(--cc-border); }.photo-list article > div { display:grid; min-width:0; gap:3px; }.photo-list span { overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@media (max-width: 720px) { .document-grid,.photo-list { grid-template-columns: 1fr; } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:10px 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px 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:8px;color:var(--cc-muted);font-size:12px}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px}.photo-caption small{white-space:nowrap}.compact-empty{margin:6px 0;color:var(--cc-muted);font-size:12px}.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:8px;padding:6px 0;border-top:1px solid var(--cc-border);font-size:12px}.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))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 720px) { .document-grid { grid-template-columns: 1fr; }.document-item:nth-child(2){border-top:1px solid var(--cc-border)} .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.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} }
|
||||
@media (max-width: 420px) { .document-item { align-items: stretch; flex-direction: column; } .document-actions :deep(.el-button) { flex: 1; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user