This commit is contained in:
2026-08-19 16:45:51 +08:00
parent d1de19dc82
commit 65cd9ecefa
79 changed files with 3397 additions and 38 deletions
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/components/CarrierScheduleQueryPanel.vue'), 'utf8')
describe('CarrierScheduleQueryPanel', () => {
it('uses the existing relation filters and never creates or executes polling tasks', () => {
expect(source).toContain('listCarrierScheduleQueries({ page: 1, size: 200')
expect(source).toContain('oceanBookingId: props.oceanBookingId')
expect(source).toContain('containerBookingId: props.containerBookingId')
expect(source).not.toContain('createCarrierSchedule')
expect(source).not.toContain('attemptCarrierSchedule')
})
it('shows source, observation/update times, history, failures and expiry truthfully', () => {
expect(source).toContain('最新来源')
expect(source).toContain('观测时间')
expect(source).toContain('来源更新时间')
expect(source).toContain('任务更新时间')
expect(source).toContain('查询尝试')
expect(source).toContain('变更历史')
expect(source).toContain('query.lastErrorMessage')
expect(source).toContain("return '结果已过期'")
})
it('keeps the missing node-event contract explicit instead of fabricating data', () => {
expect(source).toContain('待接口提供独立节点事实')
expect(source).toContain('不会模拟节点或自动创建任务')
})
})
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import type { CarrierScheduleQueryDetail, CarrierScheduleQueryView, PortScheduleView } from '../types'
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null }>()
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const detailError = ref<ApiErrorDetails | null>(null)
const selected = ref<CarrierScheduleQueryView | null>(null)
const detail = ref<CarrierScheduleQueryDetail | null>(null)
const latestSchedule = ref<PortScheduleView | null>(null)
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 (queryView.value?.expired) return '结果已过期'
return ({ READY: '待查询', MANUAL_REQUIRED: '需人工补充', SUCCESS: '最近查询成功' } as Record<string, string>)[query.value.status] || query.value.status
})
const statusType = computed<'success' | 'warning' | 'danger'>(() => queryView.value?.expired || query.value?.status === 'MANUAL_REQUIRED' ? 'danger' : query.value?.status === 'SUCCESS' ? 'success' : 'warning')
const latestSource = computed(() => latestSchedule.value?.schedule.sourceName || query.value?.carrierCode || '—')
const sourceReference = computed(() => latestSchedule.value?.schedule.sourceRef || query.value?.officialUrl || '—')
const latestObservation = computed(() => latestSchedule.value?.schedule.observedAt || query.value?.sourceUpdatedAt || query.value?.lastSuccessAt || null)
const histories = computed(() => [...(detail.value?.histories || [])].reverse())
const attempts = computed(() => [...(detail.value?.attempts || [])].reverse())
function formatTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function attemptText(status: string) { return ({ SUCCESS: '成功', FAILED: '失败', MANUAL_REQUIRED: '需人工补充' } as Record<string, string>)[status] || status }
function historyText(action: string) { return ({ CREATE: '建立查询任务', ATTEMPT: '执行查询', MANUAL_RESULT: '关联人工船期结果' } as Record<string, string>)[action] || action }
function latestTimestamp(item: CarrierScheduleQueryView) { return Date.parse(item.query.updatedAt || item.query.createdAt || '') || 0 }
async function load() {
selected.value = null; detail.value = null; latestSchedule.value = null; error.value = null; detailError.value = null
if (!props.oceanBookingId && !props.containerBookingId) return
loading.value = true
try {
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].sort((left, right) => latestTimestamp(right) - latestTimestamp(left))[0] || null
if (!selected.value) return
const tasks: [Promise<CarrierScheduleQueryDetail>, Promise<{ portSchedule: PortScheduleView } | null>] = [
getCarrierScheduleQuery(selected.value.query.id),
selected.value.query.resultPortScheduleId ? getPortSchedule(selected.value.query.resultPortScheduleId) : Promise.resolve(null),
]
const [queryResult, scheduleResult] = await Promise.allSettled(tasks)
if (queryResult.status === 'fulfilled') detail.value = queryResult.value
else detailError.value = getApiErrorDetails(queryResult.reason, '查询历史加载失败')
if (scheduleResult.status === 'fulfilled') latestSchedule.value = scheduleResult.value?.portSchedule || null
else detailError.value = getApiErrorDetails(scheduleResult.reason, '最新船期结果加载失败')
} catch (cause) { error.value = getApiErrorDetails(cause, '船节点查询状态加载失败') }
finally { loading.value = false }
}
watch(() => [props.oceanBookingId, props.containerBookingId, props.portScheduleId], () => void load(), { immediate: true })
</script>
<template>
<section class="carrier-query" aria-label="国内出仓后船节点">
<header><div><h2>国内出仓后船节点</h2><p>沿用 CarrierScheduleQuery,跟踪至目的港到达;本页面不会新建轮询任务。</p></div><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></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="2" animated /></div>
<el-empty v-else-if="!query" :image-size="52" description="尚未建立关联的船节点查询任务;当前页面不会模拟节点或自动创建任务。" />
<template v-else>
<el-alert v-if="query.lastErrorMessage || query.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="query.lastErrorMessage || query.manualReason || '自动查询暂不可用,需要人工补充可靠结果。'" />
<el-alert v-if="detailError" type="warning" :closable="false" show-icon title="部分查询详情暂不可用"><template #default><ApiErrorMeta :details="detailError" /></template></el-alert>
<dl class="query-facts">
<div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ query.trackingNo || '—' }}</dd></div>
<div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div>
<div><dt>最新节点</dt><dd>{{ latestSchedule ? '船期快照已更新' : '待接口提供独立节点事实' }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div>
<div><dt>来源更新时间</dt><dd>{{ formatTime(query.sourceUpdatedAt) }}</dd></div><div><dt>任务更新时间</dt><dd>{{ formatTime(query.updatedAt) }}</dd></div>
<div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>最近尝试</dt><dd>{{ formatTime(query.lastAttemptAt) }}</dd></div>
<div><dt>结果过期时间</dt><dd>{{ formatTime(query.expiresAt) }}</dd></div><div><dt>下次允许查询</dt><dd>{{ formatTime(queryView?.nextAllowedAt) }}</dd></div>
</dl>
<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 class="history-grid">
<section><h3>查询尝试</h3><p v-if="!attempts.length" class="empty-line">暂无查询尝试</p><ol v-else><li v-for="item in attempts" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ attemptText(item.attemptStatus) }}</strong><small>{{ item.errorMessage || item.manualReason || item.operatorName || item.carrierCode }}</small></li></ol></section>
<section><h3>变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.reason || item.operatorName || '系统记录' }}</small></li></ol></section>
</div>
</template>
</section>
</template>
<style scoped>
.carrier-query{display:grid;gap:12px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:8px 0}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 18px;margin:0}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:12px}.query-facts dd{margin:4px 0 0;font-size:13px}.break-value{overflow-wrap:anywhere}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;padding:10px 12px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:13px}.history-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.history-grid section{min-width:0}.history-grid h3{margin:0 0 8px;font-size:13px}.history-grid ol{display:grid;gap:0;margin:0;padding:0;list-style:none}.history-grid li{display:grid;grid-template-columns:128px 100px minmax(0,1fr);gap:8px;padding:7px 0;border-top:1px solid var(--cc-border);font-size:12px}.history-grid small,.empty-line{color:var(--cc-muted);font-size:12px}.empty-line{margin:0}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.query-facts,.history-grid{grid-template-columns:1fr}.history-grid li{grid-template-columns:1fr}}
</style>