734 lines
73 KiB
Vue
734 lines
73 KiB
Vue
<script setup lang="ts">
|
||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||
import '@/styles/adminDensity.css'
|
||
import '@/styles/adminWorkbench.css'
|
||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||
import axios from 'axios'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { ApiResponseError, 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'
|
||
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
|
||
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
|
||
import {
|
||
getContainerOutboundDispatch,
|
||
type ContainerOutboundDispatchDetail,
|
||
} from '@/modules/outbound-dispatch'
|
||
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||
import { containerDialogTitle } from '../containerDialogTitle'
|
||
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
|
||
import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups'
|
||
import { containerChannelLabel } from '../containerChannelLabel'
|
||
import {
|
||
cancelContainerException,
|
||
completeContainerClearance,
|
||
downloadContainerClearance,
|
||
downloadContainerDocument,
|
||
getContainer,
|
||
getContainerClearance,
|
||
getContainerOverview,
|
||
markContainerException,
|
||
uploadContainerClearance,
|
||
uploadContainerDocument,
|
||
updateContainerPickupCode,
|
||
} from '../api/platformContainerLoading'
|
||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
|
||
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||
import { projectWarehouseArrival } from '../warehouseArrival'
|
||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||
|
||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const access = useAccessPreviewStore()
|
||
const containerId = computed(() => String(route.params.containerId || ''))
|
||
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
|
||
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
|
||
const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview')
|
||
const canViewFirstLegFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||
const canViewLastLegFees = computed(() => access.can('platform:finance:customer-arap:list'))
|
||
const firstLegFeesOpened = ref(false)
|
||
const lastLegFeesOpened = ref(false)
|
||
const feeCustomers = computed(() => {
|
||
const customers = new Map<string, string | null>()
|
||
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||
})
|
||
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
|
||
accountId: item.accountId, customerName: item.customerName, orderId: item.orderId, orderNo: item.orderNo,
|
||
inboundId: item.inboundId, inboundCode: item.inboundCode,
|
||
})))
|
||
const firstLegFeesEmptyReason = computed(() => {
|
||
if (!overview.value?.loadingPlanId) return '当前货柜尚未关联装柜计划,暂无头程计费归属。'
|
||
if (!detail.value?.warehouseId) return '当前货柜尚未关联装柜仓库,无法加载头程计费归属。'
|
||
if (detail.value.planStatus === 'COMPLETED' && !canViewLoadingList.value) return '当前账号无权读取已确认装柜清单,无法查看头程计费。'
|
||
if (detail.value.planStatus !== 'COMPLETED' && !canViewPreload.value) return '当前账号无权读取预装计划,无法查看头程计费。'
|
||
return '当前柜次暂无可用费用归属明细。'
|
||
})
|
||
async function rememberWorkbenchTab(tabName: string | number) {
|
||
if ((tabName === 'first-leg-fees' && !canViewFirstLegFees.value) || (tabName === 'last-leg-fees' && !canViewLastLegFees.value)) {
|
||
activeTab.value = 'overview'
|
||
return
|
||
}
|
||
if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true
|
||
if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true
|
||
const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')
|
||
if (needsLoadingDetail && detail.value && overview.value) {
|
||
await loadLoadingDetail(detail.value, overview.value)
|
||
}
|
||
}
|
||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||
const arrivalTime = ref<ContainerArrivalTime | null>(null)
|
||
const expectedArrivalText = computed(() => {
|
||
const arrival = arrivalTime.value
|
||
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
|
||
: projected.date ? `${projected.date}${projected.isDefault ? '(默认推算)' : ''}` : '—'
|
||
})
|
||
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
||
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
|
||
const canManageDestinationDocuments = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:document:edit') && hasAction('MANAGE_DESTINATION_DOCUMENTS'))
|
||
const deliveryOrderBlockedReason = computed(() => {
|
||
if (isSeaRailChannel(detail.value?.channelCode)) return '海铁渠道不适用 DO 资料'
|
||
if (detail.value?.planStatus !== 'COMPLETED' || !detail.value?.outboundConfirmedAt) return '货柜进入海外履约后才可上传'
|
||
if (detail.value?.actualEmptyReportedAt) return '货柜已还柜,不可再上传'
|
||
return null
|
||
})
|
||
const canRecordEmptyReturn = computed(() => isOverseas.value
|
||
&& access.can('platform:firstleg:terminal-drayage:empty-return:confirm')
|
||
&& hasAction('RECORD_EMPTY_RETURN')
|
||
&& Boolean(workbenchDetail.value?.terminalDrayage?.allowedActions.includes('RECORD_EMPTY_RETURN')))
|
||
const canReadDirect = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:list'))
|
||
const terminalOpen = ref(false), terminalLoading = ref(false), terminalSaving = ref(false)
|
||
const terminal = ref<TerminalDrayageView | null>(null), terminalError = ref<ApiErrorDetails | null>(null), terminalSaveError = ref<ApiErrorDetails | null>(null)
|
||
const terminalForm = reactive({ supplierId: '', pickupAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||
const terminalBaseline = ref(JSON.stringify(terminalForm))
|
||
const terminalSuppliers = ref<Supplier[]>([]), supplierLoading = ref(false), supplierError = ref<ApiErrorDetails | null>(null)
|
||
type TerminalRequest = { kind: 'fleet'; body: Parameters<typeof selectTerminalDrayageFleet>[1]; key: string } | { kind: 'appointment'; body: Parameters<typeof scheduleTerminalDrayagePickup>[1]; key: string } | { kind: 'actual'; body: Parameters<typeof recordTerminalPickup>[1]; key: string }
|
||
const terminalPending = ref<TerminalRequest | null>(null)
|
||
const canSelectFleet = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:fleet:edit') && terminal.value?.allowedActions.includes('SELECT_FLEET'))
|
||
const canSchedulePickup = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit') && terminal.value?.allowedActions.includes('SCHEDULE_PICKUP'))
|
||
const canRecordPickup = computed(() => canReadDirect.value && access.can('platform:firstleg:port-schedule-query:manual') && !!terminal.value && !terminal.value.actualPickupAt && !terminal.value.actualEmptyReturnAt)
|
||
async function loadTerminal() {
|
||
if (!canReadDirect.value) return
|
||
terminalLoading.value = true; terminalError.value = null
|
||
try { terminal.value = await getTerminalDrayage(containerId.value) }
|
||
catch (cause) { terminalError.value = getApiErrorDetails(cause, '提柜资料读取失败') }
|
||
finally { terminalLoading.value = false }
|
||
}
|
||
let supplierRequest = 0
|
||
async function searchTerminalSuppliers(keyword = '') {
|
||
if (!access.can('platform:supplier:list')) { supplierError.value = { message: '当前没有供应商读取权限' }; return }
|
||
const request = ++supplierRequest; supplierLoading.value = true; supplierError.value = null
|
||
try { const result = await listSuppliers({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword }); if (request === supplierRequest) terminalSuppliers.value = result.records }
|
||
catch (cause) { if (request === supplierRequest) supplierError.value = getApiErrorDetails(cause, '海外车队读取失败') }
|
||
finally { if (request === supplierRequest) supplierLoading.value = false }
|
||
}
|
||
async function openTerminal() {
|
||
if (!canReadDirect.value || !await leaveDirect()) return
|
||
terminalOpen.value = true; terminalSaveError.value = null; await loadTerminal()
|
||
if (!terminal.value || terminalError.value) return
|
||
Object.assign(terminalForm, { supplierId: terminal.value.fleetSupplierId || '', pickupAt: terminal.value.pickupAppointmentAt || '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||
terminalBaseline.value = JSON.stringify(terminalForm)
|
||
}
|
||
function isDefiniteRejection(cause: unknown) {
|
||
const status = cause instanceof ApiResponseError ? cause.status : axios.isAxiosError(cause) ? cause.response?.status : undefined
|
||
return [400, 403, 404, 409, 422].includes(status || 0) || /^(HTTP_|A0?)?(400|403|404|409|422)$/.test(getApiErrorDetails(cause).code || '')
|
||
}
|
||
async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
|
||
if (terminalLoading.value || terminalSaving.value || terminalError.value || !terminal.value || (kind === 'fleet' ? !canSelectFleet.value : kind === 'appointment' ? !canSchedulePickup.value : !canRecordPickup.value)) return
|
||
if (terminalPending.value && terminalPending.value.kind !== kind) return
|
||
if (!terminalPending.value) {
|
||
const key = crypto.randomUUID(), expectedVersion = terminal.value.version
|
||
if (kind === 'fleet') {
|
||
const supplier = terminalSuppliers.value.find(row => row.id === terminalForm.supplierId)
|
||
if (!supplier || supplierLoading.value || supplierError.value) { ElMessage.warning('请选择查询结果中的有效海外车队'); return }
|
||
terminalPending.value = { kind, key, body: { expectedVersion, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name, reason: terminalForm.reason.trim() || undefined } }
|
||
} else if (kind === 'appointment') {
|
||
if (!terminalForm.pickupAt) { ElMessage.warning('请填写明确的当地提柜预约时间'); return }
|
||
terminalPending.value = { kind, key, body: { expectedVersion, pickupAppointmentAt: terminalForm.pickupAt, reason: terminalForm.reason.trim() || undefined } }
|
||
} else {
|
||
if (!terminalForm.actualAt || !terminalForm.reason.trim()) { ElMessage.warning('请填写真实提柜当地时间和登记依据'); return }
|
||
terminalPending.value = { kind, key, body: { standardNode: 'PICKED_UP', observedAt: localDateTimeValue(), estimatedAt: null, actualAt: terminalForm.actualAt, locationCode: terminalForm.locationCode.trim() || null, locationName: terminalForm.locationName.trim() || null, reason: terminalForm.reason.trim() } }
|
||
}
|
||
}
|
||
terminalSaving.value = true; terminalSaveError.value = null
|
||
try {
|
||
const request = terminalPending.value
|
||
if (request.kind === 'fleet') await selectTerminalDrayageFleet(containerId.value, request.body, request.key)
|
||
else if (request.kind === 'appointment') await scheduleTerminalDrayagePickup(containerId.value, request.body, request.key)
|
||
else await recordTerminalPickup(containerId.value, request.body, request.key)
|
||
terminalPending.value = null; terminalBaseline.value = JSON.stringify(terminalForm)
|
||
ElMessage.success(kind === 'fleet' ? '码头车队已保存' : kind === 'appointment' ? '提柜预约已保存' : '实际提柜已登记'); await Promise.all([loadTerminal(), load()])
|
||
} catch (cause) {
|
||
terminalSaveError.value = getApiErrorDetails(cause, '操作结果待确认,请重试本次提交')
|
||
if (isDefiniteRejection(cause)) { terminalPending.value = null; await loadTerminal() }
|
||
} finally { terminalSaving.value = false }
|
||
}
|
||
async function leaveTerminal() {
|
||
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
|
||
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
|
||
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', containerDialogTitle('未保存的安排', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||
}
|
||
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
|
||
onBeforeRouteLeave(leaveTerminal)
|
||
const canWriteDirect = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:direct-unload:confirm'))
|
||
const directOpen = ref(false), directLoading = ref(false), directSaving = ref(false)
|
||
const directRows = ref<DirectDelivery[]>([]), directSelected = ref<DirectDelivery | null>(null)
|
||
const directError = ref<ApiErrorDetails | null>(null), directSaveError = ref<ApiErrorDetails | null>(null)
|
||
const directForm = reactive({ actualAt: '', timezone: '', receivedBy: '', evidenceNo: '', remark: '' })
|
||
const directBaseline = ref(JSON.stringify(directForm))
|
||
const directPending = ref<{ containerId: string; requirementKey: string; body: DirectDeliveryCommand; key: string } | null>(null)
|
||
async function leaveDirect() {
|
||
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
|
||
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
|
||
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||
}
|
||
onBeforeRouteLeave(leaveDirect)
|
||
onBeforeRouteUpdate(async (to, from) => {
|
||
if (to.params.containerId === from.params.containerId) return true
|
||
if (!await leaveDirect() || !await leaveTerminal() || !await leaveEmptyReturn()) return false
|
||
terminalOpen.value = false; terminal.value = null; emptyReturnOpen.value = false
|
||
directOpen.value = false; directSelected.value = null; directRows.value = []
|
||
directBaseline.value = JSON.stringify(directForm); return true
|
||
})
|
||
async function closeDirect(done: () => void) { if (await leaveDirect()) { directBaseline.value = JSON.stringify(directForm); done() } }
|
||
async function loadDirect() {
|
||
if (!canReadDirect.value) return
|
||
directLoading.value = true; directError.value = null
|
||
try {
|
||
directRows.value = await getDirectDeliveries(containerId.value)
|
||
if (directSelected.value) directSelected.value = directRows.value.find(row => row.requirementKey === directSelected.value?.requirementKey) || null
|
||
} catch (cause) { directError.value = getApiErrorDetails(cause, '直送卸货清单读取失败') }
|
||
finally { directLoading.value = false }
|
||
}
|
||
async function openDirect() { if (!canReadDirect.value || directSaving.value) return; directOpen.value = true; await loadDirect() }
|
||
async function selectDirect(row: DirectDelivery) {
|
||
if (!await leaveDirect()) return
|
||
directSelected.value = row; directSaveError.value = null
|
||
Object.assign(directForm, { actualAt: row.actualAt || '', timezone: row.timezone || '', receivedBy: row.receivedBy || '', evidenceNo: row.evidenceNo || '', remark: row.remark || '' })
|
||
directBaseline.value = JSON.stringify(directForm)
|
||
}
|
||
async function saveDirect() {
|
||
if (!canWriteDirect.value || directSaving.value || directLoading.value || directError.value || !directSelected.value || (directSelected.value.actualAt && !directPending.value)) return
|
||
if (!directPending.value) {
|
||
if (!directForm.actualAt || !directForm.timezone.trim() || !directForm.receivedBy.trim() || !directForm.evidenceNo.trim()) { ElMessage.warning('请填写实际当地时间、地点时区、签收人和真实凭据号'); return }
|
||
try { new Intl.DateTimeFormat('en-US', { timeZone: directForm.timezone.trim() }) } catch { ElMessage.warning('请填写有效的 IANA 地点时区'); return }
|
||
directPending.value = { containerId: containerId.value, requirementKey: directSelected.value.requirementKey, key: crypto.randomUUID(), body: { expectedVersion: directSelected.value.version, actualAt: directForm.actualAt.length === 16 ? `${directForm.actualAt}:00` : directForm.actualAt, timezone: directForm.timezone.trim(), receivedBy: directForm.receivedBy.trim(), evidenceNo: directForm.evidenceNo.trim(), remark: directForm.remark.trim() || null } }
|
||
}
|
||
directSaving.value = true; directSaveError.value = null
|
||
try {
|
||
const request = directPending.value
|
||
await recordDirectDelivery(request.containerId, request.requirementKey, request.body, request.key)
|
||
directPending.value = null; directBaseline.value = JSON.stringify(directForm)
|
||
ElMessage.success('本票直送卸货已登记'); await Promise.all([loadDirect(), load()])
|
||
} catch (cause) {
|
||
directSaveError.value = getApiErrorDetails(cause, '卸货登记结果待确认,请重试本次登记')
|
||
const status = cause instanceof ApiResponseError ? cause.status : axios.isAxiosError(cause) ? cause.response?.status : undefined
|
||
if ([400, 403, 404, 409, 422].includes(status || 0) || /^(HTTP_|A0?)?(400|403|404|409|422)$/.test(directSaveError.value.code || '')) { directPending.value = null; await loadDirect() }
|
||
} finally { directSaving.value = false }
|
||
}
|
||
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
|
||
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||
const canViewCurrentLoadingList = computed(() => {
|
||
if (!isDomestic.value || !detail.value) return false
|
||
if (detail.value.planStatus === 'COMPLETED') return canViewLoadingList.value
|
||
return ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(detail.value.planStatus) && canViewPreload.value
|
||
})
|
||
const canLinkOceanBooking = computed(() => Boolean(canEdit.value
|
||
&& detail.value?.sourceType === 'SIMULATION'
|
||
&& !detail.value.oceanBookingId
|
||
&& ['WAIT_LOADING', 'EDITING'].includes(detail.value.planStatus)))
|
||
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)
|
||
type UploadDocumentType = 'DELIVERY_ORDER'
|
||
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
|
||
const documentFile = ref<File | null>(null)
|
||
const documentUploadKey = ref(0)
|
||
const documentSaving = ref(false)
|
||
const documentDownloading = ref(false)
|
||
const documentError = ref<ApiErrorDetails | null>(null)
|
||
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
|
||
const pickupCodeSaving = ref(false)
|
||
const clearanceOpen = ref(false)
|
||
const clearanceDocument = ref<BusinessDocument | null>(null)
|
||
const clearanceFile = ref<File | null>(null)
|
||
const clearanceUploadKey = ref(0)
|
||
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)
|
||
const outboundLoading = ref(false)
|
||
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
|
||
const outboundError = ref<ApiErrorDetails | null>(null)
|
||
const carrierRefreshKey = ref(0)
|
||
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
|
||
const emptyReturnOpen = ref(false)
|
||
const emptyReturnAt = ref('')
|
||
const emptyReturnReason = ref('')
|
||
const emptyReturnSaving = ref(false)
|
||
const emptyReturnError = ref<ApiErrorDetails | null>(null)
|
||
const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEmptyReturn>[1]; key: string } | null>(null)
|
||
async function leaveEmptyReturn() {
|
||
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
|
||
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
|
||
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', containerDialogTitle('未保存的登记', detail.value), { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||
}
|
||
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
|
||
onBeforeRouteLeave(leaveEmptyReturn)
|
||
const oceanBookingLinkOpen = ref(false)
|
||
|
||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||
const documentTitle = computed(() => 'DO 资料')
|
||
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
||
const overseasStageLabels: Record<string, string> = {
|
||
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
|
||
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
|
||
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
|
||
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
|
||
}
|
||
const overseasStageText = computed(() => {
|
||
const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus
|
||
|| workbenchDetail.value?.terminalDrayage?.status
|
||
|| workbenchDetail.value?.workbenchStage
|
||
return overseasStageLabels[status || ''] || '海外履约中'
|
||
})
|
||
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
|
||
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
|
||
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
|
||
const overseasDestinationText = computed(() => {
|
||
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
|
||
.filter(Boolean).join(' / ')
|
||
if (bookingDestination) return bookingDestination
|
||
const destinations = new Set((loadingDetail.value?.items || [])
|
||
.map((item) => String(item.destinationWarehouse || '').trim()).filter(Boolean))
|
||
return [...destinations].join('、') || '—'
|
||
})
|
||
const plannedChannelName = computed(() => getSmartLoadingChannelGroup(detail.value?.channelGroupCode)?.name || '—')
|
||
const emptyReturnWarningText = computed(() => {
|
||
const warning = workbenchDetail.value?.terminalDrayage?.emptyReturnWarning
|
||
if (!warning || warning.status === 'NOT_APPLICABLE') return '当前暂无还空预警'
|
||
if (warning.status === 'RESOLVED') return `已完成还柜${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
|
||
const overdue = warning.status === 'OVERDUE' ? (warning.overdueDays > 0 ? `已超时 ${warning.overdueDays} 天` : `已超时 ${warning.overdueHours} 小时`) : '待还柜'
|
||
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
|
||
})
|
||
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
|
||
!outboundDetail.value ? 'UNKNOWN'
|
||
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
|
||
function openOceanBookingLink() {
|
||
if (!detail.value || !canLinkOceanBooking.value) return
|
||
oceanBookingLinkOpen.value = true
|
||
}
|
||
|
||
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: '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 = 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
|
||
}
|
||
|
||
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) || '—' }
|
||
function localDateTimeValue() {
|
||
const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
|
||
return now.toISOString().slice(0, 19)
|
||
}
|
||
function openEmptyReturn() {
|
||
if (!canRecordEmptyReturn.value || emptyReturnSaving.value || emptyReturnPending.value) return
|
||
emptyReturnAt.value = ''
|
||
emptyReturnReason.value = ''
|
||
emptyReturnError.value = null
|
||
emptyReturnOpen.value = true
|
||
}
|
||
async function confirmEmptyReturn() {
|
||
const current = workbenchDetail.value?.terminalDrayage
|
||
if (!current || !canRecordEmptyReturn.value || !emptyReturnAt.value || emptyReturnSaving.value) return
|
||
emptyReturnSaving.value = true
|
||
emptyReturnError.value = null
|
||
emptyReturnPending.value ||= { body: { expectedVersion: current.version, actualAt: emptyReturnAt.value, remark: emptyReturnReason.value.trim() || undefined }, key: crypto.randomUUID() }
|
||
try {
|
||
await recordTerminalDrayageEmptyReturn(containerId.value, emptyReturnPending.value.body, emptyReturnPending.value.key)
|
||
emptyReturnPending.value = null
|
||
emptyReturnOpen.value = false
|
||
ElMessage.success('还柜已确认')
|
||
await load()
|
||
} catch (cause) {
|
||
const details = getApiErrorDetails(cause, '确认还柜失败')
|
||
if (isDefiniteRejection(cause)) { emptyReturnPending.value = null; await load() }
|
||
emptyReturnError.value = details
|
||
} finally { emptyReturnSaving.value = false }
|
||
}
|
||
|
||
async function load() {
|
||
if (!containerId.value) return
|
||
resetLoadingDetail()
|
||
loading.value = true; error.value = null; overviewError.value = null
|
||
try {
|
||
const [resource, aggregate, arrivals] = await Promise.all([
|
||
getContainer(containerId.value, props.workbench),
|
||
getContainerOverview(containerId.value),
|
||
isOverseas.value ? listContainerArrivalTimes([containerId.value]) : Promise.resolve([]),
|
||
])
|
||
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
|
||
arrivalTime.value = arrivals[0] || null
|
||
if (activeTab.value === 'loading-list' && !canViewCurrentLoadingList.value) activeTab.value = 'overview'
|
||
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
|
||
if (isOverseas.value && resource.planStatus === 'COMPLETED' && canViewLoadingList.value) {
|
||
await loadLoadingDetail(resource, aggregate)
|
||
}
|
||
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
watch(
|
||
() => !loading.value && isDomestic.value && detail.value?.planStatus === 'COMPLETED'
|
||
&& canViewOutboundDispatch.value ? containerId.value : null,
|
||
(eligibleContainerId) => {
|
||
if (eligibleContainerId) void loadOutboundDispatch()
|
||
else { outboundDetail.value = null; outboundError.value = null }
|
||
},
|
||
)
|
||
|
||
async function loadOutboundDispatch() {
|
||
if (!containerId.value || !canViewOutboundDispatch.value) return
|
||
outboundLoading.value = true; outboundError.value = null
|
||
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
|
||
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
|
||
finally { outboundLoading.value = false }
|
||
}
|
||
|
||
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
|
||
const mode = resource.planStatus === 'COMPLETED' ? 'confirmed' : 'preload'
|
||
if (!(mode === 'confirmed' ? canViewLoadingList.value : canViewPreload.value)) {
|
||
resetLoadingDetail()
|
||
return
|
||
}
|
||
await loadListDetail(mode, aggregate.loadingPlanId || '', resource.warehouseId || '')
|
||
}
|
||
async function refreshAfterCustoms() {
|
||
if (canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
|
||
}
|
||
|
||
function openDocument(type: string) {
|
||
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
|
||
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
|
||
if (deliveryOrderBlockedReason.value) { ElMessage.warning(deliveryOrderBlockedReason.value); return }
|
||
documentType.value = type as UploadDocumentType
|
||
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
|
||
}
|
||
function selectDocumentFile(file: UploadFile) {
|
||
if (documentSaving.value) return
|
||
documentFile.value = file.raw || null; documentError.value = null
|
||
if (documentFile.value) void saveDocument()
|
||
}
|
||
function removeDocumentFile() { documentFile.value = null }
|
||
async function saveDocument() {
|
||
if (documentSaving.value || !canManageDestinationDocuments.value) return
|
||
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
|
||
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
|
||
try {
|
||
const expectedVersion = selectedDocument.value?.version || 0
|
||
const targetType = documentType.value
|
||
const targetTitle = documentTitle.value
|
||
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
|
||
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
|
||
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
|
||
replaceOverviewDocument(optimistic)
|
||
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
|
||
ElMessage.success(`${targetTitle}已上传`)
|
||
try {
|
||
const refreshed = await getContainerOverview(containerId.value)
|
||
const current = refreshed.documents.find((item) => item.documentType === targetType)
|
||
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
|
||
else {
|
||
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
|
||
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
|
||
}
|
||
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
|
||
} catch (cause) {
|
||
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
|
||
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
|
||
documentError.value = details
|
||
}
|
||
finally { documentSaving.value = false }
|
||
}
|
||
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) {
|
||
if (!['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
||
documentDownloading.value = true
|
||
try {
|
||
const blob = item.documentType === 'DESTINATION_CLEARANCE'
|
||
? await downloadContainerClearance(containerId.value, item.fileId)
|
||
: await downloadContainerDocument(containerId.value, 'DELIVERY_ORDER', item.fileId)
|
||
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
||
} catch (cause) {
|
||
const details = getApiErrorDetails(cause, '资料下载失败')
|
||
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
|
||
ElMessage.error(details.message)
|
||
}
|
||
finally { documentDownloading.value = false }
|
||
}
|
||
async function savePickupCode(pickupCode: string) {
|
||
if (!overview.value || !canManageDestinationDocuments.value || !/^[0-9]{1,64}$/.test(pickupCode)) return
|
||
pickupCodeSaving.value = true; documentRefreshWarning.value = null
|
||
try {
|
||
const refreshed = await updateContainerPickupCode(containerId.value, pickupCode, overview.value.container.version)
|
||
overview.value = refreshed
|
||
if (detail.value) detail.value = { ...detail.value, pickupCode: refreshed.container.pickupCode, pickupCodeUpdatedBy: refreshed.container.pickupCodeUpdatedBy, pickupCodeUpdatedByName: refreshed.container.pickupCodeUpdatedByName, pickupCodeUpdatedAt: refreshed.container.pickupCodeUpdatedAt, version: refreshed.container.version }
|
||
ElMessage.success('PU 码已保存')
|
||
} catch (cause) {
|
||
const details = getApiErrorDetails(cause, 'PU 码保存失败')
|
||
documentRefreshWarning.value = details
|
||
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留并发错误证据 */ }
|
||
} finally { pickupCodeSaving.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() {
|
||
clearanceUploadKey.value += 1
|
||
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.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) {
|
||
if (clearanceSaving.value || clearanceLoading.value) return
|
||
clearanceFile.value = file.raw || null; clearanceError.value = null
|
||
if (clearanceFile.value) void uploadClearance()
|
||
}
|
||
function removeClearanceFile() { clearanceFile.value = null }
|
||
async function uploadClearance() {
|
||
if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return
|
||
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
|
||
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
|
||
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')))
|
||
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
|
||
ElMessage.success('目的港清关资料已上传')
|
||
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, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
|
||
} catch (cause) {
|
||
const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
|
||
if (details.code === 'A0409') {
|
||
try { await refreshOverviewDocument('DESTINATION_CLEARANCE') } catch { /* 保留原并发错误证据 */ }
|
||
}
|
||
clearanceError.value = details
|
||
}
|
||
finally { clearanceSaving.value = false }
|
||
}
|
||
async function completeClearance() {
|
||
if (!clearanceDocument.value) return
|
||
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
|
||
try {
|
||
const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成')
|
||
if (!completed) throw new Error('确认清关完成但未返回附件信息')
|
||
clearanceDocument.value = completed
|
||
const optimistic = toOverviewDocument(completed, 'DESTINATION_CLEARANCE')
|
||
replaceOverviewDocument(optimistic)
|
||
clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null
|
||
ElMessage.success('已完成清关')
|
||
try {
|
||
const refreshed = await getContainerOverview(containerId.value)
|
||
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, optimistic] }
|
||
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关已完成,但最新资料刷新失败;当前已保留完成事实') }
|
||
}
|
||
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
|
||
finally { clearanceSaving.value = false }
|
||
}
|
||
|
||
function goBack() { void router.push(appointmentReturnTarget(route.query) || { name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
|
||
|
||
onMounted(async () => {
|
||
await load()
|
||
if (!isOverseas.value) return
|
||
if (route.query.action === 'documents') openDocument('DELIVERY_ORDER')
|
||
if (route.query.action === 'pickup') await openTerminal()
|
||
if (route.query.action === 'direct-unload') {
|
||
await openDirect()
|
||
const selected = directRows.value.find(row => row.requirementKey === route.query.requirementKey)
|
||
if (selected) await selectDirect(selected)
|
||
}
|
||
if (route.query.action === 'empty-return') {
|
||
if (canRecordEmptyReturn.value) openEmptyReturn()
|
||
else ElMessage.warning('当前还空资格已变化,请核对卸货事实和当前操作权限。')
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage spacing="compact" class="container-detail-page admin-density admin-workbench">
|
||
<section class="container-overview workbench-panel is-flush">
|
||
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />{{ appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表' }}</el-button><div><h1>{{ title }}</h1>
|
||
<!-- <p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p> -->
|
||
</div><StatusBadge v-if="detail" :status="primaryStatusText" /></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>{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}</span><strong>{{ isDomestic ? (detail.planNo || '—') : primaryStatusText }}</strong></div><div><span>{{ isDomestic ? '柜型' : '起运仓' }}</span><strong>{{ isDomestic ? (detail.containerType || '—') : (detail.warehouseName || '—') }}</strong></div><div><span>{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}</span><strong :title="isDomestic ? detail.warehouseName || '—' : overseasDestinationText">{{ isDomestic ? (detail.warehouseName || '—') : overseasDestinationText }}</strong></div></div>
|
||
<div v-if="isOverseas && detail" class="overseas-stage-strip" :class="{ 'is-overdue': workbenchDetail?.terminalDrayage?.emptyReturnWarning.status === 'OVERDUE' }"><div><span>当前海外履约状态</span><strong>{{ primaryStatusText }}</strong></div><p>{{ emptyReturnWarningText }}</p></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">
|
||
<el-tabs v-model="activeTab" class="density-element-tabs" @tab-change="rememberWorkbenchTab">
|
||
<el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📋</span><span>{{ isDomestic ? '送柜报关' : '清关提柜' }}</span></span></template></el-tab-pane>
|
||
<el-tab-pane v-if="canViewCurrentLoadingList" label="装柜清单" name="loading-list"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📦</span><span>装柜清单</span></span></template></el-tab-pane>
|
||
<el-tab-pane v-if="canViewFirstLegFees" label="国内段费用(头程)" name="first-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>国内段费用(头程)</span></span></template></el-tab-pane>
|
||
<el-tab-pane v-if="canViewLastLegFees" label="目的港及海外段费用(尾程)" name="last-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>目的港及海外段费用(尾程)</span></span></template></el-tab-pane>
|
||
</el-tabs>
|
||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-information-heading">
|
||
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog dense v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
|
||
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ containerChannelLabel(detail) }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
|
||
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||
</section>
|
||
|
||
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
|
||
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
|
||
<div class="detail-fields"><dl class="density-fields"><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>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||
</section>
|
||
|
||
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-customs-heading">
|
||
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
|
||
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" :delivery-order-blocked-reason="deliveryOrderBlockedReason" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
|
||
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
||
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||
</section>
|
||
|
||
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-receiving-heading">
|
||
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
|
||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
|
||
</section>
|
||
|
||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
|
||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
|
||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ overseasStageLabels[workbenchDetail?.statusSummary?.terminalDrayageStatus || ''] || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||
</section>
|
||
|
||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
|
||
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header>
|
||
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||
</section>
|
||
|
||
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section workbench-panel is-compact" aria-label="头程计费">
|
||
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
|
||
</section>
|
||
|
||
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section workbench-panel is-compact" aria-label="尾程计费">
|
||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" />
|
||
</section>
|
||
</template>
|
||
|
||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" 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 class="density-form density-form--flush-end" 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>
|
||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
|
||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||
|
||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><div class="workbench-evidence"><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></div></section>
|
||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||
<section v-if="directSelected" class="density-dialog-section"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
|
||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||
</AppDialog>
|
||
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
|
||
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||
<template v-if="terminal">
|
||
<section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||
|
||
|
||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||
</template>
|
||
<template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button @click="close">取消</el-button><template v-if="terminal"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button><el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button></template></div></template></AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.container-detail-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
|
||
.container-workflow-table { table-layout: fixed; }
|
||
.direct-remark { white-space: pre-wrap; }
|
||
.loading-list-heading { margin-bottom: var(--admin-space-5); }
|
||
.loading-list-heading h2 { margin: 0; font-size: var(--admin-font-title); }
|
||
.loading-list-heading p { margin: var(--admin-space-2) 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }
|
||
.loading-list-heading ~ .container-customs, .loading-list-heading ~ .loading-detail-content { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--workbench-border); }
|
||
.workbench-tab-label { display:inline-flex; align-items:center; justify-content:center; gap:var(--admin-space-3); white-space:nowrap; }
|
||
.container-detail-page { display:grid; min-width:0; gap:var(--admin-space-5); }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-4) var(--admin-space-5); }.container-overview__header h1 { margin:0; font-size:var(--admin-font-title); }.container-overview__header p { margin:2px 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption); }.container-overview__summary { border-top:1px solid var(--workbench-border); display:flex; flex-wrap:wrap; }.container-overview__summary>div { display:flex; min-width:0; padding:var(--admin-space-3) var(--admin-space-5); border-right:1px solid var(--workbench-border); align-items:baseline; gap:var(--admin-space-2); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--workbench-muted); font-size:var(--admin-font-caption); }.container-overview__summary strong,dd { margin:0; white-space:normal; overflow-wrap:anywhere; }.detail-section {display:grid;min-width:0;gap:var(--admin-space-5);}.detail-section>h2{margin:0;font-size:var(--admin-font-section)}.detail-fields { padding:0; }.outbound-panel{padding:var(--admin-space-5) 0 0;border-top:1px solid var(--workbench-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.outbound-panel__header h2{margin:0;font-size:var(--admin-font-section)}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.outbound-panel__loading{padding:var(--admin-space-3) 0}.gate-summary{display:flex;align-items:center;gap:var(--admin-space-5);margin-top:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.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:var(--admin-space-4);border-left:1px solid var(--workbench-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-2) var(--admin-space-5);margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:var(--admin-space-2) var(--admin-space-3);min-width:0;padding:var(--admin-space-2) var(--admin-space-3);border:1px solid var(--workbench-border);border-radius:var(--admin-radius);background:var(--workbench-surface-soft)}.gate-row__actual,.gate-row small{color:var(--workbench-muted);font-size:var(--admin-font-caption);white-space:normal;overflow-wrap:anywhere}.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(--workbench-muted);font-size:var(--admin-font-caption)}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:var(--admin-space-1)}@media(max-width:1200px){.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}}
|
||
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); }
|
||
.detail-section__heading h2 { margin:0; font-size:var(--admin-font-section); }
|
||
.detail-section__heading { flex-wrap:wrap; }
|
||
.detail-section[aria-labelledby="container-information-heading"] .density-fields { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||
.detail-section[aria-labelledby="container-voyage-heading"] .density-fields { grid-template-columns:minmax(0,1fr) minmax(0,1.6fr) repeat(2,minmax(0,1fr)); }
|
||
@media(max-width:720px) {
|
||
.detail-section[aria-labelledby] .detail-fields .density-fields { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||
}
|
||
.overseas-stage-strip{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-6);padding:9px var(--admin-space-5);border-top:1px solid var(--workbench-border);background:#f5f8ff}.overseas-stage-strip>div{display:flex;align-items:baseline;gap:var(--admin-space-4)}.overseas-stage-strip span,.overseas-stage-strip p{color:var(--workbench-muted);font-size:var(--admin-font-caption)}.overseas-stage-strip strong{color:var(--workbench-primary);font-size:var(--admin-font-section)}.overseas-stage-strip p{margin:0}.overseas-stage-strip.is-overdue{background:#fff7ed}.overseas-stage-strip.is-overdue strong,.overseas-stage-strip.is-overdue p{color:#c2410c}.container-overview__header h1 { overflow-wrap:anywhere; }.workbench-tab-label { gap:var(--admin-space-2); }
|
||
</style>
|