Files
admin-web/src/modules/container-loading/pages/ContainersView.vue
T
2026-08-19 09:42:12 +08:00

663 lines
57 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<ContainerBookingResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const destinationWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const truckingSuppliers = ref<Supplier[]>([])
const oceanBookings = ref<OceanBookingResource[]>([])
const loading = ref(false)
const warehouseLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const warehouseError = ref<ApiErrorDetails | null>(null)
const businessOptionsLoading = ref(false)
const businessOptionsError = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<ContainerBookingResource | null>(null)
const detailTarget = ref<ContainerBookingResource | null>(null)
const clearanceDocument = ref<import('../types').BusinessDocument | null>(null)
const clearanceLoading = ref(false)
const clearanceActionLoading = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceDialogOpen = ref(false)
const overview = ref<ContainerOverview | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentDialogOpen = ref(false)
const documentType = ref<ContainerDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentSaving = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const documentDownloading = ref(false)
const photoBusyId = ref<string | null>(null)
const photoError = ref<ApiErrorDetails | null>(null)
const exceptionDialogOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const simulationOpen = ref(false)
const simulationSaving = ref(false)
const simulationError = ref<ApiErrorDetails | null>(null)
const simulationChannels = ref<OceanLogisticsChannel[]>([])
const simulationChannelsLoading = ref(false)
const simulationFormRef = ref<FormInstance>()
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
const linkOpen = ref(false)
const linkLoading = ref(false)
const linkSaving = ref(false)
const linkError = ref<ApiErrorDetails | null>(null)
const linkTarget = ref<ContainerBookingResource | null>(null)
const linkCandidateKey = ref('')
const linkContainerNo = ref('')
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
const selectedTruckingSupplierId = ref('')
let listSequence = 0
let detailSequence = 0
function emptyForm(): ContainerForm {
return {
id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null,
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null,
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
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' }],
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(!value || selectedTruckingSupplierId.value ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
}
const canView = computed(() => access.can('platform:firstleg:container:list'))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const selectedOverviewDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentDialogTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', DESTINATION_CLEARANCE: '目的港清关资料', CUSTOMS_DECLARATION: '报关资料' })[documentType.value])
const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING'))
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
}
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}` : ''))
const queryFields = computed<QueryField[]>(() => [
{ 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 } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
{ 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: 'customsDeclarationDocumentStatus', label: '报关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 },
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 },
{ 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' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150 },
{ key: 'loadingAt', label: '装柜时间', minWidth: 160, slotName: 'loadingAt' },
{ key: 'estimatedSailingAt', label: '预计开船', minWidth: 160, slotName: 'estimatedSailingAt' },
{ key: 'actualSailingAt', label: '实际开船', minWidth: 160, slotName: 'actualSailingAt' },
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
]
function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
.find((value) => typeof value === 'string' && value.trim())
return reference ? `查看 ${reference}` : '查看货柜详情'
}
function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
const specification = containerSpecification(form.containerType)
if (specification) applyContainerSpecification(form, specification.type)
selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || ''
}
function payload(): ContainerSaveRequest {
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
return {
containerNo: optional(form.containerNo),
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),
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg, terminalTruckingCompany: optional(form.terminalTruckingCompany),
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
}
}
function partnerLabel(partner: PlatformPartnerResource) {
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
}
function selectTruckingSupplier(supplierId: string) {
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
selectedTruckingSupplierId.value = supplier?.id || ''
form.terminalTruckingCompany = supplier?.name || null
}
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
}
async function loadWarehouses() {
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')
}
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
}
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
}
}
async function loadBusinessOptions() {
businessOptionsLoading.value = true
businessOptionsError.value = ''
const errors: string[] = []
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
loadAllPartners(),
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
loadAllOceanBookings(),
])
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
if (supplierResult.status === 'fulfilled') truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
businessOptionsError.value = errors.join(';')
businessOptionsLoading.value = false
}
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)
}
function openSimulation() {
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
simulationChannels.value = []
simulationError.value = null
simulationOpen.value = true
}
function selectSimulationContainerType(containerType: string) {
applyContainerSpecification(simulationForm, containerType)
}
async function selectSimulationWarehouse(value: unknown) {
simulationForm.warehouseId = String(value || '')
simulationForm.channelCode = ''
simulationChannels.value = []
simulationError.value = null
if (!simulationForm.warehouseId) return
simulationChannelsLoading.value = true
try { simulationChannels.value = await listWarehouseLogisticsChannels(simulationForm.warehouseId) }
catch (error) { simulationError.value = getApiErrorDetails(error, '物流渠道加载失败') }
finally { simulationChannelsLoading.value = false }
}
async function saveSimulation() {
if (!(await simulationFormRef.value?.validate().catch(() => false))) return
simulationSaving.value = true
simulationError.value = null
try {
await createContainerSimulation({ ...simulationForm })
simulationOpen.value = false
ElMessage.success('模拟柜已创建,可先排货后关联真实订舱柜')
await loadRows()
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
finally { simulationSaving.value = false }
}
async function loadReleasedBookings() {
const first = await listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })
if (first.pages <= 1) return first.records
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status: 'RELEASED' })))
return [first, ...rest].flatMap((result) => result.records)
}
async function openLink(source: object) {
const row = source as ContainerBookingResource
linkTarget.value = row
linkCandidateKey.value = ''
linkContainerNo.value = ''
linkCandidates.value = []
linkError.value = null
linkOpen.value = true
linkLoading.value = true
try {
const bookings = await loadReleasedBookings()
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
linkCandidates.value = details.flatMap((booking) => booking.containerRequirements
.map((requirement) => ({ ...requirement, remainingQuantity: Math.max(0, Number(requirement.quantity) - booking.containers.filter((container) => container.requirementId === requirement.requirementId).length) }))
.filter((requirement) => Boolean(requirement.requirementId) && Number(requirement.remainingQuantity) > 0)
.map((requirement) => {
const specificationMatches = booking.warehouseId === row.warehouseId
&& booking.channelCode === row.channelCode
&& requirement.containerType === row.containerType
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)
return {
key: `${booking.id}:${requirement.requirementId}`,
oceanBookingId: booking.id,
requirementId: String(requirement.requirementId),
disabled: !specificationMatches,
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} × ${requirement.remainingQuantity} · ${booking.customerName || '未关联客户'} · ${booking.warehouseName || '仓库待补'} · ${booking.channelName || '渠道待补'}${specificationMatches ? '' : '(与当前柜规格不一致)'}`,
}
}))
} catch (error) { linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败') }
finally { linkLoading.value = false }
}
async function saveLink() {
const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
const containerNo = linkContainerNo.value.trim()
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
linkSaving.value = true
linkError.value = null
try {
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
linkOpen.value = false
ElMessage.success('订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
}
async function loadRows() {
const sequence = ++listSequence
loading.value = true; loadError.value = null
const [from, to] = applied.value.sailingRange || []
try {
const result = await listContainers({
page: page.value, size: pageSize.value, customerId: applied.value.customerId || undefined, keyword: applied.value.keyword.trim() || undefined,
containerType: applied.value.containerType || undefined,
status: applied.value.status, estimatedSailingFrom: from ? `${from}T00:00:00` : undefined,
estimatedSailingTo: to ? `${to}T23:59:59` : undefined,
})
if (sequence !== listSequence) return
rows.value = result.records; total.value = result.total
} catch (error) {
if (sequence !== listSequence) return
rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试')
} finally { if (sequence === listSequence) loading.value = false }
}
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
function openDetail(source: object) {
const row = source as ContainerBookingResource
void router.push({ name: 'container-detail', params: { containerId: row.id } })
}
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
if (command === 'link') { void openLink(row); return }
if (command === 'overview') { openDetail(row); return }
if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } })
}
function openDocumentDialog(type: string) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) {
if (type === 'DESTINATION_CLEARANCE' && detailTarget.value) void openClearance(detailTarget.value)
return
}
documentType.value = type as ContainerDocumentType
documentFile.value = null
documentError.value = null
documentDialogOpen.value = true
}
function selectDocumentFile(uploadFile: UploadFile) { documentFile.value = uploadFile.raw ?? null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (!detailTarget.value || !overview.value || !documentFile.value) { documentError.value = { message: '请选择需要上传的资料文件', code: 'FILE_REQUIRED' }; return }
documentSaving.value = true; documentError.value = null
try {
const version = selectedOverviewDocument.value?.version || 0
if (documentType.value === 'DESTINATION_CLEARANCE') await uploadContainerClearance(detailTarget.value.id, documentFile.value, version, '上传目的港清关资料')
else if (documentType.value === 'CUSTOMS_DECLARATION') {
if (!overview.value.loadingPlanId) throw new Error('missing loading plan')
await uploadLoadingDeclaration(overview.value.loadingPlanId, detailTarget.value.warehouseId, documentFile.value, version, '上传装柜清单报关资料')
} else await uploadContainerDocument(detailTarget.value.id, documentType.value, documentFile.value, version, `上传${documentDialogTitle.value}`)
overview.value = await getContainerOverview(detailTarget.value.id)
documentDialogOpen.value = false; documentFile.value = null; ElMessage.success(`${documentDialogTitle.value}已上传`)
} catch (error) { documentError.value = getApiErrorDetails(error, `${documentDialogTitle.value}上传失败`) }
finally { documentSaving.value = false }
}
async function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click()
URL.revokeObjectURL(url)
}
async function downloadDocument(item: ContainerOverviewDocument) {
if (!detailTarget.value) return
documentDownloading.value = true
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
try { await saveBlob(await downloadContainerDocument(detailTarget.value.id, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
catch (error) { ElMessage.error(getApiErrorDetails(error, '资料下载失败').message) }
finally { documentDownloading.value = false }
}
async function previewPhoto(photo: ContainerPdaPhoto) {
photoBusyId.value = photo.evidenceId; photoError.value = null
try {
const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath))
window.open(url, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (error) { photoError.value = getApiErrorDetails(error, '装柜照片加载失败') }
finally { photoBusyId.value = null }
}
function openExceptionDialog() { exceptionReason.value = ''; exceptionError.value = null; exceptionDialogOpen.value = true }
async function saveException() {
if (!detailTarget.value || !detail.value || !overview.value || !exceptionReason.value.trim()) { exceptionError.value = { message: '请输入异常原因或取消备注', code: 'REASON_REQUIRED' }; return }
exceptionSaving.value = true; exceptionError.value = null
try {
const action = overview.value.exception.active ? cancelContainerException : markContainerException
await action(detailTarget.value.id, overview.value.container.version, exceptionReason.value.trim())
overview.value = await getContainerOverview(detailTarget.value.id)
exceptionDialogOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
} catch (error) { exceptionError.value = getApiErrorDetails(error, '货柜异常处理失败') }
finally { exceptionSaving.value = false }
}
async function openClearance(source: object) {
const row = source as ContainerBookingResource
detailTarget.value = row
clearanceDialogOpen.value = true; clearanceError.value = null; clearanceFile.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(row.id) } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料加载失败') } finally { clearanceLoading.value = false }
}
function selectClearanceFile(uploadFile: UploadFile) { clearanceFile.value = uploadFile.raw ?? null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (!detailTarget.value || !clearanceFile.value) { clearanceError.value = { message: '请选择清关资料文件', code: 'FILE_REQUIRED' }; return }
clearanceActionLoading.value = true; clearanceError.value = null
try { clearanceDocument.value = await uploadContainerClearance(detailTarget.value.id, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); clearanceFile.value = null; clearanceDialogOpen.value = false; ElMessage.success('清关资料已上传') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料上传失败') } finally { clearanceActionLoading.value = false }
}
async function completeClearance() {
if (!detailTarget.value || !clearanceDocument.value) return
clearanceActionLoading.value = true; clearanceError.value = null
try { clearanceDocument.value = await completeContainerClearance(detailTarget.value.id, clearanceDocument.value.version, '确认目的港清关完成'); ElMessage.success('已完成清关') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关完成失败') } finally { clearanceActionLoading.value = false }
}
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
try { detail.value = await getContainer(row.id); replaceForm(detail.value); editorOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
}
async function save() {
if (!(await formRef.value?.validate().catch(() => false))) return
saving.value = true
editorError.value = null
try {
if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
else await updateContainer(form.id, form.version, payload())
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '保存失败')
if (details.code === 'A0409' && form.id) {
const submitted = { ...form }
try {
const latest = await getContainer(form.id)
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)
}
} finally { saving.value = false }
}
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({
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
query: { planId: row.planId || undefined, warehouseId: row.warehouseId },
})
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
})
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
</script>
<template>
<AdminPage as="div">
<section class="container-page">
<ListPageLayout ariaLabel="货柜列表">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</template>
<template v-if="loadError" #state>
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<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="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<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>
<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>
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="documentDialogOpen" :title="documentDialogTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentDialogTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedOverviewDocument ? '上传新文件将替换当前资料并保留操作历史。' : `请选择需要提交的${documentDialogTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><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 v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(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-input :model-value="`${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /></el-form-item>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无可关联的已放舱订舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道、柜型、容量和限重必须与当前模拟柜一致;不一致的候选不可选择。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<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>
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError && !transportOnly" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="请输入集装箱号,开始装柜前必填" /></el-form-item>
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库" :disabled="transportOnly"><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="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" :disabled="transportOnly" @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="暂无可用船公司" :disabled="transportOnly" @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="暂无订舱港口数据" :disabled="transportOnly" @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="暂无订舱港口数据" :disabled="transportOnly" @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仓库" :disabled="transportOnly" @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>
<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>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" :disabled="transportOnly" @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="请选择计划装柜时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" :disabled="transportOnly" /></el-form-item>
</el-form>
</AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.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) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.document-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 16px; padding: 14px 16px; border: 1px solid var(--cc-border); background: #fff; }.document-panel > div { display: grid; gap: 4px; min-width: 0; }.document-panel span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
:global(.container-editor-dialog .el-dialog__body) { min-height: 0; padding: 28px; overflow-y: auto; }
:global(.container-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 16px 28px; border-top: 1px solid #e5eaf1; background: #f8fafc; }
@media(max-width:720px){:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
</style>