326 lines
34 KiB
Vue
326 lines
34 KiB
Vue
<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>
|