2026-08-10 15:36:53 +08:00
< script setup lang = "ts" >
2026-09-14 18:34:34 +08:00
import '@/styles/adminDensity.css'
2026-08-10 15:36:53 +08:00
import { computed , onMounted , reactive , ref , watch } from 'vue'
2026-09-09 15:26:43 +08:00
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
2026-09-10 14:14:24 +08:00
import { useRouter } from 'vue-router'
2026-09-14 18:34:34 +08:00
import { MoreHorizontal } from '@lucide/vue'
2026-09-09 14:32:04 +08:00
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
2026-08-10 15:36:53 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-14 12:50:42 +08:00
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
2026-08-10 15:36:53 +08:00
import { listAllPlatformWarehouses , type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
2026-08-11 18:06:35 +08:00
import { listPlatformPartners , type PlatformPartnerResource } from '@/modules/master-data'
2026-08-15 11:12:22 +08:00
import { listSuppliers , type Supplier } from '@/modules/suppliers'
2026-09-10 14:14:24 +08:00
import { listOceanBookings , type OceanBookingResource } from '@/modules/ocean-bookings'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-10 15:36:53 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-12 16:31:46 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-10 15:36:53 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-10 15:36:53 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-09-15 10:38:03 +08:00
import { CONTAINER_SPECIFICATIONS , DEFAULT_CONTAINER_TYPE , applyContainerSpecification , containerSpecification } from '@/shared/domain/containerSpecifications'
2026-08-10 15:36:53 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
2026-09-10 14:14:24 +08:00
import { getContainer , listContainers , updateContainer } from '../api/platformContainerLoading'
import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue'
2026-09-10 19:25:11 +08:00
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
2026-09-07 23:58:18 +08:00
import {
2026-09-09 14:32:04 +08:00
getTerminalDrayageEmails ,
sendTerminalDrayageEmail ,
retryTerminalDrayageEmail ,
type TerminalDrayageEmail ,
2026-09-07 23:58:18 +08:00
getTerminalDrayage ,
getTerminalDrayageHistory ,
recordTerminalDrayageEmptyReturn ,
selectTerminalDrayageFleet ,
type TerminalDrayageAction ,
type TerminalDrayageHistory ,
type TerminalDrayageView ,
} from '../api/terminalDrayages'
2026-09-15 15:53:03 +08:00
import { listContainerArrivalTimes , updateContainerExpectedArrival , type ContainerArrivalTime } from '../api/containerArrivalTimes'
2026-09-09 15:26:43 +08:00
import type { ContainerBookingResource , ContainerDocumentStatus , ContainerPlanStatus , ContainerSaveRequest , ContainerWorkbenchAction , ContainerWorkbenchRow } from '../types'
2026-08-10 15:36:53 +08:00
2026-08-14 12:50:42 +08:00
type Filters = QueryModel & { customerId : string ; keyword : string ; containerType : string ; status : ContainerPlanStatus | '' ; sailingRange : string [] }
2026-08-19 09:42:12 +08:00
type ContainerForm = ContainerSaveRequest & Pick < ContainerBookingResource , ' cabinetNo ' > & { id : string ; version : number }
2026-08-10 15:36:53 +08:00
2026-09-08 19:57:42 +08:00
const props = withDefaults ( defineProps < { workbench ?: 'DOMESTIC' | 'OVERSEAS' } > (), { workbench : 'DOMESTIC' })
2026-08-10 15:36:53 +08:00
const router = useRouter ()
const access = useAccessPreviewStore ()
2026-09-08 19:57:42 +08:00
const rows = ref < ContainerWorkbenchRow [] >([])
2026-08-14 12:50:42 +08:00
const customers = ref < PlatformTenantResource [] >([])
const customersLoading = ref ( false )
2026-08-10 15:36:53 +08:00
const warehouses = ref < PlatformWarehouseSummaryResource [] >([])
2026-08-11 18:06:35 +08:00
const destinationWarehouses = ref < PlatformWarehouseSummaryResource [] >([])
const partners = ref < PlatformPartnerResource [] >([])
2026-08-15 11:12:22 +08:00
const truckingSuppliers = ref < Supplier [] >([])
2026-09-09 18:29:47 +08:00
const terminalFleetOptions = ref < Supplier [] >([])
const terminalFleetLoading = ref ( false )
2026-08-11 18:06:35 +08:00
const oceanBookings = ref < OceanBookingResource [] >([])
2026-08-10 15:36:53 +08:00
const loading = ref ( false )
const warehouseLoading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const warehouseError = ref < ApiErrorDetails | null >( null )
2026-08-11 18:06:35 +08:00
const businessOptionsLoading = ref ( false )
const businessOptionsError = ref ( '' )
2026-08-10 15:36:53 +08:00
const page = ref ( 1 )
const pageSize = ref ( 20 )
const total = ref ( 0 )
2026-09-08 19:57:42 +08:00
const scope = ref < 'ACTIVE' | 'HISTORY' > ( 'ACTIVE' )
2026-08-14 12:50:42 +08:00
const filters = ref < Filters >({ customerId : '' , keyword : '' , containerType : '' , status : '' , sailingRange : [] })
2026-08-10 15:36:53 +08:00
const applied = ref < Filters >({ ... filters . value })
const detail = ref < ContainerBookingResource | null >( null )
const editorOpen = ref ( false )
const editorError = ref < ApiErrorDetails | null >( null )
const saving = ref ( false )
const formRef = ref < FormInstance >()
2026-08-14 20:59:19 +08:00
const linkOpen = ref ( false )
const linkTarget = ref < ContainerBookingResource | null >( null )
2026-08-15 11:12:22 +08:00
const selectedTruckingSupplierId = ref ( '' )
2026-08-25 19:56:37 +08:00
const unresolvedTruckingSupplierName = ref < string | null >( null )
2026-09-07 23:58:18 +08:00
const drayages = ref < Record < string , TerminalDrayageView > > ({})
2026-09-15 15:53:03 +08:00
const arrivalTimes = ref < Record < string , ContainerArrivalTime > > ({})
2026-09-15 16:11:20 +08:00
const arrivalInputs = ref < Record < string , string > > ({})
2026-09-15 15:53:03 +08:00
const arrivalSavingIds = ref < string [] >([])
2026-09-07 23:58:18 +08:00
const drayageSavingIds = ref < string [] >([])
const drayageActionOpen = ref ( false )
2026-09-09 14:32:04 +08:00
const drayageAction = ref < Exclude < TerminalDrayageAction , ' SELECT_FLEET ' | ' SEND_PICKUP_EMAIL ' > > ( 'SCHEDULE_PICKUP' )
2026-09-07 23:58:18 +08:00
const drayageTarget = ref < ContainerBookingResource | null >( null )
const drayageActionAt = ref ( '' )
const drayageReason = ref ( '' )
const drayageActionSaving = ref ( false )
const drayageActionError = ref < ApiErrorDetails | null >( null )
const drayageHistoryOpen = ref ( false )
const drayageHistory = ref < TerminalDrayageHistory [] >([])
const drayageHistoryLoading = ref ( false )
const drayageHistoryError = ref < ApiErrorDetails | null >( null )
2026-09-09 14:32:04 +08:00
const emailHistoryOpen = ref ( false )
const emailTarget = ref < ContainerBookingResource | null >( null )
const emails = ref < TerminalDrayageEmail [] >([])
const emailsLoading = ref ( false )
const emailsError = ref < ApiErrorDetails | null >( null )
const emailDeliveryEnabled = ref ( false )
const retryingEmail = ref ( '' )
let emailSequence = 0
const emailColumns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 90 , fixed : 'left' , slotName : 'emailActions' },
{ key : 'kind' , label : '类型' , width : 100 , formatter : ( row : TerminalDrayageEmail ) => row . kind === 'CANCEL_PICKUP' ? '取消提柜' : '请求提柜' },
{ key : 'fleetName' , label : '车队' , minWidth : 130 },
{ key : 'recipient' , label : '收件邮箱' , minWidth : 210 },
{ key : 'status' , label : '发送状态' , width : 100 , formatter : ( row : TerminalDrayageEmail ) => ({ PENDING : '待发送' , SENDING : '发送中' , SENT : '已发送' , FAILED : '失败待核对' })[ row . status ] },
{ key : 'content' , label : '邮件内容' , minWidth : 270 , slotName : 'emailContent' },
{ key : 'failureReason' , label : '失败说明' , minWidth : 200 },
{ key : 'createdAt' , label : '创建时间' , width : 165 , formatter : ( row : TerminalDrayageEmail ) => formatTime ( row . createdAt ) },
{ key : 'sentAt' , label : '发送时间' , width : 165 , formatter : ( row : TerminalDrayageEmail ) => formatTime ( row . sentAt ) },
]
async function sendPickupEmail ( row : ContainerWorkbenchRow ) {
const current = drayageFor ( row . id )
if ( ! current || ! canDrayage ( row , 'SEND_PICKUP_EMAIL' ) || drayageSavingIds . value . includes ( row . id )) return
drayageSavingIds . value . push ( row . id )
try {
replaceDrayage ( await sendTerminalDrayageEmail ( row . id , current . version ))
ElMessage . success ( '提柜邮件已加入发送队列,可在邮件记录中查看结果' )
await loadRows ()
} catch ( error ) {
ElMessage . error ( getApiErrorDetails ( error , '提交提柜邮件失败' ). message )
await loadRows ()
} finally { drayageSavingIds . value = drayageSavingIds . value . filter (( id ) => id !== row . id ) }
}
async function openEmailHistory ( row : ContainerBookingResource ) {
const sequence = ++ emailSequence
emailTarget . value = row ; emailHistoryOpen . value = true ; emailsLoading . value = true
emailsError . value = null ; emails . value = []
try {
const result = await getTerminalDrayageEmails ( row . id )
if ( sequence !== emailSequence ) return
emails . value = result . records ; emailDeliveryEnabled . value = result . deliveryEnabled
} catch ( error ) {
if ( sequence === emailSequence ) emailsError . value = getApiErrorDetails ( error , '邮件记录加载失败' )
} finally { if ( sequence === emailSequence ) emailsLoading . value = false }
}
async function retryEmail ( mail : TerminalDrayageEmail ) {
const target = emailTarget . value
if ( ! target || ! canFleetEdit . value || retryingEmail . value || mail . status !== 'FAILED' ) return
retryingEmail . value = mail . id
try {
2026-09-14 18:34:34 +08:00
await ElMessageBox . confirm ( '发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。' , '重试邮件' , { customClass : 'admin-density-confirm' , confirmButtonText : '确认重试' , cancelButtonText : '取消' , type : 'warning' })
2026-09-09 14:32:04 +08:00
await retryTerminalDrayageEmail ( target . id , mail . id )
ElMessage . success ( '邮件已重新加入发送队列' )
await openEmailHistory ( target )
} catch ( error ) {
if ( error !== 'cancel' && error !== 'close' ) ElMessage . error ( getApiErrorDetails ( error , '重试邮件失败' ). message )
} finally { retryingEmail . value = '' }
}
2026-08-10 15:36:53 +08:00
let listSequence = 0
let detailSequence = 0
2026-09-08 20:55:30 +08:00
let warehousesLoaded = false
let warehouseOptionsPromise : Promise < void > | null = null
let partnersLoaded = false
let truckingSuppliersLoaded = false
let truckingSuppliersPromise : Promise < Supplier [] > | null = null
2026-09-09 18:29:47 +08:00
let terminalFleetSearchSequence = 0
2026-09-08 20:55:30 +08:00
let oceanBookingsLoaded = false
let businessOptionsPromise : Promise < void > | null = null
2026-08-10 15:36:53 +08:00
function emptyForm () : ContainerForm {
return {
id : '' , version : 0 , containerNo : null , cabinetNo : '' , warehouseId : '' , warehouseName : null ,
2026-09-15 10:38:03 +08:00
billOfLadingNo : null , vesselName : null , voyageNo : null , containerType : DEFAULT_CONTAINER_TYPE , shippingCompanyId : null ,
2026-08-10 15:36:53 +08:00
shippingCompanyName : null , departurePortCode : null , departurePortName : null , destinationPortCode : null ,
destinationPortName : null , destinationWarehouseCode : null , destinationWarehouseName : null ,
2026-09-15 10:38:03 +08:00
capacityCbm : 68 , maxWeightKg : 19500 , terminalTruckingCompanyId : null , terminalTruckingCompany : null , loadingAt : null ,
2026-08-10 15:36:53 +08:00
estimatedSailingAt : null , actualSailingAt : null , estimatedPortArrivalAt : null , actualPortArrivalAt : null ,
estimatedWarehouseArrivalAt : null , actualWarehouseArrivalAt : null ,
}
}
const form = reactive < ContainerForm >( emptyForm ())
const rules : FormRules < ContainerForm > = {
warehouseId : [{ required : true , message : '请选择装柜仓库' , trigger : 'change' }],
containerType : [{ required : true , message : '请选择柜型' , trigger : 'change' }],
2026-08-25 19:56:37 +08:00
terminalTruckingCompany : [{ validator : ( _rule , value , callback ) => callback ( unresolvedTruckingSupplierName . value || ( value && ! selectedTruckingSupplierId . value ) ? new Error ( '请选择有效的合作中拖车供应商' ) : undefined ), trigger : 'change' }],
2026-08-10 15:36:53 +08:00
}
2026-09-08 19:57:42 +08:00
const isDomestic = computed (() => props . workbench === 'DOMESTIC' )
const isOverseas = computed (() => props . workbench === 'OVERSEAS' )
const canView = computed (() => isDomestic . value ? access . can ( 'platform:firstleg:container:list' ) : access . can ( 'platform:firstleg:terminal-drayage:list' ))
2026-08-10 15:36:53 +08:00
const canEdit = computed (() => access . can ( 'platform:firstleg:container:edit' ))
2026-08-14 20:59:19 +08:00
const canCreate = computed (() => access . can ( 'platform:firstleg:container:create' ))
2026-09-08 19:57:42 +08:00
const canOverseasDocumentEdit = computed (() => access . can ( 'platform:firstleg:terminal-drayage:document:edit' ))
const canFleetEdit = computed (() => access . can ( 'platform:firstleg:terminal-drayage:fleet:edit' ))
const canPickupAppointmentEdit = computed (() => access . can ( 'platform:firstleg:terminal-drayage:pickup-appointment:edit' ))
2026-09-15 16:11:20 +08:00
const canExpectedArrivalEdit = computed (() => access . can ( 'platform:overseas-warehouse:last-mile-order:edit' ))
2026-09-08 19:57:42 +08:00
const canEmptyReturnConfirm = computed (() => access . can ( 'platform:firstleg:terminal-drayage:empty-return:confirm' ))
2026-08-10 15:36:53 +08:00
const canSmartLoading = computed (() => access . can ( 'platform:firstleg:smart-loading:list' ))
2026-08-11 11:15:44 +08:00
const canViewPreload = computed (() => access . can ( 'platform:firstleg:preload:list' ))
const canViewLoadingList = computed (() => access . can ( 'platform:firstleg:loading-list:list' ))
2026-08-12 16:31:46 +08:00
const editorTitle = computed (() => form . id ? '编辑' : '新增' )
2026-08-11 18:06:35 +08:00
const shippingCompanies = computed (() => partners . value . filter (( partner ) => partner . status === 'ACTIVE' && partner . types . includes ( 'CARRIER' )))
2026-08-15 11:12:22 +08:00
const truckingCompanies = computed (() => truckingSuppliers . value . filter (( supplier ) => supplier . status === 'ACTIVE' && supplier . category === 'TRUCKING' ))
2026-08-11 18:06:35 +08:00
interface PortOption { value : string ; code : string | null ; name : string | null }
function portValue ( code : string | null | undefined , name : string | null | undefined ) { return code || name ? JSON . stringify ([ code || '' , name || '' ]) : '' }
function collectPorts ( direction : 'departure' | 'destination' ) : PortOption [] {
const entries = oceanBookings . value . map (( booking ) => direction === 'departure'
? { code : booking . departurePortCode , name : booking . departurePortName }
: { code : booking . destinationPortCode , name : booking . destinationPortName })
const current = direction === 'departure'
? { code : form . departurePortCode , name : form . departurePortName }
: { code : form . destinationPortCode , name : form . destinationPortName }
entries . push ( current )
const options = new Map < string , PortOption >()
for ( const entry of entries ) {
if ( ! entry . code && ! entry . name ) continue
const value = portValue ( entry . code , entry . name )
options . set ( value , { value , code : entry . code || null , name : entry . name || null })
}
return [... options . values ()]. sort (( left , right ) => ( left . name || left . code || '' ). localeCompare ( right . name || right . code || '' , 'zh-CN' ))
}
const departurePorts = computed (() => collectPorts ( 'departure' ))
const destinationPorts = computed (() => collectPorts ( 'destination' ))
const departurePortValue = computed (() => portValue ( form . departurePortCode , form . departurePortName ))
const destinationPortValue = computed (() => portValue ( form . destinationPortCode , form . destinationPortName ))
const shippingCompanyValue = computed (() => form . shippingCompanyId || ( form . shippingCompanyName ? `name: ${ form . shippingCompanyName } ` : '' ))
const destinationWarehouseValue = computed (() => form . destinationWarehouseCode || ( form . destinationWarehouseName ? `name: ${ form . destinationWarehouseName } ` : '' ))
2026-08-10 15:36:53 +08:00
const queryFields = computed < QueryField [] >(() => [
2026-08-14 14:27:03 +08:00
{ key : 'customerId' , type : 'select' , label : '客户' , width : 220 , placeholder : '全部客户' , options : customers . value . filter (( customer ) => customer . status === 'ACTIVE' ). map (( customer ) => ({ label : customer . tenantName , value : String ( customer . id ) })), props : { filterable : true , loading : customersLoading . value } },
2026-08-15 17:04:00 +08:00
{ key : 'keyword' , type : 'input' , label : '关键词' , width : 220 , placeholder : '柜次号、集装箱号或提单号' , props : { 'aria-label' : '关键词' , maxlength : 128 } },
2026-08-25 17:43:00 +08:00
{ key : 'containerType' , type : 'select' , label : '柜型' , width : 140 , placeholder : '请选择箱型' , options : CONTAINER_SPECIFICATIONS . map (( item ) => ({ label : item . type , value : item . type })), props : { 'aria-label' : '柜型' } },
2026-09-04 15:54:36 +08:00
{ key : 'status' , type : 'select' , label : '装柜状态' , width : 150 , placeholder : '请选择货柜状态' , options : [{ label : '待装柜' , value : 'WAIT_LOADING' }, { label : '预装柜中' , value : 'EDITING' }, { label : '拣货中' , value : 'LOADING' }, { label : '装柜完成' , value : 'COMPLETED' }], props : { 'aria-label' : '装柜状态' } },
2026-08-10 15:36:53 +08:00
{ key : 'sailingRange' , type : 'daterange' , label : '预计开船' , width : 260 , props : { 'aria-label' : '预计开船日期范围' } },
])
2026-09-08 19:57:42 +08:00
const allColumns : DataTableColumn [] = [
2026-09-09 17:42:19 +08:00
{ key : 'actions' , label : '操作' , width : 240 , fixed : 'left' , slotName : 'actions' },
2026-09-07 23:58:18 +08:00
{ key : 'emptyReturnWarning' , label : '报空预警' , width : 120 , slotName : 'emptyReturnWarning' },
2026-08-10 15:36:53 +08:00
{ key : 'containerNo' , label : '集装箱号' , minWidth : 150 , slotName : 'containerNo' },
2026-08-17 19:57:32 +08:00
{ key : 'planNo' , prop : 'planNo' , label : '装柜清单号' , minWidth : 165 , formatter : ( row : ContainerBookingResource ) => row . planNo || '—' },
2026-08-10 15:36:53 +08:00
{ key : 'planStatus' , label : '状态' , width : 110 , slotName : 'status' },
2026-08-17 19:57:32 +08:00
{ key : 'exceptionActive' , label : '异常' , width : 90 , formatter : ( row : ContainerBookingResource ) => row . exceptionActive ? '异常' : '正常' },
{ key : 'cabinetNo' , label : '柜次号' , minWidth : 180 , slotName : 'cabinet' },
{ key : 'destinationClearanceDocumentStatus' , label : '清关资料' , width : 110 , formatter : ( row : ContainerBookingResource ) => documentStatusText ( row . destinationClearanceDocumentStatus ) },
{ key : 'deliveryOrderDocumentStatus' , label : 'DO资料' , width : 100 , formatter : ( row : ContainerBookingResource ) => documentStatusText ( row . deliveryOrderDocumentStatus ) },
2026-09-08 19:57:42 +08:00
{ key : 'pickupCode' , label : 'PU码' , minWidth : 140 , formatter : ( row : ContainerBookingResource ) => row . pickupCode || '—' },
2026-08-17 19:57:32 +08:00
{ key : 'oceanBooking' , label : '真实订舱' , minWidth : 170 , slotName : 'oceanBooking' },
2026-08-10 15:36:53 +08:00
{ key : 'warehouseName' , prop : 'warehouseName' , label : '装柜仓库' , minWidth : 160 },
2026-09-10 19:25:11 +08:00
{ key : 'channelGroupCode' , label : '计划渠道' , minWidth : 140 , formatter : ( row : ContainerBookingResource ) => getSmartLoadingChannelGroup ( row . channelGroupCode ) ? . name || '—' },
{ key : 'channelName' , label : '实际渠道' , minWidth : 160 , formatter : ( row : ContainerBookingResource ) => row . channelName || '待关联订舱' },
2026-09-07 23:58:18 +08:00
{ key : 'terminalTruckingCompany' , label : '码头车队' , width : 190 , slotName : 'terminalTruckingCompany' },
2026-09-15 15:53:03 +08:00
{ key : 'pickupAppointmentAt' , label : '预计到仓' , width : 210 , slotName : 'pickupAppointmentAt' },
{ key : 'actualPickupAt' , label : '实际到仓' , width : 165 , slotName : 'actualPickupAt' },
2026-09-07 23:58:18 +08:00
{ key : 'estimatedEmptyReturnAt' , label : '预计还柜' , width : 165 , slotName : 'estimatedEmptyReturnAt' },
{ key : 'actualEmptyReturnAt' , label : '实际还柜' , width : 165 , slotName : 'actualEmptyReturnAt' },
2026-08-13 11:57:44 +08:00
{ key : 'billOfLadingNo' , prop : 'billOfLadingNo' , label : '提单号' , minWidth : 150 },
2026-08-10 15:36:53 +08:00
{ key : 'route' , label : '航线' , minWidth : 220 , slotName : 'route' },
{ key : 'containerType' , prop : 'containerType' , label : '柜型' , width : 100 },
{ key : 'capacity' , label : '容量上限' , minWidth : 150 , slotName : 'capacity' },
{ key : 'vessel' , label : '船名/航次' , minWidth : 180 , slotName : 'vessel' },
2026-08-13 11:57:44 +08:00
{ key : 'shippingCompanyName' , prop : 'shippingCompanyName' , label : '船公司' , minWidth : 150 },
{ key : 'loadingAt' , label : '装柜时间' , minWidth : 160 , slotName : 'loadingAt' },
2026-08-10 15:36:53 +08:00
{ key : 'estimatedSailingAt' , label : '预计开船' , minWidth : 160 , slotName : 'estimatedSailingAt' },
2026-08-13 11:57:44 +08:00
{ key : 'actualSailingAt' , label : '实际开船' , minWidth : 160 , slotName : 'actualSailingAt' },
{ key : 'estimatedPortArrivalAt' , label : '预计到港' , minWidth : 160 , slotName : 'estimatedPortArrivalAt' },
{ key : 'actualPortArrivalAt' , label : '实际到港' , minWidth : 160 , slotName : 'actualPortArrivalAt' },
2026-08-10 15:36:53 +08:00
]
2026-09-10 19:25:11 +08:00
const domesticColumnKeys = new Set ([ 'actions' , 'containerNo' , 'planNo' , 'planStatus' , 'exceptionActive' , 'cabinetNo' , 'oceanBooking' , 'warehouseName' , 'channelGroupCode' , 'channelName' , 'billOfLadingNo' , 'route' , 'containerType' , 'capacity' , 'vessel' , 'shippingCompanyName' , 'loadingAt' , 'estimatedSailingAt' ])
2026-09-08 19:57:42 +08:00
const overseasColumnKeys = new Set ([ 'actions' , 'emptyReturnWarning' , 'containerNo' , 'cabinetNo' , 'destinationClearanceDocumentStatus' , 'deliveryOrderDocumentStatus' , 'pickupCode' , 'terminalTruckingCompany' , 'pickupAppointmentAt' , 'actualPickupAt' , 'estimatedEmptyReturnAt' , 'actualEmptyReturnAt' , 'billOfLadingNo' , 'route' , 'vessel' , 'actualSailingAt' , 'estimatedPortArrivalAt' , 'actualPortArrivalAt' ])
2026-09-09 13:51:14 +08:00
const columns = computed (() => allColumns
. filter (( column ) => ( isDomestic . value ? domesticColumnKeys : overseasColumnKeys ). has ( String ( column . key )))
// 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。
2026-09-09 13:54:43 +08:00
. map (( column ) => isDomestic . value && column . key === 'actions' ? { ... column , width : 290 } : column ))
2026-09-07 23:58:18 +08:00
const drayageHistoryColumns : DataTableColumn [] = [
{ key : 'action' , label : '动作' , width : 130 , formatter : ( row : TerminalDrayageHistory ) => drayageActionText ( row . action ) },
{ key : 'status' , label : '状态变化' , minWidth : 180 , formatter : ( row : TerminalDrayageHistory ) => ` ${ drayageStatusText ( row . fromStatus ) } → ${ drayageStatusText ( row . toStatus ) } ` },
{ key : 'version' , label : '版本' , width : 100 , formatter : ( row : TerminalDrayageHistory ) => ` ${ row . fromVersion } → ${ row . toVersion } ` },
{ key : 'operatorName' , label : '操作人' , width : 130 , formatter : ( row : TerminalDrayageHistory ) => row . operatorName || '—' },
{ key : 'createdAt' , label : '操作时间' , width : 170 , formatter : ( row : TerminalDrayageHistory ) => formatTime ( row . createdAt ) },
{ key : 'reason' , label : '原因' , minWidth : 200 },
]
2026-08-10 15:36:53 +08:00
function optional ( value : string | null ) { return value ? . trim () || null }
2026-08-11 11:52:30 +08:00
function statusText ( status : ContainerPlanStatus ) { return ({ WAIT_LOADING : '待装柜' , EDITING : '预装柜中' , LOADING : '拣货中' , COMPLETED : '装柜完成' })[ status ] }
2026-08-17 19:57:32 +08:00
function documentStatusText ( status : ContainerDocumentStatus | null | undefined ) { return status ? ({ MISSING : '未上传' , UPLOADED : '已上传' , COMPLETED : '已完成' })[ status ] : '—' }
2026-08-10 15:36:53 +08:00
function formatTime ( value : string | null | undefined ) { return value ? . replace ( 'T' , ' ' ). slice ( 0 , 16 ) || '—' }
2026-09-07 23:58:18 +08:00
function drayageFor ( containerBookingId : string ) { return drayages . value [ containerBookingId ] }
2026-09-15 15:53:03 +08:00
function drayageStatusText ( status ?: string | null ) { return ({ WAITING_FOR_ARRIVAL : '待到港' , ARRIVED : '已到港' , PICKUP_SCHEDULED : '已设置预计到仓' , PICKED_UP : '已实际到仓' , UNLOADED : '已卸柜' , EMPTY_RETURNED : '已还柜' } as Record < string , string >)[ status || '' ] || status || '—' }
function drayageActionText ( action : TerminalDrayageAction ) { return ({ SELECT_FLEET : '选择码头车队' , SCHEDULE_PICKUP : '修改预计到仓' , RECORD_EMPTY_RETURN : '确认还柜' , SEND_PICKUP_EMAIL : '发送提柜邮件' } as Record < TerminalDrayageAction , string >)[ action ] }
2026-09-08 19:57:42 +08:00
function hasAction ( row : ContainerWorkbenchRow , action : ContainerWorkbenchAction ) { return Boolean ( row . allowedActions ? . includes ( action )) }
function canDrayage ( row : ContainerWorkbenchRow , action : TerminalDrayageAction ) {
2026-09-15 14:18:23 +08:00
const permission = action === 'SELECT_FLEET' || action === 'SEND_PICKUP_EMAIL' ? canFleetEdit . value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit . value : canEmptyReturnConfirm . value
2026-09-08 19:57:42 +08:00
return Boolean ( isOverseas . value && permission && hasAction ( row , action ) && drayageFor ( row . id ) ? . allowedActions . includes ( action ))
}
2026-09-07 23:58:18 +08:00
function drayageWarningText ( row : ContainerBookingResource ) {
const warning = drayageFor ( row . id ) ? . emptyReturnWarning
if ( ! warning || warning . status === 'NOT_APPLICABLE' ) return '—'
if ( warning . status === 'RESOLVED' ) return '已还柜'
if ( warning . status === 'OVERDUE' ) return warning . overdueDays > 0 ? `超时 ${ warning . overdueDays } 天` : `超时 ${ warning . overdueHours } 小时`
return '待还柜'
}
function drayageWarningType ( row : ContainerBookingResource ) {
const status = drayageFor ( row . id ) ? . emptyReturnWarning . status
return status === 'OVERDUE' ? 'danger' : status === 'RESOLVED' ? 'success' : status === 'PENDING' ? 'warning' : 'info'
}
function drayageWarningTitle ( row : ContainerBookingResource ) {
const warning = drayageFor ( row . id ) ? . emptyReturnWarning
if ( ! warning ) return ''
const deadline = warning . deadlineAt ? `还柜截止: ${ formatTime ( warning . deadlineAt ) } ` : ''
return [ drayageWarningText ( row ), deadline ]. filter ( Boolean ). join ( '; ' )
}
function localDateTimeValue () {
const now = new Date ( Date . now () - new Date (). getTimezoneOffset () * 60 _000 )
return now . toISOString (). slice ( 0 , 19 )
}
2026-08-10 17:42:17 +08:00
function containerViewLabel ( row : ContainerBookingResource ) {
const reference = [ row . cabinetNo , row . containerNo , row . bookingNo ]
. find (( value ) => typeof value === 'string' && value . trim ())
return reference ? `查看 ${ reference } ` : '查看货柜详情'
}
2026-08-10 15:36:53 +08:00
function replaceForm ( source : ContainerBookingResource | null ) {
Object . assign ( form , emptyForm (), source || {})
2026-08-17 12:31:27 +08:00
const specification = containerSpecification ( form . containerType )
if ( specification ) applyContainerSpecification ( form , specification . type )
2026-08-25 19:56:37 +08:00
reconcileTruckingSupplier ()
}
function reconcileTruckingSupplier () {
const supplierId = optional ( form . terminalTruckingCompanyId )
const supplierName = optional ( form . terminalTruckingCompany ) || unresolvedTruckingSupplierName . value
const supplier = supplierId
? truckingCompanies . value . find (( item ) => item . id === supplierId )
: truckingCompanies . value . find (( item ) => item . name === supplierName )
selectedTruckingSupplierId . value = supplier ? . id || ''
unresolvedTruckingSupplierName . value = supplier ? null : supplierName
if ( supplier ) {
form . terminalTruckingCompanyId = supplier . id
form . terminalTruckingCompany = supplier . name
} else if ( ! supplierName ) {
form . terminalTruckingCompanyId = null
form . terminalTruckingCompany = null
} else {
form . terminalTruckingCompanyId = null
form . terminalTruckingCompany = supplierName
}
2026-08-10 15:36:53 +08:00
}
function payload () : ContainerSaveRequest {
const selectedWarehouse = warehouses . value . find (( warehouse ) => String ( warehouse . id ) === form . warehouseId )
2026-08-25 19:56:37 +08:00
const selectedTruckingSupplier = truckingCompanies . value . find (( supplier ) => supplier . id === selectedTruckingSupplierId . value )
2026-08-10 15:36:53 +08:00
return {
2026-08-31 12:12:51 +08:00
containerNo : optional ( detail . value ? . containerNo || null ),
2026-08-10 15:36:53 +08:00
warehouseId : form . warehouseId ,
warehouseName : selectedWarehouse ? . warehouseName || optional ( form . warehouseName ),
billOfLadingNo : optional ( form . billOfLadingNo ), vesselName : optional ( form . vesselName ), voyageNo : optional ( form . voyageNo ),
containerType : form . containerType ,
shippingCompanyId : optional ( form . shippingCompanyId ), shippingCompanyName : optional ( form . shippingCompanyName ),
departurePortCode : optional ( form . departurePortCode ), departurePortName : optional ( form . departurePortName ),
destinationPortCode : optional ( form . destinationPortCode ), destinationPortName : optional ( form . destinationPortName ),
destinationWarehouseCode : optional ( form . destinationWarehouseCode ), destinationWarehouseName : optional ( form . destinationWarehouseName ),
2026-08-25 19:56:37 +08:00
capacityCbm : form . capacityCbm , maxWeightKg : form . maxWeightKg ,
terminalTruckingCompanyId : selectedTruckingSupplier ? . id || null ,
terminalTruckingCompany : selectedTruckingSupplier ? . name || null ,
2026-08-10 15:36:53 +08:00
loadingAt : form . loadingAt , estimatedSailingAt : form . estimatedSailingAt , actualSailingAt : form . actualSailingAt ,
estimatedPortArrivalAt : form . estimatedPortArrivalAt , actualPortArrivalAt : form . actualPortArrivalAt ,
estimatedWarehouseArrivalAt : form . estimatedWarehouseArrivalAt , actualWarehouseArrivalAt : form . actualWarehouseArrivalAt ,
}
}
2026-08-11 18:06:35 +08:00
function partnerLabel ( partner : PlatformPartnerResource ) {
return ` ${ partner . partnerName }${ partner . partnerCode ? `( ${ partner . partnerCode } ) ` : '' } `
}
2026-08-15 11:12:22 +08:00
function selectTruckingSupplier ( supplierId : string ) {
2026-08-25 19:56:37 +08:00
if ( ! supplierId && unresolvedTruckingSupplierName . value ) return
2026-08-15 11:12:22 +08:00
const supplier = truckingCompanies . value . find (( item ) => item . id === supplierId )
selectedTruckingSupplierId . value = supplier ? . id || ''
2026-08-25 19:56:37 +08:00
unresolvedTruckingSupplierName . value = null
form . terminalTruckingCompanyId = supplier ? . id || null
2026-08-15 11:12:22 +08:00
form . terminalTruckingCompany = supplier ? . name || null
}
2026-08-11 18:06:35 +08:00
function portLabel ( port : PortOption ) {
return port . name && port . code ? ` ${ port . name } ( ${ port . code } ) ` : port . name || port . code || '未命名港口'
}
function selectShippingCompany ( partnerId : string ) {
if ( partnerId . startsWith ( 'name:' )) {
form . shippingCompanyId = null
form . shippingCompanyName = partnerId . slice ( 5 ) || null
return
}
const partner = partners . value . find (( item ) => String ( item . id ) === partnerId )
form . shippingCompanyId = partnerId || null
form . shippingCompanyName = partner ? . partnerName || null
}
function selectPort ( direction : 'departure' | 'destination' , value : string ) {
const option = ( direction === 'departure' ? departurePorts . value : destinationPorts . value ). find (( item ) => item . value === value )
if ( direction === 'departure' ) {
form . departurePortCode = option ? . code || null
form . departurePortName = option ? . name || null
} else {
form . destinationPortCode = option ? . code || null
form . destinationPortName = option ? . name || null
}
}
function selectDestinationWarehouse ( warehouseCode : string ) {
if ( warehouseCode . startsWith ( 'name:' )) {
form . destinationWarehouseCode = null
form . destinationWarehouseName = warehouseCode . slice ( 5 ) || null
return
}
const warehouse = destinationWarehouses . value . find (( item ) => item . warehouseCode === warehouseCode )
form . destinationWarehouseCode = warehouseCode || null
form . destinationWarehouseName = warehouse ? . warehouseName || null
}
2026-08-10 15:36:53 +08:00
async function loadWarehouses () {
2026-09-08 20:55:30 +08:00
if ( warehousesLoaded ) return
if ( warehouseOptionsPromise ) return warehouseOptionsPromise
warehouseOptionsPromise = ( async () => {
warehouseLoading . value = true ; warehouseError . value = null
try {
const records = await listAllPlatformWarehouses ({ status : 'ACTIVE' })
warehouses . value = records . filter (( warehouse ) => warehouse . warehouseType === 'DOMESTIC' )
destinationWarehouses . value = records . filter (( warehouse ) => warehouse . warehouseType !== 'DOMESTIC' )
warehousesLoaded = true
}
catch ( error ) { warehouseError . value = getApiErrorDetails ( error , '装柜仓库加载失败' ) }
finally { warehouseLoading . value = false }
})()
try { await warehouseOptionsPromise }
finally { warehouseOptionsPromise = null }
2026-08-10 15:36:53 +08:00
}
2026-08-14 12:50:42 +08:00
async function loadCustomers () {
customersLoading . value = true
try {
const result = await listPlatformTenants ({ page : 1 , size : 200 , status : 'ACTIVE' })
customers . value = result . records . filter (( customer ) => customer . status === 'ACTIVE' )
} catch {
customers . value = []
} finally {
customersLoading . value = false
}
}
2026-08-11 18:06:35 +08:00
async function loadBusinessOptions () {
2026-09-08 20:55:30 +08:00
if ( partnersLoaded && truckingSuppliersLoaded && oceanBookingsLoaded ) return
if ( businessOptionsPromise ) return businessOptionsPromise
businessOptionsPromise = ( async () => {
businessOptionsLoading . value = true
businessOptionsError . value = ''
const errors : string [] = []
const [ partnerResult , supplierResult , bookingResult ] = await Promise . allSettled ([
partnersLoaded ? Promise . resolve ( partners . value ) : loadAllPartners (),
loadTruckingSuppliers (),
oceanBookingsLoaded ? Promise . resolve ( oceanBookings . value ) : loadAllOceanBookings (),
])
if ( partnerResult . status === 'fulfilled' ) { partners . value = partnerResult . value ; partnersLoaded = true }
else errors . push ( getApiErrorDetails ( partnerResult . reason , '船公司与拖车公司加载失败' ). message )
if ( supplierResult . status === 'fulfilled' ) {
if ( form . id ) reconcileTruckingSupplier ()
}
else errors . push ( getApiErrorDetails ( supplierResult . reason , '拖车供应商加载失败' ). message )
if ( bookingResult . status === 'fulfilled' ) { oceanBookings . value = bookingResult . value ; oceanBookingsLoaded = true }
else errors . push ( getApiErrorDetails ( bookingResult . reason , '港口候选加载失败' ). message )
businessOptionsError . value = errors . join ( '; ' )
businessOptionsLoading . value = false
})()
try { await businessOptionsPromise }
finally { businessOptionsPromise = null }
}
async function loadTruckingSuppliers () : Promise < Supplier [] > {
if ( truckingSuppliersLoaded ) return truckingSuppliers . value
if ( truckingSuppliersPromise ) return truckingSuppliersPromise
truckingSuppliersPromise = ( async () => {
const result = await listSuppliers ({ page : 1 , size : 200 , category : 'TRUCKING' , status : 'ACTIVE' })
const records = result . records . filter (( supplier ) => supplier . category === 'TRUCKING' && supplier . status === 'ACTIVE' )
truckingSuppliers . value = records
truckingSuppliersLoaded = true
return records
})()
try { return await truckingSuppliersPromise }
finally { truckingSuppliersPromise = null }
}
2026-09-09 18:29:47 +08:00
async function searchTerminalFleets ( keyword = '' ) {
const sequence = ++ terminalFleetSearchSequence
terminalFleetLoading . value = true
try {
const result = await listSuppliers ({
page : 1 , size : 20 , category : 'TRUCKING' , status : 'ACTIVE' , keyword : keyword . trim () || undefined ,
})
if ( sequence !== terminalFleetSearchSequence ) return
terminalFleetOptions . value = result . records . filter (( supplier ) => supplier . category === 'TRUCKING' && supplier . status === 'ACTIVE' )
} catch ( error ) {
if ( sequence !== terminalFleetSearchSequence ) return
terminalFleetOptions . value = []
ElMessage . error ( getApiErrorDetails ( error , '码头车队搜索失败' ). message )
} finally {
if ( sequence === terminalFleetSearchSequence ) terminalFleetLoading . value = false
}
}
function handleTerminalFleetVisible ( open : boolean ) {
if ( open ) void searchTerminalFleets ()
else { terminalFleetSearchSequence ++ ; terminalFleetLoading . value = false }
2026-08-11 18:06:35 +08:00
}
async function loadAllPartners () {
const firstPage = await listPlatformPartners ({ page : 1 , size : 200 , status : 'ACTIVE' })
if ( firstPage . pages <= 1 ) return firstPage . records
const remaining = await Promise . all ( Array . from ({ length : firstPage . pages - 1 }, ( _ , index ) => listPlatformPartners ({ page : index + 2 , size : 200 , status : 'ACTIVE' })))
return [ firstPage , ... remaining ]. flatMap (( pageResult ) => pageResult . records )
}
async function loadAllOceanBookings () {
const firstPage = await listOceanBookings ({ page : 1 , size : 200 })
if ( firstPage . pages <= 1 ) return firstPage . records
const remaining = await Promise . all ( Array . from ({ length : firstPage . pages - 1 }, ( _ , index ) => listOceanBookings ({ page : index + 2 , size : 200 })))
return [ firstPage , ... remaining ]. flatMap (( pageResult ) => pageResult . records )
}
2026-09-09 15:26:43 +08:00
async function loadSimulationWarehouses () {
2026-09-08 20:55:30 +08:00
await loadWarehouses ()
2026-09-09 15:26:43 +08:00
if ( warehouseError . value ) throw warehouseError . value
return warehouses . value
2026-08-14 20:59:19 +08:00
}
2026-09-10 14:14:24 +08:00
function openLink ( source : object ) {
2026-08-14 20:59:19 +08:00
const row = source as ContainerBookingResource
linkTarget . value = row
linkOpen . value = true
}
2026-09-07 23:58:18 +08:00
function replaceDrayage ( value : TerminalDrayageView ) {
drayages . value = { ... drayages . value , [ value . containerBookingId ] : value }
}
async function refreshDrayage ( containerBookingId : string ) {
const value = await getTerminalDrayage ( containerBookingId )
replaceDrayage ( value )
return value
}
async function selectRowFleet ( source : object , supplierIdValue : unknown ) {
const row = source as ContainerBookingResource
const current = drayageFor ( row . id )
2026-09-09 18:29:47 +08:00
const supplierId = String ( supplierIdValue || '' )
const supplier = terminalFleetOptions . value . find (( item ) => item . id === supplierId )
|| truckingCompanies . value . find (( item ) => item . id === supplierId )
2026-09-07 23:58:18 +08:00
if ( ! current || ! supplier || ! canDrayage ( row , 'SELECT_FLEET' ) || drayageSavingIds . value . includes ( row . id )) return
drayageSavingIds . value = [... drayageSavingIds . value , row . id ]
try {
replaceDrayage ( await selectTerminalDrayageFleet ( row . id , { expectedVersion : current . version , fleetSupplierId : supplier . id , fleetSupplierName : supplier . name }))
2026-09-09 17:42:19 +08:00
ElMessage . success ( current . fleetSupplierId && current . fleetSupplierId !== supplier . id
? '码头车队已更新,新车队提柜邮件已入队;原车队已下发过邮件时同时加入取消邮件'
: '码头车队已更新' )
2026-09-09 14:32:04 +08:00
await loadRows ()
2026-09-07 23:58:18 +08:00
} catch ( error ) {
const details = getApiErrorDetails ( error , '码头车队更新失败' )
if ( details . code === 'A0409' ) {
try { await refreshDrayage ( row . id ) } catch { /* 保留原始冲突证据 */ }
ElMessage . warning ( '运输信息已被其他人修改,已刷新当前柜最新状态' )
} else ElMessage . error ( details . message )
} finally { drayageSavingIds . value = drayageSavingIds . value . filter (( id ) => id !== row . id ) }
}
2026-09-09 14:32:04 +08:00
function openDrayageAction ( row : ContainerBookingResource , action : Exclude < TerminalDrayageAction , ' SELECT_FLEET ' | ' SEND_PICKUP_EMAIL ' >) {
2026-09-07 23:58:18 +08:00
if ( ! canDrayage ( row , action )) return
drayageTarget . value = row ; drayageAction . value = action ; drayageActionAt . value = localDateTimeValue (); drayageReason . value = ''
drayageActionError . value = null ; drayageActionOpen . value = true
}
async function saveDrayageAction () {
const row = drayageTarget . value
const current = row ? drayageFor ( row . id ) : null
if ( ! row || ! current || ! drayageActionAt . value || ! canDrayage ( row , drayageAction . value ) || drayageActionSaving . value ) return
drayageActionSaving . value = true ; drayageActionError . value = null
try {
const reason = drayageReason . value . trim () || undefined
2026-09-15 15:53:03 +08:00
const next = await recordTerminalDrayageEmptyReturn ( row . id , { expectedVersion : current . version , actualAt : drayageActionAt . value , remark : reason })
2026-09-07 23:58:18 +08:00
replaceDrayage ( next ); drayageActionOpen . value = false
ElMessage . success ( ` ${ drayageActionText ( drayageAction . value ) } 成功` )
2026-09-09 10:53:37 +08:00
await loadRows ()
2026-09-07 23:58:18 +08:00
} catch ( error ) {
const details = getApiErrorDetails ( error , ` ${ drayageActionText ( drayageAction . value ) } 失败` )
if ( details . code === 'A0409' ) {
try { await refreshDrayage ( row . id ) } catch { /* 保留原始冲突证据 */ }
drayageActionError . value = { ... details , message : '运输信息已被其他人修改,已刷新最新状态;请重新确认后提交' }
} else drayageActionError . value = details
} finally { drayageActionSaving . value = false }
}
2026-09-15 15:53:03 +08:00
async function saveExpectedArrival ( row : ContainerBookingResource , value : string | null ) {
const current = arrivalTimes . value [ row . id ]
if ( ! value || ! current || current . version < 0 || arrivalSavingIds . value . includes ( row . id )) return
arrivalSavingIds . value . push ( row . id )
try {
const updated = await updateContainerExpectedArrival ( row . id , {
estimatedWarehouseArrivalAt : value ,
expectedVersion : current . version ,
})
arrivalTimes . value = { ... arrivalTimes . value , [ row . id ] : updated }
2026-09-15 16:11:20 +08:00
arrivalInputs . value = { ... arrivalInputs . value , [ row . id ] : updated . estimatedWarehouseArrivalAt || '' }
ElMessage . success ( '预计到仓时间已同步整柜,后续入库计划将自动带出' )
2026-09-15 15:53:03 +08:00
} catch ( error ) {
ElMessage . error ( getApiErrorDetails ( error , '预计到仓时间保存失败' ). message )
const latest = await listContainerArrivalTimes ([ row . id ]). catch (() => [])
2026-09-15 16:11:20 +08:00
if ( latest [ 0 ]) {
arrivalTimes . value = { ... arrivalTimes . value , [ row . id ] : latest [ 0 ] }
arrivalInputs . value = { ... arrivalInputs . value , [ row . id ] : latest [ 0 ]. estimatedWarehouseArrivalAt || '' }
}
2026-09-15 15:53:03 +08:00
} finally { arrivalSavingIds . value = arrivalSavingIds . value . filter (( id ) => id !== row . id ) }
}
2026-09-07 23:58:18 +08:00
async function openDrayageHistory ( row : ContainerBookingResource ) {
drayageTarget . value = row ; drayageHistory . value = []; drayageHistoryError . value = null ; drayageHistoryOpen . value = true ; drayageHistoryLoading . value = true
try {
const [ current , history ] = await Promise . all ([ getTerminalDrayage ( row . id ), getTerminalDrayageHistory ( row . id )])
if ( drayageTarget . value ? . id !== row . id ) return
replaceDrayage ( current ); drayageHistory . value = history
} catch ( error ) { if ( drayageTarget . value ? . id === row . id ) drayageHistoryError . value = getApiErrorDetails ( error , '码头拖车记录加载失败' ) }
finally { if ( drayageTarget . value ? . id === row . id ) drayageHistoryLoading . value = false }
}
2026-08-10 15:36:53 +08:00
async function loadRows () {
const sequence = ++ listSequence
loading . value = true ; loadError . value = null
const [ from , to ] = applied . value . sailingRange || []
try {
const result = await listContainers ({
2026-08-14 12:50:42 +08:00
page : page . value , size : pageSize . value , customerId : applied . value . customerId || undefined , keyword : applied . value . keyword . trim () || undefined ,
2026-08-11 11:15:44 +08:00
containerType : applied . value . containerType || undefined ,
2026-08-10 15:36:53 +08:00
status : applied . value . status , estimatedSailingFrom : from ? ` ${ from } T00:00:00` : undefined ,
2026-09-08 19:57:42 +08:00
estimatedSailingTo : to ? ` ${ to } T23:59:59` : undefined , scope : scope . value ,
}, props . workbench )
2026-08-10 15:36:53 +08:00
if ( sequence !== listSequence ) return
2026-09-08 19:57:42 +08:00
rows . value = result . records as ContainerWorkbenchRow []; total . value = result . total
drayages . value = Object . fromEntries ( rows . value . flatMap (( row ) => row . terminalDrayage ? [[ row . id , row . terminalDrayage as TerminalDrayageView ]] : []))
2026-09-15 15:53:03 +08:00
const arrivalValues = isOverseas . value ? await listContainerArrivalTimes ( rows . value . map (( row ) => row . id )) : []
if ( sequence !== listSequence ) return
arrivalTimes . value = Object . fromEntries ( arrivalValues . map (( item ) => [ item . sourceContainerBookingId , item ]))
2026-09-15 16:11:20 +08:00
arrivalInputs . value = Object . fromEntries ( arrivalValues . map (( item ) => [ item . sourceContainerBookingId , item . estimatedWarehouseArrivalAt || '' ]))
2026-08-10 15:36:53 +08:00
} catch ( error ) {
if ( sequence !== listSequence ) return
rows . value = []; total . value = 0 ; loadError . value = getApiErrorDetails ( error , '货柜列表加载失败,请稍后重试' )
} finally { if ( sequence === listSequence ) loading . value = false }
}
2026-08-10 18:12:23 +08:00
function search () { applied . value = { ... filters . value , sailingRange : [... filters . value . sailingRange ] }; if ( page . value === 1 ) void loadRows (); else page . value = 1 }
2026-08-18 17:40:40 +08:00
function openDetail ( source : object ) {
2026-08-10 15:36:53 +08:00
const row = source as ContainerBookingResource
2026-09-08 19:57:42 +08:00
void router . push ({ name : props . workbench === 'DOMESTIC' ? 'domestic-container-detail' : 'overseas-container-detail' , params : { containerId : row . id } })
2026-08-10 15:36:53 +08:00
}
2026-08-13 11:57:44 +08:00
function handleMoreAction ( command : string , source : object ) {
const row = source as ContainerBookingResource
2026-08-14 20:59:19 +08:00
if ( command === 'link' ) { void openLink ( row ); return }
2026-08-18 17:40:40 +08:00
if ( command === 'overview' ) { openDetail ( row ); return }
2026-09-09 14:32:04 +08:00
if ( command === 'email-history' ) { void openEmailHistory ( row ); return }
2026-09-07 23:58:18 +08:00
if ( command === 'drayage-history' ) { void openDrayageHistory ( row ); return }
2026-09-09 14:32:04 +08:00
if ( command . startsWith ( 'drayage:' )) { openDrayageAction ( row , command . slice ( 'drayage:' . length ) as Exclude < TerminalDrayageAction , ' SELECT_FLEET ' | ' SEND_PICKUP_EMAIL ' >); return }
2026-09-08 19:57:42 +08:00
if ( command === 'documents' && isOverseas . value && hasAction ( row as ContainerWorkbenchRow , 'MANAGE_DESTINATION_DOCUMENTS' )) void router . push ({ name : 'overseas-container-detail' , params : { containerId : row . id }, query : { action : command } })
2026-08-13 11:57:44 +08:00
}
2026-08-10 15:36:53 +08:00
async function openEdit ( source : object ) {
const row = source as ContainerBookingResource
editorError . value = null
2026-08-31 12:12:51 +08:00
try {
2026-09-08 20:55:30 +08:00
const [ latest ] = await Promise . all ([ getContainer ( row . id , props . workbench ), loadWarehouses (), loadBusinessOptions ()])
2026-09-08 19:57:42 +08:00
if ( ! isDomestic . value || ! hasAction ( latest , 'EDIT_CONTAINER' )) return
2026-08-31 12:12:51 +08:00
detail . value = latest
replaceForm ( latest )
editorOpen . value = true
}
2026-08-10 15:36:53 +08:00
catch ( error ) { ElMessage . error ( getApiErrorDetails ( error , '货柜详情加载失败' ). message ) }
}
async function save () {
2026-08-25 19:56:37 +08:00
if ( unresolvedTruckingSupplierName . value ) {
editorError . value = { message : '原拖车供应商已不可用,请重新选择合作中的拖车供应商' }
return
}
2026-08-10 15:36:53 +08:00
if ( ! ( await formRef . value ? . validate (). catch (() => false ))) return
saving . value = true
editorError . value = null
try {
2026-08-31 12:12:51 +08:00
await updateContainer ( form . id , form . version , payload ())
2026-08-13 11:57:44 +08:00
ElMessage . success ( '货柜已更新' ); editorOpen . value = false ; await loadRows ()
2026-08-10 15:36:53 +08:00
} catch ( error ) {
const details = getApiErrorDetails ( error , '保存失败' )
2026-08-11 11:15:44 +08:00
if ( details . code === 'A0409' && form . id ) {
const submitted = { ... form }
try {
2026-09-08 19:57:42 +08:00
const latest = await getContainer ( form . id , props . workbench )
2026-08-11 11:15:44 +08:00
detail . value = latest
replaceForm ( latest )
Object . assign ( form , submitted , { id : latest . id , version : latest . version })
editorError . value = { ... details , message : '数据已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
ElMessage . warning ( editorError . value . message )
} catch ( refreshError ) {
editorError . value = getApiErrorDetails ( refreshError , '数据已被其他人修改,且最新数据刷新失败,请稍后重试' )
ElMessage . error ( editorError . value . message )
}
} else {
editorError . value = details
ElMessage . error ( details . message )
}
2026-08-10 15:36:53 +08:00
} finally { saving . value = false }
}
2026-08-11 11:15:44 +08:00
function goContainerProcess ( source : object ) {
const row = source as ContainerBookingResource
if ( row . planStatus === 'EDITING' ) {
void router . push ({ name : 'smart-loading-workbench' , params : { containerId : row . id } })
return
}
void router . push ({
2026-09-08 19:57:42 +08:00
name : row . planStatus === 'LOADING' ? 'loading-plans' : 'domestic-container-detail' ,
params : row . planStatus === 'COMPLETED' ? { containerId : row . id } : undefined ,
query : row . planStatus === 'COMPLETED' ? { tab : 'loading-list' } : { planId : row . planId || undefined , warehouseId : row . warehouseId },
2026-08-11 11:15:44 +08:00
})
}
2026-08-10 15:36:53 +08:00
2026-08-10 18:21:40 +08:00
watch ([ page , pageSize ], ([ nextPage , nextSize ], [ previousPage , previousSize ]) => {
if ( nextPage !== previousPage || nextSize !== previousSize ) void loadRows ()
})
2026-09-08 19:57:42 +08:00
watch ( scope , () => { if ( page . value === 1 ) void loadRows (); else page . value = 1 })
2026-09-10 14:14:24 +08:00
onMounted ( async () => { await Promise . all ([ loadCustomers (), loadRows ()]) })
2026-08-10 15:36:53 +08:00
</ script >
< template >
2026-09-14 18:34:34 +08:00
< AdminPage class = "admin-density" as = "div" >
2026-08-10 16:24:54 +08:00
< section class = "container-page" >
2026-09-08 19:57:42 +08:00
< ListPageLayout : ariaLabel = "isDomestic ? '送柜报关工作台' : '清关提柜工作台'" >
2026-08-10 16:24:54 +08:00
< template # toolbar >
2026-09-08 20:55:30 +08:00
< div class = "container-query-panel" >
2026-09-14 18:34:34 +08:00
< div class = "container-scope-tabs density-tabs" role = "tablist" aria -label =" 货柜完结状态 " >
< button type = "button" role = "tab" aria -controls =" container -workbench-results " : aria -selected =" scope = == ' ACTIVE '" : tabindex = "scope === 'ACTIVE' ? 0 : -1" : class = "['container-scope-tab', { active: scope === 'ACTIVE' }]" @click ="scope = 'ACTIVE'" > 未完结 </ button >
< button type = "button" role = "tab" aria -controls =" container -workbench-results " : aria -selected =" scope = == ' HISTORY '" : tabindex = "scope === 'HISTORY' ? 0 : -1" : class = "['container-scope-tab', { active: scope === 'HISTORY' }]" @click ="scope = 'HISTORY'" > 已完结 </ button >
2026-09-08 20:55:30 +08:00
</ div >
< QueryToolbar v-model = "filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</ div >
2026-08-10 16:24:54 +08:00
</ template >
2026-08-11 11:15:44 +08:00
< template v-if = "loadError" # state >
< LoadErrorState title = "货柜列表加载失败" :message = "loadError.message" :loading = "loading" @retry ="loadRows" >< template # details >< ApiErrorMeta :details = "loadError" /></ template ></ LoadErrorState >
2026-08-10 16:24:54 +08:00
</ template >
2026-09-08 20:55:30 +08:00
< section v-if = "!loadError" id="container-workbench-results" class="surface-panel table-panel" >
2026-08-12 18:16:44 +08:00
< DataTable v -model:page = "page" v -model:page-size = "pageSize" :data = "rows" :columns = "columns" :total = "total" :loading = "loading" stripe preference -key =" container -loading-table " empty -text =" 暂无货柜 " >
2026-09-14 18:34:34 +08:00
< template # toolbar -actions >< ContainerSimulationCreate dense v-if = "isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></ template >
2026-09-15 15:53:03 +08:00
< template # actions = "{ row }" >< div class = "row-actions density-actions" >< el-button class = "density-action" v-if = "canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)">查看</el-button><template v-if="isDomestic"><el-button class="density-action" v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)">编辑</el-button><el-button data-tone="primary" class="density-action" v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)">装柜</el-button><el-button class="density-action" v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)">预装清单</el-button><el-button class="density-action" v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)">装柜清单</el-button></template><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)">发送邮件</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button class="density-action" size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview">轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="email-history">邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history">码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents">清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link" > 关联已放舱订舱 </ el -dropdown-item ></ el-dropdown-menu ></ template ></ el-dropdown ></ div ></ template >
2026-09-07 23:58:18 +08:00
< template # emptyReturnWarning = "{ row }" >< el-tag : type = "drayageWarningType(row as ContainerBookingResource)" effect = "light" : title = "drayageWarningTitle(row as ContainerBookingResource)" >{{ drayageWarningText ( row as ContainerBookingResource ) }}</ el-tag ></ template >
2026-08-15 17:04:00 +08:00
< template # cabinet = "{ row }" >< strong >{{ row . cabinetNo }}</ strong >< small > 系统生成 </ small ></ template >
2026-09-01 15:24:37 +08:00
< template # oceanBooking = "{ row }" >< span v-if = "row.oceanBookingId" >< strong >{{ row . billOfLadingNo || '已关联订舱' }}</ strong >< small >{{ row . containerNo ? `集装箱号: ${ row . containerNo } ` : '集装箱号:待补录' }}</ small ></ span >< span v-else > 未关联 </ span ></ template >
< template # containerNo = "{ row }" >{{ row . containerNo || '待补录' }}</ template >
2026-09-09 18:29:47 +08:00
< template # terminalTruckingCompany = "{ row }" >< el-select class = "fleet-select" : model -value =" drayageFor ( row.id )? .fleetSupplierId || ''" filterable remote reserve -keyword remote -show-suffix placeholder = "输入车队名称或联系人搜索" :remote-method = "searchTerminalFleets" : loading = "terminalFleetLoading || drayageSavingIds.includes(row.id)" : disabled = "!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no -data-text =" 未找到合作中的码头车队 " @ visible -change =" handleTerminalFleetVisible " @ update : model -value =" selectRowFleet ( row , $ event )" >< el-option v-if = "drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></ el-select ></ template >
2026-09-15 16:11:20 +08:00
< template # pickupAppointmentAt = "{ row }" >< el-date-picker v-model = "arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" placeholder="选择预计到仓" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></ template >
2026-09-15 15:53:03 +08:00
< template # actualPickupAt = "{ row }" >{{ formatTime ( arrivalTimes [ row . id ] ? . actualWarehouseArrivalAt ) }}</ template >
2026-09-07 23:58:18 +08:00
< template # estimatedEmptyReturnAt = "{ row }" >{{ formatTime ( drayageFor ( row . id ) ? . estimatedEmptyReturnAt ) }}</ template >
< template # actualEmptyReturnAt = "{ row }" >{{ formatTime ( drayageFor ( row . id ) ? . actualEmptyReturnAt ) }}</ template >
2026-08-10 16:24:54 +08:00
< template # status = "{ row }" >< el-tag : type = "row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'" >{{ statusText ( row . planStatus ) }}</ el-tag ></ template >
< template # route = "{ row }" >{{ row . departurePortName || row . departurePortCode || '—' }} → {{ row . destinationPortName || row . destinationPortCode || '—' }}</ template >
< template # capacity = "{ row }" >{{ row . capacityCbm ? ` ${ row . capacityCbm } m³` : '—' }} / {{ row . maxWeightKg ? ` ${ row . maxWeightKg } kg` : '—' }}</ template >
< template # vessel = "{ row }" >{{ [ row . vesselName , row . voyageNo ]. filter ( Boolean ). join ( ' / ' ) || '—' }}</ template >
2026-08-13 11:57:44 +08:00
< template # loadingAt = "{ row }" >{{ formatTime ( row . loadingAt ) }}</ template >
2026-08-10 16:24:54 +08:00
< template # estimatedSailingAt = "{ row }" >{{ formatTime ( row . estimatedSailingAt ) }}</ template >
2026-09-08 19:57:42 +08:00
< template # actualSailingAt = "{ row }" >{{ formatTime ( row . actualFacts ? . actualSailingAt ) }}</ template >
2026-08-13 11:57:44 +08:00
< template # estimatedPortArrivalAt = "{ row }" >{{ formatTime ( row . estimatedPortArrivalAt ) }}</ template >
2026-09-08 19:57:42 +08:00
< template # actualPortArrivalAt = "{ row }" >{{ formatTime ( row . actualFacts ? . actualPortArrivalAt ) }}</ template >
2026-08-10 16:24:54 +08:00
</ DataTable >
</ section >
</ ListPageLayout >
2026-08-10 15:36:53 +08:00
2026-09-10 14:14:24 +08:00
< OceanBookingLinkDialog v-model = "linkOpen" :container="linkTarget" @linked="loadRows" />
2026-08-14 20:59:19 +08:00
2026-09-14 18:34:34 +08:00
< AppDialog v-model = "editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save" >
2026-08-10 15:36:53 +08:00
< LoadErrorState v-if = "editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></ template ></ LoadErrorState >
2026-08-31 12:12:51 +08:00
< LoadErrorState v-if = "warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></ template ></ LoadErrorState >
< el-alert v-if = "businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions" > 重新加载业务选项 </ el -button ></ el-alert >
2026-09-14 18:34:34 +08:00
< el-form v-if = "!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form" >
2026-09-01 15:24:37 +08:00
< el-form-item label = "真实集装箱号" >< el-input : model -value =" form.containerNo || ' 待补录 '" disabled />< small > 货柜基础资料不提供柜号写入 ; 请在拖车提柜或预装详情中补录 , 后续纠错也在预装详情完成 。 </ small ></ el-form-item >
2026-08-19 09:42:12 +08:00
< el-form-item label = "柜次号" >< el-input :model-value = "form.cabinetNo" disabled placeholder = "系统生成" /></ el-form-item >
2026-08-31 12:12:51 +08:00
< el-form-item label = "装柜仓库" prop = "warehouseId" >< el-select v-model = "form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}( ${warehouse.warehouseCode}) `" :value="String(warehouse.id)" /></ el-select ></ el-form-item >
< el-form-item label = "提单号" >< el-input v-model = "form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></ el-form-item >
< el-form-item label = "船名" >< el-input v-model = "form.vesselName" maxlength="128" placeholder="请输入船名" /></ el-form-item >
2026-08-11 18:06:35 +08:00
< el-form-item label = "航次" >< el-input v-model = "form.voyageNo" maxlength="64" placeholder="请输入航次" /></ el-form-item >
2026-08-31 12:12:51 +08:00
< el-form-item label = "箱型(货柜类型)" prop = "containerType" >< el-select :model-value = "form.containerType" placeholder = "请选择箱型" @ update : model -value =" applyContainerSpecification ( form , String ($ event || ''))" >< el-option v-for = "item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></ el-select ></ el-form-item >
< el-form-item label = "船公司" >< el-select :model-value = "shippingCompanyValue" filterable clearable placeholder = "请选择船公司" :loading = "businessOptionsLoading" loading -text =" 船公司加载中 " no -data-text =" 暂无可用船公司 " @ update : model -value =" selectShippingCompany ( String ($ event || ''))" >< el-option v-if = "shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></ el-select ></ el-form-item >
< el-form-item label = "起运港" >< el-select :model-value = "departurePortValue" filterable clearable placeholder = "请选择起运港" :loading = "businessOptionsLoading" loading -text =" 港口加载中 " no -data-text =" 暂无订舱港口数据 " @ update : model -value =" selectPort (' departure ', String ($ event || ''))" >< el-option v-for = "port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></ el-select ></ el-form-item >
< el-form-item label = "目的港" >< el-select :model-value = "destinationPortValue" filterable clearable placeholder = "请选择目的港" :loading = "businessOptionsLoading" loading -text =" 港口加载中 " no -data-text =" 暂无订舱港口数据 " @ update : model -value =" selectPort (' destination ', String ($ event || ''))" >< el-option v-for = "port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></ el-select ></ el-form-item >
< el-form-item label = "目的仓" >< el-select :model-value = "destinationWarehouseValue" filterable clearable placeholder = "请选择目的仓" :loading = "warehouseLoading" loading -text =" 目的仓加载中 " no -data-text =" 暂无可用海外 / FBA仓库 " @ update : model -value =" selectDestinationWarehouse ( String ($ event || ''))" >< el-option v-if = "destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}( ${warehouse.warehouseCode}) `" :value="warehouse.warehouseCode" /></ el-select ></ el-form-item >
2026-08-17 12:31:27 +08:00
< el-form-item label = "货柜容量(m³)" >< el-input : model -value =" form.capacityCbm == null ? '—' : `$ { form.capacityCbm } m ³`" disabled /></ el-form-item >
< el-form-item label = "重量上限(kg) " >< el-input : model -value =" form.maxWeightKg == null ? '—' : `$ { form.maxWeightKg } kg `" disabled /></ el-form-item >
2026-08-31 12:12:51 +08:00
< el-form-item label = "拖车公司" prop = "terminalTruckingCompany" >< el-select :model-value = "selectedTruckingSupplierId" filterable clearable placeholder = "请选择拖车供应商" :loading = "businessOptionsLoading" loading -text =" 拖车供应商加载中 " no -data-text =" 暂无可用拖车供应商 " @ update : model -value =" selectTruckingSupplier ( String ($ event || ''))" >< el-option v-if = "form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></ el-select ></ el-form-item >
< el-form-item label = "计划装柜时间" >< el-date-picker v-model = "form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></ el-form-item >
< el-form-item label = "预计开船时间" >< el-date-picker v-model = "form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></ el-form-item >
< el-form-item label = "预计到港时间" >< el-date-picker v-model = "form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></ el-form-item >
2026-08-10 15:36:53 +08:00
</ el-form >
2026-08-12 16:31:46 +08:00
</ AppDialog >
2026-09-07 23:58:18 +08:00
2026-09-14 18:34:34 +08:00
< AppDialog class = "admin-density-dialog" v-model = "drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction" >
2026-09-15 15:53:03 +08:00
< el-form class = "density-form" label -position =" top " >< el-form-item label = "实际还柜时间" required >< el-date-picker v-model = "drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></ el-form-item ></ el-form >
2026-09-07 23:58:18 +08:00
< LoadErrorState v-if = "drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></ template ></ LoadErrorState >
</ AppDialog >
2026-09-14 18:34:34 +08:00
< AppDialog class = "admin-density-dialog" v-model = "emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false" >
2026-09-09 14:32:04 +08:00
< p > 集装箱号 : {{ emailTarget ? . containerNo || '—' }} · 显示最近 100 封邮件 </ p >
< el-alert v-if = "!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
< LoadErrorState v-if = "emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></ template ></ LoadErrorState >
< DataTable v-else :data = "emails" :columns = "emailColumns" :loading = "emailsLoading" column -settings-mode =" hide " empty -text =" 暂无邮件记录 " >
< template # toolbar -actions >< el-button :loading = "emailsLoading" @click ="emailTarget && openEmailHistory(emailTarget)" > 刷新记录 </ el -button ></ template >
2026-09-14 18:34:34 +08:00
< template # emailActions = "{ row }" >< el-button class = "density-action" v-if = "canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)" > 重试 </ el -button >< span v-else > — </ span ></ template >
2026-09-09 14:32:04 +08:00
< template # emailContent = "{ row }" >< details >< summary >{{ row . subject }}</ summary >< p style = "white-space: pre-wrap" >{{ row . body }}</ p ></ details ></ template >
</ DataTable >
</ AppDialog >
2026-09-14 18:34:34 +08:00
< AppDialog class = "admin-density-dialog" v-model = "drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false" >
2026-09-07 23:58:18 +08:00
< LoadErrorState v-if = "drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></ template ></ LoadErrorState >
< template v-else >< el-descriptions v-if = "drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></ template >
</ AppDialog >
2026-08-10 16:24:54 +08:00
</ section >
</ AdminPage >
2026-08-10 15:36:53 +08:00
</ template >
< style scoped >
2026-09-15 16:11:20 +08:00
. container - page { display : grid ; gap : var ( -- admin - space - 5 ); min - width : 0 ; }. container - page small { display : block ; margin - top : var ( -- admin - space - 1 ); color : var ( -- cc - muted ); }. container - query - panel { overflow : hidden ; border : 1 px solid var ( -- cc - border ); border - radius : var ( -- admin - radius ); background : var ( -- admin - surface ); }. container - scope - tabs { display : flex ; max - width : 100 % ; overflow - x : auto ; margin - bottom : 0 ; border - bottom : 1 px solid # e5e7eb ; scrollbar - width : thin ; }. container - query - toolbar { border : 0 ; }. row - actions { flex - wrap : nowrap ; white - space : nowrap ; }. row - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }. form - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 0 var ( -- admin - space - 5 ); }. form - grid : deep (. el - select ),. form - grid : deep (. el - date - editor ),. form - grid : deep (. el - input - number ),. simulation - form : deep (. el - select ),. simulation - form : deep (. el - input - number ),. fleet - select ,. arrival - time - picker { width : 100 % ; }. details : deep (. el - descriptions__label ) { width : 120 px ; }. drayage - load - alert { margin - bottom : var ( -- admin - space - 5 ); }. drayage - summary { margin - bottom : var ( -- admin - space - 5 ); }
2026-09-14 18:34:34 +08:00
@ media ( max - width : 720 px ){. details : deep (. el - descriptions__body table ),. details : deep ( tbody ),. details : deep ( tr ),. details : deep ( td ){ display : block ; width : 100 %! important }. row - actions { min - width : 180 px ; }}
@ media ( max - width : 720 px ){. form - grid { grid - template - columns : 1 fr }}
2026-08-10 15:36:53 +08:00
</ style >