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

839 lines
59 KiB
Vue
Raw Normal View History

2026-08-08 13:32:27 +08:00
<script setup lang="ts">
2026-09-14 18:34:34 +08:00
import '@/styles/adminDensity.css'
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-08-08 13:32:27 +08:00
import { computed, onMounted, reactive, ref } from 'vue'
2026-08-10 09:12:06 +08:00
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
2026-08-08 13:32:27 +08:00
import { useRoute, useRouter } from 'vue-router'
2026-10-22 11:56:54 +08:00
import { ArrowLeft, Check, Printer, Save, Ship, Trash2 } from '@lucide/vue'
2026-08-10 09:12:06 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-25 17:43:00 +08:00
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
2026-08-08 13:32:27 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-18 09:09:34 +08:00
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
2026-08-08 13:32:27 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-10 16:24:54 +08:00
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'
2026-08-17 12:31:27 +08:00
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
2026-10-20 23:34:11 +08:00
import { listPlatformTenants, useActiveCustomerOptions } from '@/modules/iam'
2026-08-12 09:25:55 +08:00
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
2026-08-19 16:45:51 +08:00
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { SMART_LOADING_CHANNEL_GROUPS } from '@/modules/container-loading'
2026-08-10 09:12:06 +08:00
import {
createOceanBooking,
2026-08-18 16:09:43 +08:00
deleteOceanBookingDraft,
2026-08-10 09:12:06 +08:00
getOceanBooking,
listOceanBookingAgents,
releaseOceanBooking,
2026-08-14 18:50:11 +08:00
listWarehouseLogisticsChannels,
2026-08-18 16:09:43 +08:00
updateOceanBookingDraft,
2026-10-22 11:56:54 +08:00
updateOceanBookingActualCargo,
2026-08-14 18:50:11 +08:00
type OceanLogisticsChannel,
2026-08-10 09:12:06 +08:00
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail,
OceanBookingAgentOption,
OceanBookingCarrierCode,
2026-08-10 09:12:06 +08:00
OceanBookingDraftData,
OceanBookingDocument,
2026-08-10 09:12:06 +08:00
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
2026-10-22 11:56:54 +08:00
OceanBookingPrintType,
2026-08-10 09:12:06 +08:00
} from '../types'
2026-08-11 18:43:56 +08:00
import { oceanBookingStatusLabel } from '../status'
2026-08-18 16:09:43 +08:00
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { shouldClearBookingAgent } from '../bookingAgents'
2026-10-22 11:56:54 +08:00
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
2026-08-10 09:12:06 +08:00
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
{ value: 'CONSIGNEE', label: '收货人' },
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
2026-08-17 12:31:27 +08:00
const containerTypeOptions = CONTAINER_SPECIFICATIONS
2026-08-14 19:02:34 +08:00
const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
]
2026-08-10 09:12:06 +08:00
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
2026-08-25 17:43:00 +08:00
const workspace = useWorkspaceTabsStore()
2026-08-10 09:12:06 +08:00
const id = computed(() => String(route.params.bookingId || ''))
2026-08-15 09:47:23 +08:00
const isNew = computed(() => route.name === 'ocean-booking-create' || id.value === 'new')
2026-08-10 09:12:06 +08:00
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>()
2026-08-11 18:06:35 +08:00
const partners = ref<PlatformPartnerResource[]>([])
const bookingAgents = ref<OceanBookingAgentOption[]>([])
const bookingAgentsLoading = ref(false)
const bookingAgentsError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<PlatformWarehouseResource[]>([])
2026-08-11 18:06:35 +08:00
const optionsLoading = ref(false)
const optionsError = ref('')
2026-08-14 18:50:11 +08:00
const channels = ref<OceanLogisticsChannel[]>([])
const channelsLoading = ref(false)
const channelsError = ref('')
const selectedChannelName = ref('')
let channelRequestSerial = 0
let bookingAgentRequestSerial = 0
2026-08-11 18:06:35 +08:00
const selectableCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
)))
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
2026-09-15 11:54:19 +08:00
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)
2026-08-10 09:12:06 +08:00
function blankParties(): OceanBookingParty[] {
2026-10-22 11:56:54 +08:00
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', contactEmail: '', taxNo: '', address: '' }))
2026-08-10 09:12:06 +08:00
}
function blankData(): OceanBookingDraftData {
return {
2026-10-22 11:56:54 +08:00
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
cargoDescription: '', marksAndNos: '', declaredPackageCount: null, declaredGrossWeightKg: null, declaredVolumeCbm: null,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
2026-09-15 10:38:03 +08:00
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
2026-08-15 09:47:23 +08:00
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null,
2026-08-18 16:09:43 +08:00
containerRequirements: [createBlankContainerRequirement()],
2026-08-10 09:12:06 +08:00
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
2026-10-20 23:34:11 +08:00
const { customers, loading: customersLoading, error: customersError, search: loadCustomers, endReached: customerEndReached, retry: retryCustomers } = useActiveCustomerOptions(() => form.data.customerId, listPlatformTenants)
const savedDraftSignature = ref('')
2026-08-18 16:09:43 +08:00
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)
2026-08-18 16:09:43 +08:00
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'))
2026-09-09 14:32:04 +08:00
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
2026-10-22 11:56:54 +08:00
const actualSaving = ref(false)
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)
2026-08-14 14:27:03 +08:00
const releaseDialogOpen = ref(false)
const printOpen = ref(false)
2026-10-22 11:56:54 +08:00
const printType = ref<OceanBookingPrintType>('SHIPPING_INSTRUCTION')
const previewSignatures = ref<OceanBookingPreviewSignatures>({})
2026-08-10 09:12:06 +08:00
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
2026-08-11 18:43:56 +08:00
return oceanBookingStatusLabel(status)
2026-08-10 09:12:06 +08:00
}
2026-10-22 11:56:54 +08:00
function historyActionLabel(action: string) {
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
return labels[action] || '资料维护'
}
2026-08-11 18:06:35 +08:00
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
2026-10-20 23:34:11 +08:00
const [, partnerResult, warehouseResult] = await Promise.all([
loadCustomers(),
2026-08-11 18:06:35 +08:00
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
2026-08-14 18:50:11 +08:00
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
2026-08-11 18:06:35 +08:00
])
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
}
2026-08-14 18:50:11 +08:00
warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
2026-08-11 18:06:35 +08:00
} catch (cause) {
2026-10-20 23:34:11 +08:00
optionsError.value = getApiErrorDetails(cause, '船公司或仓库选项加载失败').message
2026-08-11 18:06:35 +08:00
} finally {
optionsLoading.value = false
}
}
2026-08-14 19:59:32 +08:00
function selectCustomer(value: unknown) {
const customerId = value == null || value === '' ? null : String(value)
2026-08-11 18:06:35 +08:00
form.data.customerId = customerId
2026-08-14 19:59:32 +08:00
const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId)
form.data.customerName = customer?.tenantName || null
}
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(),
2026-10-22 11:56:54 +08:00
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 })),
}
2026-08-11 18:06:35 +08:00
}
function draftSignature() { return JSON.stringify(draftPayload()) }
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
2026-10-22 11:56:54 +08:00
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
}
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) {
2026-08-18 16:09:43 +08:00
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
2026-08-14 18:50:11 +08:00
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) {
2026-09-15 11:54:19 +08:00
const channel = SMART_LOADING_CHANNEL_GROUPS.find((item) => item.code === channelCode)
2026-08-14 18:50:11 +08:00
form.data.channelCode = channelCode
2026-09-15 11:54:19 +08:00
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
}
}
2026-08-15 10:35:58 +08:00
function selectShippingCompany(partnerId: string) {
2026-08-11 18:06:35 +08:00
const partner = partners.value.find((item) => String(item.id) === partnerId)
2026-08-15 10:35:58 +08:00
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()
2026-08-15 10:35:58 +08:00
}
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
2026-08-15 10:35:58 +08:00
}
2026-08-10 09:12:06 +08:00
function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
const selectedCarrier = partners.value.find((partner) => String(partner.id) === String(value.shippingCompanyId || ''))
2026-08-10 09:12:06 +08:00
form.data = {
2026-08-17 13:24:55 +08:00
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '',
2026-08-15 09:47:23 +08:00
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
2026-08-10 09:12:06 +08:00
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
carrierCode: value.carrierCode ?? selectedCarrier?.carrierCode ?? null,
2026-08-10 09:12:06 +08:00
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
2026-09-15 10:38:03 +08:00
placeOfDelivery: value.placeOfDelivery,
2026-08-10 09:12:06 +08:00
remark: value.remark,
2026-10-22 11:56:54 +08:00
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) })),
2026-08-18 16:09:43 +08:00
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
2026-08-10 09:12:06 +08:00
}
2026-10-22 11:56:54 +08:00
actualReason.value = ''
2026-08-14 18:50:11 +08:00
selectedChannelName.value = value.channelName || ''
savedDraftSignature.value = draftSignature()
2026-08-14 18:50:11 +08:00
channels.value = []
if (form.data.warehouseId) void loadChannels(form.data.warehouseId)
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
2026-08-10 09:12:06 +08:00
}
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 }
}
2026-10-22 11:56:54 +08:00
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 }
2026-10-22 11:56:54 +08:00
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
}
2026-08-18 16:09:43 +08:00
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
2026-08-10 09:12:06 +08:00
actionError.value = null
try {
const payload = draftPayload()
2026-08-18 16:09:43 +08:00
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 || '未生成订舱号'}` : '订舱草稿已保存')
2026-08-25 17:43:00 +08:00
if (isNew.value) {
workspace.removeByIds(['ocean-booking-create'])
2026-10-22 11:56:54 +08:00
applyDetail(saved)
workspace.bumpCacheEpoch('ocean-bookings')
await router.replace(`/ocean-bookings/${encodeURIComponent(saved.id)}`)
return saved
2026-08-25 17:43:00 +08:00
}
2026-08-18 16:09:43 +08:00
applyDetail(saved)
2026-10-22 11:56:54 +08:00
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) }
2026-08-10 09:12:06 +08:00
finally { saving.value = false }
}
async function openReleaseDialog() {
if (!canRelease.value || workflowBusy.value || !(await validateDraftForm())) return
2026-08-18 16:09:43 +08:00
const validationError = releaseValidationError()
if (validationError) { actionError.value = { message: validationError }; return }
2026-08-14 14:27:03 +08:00
releaseDialogOpen.value = true
}
2026-08-18 16:09:43 +08:00
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 '放舱前请填写目的港代码或名称'
2026-08-18 16:09:43 +08:00
const requiredFields: Array<[unknown, string]> = [
[form.data.warehouseId, '装柜仓库'], [form.data.channelCode, '物流渠道'],
2026-08-18 16:09:43 +08:00
[form.data.billOfLadingNo, '提单号'], [form.data.vesselName, '船名'], [form.data.voyageNo, '航次'],
[form.data.estimatedSailingAt, '预计开船时间'], [form.data.estimatedArrivalAt, '预计到港时间'],
[form.data.placeOfDelivery, '交货地点'],
2026-08-18 16:09:43 +08:00
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
]
const missing = requiredFields.find(([value]) => !String(value || '').trim())
if (missing) return `放舱前请填写${missing[1]}`
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
2026-08-18 16:09:43 +08:00
return containerRequirementError(form.data.containerRequirements)
}
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
if (!detail.value) return
detail.value.documents = documents
detail.value.version = bookingVersion
detail.value.oceanBooking.version = bookingVersion
form.expectedVersion = bookingVersion
}
2026-08-14 14:27:03 +08:00
async function submitRelease() {
if (!detail.value || workflowBusy.value) return
2026-08-14 14:27:03 +08:00
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 }))
2026-08-14 14:27:03 +08:00
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 }
2026-08-18 16:09:43 +08:00
async function removeDraft() {
if (!detail.value || !canDelete.value) return
2026-08-10 09:12:06 +08:00
actionError.value = null
try {
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', bookingDialogTitle('删除草稿'), { customClass: 'admin-density-confirm', confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
2026-08-10 09:12:06 +08:00
actionSaving.value = true
2026-08-18 16:09:43 +08:00
await deleteOceanBookingDraft(id.value, detail.value.version)
ElMessage.success('订舱草稿已删除')
2026-08-27 21:48:15 +08:00
workspace.bumpCacheEpoch('ocean-bookings')
2026-08-18 16:09:43 +08:00
await router.replace('/ocean-bookings')
2026-08-10 09:12:06 +08:00
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
2026-08-18 16:09:43 +08:00
function addRequirement() {
if (form.data.containerRequirements.length < 20) form.data.containerRequirements.push(createBlankContainerRequirement())
2026-08-12 18:50:03 +08:00
}
2026-08-18 16:09:43 +08:00
function removeRequirement(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
2026-08-11 18:06:35 +08:00
onMounted(() => { void load(); void loadOptions() })
2026-08-08 13:32:27 +08:00
</script>
2026-08-10 09:12:06 +08:00
<template>
2026-10-20 23:34:11 +08:00
<AdminPage as="div" class="booking-detail admin-density admin-workbench">
2026-08-18 09:09:34 +08:00
<OperationErrorToast :details="actionError" />
2026-08-10 16:24:54 +08:00
<DetailPageLayout aria-label="订舱详情">
<template #header>
<div class="detail-heading">
2026-09-14 18:34:34 +08:00
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
2026-10-22 11:56:54 +08:00
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : '出口订舱作业'">
<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>
</div>
</template>
2026-08-10 16:24:54 +08:00
</PageToolbar>
</div>
</template>
2026-08-10 09:12:06 +08:00
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
2026-08-10 16:24:54 +08:00
<template v-else>
2026-10-22 11:56:54 +08:00
<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>
2026-08-10 09:12:06 +08:00
</div>
2026-08-11 18:06:35 +08:00
<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>
2026-10-22 11:56:54 +08:00
<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">
2026-08-12 16:40:09 +08:00
<section class="booking-group" aria-label="基本信息">
2026-10-22 11:56:54 +08:00
<h3 class="booking-group__title"><span>基础信息</span></h3>
<div class="booking-group__body booking-group__body--fields">
2026-08-12 16:40:09 +08:00
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
2026-08-15 10:35:58 +08:00
<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>
2026-10-22 11:56:54 +08:00
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></el-form-item>
<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>
2026-08-12 16:40:09 +08:00
</div>
</section>
<section class="booking-group" aria-label="航线船期">
2026-10-22 11:56:54 +08:00
<h3 class="booking-group__title"><span>港口航线</span></h3>
<div class="booking-group__body booking-group__body--fields">
<el-form-item label="起运港"><div class="booking-port-fields"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /><el-input v-model="form.data.departurePortName" placeholder="港口名称" aria-label="起运港名称" /></div></el-form-item>
<el-form-item label="目的港"><div class="booking-port-fields"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /><el-input v-model="form.data.destinationPortName" placeholder="港口名称" aria-label="目的港名称" /></div></el-form-item>
2026-08-15 09:47:23 +08:00
<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>
2026-10-22 11:56:54 +08:00
<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>
2026-08-12 16:40:09 +08:00
</div>
</section>
2026-10-22 11:56:54 +08:00
</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="货物预报与实际数量">
<!-- 数字组件仅挂载时写入禁用标记,且可能保留输入缓存;资格切换及实值保存成功后按权威版本重建。 -->
2026-10-22 11:56:54 +08:00
<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="预报件数" />
2026-10-22 11:56:54 +08:00
<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="实际件数" />
2026-10-22 11:56:54 +08:00
<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="预报毛重" />
2026-10-22 11:56:54 +08:00
<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="实际毛重" />
2026-10-22 11:56:54 +08:00
<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="预报体积" />
2026-10-22 11:56:54 +08:00
<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="实际体积" />
2026-10-22 11:56:54 +08:00
<span class="cargo-metric__unit">CBM</span>
</div>
</div>
</div>
<div class="booking-group__body--fields">
<el-form-item label="包装单位"><el-select v-model="form.data.packageUnit" :disabled="!formWritable" placeholder="请选择包装单位"><el-option label="箱 CARTONS" value="CARTONS" /><el-option label="件 PCS" value="PCS" /><el-option label="托 PALLETS" value="PALLETS" /></el-select></el-form-item>
<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">
2026-09-15 10:38:03 +08:00
<section class="booking-group" aria-label="柜型需求">
2026-10-22 11:56:54 +08:00
<h3 class="booking-group__title"><span>柜型需求与备注</span></h3><div class="booking-group__body">
2026-08-18 16:09:43 +08:00
<div class="container-table-wrap" aria-label="柜型需求">
2026-10-20 23:34:11 +08:00
<div class="workbench-evidence"><table class="container-table density-table">
2026-08-18 16:09:43 +08:00
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
2026-08-14 18:50:11 +08:00
<tbody>
2026-08-18 16:09:43 +08:00
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
2026-08-14 18:50:11 +08:00
<td class="container-table__action">
<el-tooltip v-if="formWritable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
2026-09-14 18:34:34 +08:00
<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>
2026-08-14 18:50:11 +08:00
</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-select v-model="requirement.containerKind" :disabled="!formWritable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
2026-09-03 19:29:38 +08:00
<td><el-input-number v-model="requirement.quantity" :disabled="!formWritable" :min="1" :max="99" controls-position="right" placeholder="请输入柜量" /></td>
2026-08-18 16:09:43 +08:00
<td class="container-table__specification"><span>{{ requirement.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ requirement.maxWeightKg }} kg</span></td>
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
2026-08-14 18:50:11 +08:00
</tr>
</tbody>
2026-10-20 23:34:11 +08:00
</table></div>
2026-08-12 16:40:09 +08:00
</div>
2026-09-14 18:34:34 +08:00
<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>
2026-10-20 14:52:06 +08:00
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" /></el-form-item>
2026-08-12 16:40:09 +08:00
</div>
</section>
2026-08-10 09:12:06 +08:00
</el-form>
2026-10-22 11:56:54 +08:00
<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>
2026-08-10 09:12:06 +08:00
</section>
2026-10-22 11:56:54 +08:00
<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>
2026-08-10 09:12:06 +08:00
</section>
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
2026-10-20 23:34:11 +08:00
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
2026-08-19 16:45:51 +08:00
2026-10-20 23:34:11 +08:00
<section v-if="detail" class="workbench-panel is-compact history-panel">
2026-08-10 09:12:06 +08:00
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
2026-10-22 11:56:54 +08:00
<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>
2026-08-10 09:12:06 +08:00
</section>
2026-08-10 16:24:54 +08:00
</template>
</DetailPageLayout>
2026-10-22 11:56:54 +08:00
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
2026-08-10 09:12:06 +08:00
2026-10-20 23:34:11 +08:00
<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>
2026-08-10 16:24:54 +08:00
</AppDialog>
</AdminPage>
</template>
2026-08-10 09:12:06 +08:00
<style scoped>
2026-10-22 11:56:54 +08:00
.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-toolbar :deep(.page-toolbar__description) { margin-top: var(--admin-space-1); font-size: var(--admin-font-caption); color: var(--workbench-muted); }
.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-port-fields { display: grid; grid-template-columns: minmax(76px, .65fr) minmax(0, 1fr); gap: var(--admin-space-2); width: 100%; }
.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-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: 780px; 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 th:nth-child(5), .container-table td:nth-child(5), .container-table th:nth-child(6), .container-table td:nth-child(6) { width: 100px; }
.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-table__specification { color: var(--workbench-muted); white-space: nowrap; }
.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; }
}
2026-08-10 09:12:06 +08:00
</style>