2026-08-08 13:32:27 +08:00
< script setup lang = "ts" >
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-08-17 12:31:27 +08:00
import { ArrowLeft , Plus , Save , Trash2 } from '@lucide/vue'
2026-08-10 09:12:06 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
2026-08-08 13:32:27 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-11 11:26:31 +08:00
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-08-11 18:06:35 +08:00
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
2026-08-12 09:25:55 +08:00
import { listPlatformPartners , type PlatformPartnerResource } from '@/modules/master-data'
2026-08-15 17:04:00 +08:00
import { listSuppliers , type Supplier } from '@/modules/suppliers'
2026-08-12 18:43:54 +08:00
import { listPlatformWarehouses , type PlatformWarehouseResource } from '@/modules/warehouses'
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 ,
2026-08-12 18:43:54 +08:00
releaseOceanBooking ,
2026-08-14 18:50:11 +08:00
listWarehouseLogisticsChannels ,
2026-08-18 16:09:43 +08:00
updateOceanBookingDraft ,
2026-08-14 18:50:11 +08:00
type OceanLogisticsChannel ,
2026-08-10 09:12:06 +08:00
} from '../api/platformOceanBookings'
import type {
OceanBookingDetail ,
OceanBookingDraftData ,
OceanBookingParty ,
OceanBookingSaveRequest ,
OceanBookingStatus ,
} 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'
2026-08-17 16:45:46 +08:00
import { isActiveBookingAgent } from '../bookingAgents'
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 ()
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 customers = ref < PlatformTenantResource [] >([])
const partners = ref < PlatformPartnerResource [] >([])
2026-08-15 10:35:58 +08:00
const bookingSuppliers = ref < Supplier [] >([])
2026-08-12 18:43:54 +08:00
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
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-08-17 16:45:46 +08:00
const selectableBookingAgents = computed (() => bookingSuppliers . value . filter ( isActiveBookingAgent ))
2026-08-14 18:50:11 +08:00
const channelUnavailable = computed (() => Boolean ( form . data . warehouseId ) && ! channelsLoading . value && ! channelsError . value && channels . value . length === 0 )
2026-08-10 09:12:06 +08:00
function blankParties () : OceanBookingParty [] {
return partyTypes . map (({ value }) => ({ partyType : value , partyName : '' , contactName : '' , contactPhone : '' , address : '' }))
}
function blankData () : OceanBookingDraftData {
return {
2026-08-17 13:24:55 +08:00
customerId : null , customerName : null , channelCode : '' , warehouseId : '' , serviceTerm : null , freightTerm : null , parties : blankParties (),
2026-08-15 09:47:23 +08:00
billOfLadingNo : '' , vesselName : '' , voyageNo : '' , estimatedSailingAt : null , estimatedArrivalAt : 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-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 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-08-14 14:27:03 +08:00
const releaseDialogOpen = ref ( false )
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-08-11 18:06:35 +08:00
async function loadOptions () {
optionsLoading . value = true
optionsError . value = ''
try {
2026-08-15 10:35:58 +08:00
const [ customerResult , partnerResult , supplierResult , warehouseResult ] = await Promise . all ([
2026-08-14 12:50:42 +08:00
listPlatformTenants ({ page : 1 , size : 200 , status : 'ACTIVE' }),
2026-08-11 18:06:35 +08:00
listPlatformPartners ({ page : 1 , size : 200 , status : 'ACTIVE' }),
2026-08-15 10:35:58 +08:00
listSuppliers ({ page : 1 , size : 200 , category : 'BOOKING' , 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
])
2026-08-14 12:50:42 +08:00
customers . value = customerResult . records . filter (( customer ) => customer . status === 'ACTIVE' )
2026-08-11 18:06:35 +08:00
partners . value = partnerResult . records
2026-08-17 16:45:46 +08:00
bookingSuppliers . value = supplierResult . records . filter ( isActiveBookingAgent )
2026-08-14 18:50:11 +08:00
warehouses . value = warehouseResult . records . filter (( warehouse ) => warehouse . status === 'ACTIVE' && warehouse . warehouseType === 'DOMESTIC' )
2026-08-11 18:06:35 +08:00
} catch ( cause ) {
2026-08-15 10:35:58 +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 normalizeDraftData () {
form . data . customerId = form . data . customerId || null
form . data . customerName = form . data . customerId ? form . data . customerName : null
form . data . serviceTerm = form . data . serviceTerm ? . trim () || null
form . data . freightTerm = form . data . freightTerm ? . trim () || null
2026-08-15 09:47:23 +08:00
form . data . billOfLadingNo = form . data . billOfLadingNo . trim ()
form . data . vesselName = form . data . vesselName . trim ()
form . data . voyageNo = form . data . voyageNo . trim ()
2026-08-18 16:09:43 +08:00
form . data . containerRequirements . forEach (( requirement ) => { requirement . quantity = Number ( requirement . quantity ) })
2026-08-11 18:06:35 +08:00
}
2026-08-12 19:27:55 +08:00
function selectWarehouse ( value : unknown ) {
2026-08-18 16:09:43 +08:00
const warehouseId = value == null || value === '' ? null : String ( value )
2026-08-12 19:27:55 +08:00
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 ) {
const channel = channels . value . find (( item ) => item . channelCode === channelCode )
form . data . channelCode = channelCode
selectedChannelName . value = channel ? . channelName || ''
2026-08-12 19:27:55 +08:00
}
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
}
function selectBookingAgent ( supplierId : string ) {
2026-08-17 16:45:46 +08:00
const supplier = selectableBookingAgents . value . find (( item ) => String ( item . id ) === supplierId )
form . data . bookingAgentId = supplier ? String ( supplier . id ) : null
2026-08-15 10:35:58 +08:00
form . data . bookingAgentName = supplier ? . name || null
}
2026-08-10 09:12:06 +08:00
function applyDetail ( value : OceanBookingDetail ) {
detail . value = value
form . expectedVersion = value . version
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 ,
bookingAgentId : value . bookingAgentId , bookingAgentName : value . bookingAgentName ,
departurePortCode : value . departurePortCode , departurePortName : value . departurePortName ,
destinationPortCode : value . destinationPortCode , destinationPortName : value . destinationPortName ,
placeOfDelivery : value . placeOfDelivery , serviceTerm : value . serviceTerm , freightTerm : value . freightTerm ,
remark : value . remark ,
parties : value . parties . length ? value . parties . map (({ partyType , partyName , contactName , contactPhone , address }) => ({ partyType , partyName , contactName , contactPhone , address })) : blankParties (),
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-08-14 18:50:11 +08:00
selectedChannelName . value = value . channelName || ''
channels . value = []
if ( form . data . warehouseId ) void loadChannels ( form . data . warehouseId )
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 }
}
async function save () {
if ( ! canSave . value ) return
2026-08-14 19:02:34 +08:00
actionError . value = null
2026-08-14 19:59:32 +08:00
normalizeDraftData ()
2026-08-18 16:09:43 +08:00
const requirementMessage = containerRequirementError ( form . data . containerRequirements )
if ( requirementMessage ) { actionError . value = { message : requirementMessage }; return }
2026-08-10 09:12:06 +08:00
saving . value = true
actionError . value = null
try {
2026-08-18 16:09:43 +08:00
const payload = {
... form . data ,
channelCode : form . data . channelCode ? . trim () || null ,
warehouseId : form . data . warehouseId ? . trim () || null ,
parties : form . data . parties . filter (( party ) => party . partyName . trim ()). map (( party ) => ({ ... party , partyName : party . partyName . trim () })),
containerRequirements : form . data . containerRequirements . map (({ containerType , containerKind , quantity , capacityCbm , maxWeightKg }) => ({ containerType , containerKind , quantity , capacityCbm , maxWeightKg })),
}
const saved = isNew . value
? await createOceanBooking ({ expectedVersion : 0 , data : payload })
: await updateOceanBookingDraft ( id . value , { expectedVersion : detail . value ? . version || 0 , data : payload })
ElMessage . success ( '订舱草稿已保存' )
if ( isNew . value ) await router . replace ( `/ocean-bookings/ ${ saved . id } ` )
applyDetail ( saved )
2026-08-10 09:12:06 +08:00
} catch ( cause ) { actionError . value = getApiErrorDetails ( cause ) }
finally { saving . value = false }
}
2026-08-14 14:27:03 +08:00
function openReleaseDialog () {
2026-08-18 16:09:43 +08:00
normalizeDraftData ()
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 () {
const requiredFields : Array < [ unknown , string ] > = [
[ form . data . customerId , '客户' ], [ form . data . warehouseId , '装柜仓库' ], [ form . data . channelCode , '物流渠道' ],
[ form . data . billOfLadingNo , '提单号' ], [ form . data . vesselName , '船名' ], [ form . data . voyageNo , '航次' ],
[ form . data . estimatedSailingAt , '预计开船时间' ], [ form . data . estimatedArrivalAt , '预计到港时间' ],
[ form . data . serviceTerm , 'Service Term' ], [ form . data . freightTerm , 'Freight Term' ],
[ 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 '放舱前请填写发货人、收货人和通知人名称'
return containerRequirementError ( form . data . containerRequirements )
}
2026-08-14 14:27:03 +08:00
async function submitRelease () {
if ( ! detail . value ) return
2026-08-18 16:09:43 +08:00
const validationError = releaseValidationError ()
if ( validationError ) { actionError . value = { message : validationError }; releaseDialogOpen . value = false ; return }
2026-08-14 14:27:03 +08:00
actionSaving . value = true
actionError . value = null
try {
2026-08-18 16:09:43 +08:00
applyDetail ( await releaseOceanBooking ( id . value , { expectedVersion : detail . value . version }))
2026-08-14 14:27:03 +08:00
releaseDialogOpen . value = false
ElMessage . success ( '确认放舱成功' )
} catch ( cause ) { actionError . value = getApiErrorDetails ( cause ) }
finally { actionSaving . value = false }
}
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 {
2026-08-18 16:09:43 +08:00
await ElMessageBox . 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 ( '订舱草稿已删除' )
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-08 15:15:04 +08:00
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
2026-08-08 15:15:04 +08:00
< template >
2026-08-10 16:24:54 +08:00
< AdminPage as = "div" class = "booking-detail" >
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" >
< el-button @click ="router.push('/ocean-bookings')" >< ArrowLeft :size = "16" /> 返回列表 </ el-button >
2026-08-18 16:09:43 +08:00
< PageToolbar class = "booking-page-toolbar" as = "section" : title = "detail?.bookingNo || '订舱草稿'" : description = "detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '先保存订舱草稿,资料完整后再确认放舱'" >
2026-08-11 18:43:56 +08:00
< template v-if = "detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></ div ></ template >
2026-08-10 16:24:54 +08:00
</ PageToolbar >
</ div >
</ template >
2026-08-10 09:12:06 +08:00
2026-08-11 11:26:31 +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-08-10 09:12:06 +08:00
< section class = "surface-panel" >
< div class = "section-title" >
2026-08-18 16:09:43 +08:00
< div >< h2 > 订舱信息 </ h2 >< p v-if = "detail?.status === 'DRAFT'">草稿可反复保存;放舱后资料同步至关联货柜并锁定。</p><p v-else-if="detail" > 已放舱资料只读 。 </ p ></ div >
2026-08-10 09:12:06 +08:00
< div class = "actions" >
2026-08-18 16:09:43 +08:00
< el-button v-if = "canDelete" type="danger" plain :loading="actionSaving" @click="removeDraft"><Trash2 :size="16" /> 删除草稿 </ el-button >
< el-button v-if = "canRelease" type="primary" plain :loading="actionSaving" @click="openReleaseDialog" > 放舱 </ el -button >
< el-button v-if = "canSave" type="primary" :loading="saving" @click="save"><Save :size="16" /> 保存草稿 </ el-button >
2026-08-10 09:12:06 +08:00
</ div >
</ 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-08-12 16:40:09 +08:00
< el-form ref = "formRef" :model = "form.data" :rules = "rules" label -position =" top " :disabled = "!editable" class = "booking-groups" >
< section class = "booking-group" aria -label =" 基本信息 " >
2026-08-14 18:50:11 +08:00
< h3 > 基本信息 </ 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-18 16:09:43 +08:00
< el-form-item label = "客户(放舱前必填)" >< el-select :model-value = "form.data.customerId" filterable clearable placeholder = "草稿可暂不选择" no -data-text =" 暂无可选客户 " :loading = "optionsLoading" 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 ></ el-form-item >
< el-form-item label = "状态" >< el-input : model -value =" detail ? statusLabel ( detail.status ) : ' 保存后为草稿 '" disabled /></ el-form-item >
2026-08-14 18:50:11 +08:00
< 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 >
2026-08-18 16:09:43 +08:00
< el-form-item label = "物流渠道" prop = "channelCode" >< el-select :model-value = "form.data.channelCode" filterable : disabled = "!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 && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></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-15 09:47:23 +08:00
< el-form-item label = "提单号" prop = "billOfLadingNo" >< el-input v-model = "form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></ 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 >
2026-08-17 16:45:46 +08:00
< el-form-item label = "订舱代理" >< el-select :model-value = "form.data.bookingAgentId" filterable clearable placeholder = "请选择订舱代理" :loading = "optionsLoading" no -data-text =" 暂无启用的订舱代理 " style = "width:100%" @ update : model -value =" selectBookingAgent ( String ($ event || ''))" >< el-option v-if = "!editable && form.data.bookingAgentId && !selectableBookingAgents.some((supplier) => String(supplier.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in selectableBookingAgents" :key="String(supplier.id)" :label="supplier.name" :value="String(supplier.id)" /></ el-select ></ el-form-item >
2026-08-12 16:40:09 +08:00
</ div >
</ section >
< section class = "booking-group" aria -label =" 航线船期 " >
2026-08-14 18:50:11 +08:00
< h3 > 航线船期 </ h3 >< div class = "booking-group__body booking-group__body--fields" >
2026-08-15 09:47:23 +08:00
< 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 >
2026-08-12 16:40:09 +08:00
< el-form-item label = "起运港代码" >< el-input v-model = "form.data.departurePortCode" placeholder="例如:CNNGB" /></ el-form-item >
< el-form-item label = "起运港" >< el-input v-model = "form.data.departurePortName" placeholder="例如:宁波港" /></ el-form-item >
< el-form-item label = "目的港代码" >< el-input v-model = "form.data.destinationPortCode" placeholder="例如:USLAX" /></ el-form-item >
< el-form-item label = "目的港" >< el-input v-model = "form.data.destinationPortName" placeholder="例如:洛杉矶港" /></ el-form-item >
2026-08-14 18:50:11 +08:00
< el-form-item class = "booking-field--wide" label = "交货地点" >< el-input v-model = "form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></ 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-08-12 16:40:09 +08:00
</ div >
</ section >
2026-08-18 16:09:43 +08:00
< section class = "booking-group" aria -label =" 条款与柜型需求 " >
< h3 > 条款与柜型需求 </ h3 >< div class = "booking-group__body" >
2026-08-14 19:02:34 +08:00
< div class = "booking-terms" >
2026-08-14 19:59:32 +08:00
< el-form-item label = "主要运输条款 Service Term" prop = "serviceTerm" >< el-input v-model = "form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></ el-form-item >
< el-form-item label = "运费条款 Freight Term" prop = "freightTerm" >< el-input v-model = "form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></ el-form-item >
2026-08-14 19:02:34 +08:00
</ div >
2026-08-18 16:09:43 +08:00
< div class = "container-table-wrap" aria -label =" 柜型需求 " >
2026-08-14 18:50:11 +08:00
< table class = "container-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" >
2026-08-18 16:09:43 +08:00
< el-tooltip v-if = "editable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top" >
< span >< el-button circle plain size = "small" type = "danger" : aria -label ="` 删除柜型需求 $ { index + 1 } `" : disabled = "form.data.containerRequirements.length === 1" @click ="removeRequirement(index)" >< Trash2 :size = "14" /></ el-button ></ span >
2026-08-14 18:50:11 +08:00
</ el-tooltip >
< span v-else aria -hidden =" true " > — </ span >
</ td >
2026-08-18 16:09:43 +08:00
< td >< el-select :model-value = "requirement.containerType" :disabled = "!editable" 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="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></ el-select ></ td >
< td >< el-input-number v-model = "requirement.quantity" :disabled="!editable" :min="1" :max="99" controls-position="right" /></ td >
< 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 >
</ table >
2026-08-12 16:40:09 +08:00
</ div >
2026-08-18 16:09:43 +08:00
< div v-if = "editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement"><Plus :size="15" /> 新增柜型需求 </ el-button >< span >{{ form . data . containerRequirements . length }} / 20 项 </ span ></ div >
2026-08-12 16:40:09 +08:00
< el-form-item label = "备注" >< el-input v-model = "form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></ el-form-item >
</ div >
</ section >
2026-08-10 09:12:06 +08:00
</ el-form >
</ section >
< section class = "surface-panel" >
< h2 > 订舱关系方 </ h2 >
< div class = "party-grid" >
< div v-for = "(party, index) in form.data.parties" :key="party.partyType" class="subsection" >
2026-08-14 19:02:34 +08:00
< h3 >< span class = "required-mark" aria -hidden =" true " > * </ span >{{ partyTypes [ index ] ? . label || party . partyType }}</ h3 >
< el-input v-model = "party.partyName" :disabled="!editable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
2026-08-10 09:12:06 +08:00
< el-input v-model = "party.contactName" :disabled="!editable" placeholder="联系人" />
< el-input v-model = "party.contactPhone" :disabled="!editable" placeholder="联系电话" />
< el-input v-model = "party.address" :disabled="!editable" placeholder="地址" />
</ div >
</ div >
</ section >
< section v-if = "detail" class="surface-panel history-panel" >
< div class = "section-title" >< div >< h2 > 状态历史 </ h2 >< p > 当前进度 : {{ statusLabel ( detail . status ) }}</ p ></ div ></ div >
< el-timeline >< el-timeline-item v-for = "event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason" > · {{ event.reason }}</ span ></ p > </ el -timeline-item ></ el-timeline >
</ section >
2026-08-10 16:24:54 +08:00
</ template >
</ DetailPageLayout >
2026-08-10 09:12:06 +08:00
2026-08-15 09:47:23 +08:00
< AppDialog v-model = "releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease" >
2026-08-14 14:27:03 +08:00
< el-form label -position =" top " >
2026-08-18 16:09:43 +08:00
< el-alert title = "放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。" type = "warning" :closable = "false" show -icon />
2026-08-15 09:47:23 +08:00
< el-descriptions class = "release-summary" :column = "2" border >
< el-descriptions-item label = "提单号" >{{ detail ? . billOfLadingNo || '—' }}</ el-descriptions-item >
< el-descriptions-item label = "船名" >{{ detail ? . vesselName || '—' }}</ el-descriptions-item >
< el-descriptions-item label = "航次" >{{ detail ? . voyageNo || '—' }}</ el-descriptions-item >
< el-descriptions-item label = "预计开船" >{{ detail ? . estimatedSailingAt || '—' }}</ el-descriptions-item >
< el-descriptions-item label = "预计到港" >{{ detail ? . estimatedArrivalAt || '—' }}</ el-descriptions-item >
</ el-descriptions >
2026-08-14 14:27:03 +08:00
</ el-form >
2026-08-10 16:24:54 +08:00
</ AppDialog >
</ AdminPage >
2026-08-08 15:15:04 +08:00
</ template >
2026-08-10 09:12:06 +08:00
< style scoped >
2026-08-14 18:50:11 +08:00
. channel - code { display : block ; margin - left : 8 px ; color : var ( -- cc - muted ); font - size : 11 px }. field - error ,. field - hint { display : flex ; align - items : center ; justify - content : space - between ; gap : 8 px ; margin - top : 5 px ; color : var ( -- cc - muted ); font - size : 12 px ; line - height : 20 px }. field - error { color : var ( -- el - color - danger )}
2026-08-17 16:45:46 +08:00
. booking - detail { min - width : 0 ; max - width : 1880 px }. detail - heading { display : flex ; min - width : 0 ; align - items : flex - start ; gap : 16 px }. booking - page - toolbar { min - width : 0 ; flex : 1 }. section - title p ,. history - panel p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 13 px }. heading - status { display : flex ; align - items : center ; gap : 8 px }. surface - panel { padding : 16 px }. surface - panel h2 { margin : 0 ; font - size : 16 px }. section - title { display : flex ; justify - content : space - between ; gap : 12 px ; align - items : flex - start ; margin - bottom : 16 px }. actions { display : flex ; flex - wrap : wrap ; gap : 8 px }. booking - groups { display : grid ; grid - template - columns : minmax ( 0 , 1.5 fr ) repeat ( 2 , minmax ( 0 , 1 fr )); gap : 14 px }. booking - group { min - width : 0 ; overflow : hidden ; border : 1 px solid # e8edf3 ; border - radius : 10 px ; background : # fff }. booking - group > h3 { margin : 0 ; padding : 12 px 14 px ; border - bottom : 1 px solid # eef2f6 ; color : # 1 f2937 ; font - size : 14 px }. booking - group__body { padding : 12 px 14 px }. booking - group__body -- fields ,. booking - terms { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); column - gap : 10 px ; row - gap : 8 px }. booking - field -- wide { grid - column : 1 /- 1 }. booking - terms : deep (. el - select ){ width : 100 % }. party - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 10 px ; margin - top : 14 px }. subsection { display : grid ; gap : 10 px ; padding : 12 px ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px }. subsection h3 { margin : 0 ; font - size : 14 px }. required - mark { margin - right : 4 px ; color : var ( -- el - color - danger )}. container - table - wrap { overflow - x : auto ; margin - bottom : 10 px }. container - table { width : 100 % ; min - width : 780 px ; table - layout : fixed ; border - collapse : collapse ; font - size : 12 px }. container - table th ,. container - table td { padding : 7 px 6 px ; border - bottom : 1 px solid # eef2f6 ; text - align : left ; vertical - align : middle }. container - table th { background : # fafafa ; color : var ( -- cc - muted ); font - weight : 650 ; white - space : nowrap }. container - table th : first - child ,. container - table td : first - child { width : 56 px }. container - table th : nth - child ( 2 ),. container - table td : nth - child ( 2 ){ width : 180 px }. container - table th : nth - child ( 3 ),. container - table td : nth - child ( 3 ){ width : 90 px }. container - table th : nth - child ( 4 ),. container - table td : nth - child ( 4 ){ width : 110 px }. 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 : 100 px }. 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__action : deep (. el - button ){ width : 28 px ; height : 28 px }. container - table__specification { color : var ( -- cc - muted ); white - space : nowrap }. container - actions { display : flex ; align - items : center ; gap : 10 px ; margin - bottom : 14 px ; color : var ( -- cc - muted ); font - size : 12 px }. add - container { margin : 0 }. history - panel strong { font - size : 14 px } @ media ( max - width : 1599 px ){. booking - groups { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ))}. booking - group : last - child { grid - column : 1 /- 1 }} @ media ( max - width : 720 px ){. booking - groups ,. party - grid { grid - template - columns : 1 fr }. booking - group : last - child { grid - column : auto }. detail - heading { flex - direction : column }. booking - page - toolbar { width : 100 % }. section - title { flex - direction : column }. actions { width : 100 % }} @ media ( max - width : 520 px ){. booking - group__body -- fields ,. booking - terms { grid - template - columns : 1 fr }. booking - field -- wide { grid - column : auto }} @ media ( max - width : 420 px ){. surface - panel { padding : 12 px }. booking - group__body { padding : 12 px }. actions : deep (. el - button ){ min - height : 44 px }}
2026-08-10 09:12:06 +08:00
</ style >