Files
admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
T

851 lines
58 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Check, Printer, Save, Ship, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { SMART_LOADING_CHANNEL_GROUPS } from '@/modules/container-loading'
import {
createOceanBooking,
deleteOceanBookingDraft,
getOceanBooking,
listOceanBookingAgents,
releaseOceanBooking,
listWarehouseLogisticsChannels,
updateOceanBookingDraft,
updateOceanBookingActualCargo,
updateOceanBookingEstimatedArrival,
type OceanLogisticsChannel,
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
OceanBookingAgentOption,
OceanBookingCarrierCode,
OceanBookingDraftData,
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
OceanBookingPrintType,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { shouldClearBookingAgent } from '../bookingAgents'
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
{ value: 'CONSIGNEE', label: '收货人' },
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
const containerTypeOptions = CONTAINER_SPECIFICATIONS
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const workspace = useWorkspaceTabsStore()
const id = computed(() => String(route.params.bookingId || ''))
const isNew = computed(() => route.name === 'ocean-booking-create' || id.value === 'new')
const detail = ref<OceanBookingDetail | null>(null)
const loading = ref(false)
const saving = ref(false)
const actionSaving = ref(false)
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const formRef = ref<FormInstance>()
const partners = ref<PlatformPartnerResource[]>([])
const bookingAgents = ref<OceanBookingAgentOption[]>([])
const bookingAgentsLoading = ref(false)
const bookingAgentsError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<PlatformWarehouseResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const channels = ref<OceanLogisticsChannel[]>([])
const channelsLoading = ref(false)
const channelsError = ref('')
const selectedChannelName = ref('')
let channelRequestSerial = 0
let bookingAgentRequestSerial = 0
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
const channelGroups = computed(() => {
const activeCodes = new Set(channels.value.map((channel) => channel.channelCode))
return SMART_LOADING_CHANNEL_GROUPS.filter((group) => group.channelCodes.some((code) => activeCodes.has(code)))
})
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channelGroups.value.length === 0)
function blankParties(): OceanBookingParty[] {
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', contactEmail: '', taxNo: '', address: '' }))
}
function blankData(): OceanBookingDraftData {
return {
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
cargoDescription: '', marksAndNos: '', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null,
containerRequirements: [createBlankContainerRequirement()],
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const savedDraftSignature = ref('')
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit'))
const formWritable = computed(() => canSave.value)
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
const actualSaving = ref(false)
const arrivalDialogOpen = ref(false)
const arrivalSaving = ref(false)
const arrivalValue = ref<string | null>(null)
const arrivalError = ref<ApiErrorDetails | null>(null)
const canMaintainArrival = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit'))
const actualReason = ref('')
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)
const releaseDialogOpen = ref(false)
const operationSections = ref<string[]>([])
const printOpen = ref(false)
const printType = ref<OceanBookingPrintType>('SHIPPING_INSTRUCTION')
const previewSignatures = ref<OceanBookingPreviewSignatures>({})
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
}
function historyActionLabel(action: string) {
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ESTIMATED_ARRIVAL: '维护预计到港', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
return labels[action] || '资料维护'
}
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [partnerResult, warehouseResult] = await Promise.all([
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
])
partners.value = partnerResult.records
if (form.data.shippingCompanyId && !form.data.carrierCode) {
const selected = partners.value.find((partner) => String(partner.id) === String(form.data.shippingCompanyId))
form.data.carrierCode = selected?.carrierCode ?? null
}
warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
} catch (cause) {
optionsError.value = getApiErrorDetails(cause, '船公司或仓库选项加载失败').message
} finally {
optionsLoading.value = false
}
}
function draftPayload(): OceanBookingDraftData {
const customerId = form.data.customerId || null
return {
...form.data,
customerId,
customerName: customerId ? form.data.customerName : null,
channelCode: form.data.channelCode?.trim() || null,
warehouseId: form.data.warehouseId?.trim() || null,
billOfLadingNo: form.data.billOfLadingNo.trim(),
vesselName: form.data.vesselName.trim(),
voyageNo: form.data.voyageNo.trim(),
parties: form.data.parties.map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })),
}
}
function draftSignature() { return JSON.stringify(draftPayload()) }
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
const hasUnsavedActualChanges = computed(() => detail.value?.status === 'RELEASED' && (
(form.data.actualPackageCount ?? null) !== (detail.value.actualPackageCount ?? null)
|| (form.data.actualGrossWeightKg ?? null) !== (detail.value.actualGrossWeightKg ?? null)
|| (form.data.actualVolumeCbm ?? null) !== (detail.value.actualVolumeCbm ?? null)
))
const printDisabledReason = computed(() => !detail.value ? '请先保存草稿' : hasUnsavedDraftChanges.value || hasUnsavedActualChanges.value ? '请先保存修改,再打印已保存资料' : !canPrint.value ? '暂无订舱单据打印权限' : '')
const workflowSteps = computed(() => oceanBookingWorkflow(detail.value, hasUnsavedDraftChanges.value, hasUnsavedActualChanges.value, previewSignatures.value))
function onPrintPreviewed(event: { bookingId: string; type: OceanBookingPrintType }) {
if (!detail.value || event.bookingId !== detail.value.id || printDisabledReason.value) return
previewSignatures.value[event.type] = oceanBookingPrintSignature(detail.value, event.type)
}
function openPrint(type: OceanBookingPrintType) {
if (printDisabledReason.value || workflowBusy.value) return
printType.value = type
printOpen.value = true
}
function openArrivalDialog() {
if (!canMaintainArrival.value || workflowBusy.value) return
arrivalValue.value = detail.value?.estimatedArrivalAt || null
arrivalError.value = null
arrivalDialogOpen.value = true
}
async function saveEstimatedArrival() {
const current = detail.value
if (!current || !canMaintainArrival.value || workflowBusy.value) return
if (!arrivalValue.value || (current.estimatedSailingAt && arrivalValue.value < current.estimatedSailingAt)) {
arrivalError.value = { message: !arrivalValue.value ? '请选择预计到港时间' : '预计到港时间不能早于预计开船时间' }
return
}
arrivalSaving.value = true
arrivalError.value = null
try {
const saved = await updateOceanBookingEstimatedArrival(current.id, { expectedVersion: current.version, estimatedArrivalAt: arrivalValue.value })
// 单独保存预计到港不丢弃客服尚未提交的实际货量。
const pendingCargo = { actualPackageCount: form.data.actualPackageCount, actualGrossWeightKg: form.data.actualGrossWeightKg, actualVolumeCbm: form.data.actualVolumeCbm }
const pendingReason = actualReason.value
applyDetail(saved)
Object.assign(form.data, pendingCargo)
actualReason.value = pendingReason
workspace.bumpCacheEpoch('ocean-bookings')
arrivalDialogOpen.value = false
ElMessage.success('预计到港已保存')
} catch (cause) { arrivalError.value = getApiErrorDetails(cause, '预计到港保存失败') }
finally { arrivalSaving.value = false }
}
async function saveActualCargo() {
if (detail.value?.status !== 'RELEASED' || !cargoWritable.value || workflowBusy.value) return
const validationError = cargoValidationError()
if (validationError || !actualReason.value.trim()) {
actionError.value = { message: validationError || '请填写实货量补录或修正原因' }
return
}
actualSaving.value = true
actionError.value = null
try {
applyDetail(await updateOceanBookingActualCargo(detail.value.id, {
expectedVersion: detail.value.version,
actualPackageCount: form.data.actualPackageCount ?? null,
actualGrossWeightKg: form.data.actualGrossWeightKg ?? null,
actualVolumeCbm: form.data.actualVolumeCbm ?? null,
reason: actualReason.value.trim(),
}))
ElMessage.success('实际货量已保存')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actualSaving.value = false }
}
function selectWarehouse(value: unknown) {
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
form.data.channelCode = ''
selectedChannelName.value = ''
channels.value = []
channelsError.value = ''
if (warehouseId) void loadChannels(warehouseId)
}
async function loadChannels(warehouseId: string) {
const serial = ++channelRequestSerial
channelsLoading.value = true
channelsError.value = ''
try {
const result = await listWarehouseLogisticsChannels(warehouseId)
if (serial === channelRequestSerial && warehouseId === form.data.warehouseId) channels.value = result
} catch (cause) {
if (serial === channelRequestSerial) channelsError.value = getApiErrorDetails(cause, '物流渠道加载失败').message
} finally {
if (serial === channelRequestSerial) channelsLoading.value = false
}
}
function selectChannel(channelCode: string) {
const channel = SMART_LOADING_CHANNEL_GROUPS.find((item) => item.code === channelCode)
form.data.channelCode = channelCode
selectedChannelName.value = channel?.name || ''
}
function clearBookingAgent() {
form.data.bookingAgentId = null
form.data.bookingAgentName = null
}
async function loadBookingAgents(carrierCode: OceanBookingCarrierCode) {
const serial = ++bookingAgentRequestSerial
bookingAgentsLoading.value = true
bookingAgentsError.value = null
try {
const options = await listOceanBookingAgents(carrierCode)
if (serial !== bookingAgentRequestSerial || carrierCode !== form.data.carrierCode) return
bookingAgents.value = options
if (formWritable.value && shouldClearBookingAgent(form.data.bookingAgentId, options)) clearBookingAgent()
} catch (cause) {
if (serial !== bookingAgentRequestSerial) return
bookingAgents.value = []
bookingAgentsError.value = getApiErrorDetails(cause, '订舱代理候选加载失败')
} finally {
if (serial === bookingAgentRequestSerial) bookingAgentsLoading.value = false
}
}
function selectShippingCompany(partnerId: string) {
const partner = partners.value.find((item) => String(item.id) === partnerId)
form.data.shippingCompanyId = partnerId || null
form.data.shippingCompanyName = partner?.partnerName || null
form.data.carrierCode = partner?.carrierCode ?? null
bookingAgents.value = []
bookingAgentsError.value = null
bookingAgentRequestSerial += 1
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
else clearBookingAgent()
}
function selectBookingAgent(supplierId: string) {
const supplier = bookingAgents.value.find((item) => item.supplierId === supplierId)
form.data.bookingAgentId = supplier?.supplierId ?? null
form.data.bookingAgentName = supplier?.name ?? null
}
function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
const selectedCarrier = partners.value.find((partner) => String(partner.id) === String(value.shippingCompanyId || ''))
form.data = {
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '',
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
carrierCode: value.carrierCode ?? selectedCarrier?.carrierCode ?? null,
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
placeOfDelivery: value.placeOfDelivery,
remark: value.remark,
freightTerm: value.freightTerm, serviceTerm: value.serviceTerm, packageUnit: value.packageUnit,
cargoDescription: value.cargoDescription, marksAndNos: value.marksAndNos,
declaredPackageCount: value.declaredPackageCount ?? null,
declaredGrossWeightKg: value.declaredGrossWeightKg == null ? null : Number(value.declaredGrossWeightKg),
declaredVolumeCbm: value.declaredVolumeCbm == null ? null : Number(value.declaredVolumeCbm),
actualPackageCount: value.actualPackageCount ?? null,
actualGrossWeightKg: value.actualGrossWeightKg == null ? null : Number(value.actualGrossWeightKg),
actualVolumeCbm: value.actualVolumeCbm == null ? null : Number(value.actualVolumeCbm),
parties: blankParties().map((blank) => ({ ...blank, ...value.parties.find((party) => party.partyType === blank.partyType) })),
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
}
actualReason.value = ''
selectedChannelName.value = value.channelName || ''
savedDraftSignature.value = draftSignature()
channels.value = []
if (form.data.warehouseId) void loadChannels(form.data.warehouseId)
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
}
async function load() {
if (!id.value || isNew.value) return
loading.value = true
error.value = null
try { applyDetail(await getOceanBooking(id.value)) }
catch (cause) { error.value = getApiErrorDetails(cause) }
finally { loading.value = false }
}
function cargoValidationError() {
for (const kind of ['declared', 'actual'] as const) {
const count = form.data[`${kind}PackageCount`]
if (count != null && (!Number.isInteger(count) || count < 1 || count > 999999999)) return '货物件数必须为1到999999999的整数'
for (const number of [form.data[`${kind}GrossWeightKg`], form.data[`${kind}VolumeCbm`]]) {
if (number != null && (!Number.isFinite(number) || number <= 0 || number > 999999999999.999)) return '货物重量和体积必须为有效正数'
}
}
return ''
}
async function validateDraftForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false }
const cargoMessage = cargoValidationError()
if (cargoMessage) { actionError.value = { message: cargoMessage }; return false }
if (form.data.parties.some((party) => party.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(party.contactEmail))) {
actionError.value = { message: '请填写有效的关系方邮箱' }; return false
}
const requirementMessage = containerRequirementError(form.data.containerRequirements)
if (requirementMessage) { actionError.value = { message: requirementMessage }; return false }
return true
}
async function persistDraft(showSuccess: boolean) {
if (!canSave.value || !(await validateDraftForm())) return null
actionError.value = null
try {
const payload = draftPayload()
const saved = isNew.value
? await createOceanBooking({ expectedVersion: 0, data: payload })
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || '未生成订舱号'}` : '订舱草稿已保存')
if (isNew.value) {
workspace.removeByIds(['ocean-booking-create'])
applyDetail(saved)
workspace.bumpCacheEpoch('ocean-bookings')
await router.replace(`/ocean-bookings/${encodeURIComponent(saved.id)}`)
return saved
}
applyDetail(saved)
workspace.bumpCacheEpoch('ocean-bookings')
return saved
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
}
async function save() {
if (!canSave.value || workflowBusy.value) return
saving.value = true
try { await persistDraft(true) }
finally { saving.value = false }
}
async function openReleaseDialog() {
if (!canRelease.value || workflowBusy.value || !(await validateDraftForm())) return
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; return }
releaseDialogOpen.value = true
}
function releaseValidationError() {
if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'
if (!form.data.departurePortCode?.trim() && !form.data.departurePortName?.trim()) return '放舱前请填写起运港代码或名称'
if (!form.data.destinationPortCode?.trim() && !form.data.destinationPortName?.trim()) return '放舱前请填写目的港代码或名称'
const requiredFields: Array<[unknown, string]> = [
[form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
[form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
[form.data.placeOfDelivery, '交货地点'],
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
]
const missing = requiredFields.find(([value]) => !String(value || '').trim())
if (missing) {
if (missing[1] === '装柜仓库' || missing[1] === '物流渠道') operationSections.value = ['operation']
return `放舱前请填写${missing[1]}`
}
if (form.data.estimatedArrivalAt && form.data.estimatedSailingAt && form.data.estimatedArrivalAt < form.data.estimatedSailingAt) return '预计到港时间不能早于预计开船时间'
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
return containerRequirementError(form.data.containerRequirements)
}
async function submitRelease() {
if (!detail.value || workflowBusy.value) return
actionSaving.value = true
actionError.value = null
try {
if (!(await validateDraftForm())) { releaseDialogOpen.value = false; return }
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
let releaseVersion = detail.value.version
if (hasUnsavedDraftChanges.value) {
const saved = await persistDraft(false)
if (!saved) return
releaseVersion = saved.version
}
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: releaseVersion }))
releaseDialogOpen.value = false
ElMessage.success('确认放舱成功')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actionSaving.value = false }
}
function bookingDialogTitle(action: string) { const code = detail.value?.bookingNo?.trim(); return code ? `${action} · ${code}` : action }
async function removeDraft() {
if (!detail.value || !canDelete.value) return
actionError.value = null
try {
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', bookingDialogTitle('删除草稿'), { customClass: 'admin-density-confirm', confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
actionSaving.value = true
await deleteOceanBookingDraft(id.value, detail.value.version)
ElMessage.success('订舱草稿已删除')
workspace.bumpCacheEpoch('ocean-bookings')
await router.replace('/ocean-bookings')
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
function addRequirement() {
if (form.data.containerRequirements.length < 20) form.data.containerRequirements.push(createBlankContainerRequirement())
}
function removeRequirement(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
onMounted(() => { void load(); void loadOptions() })
</script>
<template>
<AdminPage as="div" class="booking-detail admin-density admin-workbench">
<OperationErrorToast :details="actionError" />
<DetailPageLayout aria-label="订舱详情">
<template #header>
<div class="detail-heading">
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'">
<template #actions>
<div class="actions booking-page-actions">
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canSave" plain :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
<el-button v-if="canMaintainArrival" plain :disabled="workflowBusy" @click="openArrivalDialog">维护预计到港</el-button>
</div>
</template>
</PageToolbar>
</div>
</template>
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<ol class="booking-workflow" aria-label="订舱作业流程">
<li v-for="(step, index) in workflowSteps" :key="step.label" :data-state="step.state" :aria-current="step.state === 'current' ? 'step' : undefined">
<span class="booking-workflow__index" aria-hidden="true"><Check v-if="step.state === 'complete'" :size="16" /><template v-else>{{ index + 1 }}</template></span>
<span class="booking-workflow__label">{{ step.label }}</span>
<span class="booking-workflow__status">{{ step.status }}</span>
</li>
</ol>
<section class="workbench-panel is-compact booking-section booking-section--core">
<div class="section-title booking-section__heading">
<h2><span class="booking-section__number" aria-hidden="true">1</span>一、核心订舱信息</h2>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<div class="booking-core">
<div class="booking-core__main">
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
<section class="booking-group" aria-label="基本信息">
<h3 class="booking-group__title"><span>基础信息</span></h3>
<div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
<h3 class="booking-group__title"><span>港口航线</span></h3>
<div class="booking-group__body booking-group__body--fields">
<el-form-item label="起运港"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /></el-form-item>
<el-form-item label="预计开船" prop="estimatedSailingAt"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" maxlength="64" /></el-form-item>
<el-form-item label="运输条款"><el-input v-model="form.data.serviceTerm" maxlength="64" /></el-form-item>
</div>
</section>
</el-form>
<section class="booking-group" aria-label="货物信息">
<h3 class="booking-group__title"><span>货物信息</span></h3>
<el-form label-position="top" class="density-form booking-group__body">
<div class="cargo-metrics" role="group" aria-label="货物预报与实际数量">
<!-- 数字组件仅挂载时写入禁用标记,且可能保留输入缓存;资格切换及实值保存成功后按权威版本重建。 -->
<div class="cargo-metric">
<span class="cargo-metric__label">件数(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-count-' + formWritable" v-model="form.data.declaredPackageCount" :disabled="!formWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="预报量" aria-label="预报件数" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-count-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
<span class="cargo-metric__unit">{{ form.data.packageUnit || '—' }}</span>
</div>
</div>
<div class="cargo-metric">
<span class="cargo-metric__label">毛重(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-weight-' + formWritable" v-model="form.data.declaredGrossWeightKg" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报毛重" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-weight-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
<span class="cargo-metric__unit">KGS</span>
</div>
</div>
<div class="cargo-metric">
<span class="cargo-metric__label">体积(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-volume-' + formWritable" v-model="form.data.declaredVolumeCbm" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报体积" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-volume-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
<span class="cargo-metric__unit">CBM</span>
</div>
</div>
</div>
<div class="booking-group__body--fields">
<el-form-item label="唛头和号码"><el-input v-model="form.data.marksAndNos" :disabled="!formWritable" maxlength="1000" /></el-form-item>
<el-form-item class="booking-field--wide" label="品名及托运要求"><el-input v-model="form.data.cargoDescription" :disabled="!formWritable" type="textarea" :rows="2" maxlength="1000" placeholder="填写实际品名及免用箱等托运要求" /></el-form-item>
</div>
<template v-if="detail?.status === 'RELEASED' && cargoWritable">
<el-form-item label="补录或修正原因" required><el-input v-model="actualReason" type="textarea" :rows="2" maxlength="1000" placeholder="请填写本次补录或修正原因" /></el-form-item>
<div class="actions"><el-button type="primary" :loading="actualSaving" :disabled="workflowBusy || !hasUnsavedActualChanges" @click="saveActualCargo">保存实际货量</el-button></div>
</template>
</el-form>
</section>
</div>
<aside class="booking-parties" aria-label="订舱关系方"><h3 class="booking-group__title"><span>订舱关系方</span></h3>
<el-form class="party-grid density-form" label-position="top" :disabled="!formWritable">
<section v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection booking-party-card">
<h3>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-form-item label="单位名称" :required="true"><el-input v-model="party.partyName" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称" /></el-form-item>
<el-form-item label="联系人" ><el-input v-model="party.contactName" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}联系人`" placeholder="联系人" /></el-form-item>
<el-form-item label="邮箱" ><el-input v-model="party.contactEmail" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}邮箱`" placeholder="邮箱" /></el-form-item>
<el-form-item label="联系电话" ><el-input v-model="party.contactPhone" :disabled="!formWritable" maxlength="64" :aria-label="`${partyTypes[index]?.label || party.partyType}联系电话`" placeholder="联系电话" /></el-form-item>
<el-form-item label="税号" ><el-input v-model="party.taxNo" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}税号`" placeholder="税号" /></el-form-item>
<el-form-item label="地址" ><el-input v-model="party.address" :disabled="!formWritable" maxlength="1000" :aria-label="`${partyTypes[index]?.label || party.partyType}地址`" placeholder="地址" type="textarea" :rows="2" /></el-form-item>
</section>
</el-form>
</aside>
</div>
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-requirements">
<section class="booking-group" aria-label="柜型需求">
<h3 class="booking-group__title"><span>柜型需求</span></h3><div class="booking-group__body">
<div class="container-table-wrap" aria-label="柜型需求">
<div class="workbench-evidence"><table class="container-table density-table">
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">数量</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
<tbody>
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
<td class="container-table__action">
<el-tooltip v-if="formWritable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
<span><el-button data-tone="danger" class="density-action" plain size="small" type="danger" :aria-label="`删除柜型需求 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeRequirement(index)">删除</el-button></span>
</el-tooltip>
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select :model-value="requirement.containerType" :disabled="!formWritable" placeholder="请选择柜型" @update:model-value="selectContainerRequirementType(requirement, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-input-number v-model="requirement.quantity" :disabled="!formWritable" :min="1" :max="99" controls-position="right" placeholder="请输入柜量" /></td>
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
</tr>
</tbody>
</table></div>
</div>
<div v-if="formWritable" class="container-actions"><el-button class="add-container density-action" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement">新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
</div>
</section>
</el-form>
<el-collapse v-model="operationSections" class="booking-operation-config">
<el-collapse-item title="作业配置" name="operation">
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
</el-form>
</el-collapse-item>
</el-collapse>
<footer class="booking-section__footer">
<el-tooltip :content="printDisabledReason || '按已保存预报货量打印托运单'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('SHIPPING_INSTRUCTION')"><Printer :size="16" />打印托运单</el-button></span></el-tooltip>
</footer>
</section>
<section class="workbench-panel is-compact booking-section booking-section--release" aria-label="放舱信息">
<div class="section-title booking-section__heading"><h2><span class="booking-section__number" aria-hidden="true">3</span>二、放舱信息</h2></div>
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item class="booking-field--wide" label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
</el-form>
<footer class="booking-section__footer">
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
<el-tooltip :content="printDisabledReason || '按已保存实际货量打印提单确认件'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('BILL_OF_LADING_CONFIRMATION')"><Printer :size="16" />打印提单确认件</el-button></span></el-tooltip>
</footer>
</section>
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
<section v-if="detail" class="workbench-panel is-compact history-panel">
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>操作</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.createdAt || '—' }}</td><td>{{ historyActionLabel(event.action) }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="5">暂无状态历史</td></tr></tbody></table></div>
</section>
</template>
</DetailPageLayout>
<AppDialog v-if="detail?.status === 'RELEASED'" v-model="arrivalDialogOpen" class="admin-density-dialog admin-workbench is-dialog" :title="bookingDialogTitle('维护预计到港')" width="560px" :confirm-loading="arrivalSaving" :confirm-disabled="!canMaintainArrival" confirm-text="保存" @confirm="saveEstimatedArrival">
<el-form class="density-form" label-position="top"><el-form-item label="预计到港" required><el-date-picker v-model="arrivalValue" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item></el-form>
<LoadErrorState v-if="arrivalError" compact title="预计到港保存失败" :message="arrivalError.message" :show-retry="false"><template #details><ApiErrorMeta :details="arrivalError" /></template></LoadErrorState>
</AppDialog>
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<div class="density-dialog-sections">
<section class="density-dialog-section"><el-descriptions class="release-summary density-descriptions" :column="2" border>
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ form.data.voyageNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计开船">{{ form.data.estimatedSailingAt || '—' }}</el-descriptions-item>
<el-descriptions-item label="预计到港">{{ form.data.estimatedArrivalAt || '—' }}</el-descriptions-item>
</el-descriptions></section>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.booking-detail {
min-width: 0;
}
.detail-heading {
display: flex;
align-items: center;
gap: var(--admin-space-5);
min-width: 0;
padding: var(--admin-space-3) 0 var(--admin-space-5);
border-bottom: 1px solid var(--workbench-border);
}
.booking-brand {
display: grid;
flex: 0 0 36px;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--admin-dialog-section-radius);
color: #fff;
background: var(--workbench-primary);
box-shadow: 0 3px 8px rgb(55 132 250 / 24%);
}
.booking-page-toolbar { min-width: 0; flex: 1; }
.booking-page-toolbar :deep(h1) { font-size: var(--admin-font-title); }
.booking-page-actions { align-items: center; }
.actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }
.booking-workflow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
list-style: none;
margin: 0 0 var(--admin-space-6);
padding: var(--admin-space-6) var(--admin-space-5);
border: 1px solid var(--workbench-border);
border-radius: var(--admin-dialog-section-radius);
background: var(--workbench-surface-tint);
}
.booking-workflow li { position: relative; display: grid; justify-items: center; align-content: start; gap: var(--admin-space-3); min-width: 0; }
.booking-workflow li:not(:last-child)::after {
position: absolute;
top: 14px;
left: calc(50% + 16px);
width: calc(100% - 32px);
height: 2px;
background: var(--workbench-border);
content: '';
}
.booking-workflow__index {
z-index: 1;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
color: var(--workbench-muted);
background: var(--workbench-surface-soft);
border: 1px solid var(--workbench-border);
box-sizing: border-box;
font-size: var(--admin-font-section);
font-weight: 600;
}
.booking-workflow__label { padding: 0 var(--admin-space-2); color: var(--workbench-text); font-size: var(--admin-font-caption); font-weight: 600; text-align: center; line-height: 1.5; }
.booking-workflow__status { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 18px; text-align: center; }
.booking-workflow li[data-state='current'] .booking-workflow__index { border-color: var(--workbench-primary); color: #fff; background: var(--workbench-primary); box-shadow: 0 0 0 4px var(--workbench-accent-soft); }
.booking-workflow li[data-state='current'] .booking-workflow__label,
.booking-workflow li[data-state='current'] .booking-workflow__status { color: var(--workbench-primary-active); }
.booking-workflow li[data-state='complete'] .booking-workflow__index { color: var(--workbench-primary-active); background: var(--workbench-accent-soft); border-color: var(--workbench-accent-border); }
.booking-workflow li[data-state='complete']::after { background: var(--workbench-primary); }
.booking-workflow li[data-state='pending'] .booking-workflow__label { color: var(--workbench-muted); font-weight: 500; }
.booking-detail.admin-workbench .booking-section {
padding: var(--admin-space-6);
margin-bottom: var(--admin-space-6);
border-radius: var(--admin-dialog-section-radius);
}
.section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.booking-section__heading { margin-bottom: var(--admin-space-6); }
.booking-section__heading h2 {
display: flex;
align-items: center;
gap: var(--admin-space-3);
margin: 0;
color: var(--workbench-text);
font-size: var(--admin-font-section);
font-weight: 700;
line-height: 24px;
}
.booking-section__number {
display: inline-grid;
flex: 0 0 24px;
place-items: center;
width: 24px;
height: 24px;
border-radius: var(--admin-action-radius);
background: var(--workbench-primary);
color: #fff;
box-shadow: 0 2px 5px rgb(55 132 250 / 20%);
font-size: var(--admin-font-caption);
}
.booking-core { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--admin-space-7); }
.booking-core__main, .booking-parties, .booking-group { min-width: 0; }
.booking-groups { display: grid; grid-template-columns: 1fr; gap: var(--admin-space-6); }
.booking-group:last-child { grid-column: 1 / -1; }
.booking-group__title {
display: flex;
align-items: center;
gap: var(--admin-space-3);
margin: 0 0 var(--admin-space-5);
color: var(--workbench-muted);
font-size: var(--admin-font-caption);
font-weight: 600;
line-height: 20px;
}
.booking-group__title::before { flex: 0 0 5px; width: 5px; height: 5px; border-radius: 50%; background: var(--workbench-accent-border); content: ''; }
.booking-group__title::after { flex: 1; height: 1px; background: var(--workbench-border); content: ''; }
.booking-group__body--fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: var(--admin-space-3); }
.booking-field--wide { grid-column: 1 / -1; }
.booking-core__main > .booking-group { margin-top: var(--admin-space-6); }
.booking-parties { padding-left: var(--admin-space-7); border-left: 1px solid var(--workbench-border); }
.party-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); align-items: start; }
.booking-party-card { display: grid; min-width: 0; gap: var(--admin-space-3); padding: var(--admin-space-4); border: 1px solid var(--workbench-border); border-radius: var(--admin-dialog-section-radius); background: var(--workbench-surface-tint); }
.booking-party-card h3 { margin: 0 0 var(--admin-space-1); padding-bottom: var(--admin-space-3); border-bottom: 1px dashed var(--workbench-border); color: var(--workbench-text); font-size: var(--admin-font-body); font-weight: 600; line-height: 20px; }
/* 网格间距负责排版,避免再叠加 Element 表单项的默认下边距。 */
.booking-detail :deep(.density-form .el-form-item) { margin-bottom: 0; }
.booking-detail :deep(.density-form .el-form-item__label) { height: auto; margin-bottom: var(--admin-space-1); padding: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
.booking-detail :deep(.density-form .el-input),
.booking-detail :deep(.density-form .el-select),
.booking-detail :deep(.density-form .el-textarea) {
--el-input-bg-color: var(--workbench-surface);
--el-fill-color-blank: var(--workbench-surface);
--el-input-text-color: var(--workbench-text);
--el-input-border-color: var(--el-border-color-darker);
--el-input-hover-border-color: var(--workbench-primary-hover);
--el-input-focus-border-color: var(--workbench-primary);
--el-border-color: var(--el-border-color-darker);
--el-border-color-hover: var(--workbench-primary-hover);
}
/* 白底和清晰文字表示可编辑;禁用控件继续使用组件原生灰底、弱文字及禁止光标。 */
.booking-detail :deep(.density-form .el-input:not(.is-disabled)),
.booking-detail :deep(.density-form .el-textarea:not(.is-disabled)),
.booking-detail :deep(.density-form .el-select__wrapper:not(.is-disabled)) {
--el-input-placeholder-color: var(--workbench-muted);
--el-text-color-placeholder: var(--workbench-muted);
}
.booking-detail :deep(.density-form .el-input__inner),
.booking-detail :deep(.density-form .el-textarea__inner),
.booking-detail :deep(.density-form .el-select__wrapper) { font-size: var(--admin-font-body); }
.booking-detail :deep(.density-form .el-input__wrapper),
.booking-detail :deep(.density-form .el-select__wrapper) { min-height: var(--admin-control-height); box-sizing: border-box; }
.cargo-metrics { display: grid; gap: var(--admin-space-3); margin-bottom: var(--admin-space-5); }
.cargo-metric { display: grid; gap: var(--admin-space-1); }
.cargo-metric__label { color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
.cargo-metric__inputs { display: grid; grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr) 64px; align-items: center; gap: var(--admin-space-2); }
.cargo-metric__separator { color: var(--workbench-muted); text-align: center; }
.cargo-metric__unit { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.cargo-metric__inputs :deep(.el-input__inner) { text-align: left; }
.booking-operation-config { margin-top: var(--admin-space-4); }
.booking-requirements { margin-top: var(--admin-space-6); }
.container-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: var(--admin-space-4); }
.container-table { width: 100%; min-width: 420px; table-layout: fixed; }
.container-table th:first-child, .container-table td:first-child { width: 80px; }
.container-table th:nth-child(2), .container-table td:nth-child(2) { width: 180px; }
.container-table th:nth-child(3), .container-table td:nth-child(3) { width: 90px; }
.container-table th:nth-child(4), .container-table td:nth-child(4) { width: 110px; }
.container-table td :deep(.el-select), .container-table td :deep(.el-input) { width: 100%; min-width: 0; }
.container-table__action { text-align: center !important; }
.container-actions { display: flex; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.add-container { margin: 0; }
.booking-section__footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px dashed var(--workbench-border); }
.field-error, .field-hint { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-top: var(--admin-space-1); color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 20px; }
.field-error { color: var(--el-color-danger); }
.history-panel h2 { margin: 0; font-size: var(--admin-font-section); }
.history-panel p { margin: var(--admin-space-1) 0 var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.booking-history-table th:nth-child(1) { width: 180px; }
.booking-history-table th:nth-child(2) { width: 160px; }
.booking-history-table th:nth-child(3) { width: 200px; }
.booking-history-table th:nth-child(4) { width: 140px; }
@media(max-width:720px) {
.booking-core, .booking-workflow, .party-grid { grid-template-columns: 1fr; }
.booking-parties { padding-left: 0; border-left: 0; }
.detail-heading { flex-wrap: wrap; }
.booking-page-toolbar { width: 100%; }
.booking-workflow li:not(:last-child)::after { display: none; }
}
@media(max-width:520px) {
.booking-group__body--fields { grid-template-columns: 1fr; }
.booking-field--wide { grid-column: auto; }
}
@media(max-width:420px) {
.actions :deep(.el-button) { min-height: 44px; }
}
</style>