优化
This commit is contained in:
@@ -10,7 +10,13 @@ import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierSc
|
||||
import { getPortSchedule } from '../api/platformPortSchedules'
|
||||
import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
|
||||
|
||||
type WarehouseTimelineEntry = { eventType: string; detail?: string | null; operatorName?: string | null; occurredAt?: string | null }
|
||||
type WarehouseTimelineEntry = {
|
||||
id?: string | null; eventId?: string | null; idempotencyKey?: string | null
|
||||
eventType: string; action?: string | null; fromStatus?: string | null; toStatus?: string | null
|
||||
detail?: string | null; summary?: string | null; changeSummary?: string | null; reason?: string | null
|
||||
changedFields?: unknown; fieldChanges?: unknown; changes?: unknown
|
||||
operatorName?: string | null; occurredAt?: string | null
|
||||
}
|
||||
type FulfillmentEvent = { key: string; time: string; type: '仓内操作' | '确认离仓' | '船运节点' | '人工补录' | '异常'; title: string; summary: string; source: string; actual: boolean }
|
||||
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null; warehouseTimeline?: WarehouseTimelineEntry[] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -86,17 +92,78 @@ function exceptionLocation(item: CarrierScheduleExceptionFact) { return [item.lo
|
||||
function nodeTimeText(item: CarrierScheduleNodeFact) { return item.actualAt ? `实际 ${formatTime(item.actualAt)}` : item.estimatedAt ? `预计 ${formatTime(item.estimatedAt)}` : `观测 ${formatTime(item.observedAt)}` }
|
||||
function exceptionText(value: CarrierScheduleExceptionFact['exceptionCode']) { return ({ CUSTOMS_HOLD: '海关扣留', TERMINAL_HOLD: '码头扣留', DOCUMENT_HOLD: '单证扣留', UNKNOWN_HOLD: '其他扣留' } as Record<string, string>)[value] || value }
|
||||
function historyText(action: string) { return ({ AUTO_CREATE: '国内出仓自动建任务', CREATE: '人工建立查询任务', NODE_OBSERVED: '记录标准节点', PROVIDER_QUERY: '船司查询', MANUAL_NODE: '人工补录标准节点', STOP: '停止查询' } as Record<string, string>)[action] || action }
|
||||
function warehouseEventText(value: string) { return ({ TRANSPORT_UPDATE: '更新运输信息', PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜' } as Record<string, string>)[value] || value || '状态更新' }
|
||||
function warehouseEventText(value: string) { return ({ STATUS_UPDATE: '状态更新', PUSH_PRELOAD: '推送预装清单', CONFIRM_PRELOAD: '确认预装清单', TRANSPORT_UPDATE: '更新运输信息', PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜' } as Record<string, string>)[value] || value || '状态更新' }
|
||||
function nonBlank(value: unknown) { return typeof value === 'string' ? value.trim() : '' }
|
||||
function hasStructuredChanges(value: unknown) {
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (value && typeof value === 'object') return Object.keys(value as Record<string, unknown>).length > 0
|
||||
return Boolean(nonBlank(value))
|
||||
}
|
||||
function statusLabel(value?: string | null) {
|
||||
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as Record<string, string>)[value || ''] || nonBlank(value)
|
||||
}
|
||||
function isStatusEcho(detail: string, fromStatus?: string | null, toStatus?: string | null) {
|
||||
const from = nonBlank(fromStatus); const to = nonBlank(toStatus)
|
||||
if (!from || !to) return false
|
||||
const compact = detail.replace(/\s+/g, '')
|
||||
return [[from, to], [statusLabel(from), statusLabel(to)]].some(([left, right]) =>
|
||||
[`${left}→${right}`, `${left}->${right}`].includes(compact))
|
||||
}
|
||||
function warehouseAction(entry: WarehouseTimelineEntry) { return nonBlank(entry.action) || nonBlank(entry.eventType) }
|
||||
function hasEffectiveWarehouseChange(entry: WarehouseTimelineEntry) {
|
||||
if (hasStructuredChanges(entry.changedFields) || hasStructuredChanges(entry.fieldChanges) || hasStructuredChanges(entry.changes)) return true
|
||||
if ([entry.summary, entry.changeSummary, entry.reason].some((value) => nonBlank(value))) return true
|
||||
const detail = nonBlank(entry.detail)
|
||||
return Boolean(detail && !isStatusEcho(detail, entry.fromStatus, entry.toStatus))
|
||||
}
|
||||
function isMeaningfulWarehouseAction(entry: WarehouseTimelineEntry) {
|
||||
return [entry.action, entry.eventType].some((value) => /^(?:PUSH_PRELOAD|PDA(?:_|$)|CONFIRM(?:_|$))/.test(nonBlank(value)))
|
||||
}
|
||||
function isNoopWarehouseStatus(entry: WarehouseTimelineEntry) {
|
||||
if (isMeaningfulWarehouseAction(entry)) return false
|
||||
return warehouseAction(entry) === 'STATUS_UPDATE'
|
||||
&& nonBlank(entry.fromStatus) !== ''
|
||||
&& nonBlank(entry.fromStatus) === nonBlank(entry.toStatus)
|
||||
&& !hasEffectiveWarehouseChange(entry)
|
||||
}
|
||||
function eventIdentity(entry: WarehouseTimelineEntry) { return nonBlank(entry.eventId) || nonBlank(entry.idempotencyKey) }
|
||||
function eventSignature(entry: WarehouseTimelineEntry) {
|
||||
return JSON.stringify([warehouseAction(entry), entry.fromStatus || null, entry.toStatus || null, entry.summary || null, entry.changeSummary || null, entry.reason || null, entry.detail || null, entry.changedFields || null, entry.fieldChanges || null, entry.changes || null])
|
||||
}
|
||||
function sameExplicitEvent(left: WarehouseTimelineEntry, right: WarehouseTimelineEntry) {
|
||||
const sameId = nonBlank(left.eventId) && nonBlank(left.eventId) === nonBlank(right.eventId)
|
||||
const sameKey = nonBlank(left.idempotencyKey) && nonBlank(left.idempotencyKey) === nonBlank(right.idempotencyKey)
|
||||
return Boolean((sameId || sameKey) && eventSignature(left) === eventSignature(right))
|
||||
}
|
||||
const filteredWarehouseTimeline = computed(() => {
|
||||
const sorted = (props.warehouseTimeline || []).map((entry, index) => ({ entry, index }))
|
||||
.filter(({ entry }) => entry.occurredAt && !['MARK', 'CANCEL'].includes(warehouseAction(entry)) && !isNoopWarehouseStatus(entry))
|
||||
.sort((left, right) => {
|
||||
const time = Date.parse(right.entry.occurredAt || '') - Date.parse(left.entry.occurredAt || '')
|
||||
return time || left.index - right.index
|
||||
})
|
||||
const result: WarehouseTimelineEntry[] = []
|
||||
for (const { entry } of sorted) {
|
||||
const previous = result[result.length - 1]
|
||||
if (previous && sameExplicitEvent(previous, entry)) continue
|
||||
result.push(entry)
|
||||
}
|
||||
return result
|
||||
})
|
||||
function warehouseEventSummary(entry: WarehouseTimelineEntry) {
|
||||
const value = String(entry.detail || '').trim()
|
||||
return !value || value.length > 120 || /(?:operation|idempot|claim-|pda-submit|complete-)/i.test(value) ? '操作已记录' : value
|
||||
const value = nonBlank(entry.summary) || nonBlank(entry.changeSummary) || nonBlank(entry.reason) || nonBlank(entry.detail)
|
||||
if (value && value.length <= 120 && !/(?:operation|idempot|claim-|pda-submit|complete-)/i.test(value)) return value
|
||||
const from = nonBlank(entry.fromStatus); const to = nonBlank(entry.toStatus)
|
||||
if (from && to && from !== to) return `${statusLabel(from)} → ${statusLabel(to)}`
|
||||
if (hasStructuredChanges(entry.changedFields) || hasStructuredChanges(entry.fieldChanges) || hasStructuredChanges(entry.changes)) return '业务字段已更新'
|
||||
return '操作已记录'
|
||||
}
|
||||
const fulfillmentEvents = computed<FulfillmentEvent[]>(() => {
|
||||
const values: FulfillmentEvent[] = []
|
||||
for (const [index, item] of (props.warehouseTimeline || []).entries()) {
|
||||
if (!item.occurredAt || ['MARK', 'CANCEL'].includes(item.eventType)) continue
|
||||
const departure = /OUTBOUND|DISPATCH|DEPARTED_WAREHOUSE/.test(item.eventType)
|
||||
values.push({ key: `warehouse-${item.eventType}-${item.occurredAt}-${index}`, time: item.occurredAt, type: departure ? '确认离仓' : '仓内操作', title: warehouseEventText(item.eventType), summary: warehouseEventSummary(item), source: item.operatorName || '系统', actual: true })
|
||||
for (const [index, item] of filteredWarehouseTimeline.value.entries()) {
|
||||
const action = warehouseAction(item)
|
||||
const departure = /OUTBOUND|DISPATCH|DEPARTED_WAREHOUSE/.test(action)
|
||||
values.push({ key: `warehouse-${eventIdentity(item) || `${action}-${item.occurredAt}-${index}`}`, time: item.occurredAt!, type: departure ? '确认离仓' : '仓内操作', title: warehouseEventText(action), summary: warehouseEventSummary(item), source: item.operatorName || '系统', actual: true })
|
||||
}
|
||||
const nodeKeys = new Set<string>()
|
||||
for (const item of primaryNodeFacts.value) {
|
||||
@@ -179,7 +246,7 @@ watch(() => manualForm.actualAt, (actualAt) => {
|
||||
|
||||
<template>
|
||||
<section class="carrier-query" aria-label="货柜履约轨迹">
|
||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><el-button v-if="query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
|
||||
<template v-else>
|
||||
@@ -209,5 +276,6 @@ watch(() => manualForm.actualAt, (actualAt) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-count{color:var(--cc-muted);font-size:12px;white-space:nowrap}
|
||||
.carrier-query{display:grid;gap:9px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.header-actions{display:flex;align-items:center;gap:8px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:6px 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:7px 10px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:12px}.fulfillment-timeline{display:grid;margin:0;padding:0;list-style:none}.fulfillment-timeline li{display:grid;grid-template-columns:130px 86px 150px minmax(180px,1fr) minmax(120px,.8fr);align-items:center;gap:7px;padding:6px 2px;border-top:1px solid var(--cc-border);font-size:12px}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px 10px;border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-size:12px}.empty-line{margin:0;color:var(--cc-muted);font-size:12px}.technical-details{border-top:1px solid var(--cc-border);font-size:12px}.technical-details summary{padding:7px 2px;color:var(--cc-primary);cursor:pointer}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px 16px;margin:4px 0 10px}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:11px}.query-facts dd{margin:2px 0 0;font-size:12px}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:8px 0 5px;font-size:12px}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:8px;padding:5px 0;border-top:1px solid var(--cc-border)}.technical-history small{color:var(--cc-muted)}.manual-form{display:grid;gap:2px;margin-top:12px}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.fulfillment-timeline li{grid-template-columns:120px 82px 140px minmax(0,1fr)}.fulfillment-timeline small{grid-column:4}}@media(max-width:720px){.carrier-query>header{align-items:flex-start;flex-direction:column}.query-facts,.manual-form-grid{grid-template-columns:1fr}.fulfillment-timeline li{grid-template-columns:90px minmax(0,1fr)}.fulfillment-timeline strong,.fulfillment-timeline span,.fulfillment-timeline small{grid-column:2}.technical-history li{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user