Files
admin-web/src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue
T

326 lines
34 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 '@/styles/adminWorkbench.css'
import { computed, reactive, ref, watch } from 'vue'
import { Plus } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { appendContainerShippingNode, getContainerShippingNodes } from '../api/platformContainerShippingNodes'
import { getPortSchedule } from '../api/platformPortSchedules'
import type { CarrierScheduleExceptionFact, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, ContainerManualNodeRequest, ContainerShippingNodeDetail, PortScheduleView } from '../types'
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<{ dense?: boolean; hideTechnicalDetails?: boolean; cabinetNo?: string | null; containerNo?: string | null; oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null; warehouseTimeline?: WarehouseTimelineEntry[] }>()
const manualDialogTitle = computed(() => {
const code = props.cabinetNo?.trim() || props.containerNo?.trim()
return code ? `人工补录船节点 · ${code}` : '人工补录船节点'
})
const access = useAccessPreviewStore()
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const queryDetailError = ref<ApiErrorDetails | null>(null)
const scheduleError = ref<ApiErrorDetails | null>(null)
const selected = ref<CarrierScheduleQueryView | null>(null)
const detail = ref<CarrierScheduleQueryDetail | null>(null)
const containerNodes = ref<ContainerShippingNodeDetail | null>(null)
const latestSchedule = ref<PortScheduleView | null>(null)
const manualDialogOpen = ref(false)
const manualIdempotencyKey = ref('')
const manualObservedAt = ref('')
const manualSaving = ref(false)
const manualError = ref<ApiErrorDetails | null>(null)
const manualValidationError = ref('')
const manualForm = reactive({ standardNode: '' as CarrierScheduleStandardNode | '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
const query = computed(() => detail.value?.query.query || selected.value?.query || null)
const queryView = computed(() => detail.value?.query || selected.value)
const statusText = computed(() => {
if (!query.value) return '未建立'
if (query.value.status === 'STOPPED' && query.value.latestActualNode === 'EMPTY_RETURNED' && query.value.latestActualAt) return '已停止 · 已还柜'
if (query.value.status === 'STOPPED') return '任务已停止'
return ({ READY: '运行中', MANUAL_REQUIRED: '待人工处理' } as Record<string, string>)[query.value.status] || query.value.status
})
const statusType = computed<'success' | 'warning' | 'danger' | 'info'>(() => {
if (query.value?.status === 'STOPPED') return 'info'
if (queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED') return 'danger'
return 'warning'
})
const nodes = computed(() => containerNodes.value?.nodes?.length ? containerNodes.value.nodes : detail.value?.nodes || [])
const exceptions = computed(() => [...(detail.value?.exceptions || [])].reverse())
const latestActualNodeFact = computed(() => [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt === query.value?.latestActualAt)
|| [...nodes.value].reverse().find((item) => item.standardNode === query.value?.latestActualNode && item.actualAt)
|| null)
const latestSource = computed(() => latestActualNodeFact.value?.providerCode || latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
const sourceReference = computed(() => latestActualNodeFact.value?.sourceReference || latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
const latestObservation = computed(() => latestActualNodeFact.value?.observedAt || null)
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
const standardNodes: CarrierScheduleStandardNode[] = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', 'PICKED_UP', 'EMPTY_RETURNED']
function compareActualFacts(left: CarrierScheduleNodeFact, right: CarrierScheduleNodeFact) {
for (const field of ['actualAt', 'observedAt', 'createdAt'] as const) {
const compared = String(left[field] || '').localeCompare(String(right[field] || ''))
if (compared) return compared
}
return left.id.localeCompare(right.id)
}
const latestActualFactsByNode = computed(() => {
const result = new Map<CarrierScheduleStandardNode, CarrierScheduleNodeFact>()
for (const item of nodes.value) {
if (!item.actualAt) continue
const current = result.get(item.standardNode)
if (!current || compareActualFacts(item, current) > 0) result.set(item.standardNode, item)
}
return result
})
const supersededActualNodes = computed(() => nodes.value
.filter((item) => item.actualAt && latestActualFactsByNode.value.get(item.standardNode)?.id !== item.id)
.sort((left, right) => compareActualFacts(right, left)))
const primaryNodeFacts = computed(() => nodes.value.filter((item) => !item.actualAt || latestActualFactsByNode.value.get(item.standardNode)?.id === item.id))
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function trackingTypeText(value: string) { return ({ BOOKING_NO: '订舱号', BILL_OF_LADING: '提单号', CONTAINER_NO: '集装箱号' } as Record<string, string>)[value] || value }
function triggerSourceText(value?: string | null) { return ({ OUTBOUND_CONFIRM: '国内出仓确认自动建立', MANUAL: '平台人工建立' } as Record<string, string>)[value || ''] || value || '—' }
function nodeText(value?: CarrierScheduleStandardNode | null) { return ({ DEPARTED_WAREHOUSE: '已离开国内仓库', AT_ORIGIN_PORT: '重箱已进入起运港', LOADED_ON_VESSEL: '已装船', DEPARTED_ORIGIN_PORT: '船舶已离开起运港', IN_TRANSIT: '海运途中', ARRIVED_DESTINATION_PORT: '船舶已到目的港', DISCHARGED_FROM_VESSEL: '已卸船、未清关', PICKUP_READY: '已清关、可提柜', PICKED_UP: '已提柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[value || ''] || value || '—' }
function nodeLocation(item?: CarrierScheduleNodeFact | null) { return item ? [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' : '—' }
function exceptionLocation(item: CarrierScheduleExceptionFact) { return [item.locationCode, item.locationName].filter(Boolean).join(' · ') || '—' }
function nodeTimeText(item: CarrierScheduleNodeFact) { return item.actualAt ? `实际 ${formatTime(item.actualAt)}` : item.estimatedAt ? `预计 ${formatTime(item.estimatedAt)}` : `观测 ${formatTime(item.observedAt)}` }
function nodeSourceText(item: CarrierScheduleNodeFact) {
const operatorName = nonBlank(item.operatorName)
if (operatorName) return operatorName
if (/MANUAL|PLATFORM/i.test(item.providerCode) || item.providerCode === 'FIRSTLEG') return '操作人未记录'
return [item.providerCode, item.sourceReference].filter(Boolean).join(' · ') || '—'
}
function exceptionText(value: CarrierScheduleExceptionFact['exceptionCode']) { return ({ CUSTOMS_HOLD: '海关扣留', TERMINAL_HOLD: '码头扣留', DOCUMENT_HOLD: '单证扣留', UNKNOWN_HOLD: '其他扣留' } as Record<string, string>)[value] || value }
function providerErrorText(value?: string | null) {
const message = nonBlank(value)
if (!message) return '自动查询暂不可用,需要人工补充可靠结果。'
if (/(?:NoFallbackAvailableException|FeignException|ConnectException|SocketTimeoutException)/i.test(message)) {
return '轨迹供应商暂时不可用,请稍后重试或人工补录节点。'
}
return message
}
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 ({ 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 = 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 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) {
const actual = Boolean(item.actualAt)
const time = item.actualAt || item.estimatedAt || item.observedAt
const key = JSON.stringify([item.standardNode, item.actualAt || null, item.estimatedAt || null, item.locationCode || null, item.locationName || null, item.providerCode, item.sourceReference || null])
if (nodeKeys.has(key)) continue
nodeKeys.add(key)
const manual = /MANUAL|PLATFORM/i.test(item.providerCode)
const timing = actual ? `实际 ${formatTime(item.actualAt)}` : item.estimatedAt ? `预计 ${formatTime(item.estimatedAt)}` : `观测 ${formatTime(item.observedAt)}`
const expected = actual && item.estimatedAt ? ` · 原预计 ${formatTime(item.estimatedAt)}` : ''
const type = manual ? '人工补录' : item.standardNode === 'DEPARTED_WAREHOUSE' && actual ? '确认离仓' : '船运节点'
values.push({ key: `node-${key}`, time, type, title: nodeText(item.standardNode), summary: `${timing}${expected} · ${nodeLocation(item)}`, source: nodeSourceText(item), actual })
}
for (const item of exceptions.value) values.push({ key: `exception-${item.id}`, time: item.observedAt, type: '异常', title: `${exceptionText(item.exceptionCode)} · ${item.active ? '发生' : '已解除'}`, summary: `${exceptionLocation(item)} · ${item.providerStatus}`, source: `船运跟踪 · ${[item.providerCode, item.sourceReference].filter(Boolean).join(' · ')}`, actual: true })
return values.sort((left, right) => {
const time = Date.parse(right.time) - Date.parse(left.time)
if (time) return time
const actual = Number(right.actual) - Number(left.actual)
return actual || left.key.localeCompare(right.key)
})
})
function eventTagType(type: FulfillmentEvent['type']) { return ({ '仓内操作': 'info', '确认离仓': 'success', '船运节点': 'primary', '人工补录': 'warning', '异常': 'danger' } as const)[type] }
function resetManualForm(initialTime = '') {
Object.assign(manualForm, { standardNode: '', estimatedAt: initialTime, actualAt: initialTime, locationCode: '', locationName: '', reason: '' })
manualError.value = null; manualValidationError.value = ''
}
function createIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `carrier-node-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function localDateTimeNow() {
const now = new Date()
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19)
}
function openManualDialog() {
if (!props.containerBookingId) return
const now = localDateTimeNow()
resetManualForm(now); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = now; manualDialogOpen.value = true
}
async function submitManualNode() {
manualValidationError.value = ''
if (!props.containerBookingId || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return }
if (!manualForm.estimatedAt && !manualForm.actualAt) { manualValidationError.value = '预计时间和实际时间至少填写一项'; return }
if (!manualForm.reason.trim()) { manualValidationError.value = '请输入补录原因'; return }
const base = { standardNode: manualForm.standardNode, observedAt: manualObservedAt.value, locationCode: manualForm.locationCode.trim() || null, locationName: manualForm.locationName.trim() || null, reason: manualForm.reason.trim() }
const request: ContainerManualNodeRequest = manualForm.actualAt
? { ...base, actualAt: manualForm.actualAt, estimatedAt: manualForm.estimatedAt || null }
: { ...base, estimatedAt: manualForm.estimatedAt, actualAt: null }
manualSaving.value = true; manualError.value = null
try {
containerNodes.value = await appendContainerShippingNode(props.containerBookingId, manualIdempotencyKey.value, request)
manualDialogOpen.value = false; manualIdempotencyKey.value = ''; manualObservedAt.value = ''; resetManualForm()
} catch (cause) { manualError.value = getApiErrorDetails(cause, '人工节点补录失败') }
finally { manualSaving.value = false }
}
async function load() {
selected.value = null; detail.value = null; containerNodes.value = null; latestSchedule.value = null; error.value = null; queryDetailError.value = null; scheduleError.value = null
if (!props.oceanBookingId && !props.containerBookingId) return
loading.value = true
try {
if (props.containerBookingId) containerNodes.value = await getContainerShippingNodes(props.containerBookingId)
const page = await listCarrierScheduleQueries({ page: 1, size: 200, oceanBookingId: props.oceanBookingId || undefined, containerBookingId: props.containerBookingId || undefined })
const matching = props.portScheduleId ? page.records.filter((item) => item.query.resultPortScheduleId === String(props.portScheduleId)) : page.records
selected.value = matching[0] || page.records[0] || null
if (!selected.value) return
const [queryResult] = await Promise.allSettled([getCarrierScheduleQuery(selected.value.query.id)])
if (queryResult.status === 'fulfilled') detail.value = queryResult.value
else queryDetailError.value = getApiErrorDetails(queryResult.reason, '查询历史加载失败')
const resultPortScheduleId = detail.value?.query.query.resultPortScheduleId || selected.value.query.resultPortScheduleId
if (resultPortScheduleId) {
try { latestSchedule.value = (await getPortSchedule(resultPortScheduleId)).portSchedule }
catch (cause) { scheduleError.value = getApiErrorDetails(cause, '最新船期结果加载失败') }
}
} catch (cause) { error.value = getApiErrorDetails(cause, '船节点查询状态加载失败') }
finally { loading.value = false }
}
watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true })
watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualIdempotencyKey.value = ''; manualObservedAt.value = '' } })
</script>
<template>
<section class="carrier-query admin-workbench" :class="{ 'admin-density': dense }" aria-label="货柜履约轨迹">
<header><div><h2>货柜履约轨迹</h2></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" :class="{ 'density-action': dense }" data-tone="primary" size="small" type="primary" plain @click="openManualDialog"><Plus v-if="!dense" :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>
<el-alert v-if="query?.lastErrorMessage || query?.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="providerErrorText(query?.lastErrorMessage || query?.manualReason)" />
<el-alert v-if="query?.status === 'STOPPED'" type="info" :closable="false" show-icon :title="query.stopReason || '船节点查询已停止。'" />
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="标准节点与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>
<div v-if="latestSchedule" class="latest-result"><strong>{{ latestSchedule.schedule.vesselName }} / {{ latestSchedule.schedule.voyageNo }}</strong><span>{{ latestSchedule.schedule.departurePortName || latestSchedule.schedule.departurePortCode }} → {{ latestSchedule.schedule.destinationPortName || latestSchedule.schedule.destinationPortCode }}</span><span>预计开船 {{ formatTime(latestSchedule.schedule.etdAt) }} · 预计到港 {{ formatTime(latestSchedule.schedule.etaAt) }}</span></div>
<div v-if="dense && fulfillmentEvents.length" class="fulfillment-table-wrap workbench-evidence">
<table class="fulfillment-table density-table" aria-label="货柜履约轨迹">
<colgroup><col class="fulfillment-table__time" /><col class="fulfillment-table__type" /><col class="fulfillment-table__event" /><col /><col class="fulfillment-table__source" /></colgroup>
<thead><tr><th scope="col">时间</th><th scope="col">类型</th><th scope="col">事件</th><th scope="col">说明</th><th scope="col">操作人 / 来源</th></tr></thead>
<tbody><tr v-for="item in fulfillmentEvents" :key="item.key"><td><time>{{ formatTime(item.time) }}</time></td><td><el-tag size="small" :type="eventTagType(item.type)">{{ item.type }}</el-tag></td><td><strong>{{ item.title }}</strong></td><td>{{ item.summary }}</td><td>{{ item.source }}</td></tr></tbody>
</table>
</div>
<ol v-else-if="fulfillmentEvents.length" class="fulfillment-timeline"><li v-for="item in fulfillmentEvents" :key="item.key"><time>{{ formatTime(item.time) }}</time><el-tag size="small" :type="eventTagType(item.type)">{{ item.type }}</el-tag><strong>{{ item.title }}</strong><span>{{ item.summary }}</span><small>{{ item.source }}</small></li></ol>
<p v-else class="empty-line">暂无货柜履约记录</p>
<p v-if="!query" class="tracking-prompt">当前未开启跟踪任务。</p>
<details v-if="!hideTechnicalDetails && supersededActualNodes.length" class="technical-details duplicate-node-history"><summary>历史重复实际节点({{ supersededActualNodes.length }})</summary><ol class="technical-history"><li v-for="item in supersededActualNodes" :key="item.id"><span>{{ nodeTimeText(item) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></details>
<details v-if="!hideTechnicalDetails && query" class="technical-details"><summary>查询任务摘要、异常与变更历史</summary><dl class="query-facts" :class="{ 'density-fields': dense }"><div><dt>查询任务</dt><dd>{{ query.queryNo || '—' }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div><div><dt>任务状态</dt><dd>{{ statusText }}</dd></div><div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>最新实际节点</dt><dd>{{ nodeText(query.latestActualNode) }}</dd></div><div><dt>实际发生时间</dt><dd>{{ formatTime(query.latestActualAt) }}</dd></div><div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>结果有效性</dt><dd>{{ queryView?.expired ? '结果已过期' : '结果有效' }}</dd></div><div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div></dl><h3>任务变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else class="technical-history"><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></details>
</template>
<AppDialog class="container-node-dialog admin-workbench is-dialog" :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" :title="manualDialogTitle" :width="dense ? 'min(780px, calc(100vw - 28px))' : 'min(620px, calc(100vw - 28px))'" :fullscreenable="dense" append-to-body confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
<el-alert v-if="manualError" type="error" :closable="false" show-icon title="补录未成功,已保留当前填写内容"><template #default><ApiErrorMeta :details="manualError" /></template></el-alert>
<el-alert v-if="manualValidationError" type="warning" :closable="false" show-icon :title="manualValidationError" />
<section v-if="dense" class="density-dialog-section">
<el-form class="density-form">
<div class="workbench-evidence"><table class="density-table manual-editor-table" aria-label="人工补录船节点"><tbody>
<tr><td colspan="2"><el-form-item label-position="left" label-width="90px" label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="90px" label="预计时间"><el-date-picker v-model="manualForm.estimatedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选" /></el-form-item></td><td><el-form-item label-position="left" label-width="90px" label="实际时间"><el-date-picker v-model="manualForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选,填写后推进实际节点" /></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="90px" label="地点编码"><el-input v-model="manualForm.locationCode" maxlength="32" placeholder="可选,如 USLAX" /></el-form-item></td><td><el-form-item label-position="left" label-width="90px" label="地点名称"><el-input v-model="manualForm.locationName" maxlength="128" placeholder="可选" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label-position="left" label-width="90px" label="补录原因" required><el-input v-model="manualForm.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入事实来源与补录原因" /></el-form-item></td></tr>
</tbody></table></div>
</el-form>
</section>
<el-form v-else label-position="top" class="manual-form">
<el-form-item label="标准节点" required><el-select v-model="manualForm.standardNode" placeholder="请选择标准节点" style="width:100%"><el-option v-for="node in standardNodes" :key="node" :label="nodeText(node)" :value="node" /></el-select></el-form-item>
<div class="manual-form-grid"><el-form-item label="预计时间"><el-date-picker v-model="manualForm.estimatedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选" style="width:100%" /></el-form-item><el-form-item label="实际时间"><el-date-picker v-model="manualForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="可选,填写后推进实际节点" style="width:100%" /></el-form-item></div>
<div class="manual-form-grid"><el-form-item label="地点编码"><el-input v-model="manualForm.locationCode" maxlength="32" placeholder="可选,如 USLAX" /></el-form-item><el-form-item label="地点名称"><el-input v-model="manualForm.locationName" maxlength="128" placeholder="可选" /></el-form-item></div>
<el-form-item label="补录原因" required><el-input v-model="manualForm.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入事实来源与补录原因" /></el-form-item>
</el-form>
</AppDialog>
</section>
</template>
<style scoped>
.container-node-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
.fulfillment-table-wrap { min-width:0; overflow-x:auto; }
.fulfillment-table { min-width:800px; table-layout:fixed; }
.fulfillment-table__time { width:160px; }
.fulfillment-table__type { width:110px; }
.fulfillment-table__event { width:160px; }
.fulfillment-table__source { width:20%; }
.fulfillment-table time { white-space:nowrap; font-variant-numeric:tabular-nums; }
.history-count{color:var(--workbench-muted);font-size:var(--admin-font-caption, 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:var(--admin-space-5, 12px)}.header-actions{display:flex;align-items:center;gap:var(--admin-space-3, 8px)}.carrier-query h2{margin:0;font-size:var(--admin-font-section, 15px)}.loading-state{padding:var(--admin-space-2, 6px) 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:var(--admin-space-2, 6px) var(--admin-space-6, 16px);padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:var(--admin-font-caption, 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:var(--admin-space-2, 6px) 2px;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--workbench-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--workbench-muted);font-size:var(--admin-font-caption, 12px)}.empty-line{margin:0;color:var(--workbench-muted);font-size:var(--admin-font-caption, 12px)}.technical-details{border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption, 12px)}.technical-details summary{padding:7px 2px;color:var(--workbench-primary);cursor:pointer}.query-facts:not(.density-fields){display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--admin-space-3, 8px) var(--admin-space-6, 16px);margin:4px 0 var(--admin-space-4, 10px)}.query-facts:not(.density-fields)>div{min-width:0}.query-facts:not(.density-fields) dt{color:var(--workbench-muted);font-size:var(--admin-font-caption, 11px)}.query-facts:not(.density-fields) dd{margin:2px 0 0;font-size:var(--admin-font-caption, 12px)}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:var(--admin-space-3, 8px) 0 5px;font-size:var(--admin-font-caption, 12px)}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:var(--admin-space-3, 8px);padding:5px 0;border-top:1px solid var(--workbench-border)}.technical-history small{color:var(--workbench-muted)}.manual-editor-table{table-layout:fixed}.manual-form{display:grid;gap:2px;margin-top:var(--admin-space-5, 12px)}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5, 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}}.carrier-query.admin-density .fulfillment-timeline :is(time,span,small){overflow:visible;white-space:normal;overflow-wrap:anywhere}.carrier-query.admin-density .technical-history small{overflow-wrap:anywhere}
</style>