2026-08-18 17:40:40 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import { ArrowLeft, FileUp } from '@lucide/vue'
|
2026-08-26 12:04:50 +08:00
|
|
|
|
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
2026-08-18 17:40:40 +08:00
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
2026-08-19 16:45:51 +08:00
|
|
|
|
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
2026-08-26 13:06:00 +08:00
|
|
|
|
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
|
2026-08-26 12:04:50 +08:00
|
|
|
|
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
|
2026-08-26 12:36:01 +08:00
|
|
|
|
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
|
2026-08-18 17:40:40 +08:00
|
|
|
|
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
|
|
|
|
|
import {
|
|
|
|
|
|
cancelContainerException,
|
|
|
|
|
|
completeContainerClearance,
|
2026-08-26 13:06:00 +08:00
|
|
|
|
downloadContainerClearance,
|
2026-08-18 17:40:40 +08:00
|
|
|
|
downloadContainerDocument,
|
|
|
|
|
|
getContainer,
|
|
|
|
|
|
getContainerClearance,
|
|
|
|
|
|
getContainerOverview,
|
|
|
|
|
|
markContainerException,
|
|
|
|
|
|
uploadContainerClearance,
|
|
|
|
|
|
uploadContainerDocument,
|
|
|
|
|
|
} from '../api/platformContainerLoading'
|
2026-08-26 11:10:48 +08:00
|
|
|
|
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus } from '../types'
|
2026-08-18 17:40:40 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
|
const containerId = computed(() => String(route.params.containerId || ''))
|
|
|
|
|
|
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
2026-08-26 11:10:48 +08:00
|
|
|
|
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
2026-08-26 13:00:51 +08:00
|
|
|
|
const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list'))
|
2026-08-26 12:04:50 +08:00
|
|
|
|
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
|
|
|
|
|
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
2026-08-18 17:40:40 +08:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const detail = ref<ContainerBookingResource | null>(null)
|
|
|
|
|
|
const overview = ref<ContainerOverview | null>(null)
|
|
|
|
|
|
const error = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const overviewError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const documentOpen = ref(false)
|
2026-08-26 11:10:48 +08:00
|
|
|
|
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
|
|
|
|
|
|
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
|
2026-08-18 17:40:40 +08:00
|
|
|
|
const documentFile = ref<File | null>(null)
|
|
|
|
|
|
const documentSaving = ref(false)
|
|
|
|
|
|
const documentDownloading = ref(false)
|
|
|
|
|
|
const documentError = ref<ApiErrorDetails | null>(null)
|
2026-08-26 12:46:46 +08:00
|
|
|
|
const customsCompleting = ref(false)
|
2026-08-26 13:00:51 +08:00
|
|
|
|
const customsActionError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
|
2026-08-18 17:40:40 +08:00
|
|
|
|
const clearanceOpen = ref(false)
|
|
|
|
|
|
const clearanceDocument = ref<BusinessDocument | null>(null)
|
|
|
|
|
|
const clearanceFile = ref<File | null>(null)
|
|
|
|
|
|
const clearanceLoading = ref(false)
|
|
|
|
|
|
const clearanceSaving = ref(false)
|
|
|
|
|
|
const clearanceError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const exceptionOpen = ref(false)
|
|
|
|
|
|
const exceptionReason = ref('')
|
|
|
|
|
|
const exceptionSaving = ref(false)
|
|
|
|
|
|
const exceptionError = ref<ApiErrorDetails | null>(null)
|
2026-08-26 12:04:50 +08:00
|
|
|
|
const outboundLoading = ref(false)
|
|
|
|
|
|
const outboundConfirming = ref(false)
|
|
|
|
|
|
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
|
|
|
|
|
|
const outboundError = ref<ApiErrorDetails | null>(null)
|
2026-08-26 12:36:01 +08:00
|
|
|
|
const showPassedGates = ref(false)
|
2026-08-26 12:04:50 +08:00
|
|
|
|
const carrierRefreshKey = ref(0)
|
2026-08-18 17:40:40 +08:00
|
|
|
|
|
|
|
|
|
|
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
2026-08-26 11:10:48 +08:00
|
|
|
|
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
|
2026-08-18 17:40:40 +08:00
|
|
|
|
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
|
|
|
|
|
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
2026-08-26 12:36:01 +08:00
|
|
|
|
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
|
2026-08-26 12:46:46 +08:00
|
|
|
|
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
|
2026-08-26 12:36:01 +08:00
|
|
|
|
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
|
|
|
|
|
|
const grouped = new Map<string, AggregatedGateRow>()
|
|
|
|
|
|
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
|
|
|
|
|
|
for (const item of inbound.gate.items || []) {
|
2026-08-26 12:46:46 +08:00
|
|
|
|
if (referenceOnlyGateCodes.has(item.code)) continue
|
2026-08-26 12:36:01 +08:00
|
|
|
|
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
|
|
|
|
|
|
const existing = grouped.get(key)
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
|
|
|
|
|
|
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...grouped.values()]
|
|
|
|
|
|
})
|
|
|
|
|
|
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
|
|
|
|
|
|
const outboundGateSummary = computed(() => ({
|
|
|
|
|
|
total: outboundGateRows.value.length,
|
|
|
|
|
|
passed: outboundGateRows.value.filter((row) => row.passed).length,
|
|
|
|
|
|
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
|
|
|
|
|
|
}))
|
2026-08-26 12:04:50 +08:00
|
|
|
|
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
|
|
|
|
|
|
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
|
2026-08-26 12:36:01 +08:00
|
|
|
|
&& outboundGateSummary.value.blocked === 0)
|
2026-08-18 17:40:40 +08:00
|
|
|
|
|
2026-08-26 13:06:00 +08:00
|
|
|
|
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: document.id || fallback?.id || document.fileId,
|
|
|
|
|
|
documentType, fileId: document.fileId, fileName: document.fileName, fileHash: document.fileHash,
|
|
|
|
|
|
uploadedBy: document.uploadedBy, uploadedByName: document.uploadedByName, uploadedAt: document.uploadedAt,
|
|
|
|
|
|
version: document.version, completed: Boolean(document.completedAt),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function replaceOverviewDocument(document: ContainerOverviewDocument) {
|
|
|
|
|
|
if (!overview.value) return
|
|
|
|
|
|
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function refreshOverviewDocument(documentType: 'CUSTOMS_DECLARATION' | 'DESTINATION_CLEARANCE') {
|
|
|
|
|
|
let refreshCause: unknown
|
|
|
|
|
|
let refreshed: ContainerOverview | null = null
|
|
|
|
|
|
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
|
|
|
|
|
|
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
|
|
|
|
|
|
if (!current) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const business = documentType === 'CUSTOMS_DECLARATION'
|
|
|
|
|
|
? overview.value?.loadingPlanId && detail.value ? await getLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId) : null
|
|
|
|
|
|
: await getContainerClearance(containerId.value)
|
|
|
|
|
|
if (business) {
|
|
|
|
|
|
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
|
|
|
|
|
|
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (cause) { if (!refreshCause) refreshCause = cause }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (refreshed) overview.value = current && !refreshed.documents.some((item) => item.documentType === documentType)
|
|
|
|
|
|
? { ...refreshed, documents: [...refreshed.documents, current] }
|
|
|
|
|
|
: refreshed
|
|
|
|
|
|
else if (current) replaceOverviewDocument(current)
|
|
|
|
|
|
else if (refreshCause) throw refreshCause
|
|
|
|
|
|
return current
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 17:40:40 +08:00
|
|
|
|
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
|
|
|
|
|
|
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
if (!containerId.value) return
|
|
|
|
|
|
loading.value = true; error.value = null; overviewError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
|
|
|
|
|
detail.value = resource; overview.value = aggregate
|
2026-08-26 12:04:50 +08:00
|
|
|
|
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
2026-08-18 17:40:40 +08:00
|
|
|
|
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
|
|
|
|
|
finally { loading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-26 12:04:50 +08:00
|
|
|
|
async function loadOutboundDispatch() {
|
|
|
|
|
|
if (!containerId.value || !canViewOutboundDispatch.value) return
|
2026-08-26 12:36:01 +08:00
|
|
|
|
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
|
2026-08-26 12:04:50 +08:00
|
|
|
|
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
|
|
|
|
|
|
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
|
|
|
|
|
|
finally { outboundLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmDeparture() {
|
|
|
|
|
|
const aggregate = outboundDetail.value?.aggregate
|
|
|
|
|
|
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
|
|
|
|
|
outboundConfirming.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { type: 'warning', confirmButtonText: '确认离仓' })
|
|
|
|
|
|
} catch { outboundConfirming.value = false; return }
|
|
|
|
|
|
outboundError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
|
|
|
|
|
|
expectedVersion: aggregate.version,
|
|
|
|
|
|
remark: '货柜详情确认国内出仓',
|
|
|
|
|
|
})
|
|
|
|
|
|
carrierRefreshKey.value += 1
|
|
|
|
|
|
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
|
|
|
|
|
|
try { overview.value = await getContainerOverview(containerId.value) }
|
|
|
|
|
|
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
|
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
const details = getApiErrorDetails(cause, '确认国内出仓失败')
|
|
|
|
|
|
if (details.code === 'A0409') await loadOutboundDispatch()
|
|
|
|
|
|
outboundError.value = details
|
|
|
|
|
|
} finally { outboundConfirming.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function outboundStatusText(status?: string | null) {
|
|
|
|
|
|
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
|
|
|
|
|
|
}
|
2026-08-26 12:36:01 +08:00
|
|
|
|
function truckReferenceText(status?: string | null) {
|
|
|
|
|
|
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
|
|
|
|
|
|
}
|
2026-08-26 12:04:50 +08:00
|
|
|
|
|
2026-08-18 17:40:40 +08:00
|
|
|
|
function openDocument(type: string) {
|
2026-08-26 11:10:48 +08:00
|
|
|
|
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
|
|
|
|
|
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
|
|
|
|
|
|
if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
|
|
|
|
|
|
documentType.value = type as UploadDocumentType
|
2026-08-18 17:40:40 +08:00
|
|
|
|
documentFile.value = null; documentError.value = null; documentOpen.value = true
|
2026-08-26 11:10:48 +08:00
|
|
|
|
if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
|
|
|
|
|
|
documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
|
|
|
|
|
|
function removeDocumentFile() { documentFile.value = null }
|
|
|
|
|
|
async function saveDocument() {
|
2026-08-26 11:10:48 +08:00
|
|
|
|
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
|
2026-08-26 13:00:51 +08:00
|
|
|
|
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
|
2026-08-18 17:40:40 +08:00
|
|
|
|
try {
|
2026-08-26 11:10:48 +08:00
|
|
|
|
const expectedVersion = selectedDocument.value?.version || 0
|
|
|
|
|
|
if (documentType.value === 'CUSTOMS_DECLARATION') {
|
|
|
|
|
|
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
|
2026-08-26 13:00:51 +08:00
|
|
|
|
const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
|
|
|
|
|
|
if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息')
|
2026-08-26 13:06:00 +08:00
|
|
|
|
const optimistic = toOverviewDocument(uploaded, 'CUSTOMS_DECLARATION', selectedDocument.value)
|
|
|
|
|
|
replaceOverviewDocument(optimistic)
|
2026-08-26 13:00:51 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const refreshed = await getContainerOverview(containerId.value)
|
|
|
|
|
|
if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed
|
|
|
|
|
|
else {
|
|
|
|
|
|
overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] }
|
|
|
|
|
|
documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
2026-08-26 11:10:48 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`)
|
2026-08-26 13:00:51 +08:00
|
|
|
|
overview.value = await getContainerOverview(containerId.value)
|
2026-08-26 11:10:48 +08:00
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
|
2026-08-26 13:06:00 +08:00
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
|
|
|
|
|
|
if (details.code === 'A0409') {
|
|
|
|
|
|
if (documentType.value === 'CUSTOMS_DECLARATION') {
|
|
|
|
|
|
try { await refreshOverviewDocument('CUSTOMS_DECLARATION') } catch { /* 保留原并发错误证据 */ }
|
|
|
|
|
|
} else {
|
|
|
|
|
|
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
documentError.value = details
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
finally { documentSaving.value = false }
|
|
|
|
|
|
}
|
2026-08-26 12:46:46 +08:00
|
|
|
|
async function refreshCustomsFacts() {
|
|
|
|
|
|
overview.value = await getContainerOverview(containerId.value)
|
|
|
|
|
|
await loadOutboundDispatch()
|
|
|
|
|
|
}
|
|
|
|
|
|
async function completeCustomsDeclaration(document: ContainerOverviewDocument) {
|
|
|
|
|
|
if (!canManageCustoms.value || customsCompleting.value || document.completed) return
|
|
|
|
|
|
if (!overview.value?.loadingPlanId || !detail.value) {
|
2026-08-26 13:00:51 +08:00
|
|
|
|
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
|
2026-08-26 12:46:46 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-26 13:00:51 +08:00
|
|
|
|
customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null
|
2026-08-26 12:46:46 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' })
|
|
|
|
|
|
} catch { customsCompleting.value = false; return }
|
|
|
|
|
|
try {
|
|
|
|
|
|
await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成')
|
2026-08-26 13:00:51 +08:00
|
|
|
|
try { await refreshCustomsFacts() }
|
|
|
|
|
|
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') }
|
2026-08-26 12:46:46 +08:00
|
|
|
|
ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验')
|
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
const details = getApiErrorDetails(cause, '确认报关完成失败')
|
|
|
|
|
|
if (details.code === 'A0409') {
|
|
|
|
|
|
try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ }
|
|
|
|
|
|
}
|
2026-08-26 13:00:51 +08:00
|
|
|
|
customsActionError.value = details
|
2026-08-26 12:46:46 +08:00
|
|
|
|
} finally { customsCompleting.value = false }
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
|
|
|
|
|
|
async function downloadDocument(item: ContainerOverviewDocument) {
|
2026-08-26 13:06:00 +08:00
|
|
|
|
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
2026-08-26 13:00:51 +08:00
|
|
|
|
if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) {
|
|
|
|
|
|
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' }
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
documentDownloading.value = true
|
2026-08-26 13:00:51 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const blob = item.documentType === 'CUSTOMS_DECLARATION'
|
|
|
|
|
|
? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId)
|
2026-08-26 13:06:00 +08:00
|
|
|
|
: item.documentType === 'DESTINATION_CLEARANCE'
|
|
|
|
|
|
? await downloadContainerClearance(containerId.value, item.fileId)
|
|
|
|
|
|
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
|
2026-08-26 13:00:51 +08:00
|
|
|
|
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
const details = getApiErrorDetails(cause, '资料下载失败')
|
|
|
|
|
|
if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details
|
2026-08-26 13:06:00 +08:00
|
|
|
|
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
|
2026-08-26 13:00:51 +08:00
|
|
|
|
ElMessage.error(details.message)
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
finally { documentDownloading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
|
|
|
|
|
|
async function saveException() {
|
|
|
|
|
|
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
|
|
|
|
|
|
exceptionSaving.value = true; exceptionError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const action = overview.value.exception.active ? cancelContainerException : markContainerException
|
|
|
|
|
|
await action(containerId.value, overview.value.container.version, exceptionReason.value.trim())
|
|
|
|
|
|
overview.value = await getContainerOverview(containerId.value)
|
|
|
|
|
|
exceptionOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
|
|
|
|
|
|
} catch (cause) { exceptionError.value = getApiErrorDetails(cause, '货柜异常处理失败') }
|
|
|
|
|
|
finally { exceptionSaving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openClearance() {
|
|
|
|
|
|
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; clearanceLoading.value = true
|
|
|
|
|
|
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
|
|
|
|
|
|
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
|
|
|
|
|
|
finally { clearanceLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
|
|
|
|
|
|
function removeClearanceFile() { clearanceFile.value = null }
|
|
|
|
|
|
async function uploadClearance() {
|
2026-08-26 11:10:48 +08:00
|
|
|
|
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
|
2026-08-18 17:40:40 +08:00
|
|
|
|
clearanceSaving.value = true; clearanceError.value = null
|
2026-08-26 13:06:00 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
|
|
|
|
|
|
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
|
|
|
|
|
|
clearanceDocument.value = uploaded
|
|
|
|
|
|
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE')))
|
|
|
|
|
|
try {
|
|
|
|
|
|
const refreshed = await getContainerOverview(containerId.value)
|
|
|
|
|
|
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] }
|
|
|
|
|
|
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
|
|
|
|
|
clearanceFile.value = null; ElMessage.success('目的港清关资料已上传')
|
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
|
|
|
|
|
|
if (details.code === 'A0409') {
|
|
|
|
|
|
try { await refreshOverviewDocument('DESTINATION_CLEARANCE') } catch { /* 保留原并发错误证据 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
clearanceError.value = details
|
|
|
|
|
|
}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
finally { clearanceSaving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function completeClearance() {
|
|
|
|
|
|
if (!clearanceDocument.value) return
|
|
|
|
|
|
clearanceSaving.value = true; clearanceError.value = null
|
2026-08-26 13:06:00 +08:00
|
|
|
|
try { const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); if (!completed) throw new Error('确认清关完成但未返回附件信息'); clearanceDocument.value = completed; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') }
|
2026-08-18 17:40:40 +08:00
|
|
|
|
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
|
|
|
|
|
|
finally { clearanceSaving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => { await load(); if (route.query.action === 'documents') openDocument('DELIVERY_ORDER'); if (route.query.action === 'exception' && canEdit.value) openException() })
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<AdminPage class="container-detail-page">
|
|
|
|
|
|
<section class="container-overview surface-panel">
|
|
|
|
|
|
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
|
|
|
|
|
|
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待录入' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
|
|
|
|
|
<template v-else-if="detail">
|
2026-08-20 18:02:52 +08:00
|
|
|
|
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
2026-08-26 13:00:51 +08:00
|
|
|
|
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-download-customs="canDownloadCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
|
|
|
|
|
|
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
|
|
|
|
|
<LoadErrorState v-if="customsActionError" compact title="国内出口报关附件操作失败" :message="customsActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsActionError" /></template></LoadErrorState>
|
2026-08-26 12:04:50 +08:00
|
|
|
|
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
|
2026-08-26 12:36:01 +08:00
|
|
|
|
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
2026-08-26 12:04:50 +08:00
|
|
|
|
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
|
|
|
|
|
|
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
|
|
|
|
|
|
<template v-else-if="outboundDetail">
|
2026-08-26 12:36:01 +08:00
|
|
|
|
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
|
|
|
|
|
|
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
|
|
|
|
|
|
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。</p>
|
2026-08-26 12:04:50 +08:00
|
|
|
|
<div class="gate-list">
|
2026-08-26 12:36:01 +08:00
|
|
|
|
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
|
|
|
|
|
|
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
|
|
|
|
|
|
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
|
2026-08-26 12:04:50 +08:00
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</section>
|
2026-08-26 13:58:39 +08:00
|
|
|
|
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
2026-08-26 13:06:00 +08:00
|
|
|
|
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
2026-08-26 13:06:00 +08:00
|
|
|
|
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
2026-08-18 17:40:40 +08:00
|
|
|
|
</AdminPage>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-26 12:36:01 +08:00
|
|
|
|
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:12px 16px; }.detail-fields h2 { margin:0 0 10px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 16px}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding:12px 16px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
2026-08-18 17:40:40 +08:00
|
|
|
|
</style>
|