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 ,
getOceanBooking ,
performOceanBookingAction ,
2026-08-12 18:43:54 +08:00
releaseOceanBooking ,
2026-08-10 09:12:06 +08:00
requestOceanBookingChange ,
2026-08-14 18:50:11 +08:00
listWarehouseLogisticsChannels ,
type OceanLogisticsChannel ,
2026-08-10 09:12:06 +08:00
type OceanBookingAction ,
} 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-17 13:24:55 +08:00
import { bookingContainerError , createBlankBookingContainer , selectBookingContainerType } 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 actionLabels : Record < OceanBookingAction , string > = {
2026-08-15 09:47:23 +08:00
release : '确认放舱' , cancel : '取消订舱' ,
2026-08-10 09:12:06 +08:00
'change-request' : '申请改船' , 'change-confirm' : '确认改船' , 'change-fail' : '改船失败' ,
}
const actionPermissions : Record < OceanBookingAction , string > = {
2026-08-15 09:47:23 +08:00
release : 'platform:firstleg:ocean-booking:release' ,
2026-08-10 09:12:06 +08:00
cancel : 'platform:firstleg:ocean-booking:cancel' , 'change-request' : 'platform:firstleg:ocean-booking:change' ,
'change-confirm' : 'platform:firstleg:ocean-booking:change' , 'change-fail' : 'platform:firstleg:ocean-booking:change' ,
}
const actionsByStatus : Partial < Record < OceanBookingStatus , OceanBookingAction [] > > = {
CONFIRMED : [ 'release' , 'change-request' , 'cancel' ],
CHANGE_PENDING : [ 'change-confirm' , 'change-fail' , 'cancel' ],
}
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-17 13:24:55 +08:00
containers : [ createBlankBookingContainer ()],
2026-08-10 09:12:06 +08:00
}
}
const form = reactive < OceanBookingSaveRequest >({ expectedVersion : 0 , data : blankData () })
const rules : FormRules = {
2026-08-14 18:50:11 +08:00
warehouseId : [{ required : true , message : '请选择装柜仓库' , trigger : 'change' }],
channelCode : [{ required : true , message : '请选择物流渠道' , trigger : 'change' }],
2026-08-15 09:47:23 +08:00
billOfLadingNo : [{ required : true , message : '请输入提单号' , trigger : 'blur' }],
vesselName : [{ required : true , message : '请输入船名' , trigger : 'blur' }],
voyageNo : [{ required : true , message : '请输入航次' , trigger : 'blur' }],
estimatedSailingAt : [{ required : true , message : '请选择预计开船时间' , trigger : 'change' }],
estimatedArrivalAt : [{ required : true , message : '请选择预计到港时间' , trigger : 'change' }],
2026-08-14 19:59:32 +08:00
serviceTerm : [{ required : true , message : '请输入主要运输条款 Service Term' , trigger : 'blur' }],
freightTerm : [{ required : true , message : '请输入运费条款 Freight Term' , trigger : 'blur' }],
2026-08-10 09:12:06 +08:00
}
2026-08-15 09:47:23 +08:00
const editable = computed (() => isNew . value )
2026-08-15 10:03:03 +08:00
const canSave = computed (() => isNew . value && access . can ( 'platform:firstleg:ocean-booking:create' ))
2026-08-15 09:47:23 +08:00
const availableActions = computed (() => ( actionsByStatus [ detail . value ? . status || 'CONFIRMED' ] || [])
2026-08-10 09:12:06 +08:00
. filter (( action ) => access . can ( actionPermissions [ action ])))
const changeDialogOpen = ref ( false )
const changeForm = reactive ({ vesselName : '' , voyageNo : '' , estimatedSailingAt : null as string | null , estimatedArrivalAt : null as string | null , reason : '' })
2026-08-14 14:27:03 +08:00
const releaseDialogOpen = ref ( false )
2026-08-15 09:47:23 +08:00
const releaseReason = ref ( '' )
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-17 13:24:55 +08:00
form . data . containers . forEach (( container ) => { container . containerNo = container . containerNo . trim () })
2026-08-11 18:06:35 +08:00
}
2026-08-12 19:27:55 +08:00
function selectWarehouse ( value : unknown ) {
const warehouseId = 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 ) {
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-17 13:24:55 +08:00
containers : value . containers . length ? value . containers . map (({ containerNo , containerType , containerKind , capacityCbm , maxWeightKg }) => ({ containerNo , containerType , containerKind , capacityCbm , maxWeightKg })) : [ createBlankBookingContainer ()],
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-14 19:02:34 +08:00
if ( ! await formRef . value ? . validate (). catch (() => false )) {
2026-08-15 09:47:23 +08:00
actionError . value = { message : '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }
return
}
if ( form . data . estimatedArrivalAt ! < form . data . estimatedSailingAt ! ) {
actionError . value = { message : '预计到港时间不能早于预计开船时间' }
2026-08-14 19:02:34 +08:00
return
}
2026-08-10 09:12:06 +08:00
if ( form . data . parties . some (( party ) => ! party . partyName . trim ())) {
actionError . value = { message : '发货人、收货人和通知人名称均为必填项' }
return
}
2026-08-17 13:24:55 +08:00
if ( ! validateBookingContainers ()) return
2026-08-10 09:12:06 +08:00
saving . value = true
actionError . value = null
try {
2026-08-14 18:50:11 +08:00
const payload = { ... form . data }
2026-08-15 10:03:03 +08:00
const created = await createOceanBooking ({ expectedVersion : 0 , data : payload })
ElMessage . success ( '线下订舱资料已登记并确认' )
await router . replace ( `/ocean-bookings/ ${ created . id } ` )
applyDetail ( created )
2026-08-10 09:12:06 +08:00
} catch ( cause ) { actionError . value = getApiErrorDetails ( cause ) }
finally { saving . value = false }
}
async function requiredReason ( action : OceanBookingAction ) {
2026-08-15 09:47:23 +08:00
if ( ! [ 'cancel' , 'change-fail' ]. includes ( action )) return null
2026-08-10 09:12:06 +08:00
const result = await ElMessageBox . prompt ( `请填写“ ${ actionLabels [ action ] } ”原因,服务端将写入状态历史。` , actionLabels [ action ], {
inputValidator : ( value ) => Boolean ( value ? . trim ()) || '原因不能为空' ,
confirmButtonText : '确认' , cancelButtonText : '取消' , type : 'warning' ,
})
return result . value . trim ()
}
function openChangeDialog () {
changeForm . vesselName = detail . value ? . vesselName || ''
changeForm . voyageNo = detail . value ? . voyageNo || ''
changeForm . estimatedSailingAt = detail . value ? . estimatedSailingAt || null
changeForm . estimatedArrivalAt = detail . value ? . estimatedArrivalAt || null
changeForm . reason = ''
changeDialogOpen . value = true
}
2026-08-14 14:27:03 +08:00
function openReleaseDialog () {
2026-08-15 09:47:23 +08:00
releaseReason . value = ''
2026-08-14 14:27:03 +08:00
releaseDialogOpen . value = true
}
async function submitRelease () {
if ( ! detail . value ) return
2026-08-15 09:47:23 +08:00
const billOfLadingNo = detail . value . billOfLadingNo ? . trim ()
const vesselName = detail . value . vesselName ? . trim ()
const voyageNo = detail . value . voyageNo ? . trim ()
const estimatedSailingAt = detail . value . estimatedSailingAt
const estimatedArrivalAt = detail . value . estimatedArrivalAt
if ( ! billOfLadingNo || ! vesselName || ! voyageNo || ! estimatedSailingAt || ! estimatedArrivalAt ) {
actionError . value = getApiErrorDetails ( new Error ( '当前订舱缺少已确认运输资料,无法放舱' ))
2026-08-14 14:27:03 +08:00
return
}
2026-08-15 09:47:23 +08:00
if ( estimatedArrivalAt < estimatedSailingAt ) {
2026-08-14 14:27:03 +08:00
actionError . value = getApiErrorDetails ( new Error ( '预计到港时间不能早于预计开船时间' ))
return
}
actionSaving . value = true
actionError . value = null
try {
2026-08-15 09:47:23 +08:00
applyDetail ( await releaseOceanBooking ( id . value , { expectedVersion : detail . value . version , billOfLadingNo , vesselName , voyageNo , estimatedSailingAt , estimatedArrivalAt , reason : releaseReason . value . trim () || null }))
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-10 09:12:06 +08:00
async function submitChange () {
if ( ! detail . value || ! changeForm . vesselName . trim () || ! changeForm . voyageNo . trim () || ! changeForm . reason . trim ()) return
actionSaving . value = true
actionError . value = null
try {
applyDetail ( await requestOceanBookingChange ( id . value , { expectedVersion : detail . value . version , ... changeForm }))
changeDialogOpen . value = false
ElMessage . success ( '改船申请已提交' )
} catch ( cause ) { actionError . value = getApiErrorDetails ( cause ) }
finally { actionSaving . value = false }
}
async function runAction ( action : OceanBookingAction ) {
if ( ! detail . value || action === 'change-request' ) { if ( action === 'change-request' ) openChangeDialog (); return }
2026-08-14 14:27:03 +08:00
if ( action === 'release' ) { openReleaseDialog (); return }
2026-08-10 09:12:06 +08:00
actionError . value = null
try {
const reason = await requiredReason ( action )
await ElMessageBox . confirm ( `确认执行“ ${ actionLabels [ action ] } ”吗?` , actionLabels [ action ], { confirmButtonText : '确认' , cancelButtonText : '取消' , type : 'warning' })
actionSaving . value = true
2026-08-14 14:27:03 +08:00
applyDetail ( await performOceanBookingAction ( id . value , action , { expectedVersion : detail . value . version , reason }))
2026-08-10 09:12:06 +08:00
ElMessage . success ( ` ${ actionLabels [ action ] } 成功` )
} catch ( cause ) {
if ( cause !== 'cancel' && ! ( cause instanceof Error && cause . message === 'cancel' )) actionError . value = getApiErrorDetails ( cause )
} finally { actionSaving . value = false }
}
2026-08-17 13:24:55 +08:00
function validateBookingContainers () {
const message = bookingContainerError ( form . data . containers )
2026-08-12 18:53:18 +08:00
if ( message ) {
actionError . value = { message }
2026-08-12 18:50:03 +08:00
return false
}
return true
}
2026-08-17 13:24:55 +08:00
function addContainer () {
if ( form . data . containers . length < 20 ) form . data . containers . push ( createBlankBookingContainer ())
2026-08-14 19:02:34 +08:00
}
2026-08-17 13:24:55 +08:00
function removeContainer ( index : number ) { if ( form . data . containers . length > 1 ) form . data . containers . 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-15 09:47:23 +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-15 09:47:23 +08:00
< div >< h2 > 订舱信息 </ h2 >< p v-if = "detail && !editable" > 已确认订舱资料仅可查看 , 船期变更请使用 “ 申请改船 ”。 </ p ></ div >
2026-08-10 09:12:06 +08:00
< div class = "actions" >
< el-button v-for = "item in availableActions" :key="item" :loading="actionSaving" @click="runAction(item)" >{{ actionLabels [ item ] }} </ el -button >
2026-08-15 10:03:03 +08:00
< 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-14 19:59:32 +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 >
2026-08-15 09:47:23 +08:00
< 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 >
< 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="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-17 13:24:55 +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-17 13:24:55 +08:00
< div class = "container-table-wrap" aria -label =" 真实柜登记 " >
2026-08-14 18:50:11 +08:00
< table class = "container-table" >
2026-08-17 13:24:55 +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" scope="col" > 柜次号 </ th ></ tr ></ thead >
2026-08-14 18:50:11 +08:00
< tbody >
2026-08-17 13:24:55 +08:00
< tr v-for = "(container, index) in form.data.containers" :key="detail?.containers[index]?.containerBookingId || index" >
2026-08-14 18:50:11 +08:00
< td class = "container-table__action" >
2026-08-17 13:24:55 +08:00
< el-tooltip v-if = "editable" :content="form.data.containers.length === 1 ? '至少保留一个真实柜' : '删除该真实柜'" placement="top" >
< span >< el-button circle plain size = "small" type = "danger" : aria -label ="` 删除真实柜 $ { index + 1 } `" : disabled = "form.data.containers.length === 1" @click ="removeContainer(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-17 13:24:55 +08:00
< td >< el-input v-model = "container.containerNo" :disabled="!editable" maxlength="64" placeholder="请输入真实集装箱号" /></ td >
< td >< el-select :model-value = "container.containerType" :disabled = "!editable" placeholder = "请选择柜型" @ update : model -value =" selectBookingContainerType ( container , String ($ event || ''))" >< el-option v-for = "item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></ el-select ></ td >
2026-08-14 19:02:34 +08:00
< td >< el-select v-model = "container.containerKind" :disabled="!editable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></ el-select ></ td >
2026-08-17 13:24:55 +08:00
< td class = "container-table__specification" >< span >{{ container . capacityCbm }} m³ </ span ></ td >
< td class = "container-table__specification" >< span >{{ container . maxWeightKg }} kg </ span ></ td >
< td v-if = "detail" >< span >{{ detail . containers [ index ] ? . cabinetNo || '—' }}</ span ></ td >
2026-08-14 18:50:11 +08:00
</ tr >
</ tbody >
</ table >
2026-08-12 16:40:09 +08:00
</ div >
2026-08-17 13:24:55 +08:00
< div v-if = "editable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containers.length >= 20" @click="addContainer"><Plus :size="15" /> 新增真实柜 </ el-button >< span >{{ form . data . containers . 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-10 16:24:54 +08:00
< AppDialog v-model = "changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" confirm-text="提交申请" @confirm="submitChange" >
2026-08-10 09:12:06 +08:00
< el-form label -position =" top " >
2026-08-11 18:06:35 +08:00
< el-form-item label = "新船名" required >< el-input v-model = "changeForm.vesselName" placeholder="请输入新船名" /></ el-form-item >
< el-form-item label = "新航次" required >< el-input v-model = "changeForm.voyageNo" placeholder="请输入新航次" /></ el-form-item >
< el-form-item label = "预计开船" >< el-date-picker v-model = "changeForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></ el-form-item >
< el-form-item label = "预计到港" >< el-date-picker v-model = "changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></ el-form-item >
< el-form-item label = "变更原因" required >< el-input v-model = "changeForm.reason" type="textarea" placeholder="请说明改船原因" /></ el-form-item >
2026-08-10 09:12:06 +08:00
</ el-form >
2026-08-14 14:27:03 +08:00
</ AppDialog >
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-15 09:47:23 +08:00
< el-alert title = "请复核已登记的真实订舱资料。放舱不会重新采集或修改这些信息。" type = "info" :closable = "false" show -icon />
< 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 >
< el-form-item label = "备注" >< el-input v-model = "releaseReason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></ el-form-item >
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 >