2026-08-10 15:36:53 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
|
|
|
|
import { useRouter } from 'vue-router'
|
2026-08-18 16:09:43 +08:00
|
|
|
|
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
|
2026-08-13 11:57:44 +08:00
|
|
|
|
import { ElMessage, type FormInstance, type FormRules, type UploadFile } 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-08-17 13:24:55 +08:00
|
|
|
|
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
2026-08-13 11:57:44 +08:00
|
|
|
|
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
|
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-08-17 12:31:27 +08:00
|
|
|
|
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
2026-08-10 15:36:53 +08:00
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
2026-08-13 11:57:44 +08:00
|
|
|
|
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
2026-08-18 16:09:43 +08:00
|
|
|
|
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
|
2026-08-17 19:57:32 +08:00
|
|
|
|
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } 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-10 15:36:53 +08:00
|
|
|
|
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
|
|
|
|
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
|
|
|
|
|
const rows = ref<ContainerBookingResource[]>([])
|
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-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-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 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)
|
2026-08-13 11:57:44 +08:00
|
|
|
|
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)
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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 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>()
|
2026-08-17 12:31:27 +08:00
|
|
|
|
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
2026-08-14 20:59:19 +08:00
|
|
|
|
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('')
|
2026-08-18 16:09:43 +08:00
|
|
|
|
const linkContainerNo = ref('')
|
|
|
|
|
|
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
|
2026-08-15 11:12:22 +08:00
|
|
|
|
const selectedTruckingSupplierId = ref('')
|
2026-08-10 15:36:53 +08:00
|
|
|
|
let listSequence = 0
|
|
|
|
|
|
let detailSequence = 0
|
|
|
|
|
|
|
|
|
|
|
|
function emptyForm(): ContainerForm {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null,
|
2026-08-17 12:31:27 +08:00
|
|
|
|
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', 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-08-17 12:31:27 +08:00
|
|
|
|
capacityCbm: 76, maxWeightKg: 28000, 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> = {
|
|
|
|
|
|
cabinetNo: [{ required: true, message: '请输入柜次号', trigger: 'blur' }],
|
|
|
|
|
|
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
|
|
|
|
|
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
2026-08-15 11:12:22 +08:00
|
|
|
|
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(!value || selectedTruckingSupplierId.value ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
2026-08-10 15:36:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 11:57:44 +08:00
|
|
|
|
const canView = computed(() => access.can('platform:firstleg:container: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-08-10 15:36:53 +08:00
|
|
|
|
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
2026-08-13 11:57:44 +08:00
|
|
|
|
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
|
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-13 11:57:44 +08:00
|
|
|
|
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 ? '取消货柜异常' : '标记货柜异常')
|
2026-08-10 15:36:53 +08:00
|
|
|
|
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
|
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-14 20:59:19 +08:00
|
|
|
|
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
|
|
|
|
|
|
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
|
|
|
|
|
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
|
|
|
|
|
|
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
|
|
|
|
|
|
}
|
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-17 12:31:27 +08:00
|
|
|
|
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
|
2026-08-11 11:52:30 +08:00
|
|
|
|
{ key: 'status', type: 'select', label: '排柜状态', width: 150, 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': '预计开船日期范围' } },
|
|
|
|
|
|
])
|
|
|
|
|
|
const columns: DataTableColumn[] = [
|
2026-08-11 11:15:44 +08:00
|
|
|
|
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
|
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: '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' },
|
2026-08-10 15:36:53 +08:00
|
|
|
|
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
2026-08-13 11:57:44 +08:00
|
|
|
|
{ key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 },
|
|
|
|
|
|
{ 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
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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-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-15 11:12:22 +08:00
|
|
|
|
selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || ''
|
2026-08-10 15:36:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
function payload(): ContainerSaveRequest {
|
|
|
|
|
|
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
|
|
|
|
|
|
return {
|
|
|
|
|
|
containerNo: optional(form.containerNo), cabinetNo: form.cabinetNo.trim(),
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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) {
|
|
|
|
|
|
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
|
|
|
|
|
|
selectedTruckingSupplierId.value = supplier?.id || ''
|
|
|
|
|
|
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() {
|
|
|
|
|
|
warehouseLoading.value = true; warehouseError.value = null
|
2026-08-11 18:06:35 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
|
|
|
|
|
|
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
|
|
|
|
|
|
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
|
|
|
|
|
|
}
|
2026-08-10 15:36:53 +08:00
|
|
|
|
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
|
|
|
|
|
|
finally { warehouseLoading.value = false }
|
|
|
|
|
|
}
|
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() {
|
|
|
|
|
|
businessOptionsLoading.value = true
|
|
|
|
|
|
businessOptionsError.value = ''
|
|
|
|
|
|
const errors: string[] = []
|
2026-08-15 11:12:22 +08:00
|
|
|
|
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
|
2026-08-11 18:06:35 +08:00
|
|
|
|
loadAllPartners(),
|
2026-08-15 11:12:22 +08:00
|
|
|
|
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
|
2026-08-11 18:06:35 +08:00
|
|
|
|
loadAllOceanBookings(),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
|
|
|
|
|
|
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
|
2026-08-15 11:12:22 +08:00
|
|
|
|
if (supplierResult.status === 'fulfilled') truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
|
|
|
|
|
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
|
2026-08-11 18:06:35 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-14 20:59:19 +08:00
|
|
|
|
function openSimulation() {
|
2026-08-17 12:31:27 +08:00
|
|
|
|
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
2026-08-14 20:59:19 +08:00
|
|
|
|
simulationChannels.value = []
|
|
|
|
|
|
simulationError.value = null
|
|
|
|
|
|
simulationOpen.value = true
|
|
|
|
|
|
}
|
2026-08-17 12:31:27 +08:00
|
|
|
|
function selectSimulationContainerType(containerType: string) {
|
|
|
|
|
|
applyContainerSpecification(simulationForm, containerType)
|
|
|
|
|
|
}
|
2026-08-14 20:59:19 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
2026-08-18 16:09:43 +08:00
|
|
|
|
async function loadReleasedBookings() {
|
|
|
|
|
|
const first = await listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })
|
2026-08-14 20:59:19 +08:00
|
|
|
|
if (first.pages <= 1) return first.records
|
2026-08-18 16:09:43 +08:00
|
|
|
|
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status: 'RELEASED' })))
|
2026-08-14 20:59:19 +08:00
|
|
|
|
return [first, ...rest].flatMap((result) => result.records)
|
|
|
|
|
|
}
|
|
|
|
|
|
async function openLink(source: object) {
|
|
|
|
|
|
const row = source as ContainerBookingResource
|
|
|
|
|
|
linkTarget.value = row
|
|
|
|
|
|
linkCandidateKey.value = ''
|
2026-08-18 16:09:43 +08:00
|
|
|
|
linkContainerNo.value = ''
|
2026-08-14 20:59:19 +08:00
|
|
|
|
linkCandidates.value = []
|
|
|
|
|
|
linkError.value = null
|
|
|
|
|
|
linkOpen.value = true
|
|
|
|
|
|
linkLoading.value = true
|
|
|
|
|
|
try {
|
2026-08-18 16:09:43 +08:00
|
|
|
|
const bookings = await loadReleasedBookings()
|
2026-08-14 20:59:19 +08:00
|
|
|
|
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
|
2026-08-18 16:09:43 +08:00
|
|
|
|
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, '已放舱订舱候选加载失败') }
|
2026-08-14 20:59:19 +08:00
|
|
|
|
finally { linkLoading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function saveLink() {
|
|
|
|
|
|
const target = linkTarget.value
|
|
|
|
|
|
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
|
2026-08-18 16:09:43 +08:00
|
|
|
|
const containerNo = linkContainerNo.value.trim()
|
|
|
|
|
|
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
|
|
|
|
|
|
if (!containerNo) { linkError.value = { message: '请输入港口取得的真实集装箱号' }; return }
|
2026-08-14 20:59:19 +08:00
|
|
|
|
linkSaving.value = true
|
|
|
|
|
|
linkError.value = null
|
|
|
|
|
|
try {
|
2026-08-18 16:09:43 +08:00
|
|
|
|
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo, expectedVersion: target.version })
|
2026-08-14 20:59:19 +08:00
|
|
|
|
linkOpen.value = false
|
2026-08-18 16:09:43 +08:00
|
|
|
|
ElMessage.success('订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变')
|
2026-08-14 20:59:19 +08:00
|
|
|
|
await loadRows()
|
|
|
|
|
|
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
|
|
|
|
|
|
finally { linkSaving.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,
|
|
|
|
|
|
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 }
|
|
|
|
|
|
}
|
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-10 15:36:53 +08:00
|
|
|
|
async function openDetail(source: object) {
|
|
|
|
|
|
const row = source as ContainerBookingResource
|
|
|
|
|
|
detailTarget.value = row
|
2026-08-13 11:57:44 +08:00
|
|
|
|
detailOpen.value = true; detail.value = null; overview.value = null; detailError.value = null; overviewError.value = null; detailLoading.value = true
|
2026-08-10 15:36:53 +08:00
|
|
|
|
const sequence = ++detailSequence
|
2026-08-13 11:57:44 +08:00
|
|
|
|
try { const [result, aggregate] = await Promise.all([getContainer(row.id), getContainerOverview(row.id)]); if (sequence === detailSequence) { detail.value = result; overview.value = aggregate; clearanceDocument.value = null } }
|
2026-08-10 15:36:53 +08:00
|
|
|
|
catch (error) { if (sequence === detailSequence) detailError.value = getApiErrorDetails(error, '货柜详情加载失败') }
|
|
|
|
|
|
finally { if (sequence === detailSequence) detailLoading.value = false }
|
|
|
|
|
|
}
|
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-13 11:57:44 +08:00
|
|
|
|
if (command === 'overview') { void openDetail(row); return }
|
|
|
|
|
|
detailTarget.value = row
|
|
|
|
|
|
void openDetail(row).then(() => {
|
|
|
|
|
|
if (command === 'exception') openExceptionDialog()
|
|
|
|
|
|
if (command === 'documents') openDocumentDialog('DELIVERY_ORDER')
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
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 }
|
|
|
|
|
|
}
|
2026-08-10 15:36:53 +08:00
|
|
|
|
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 {
|
2026-08-13 11:57:44 +08:00
|
|
|
|
if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
|
2026-08-10 15:36:53 +08:00
|
|
|
|
else 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 {
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
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({
|
|
|
|
|
|
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
|
|
|
|
|
query: { planId: row.planId || undefined, warehouseId: row.warehouseId },
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
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-08-14 12:50:42 +08:00
|
|
|
|
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
<AdminPage as="div">
|
|
|
|
|
|
<section class="container-page">
|
|
|
|
|
|
<ListPageLayout ariaLabel="货柜列表">
|
|
|
|
|
|
<template #toolbar>
|
2026-08-10 18:12:23 +08:00
|
|
|
|
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
|
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-08-11 11:15:44 +08:00
|
|
|
|
<section v-if="!loadError" 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-08-14 20:59:19 +08:00
|
|
|
|
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
2026-08-18 16:09:43 +08:00
|
|
|
|
<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>
|
2026-08-15 17:04:00 +08:00
|
|
|
|
<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>
|
2026-08-17 20:01:51 +08:00
|
|
|
|
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</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-08-13 11:57:44 +08:00
|
|
|
|
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
|
|
|
|
|
|
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
|
|
|
|
|
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</DataTable>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</ListPageLayout>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
|
2026-08-17 18:32:41 +08:00
|
|
|
|
<AppDialog v-model="detailOpen" class="container-detail-dialog" title="货柜详情" width="min(820px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
2026-08-17 19:57:32 +08:00
|
|
|
|
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="装柜清单号">{{ detail.planNo || '—' }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="异常状态">{{ detail.exceptionActive ? '异常' : '正常' }}</el-descriptions-item><el-descriptions-item label="目的港清关资料">{{ documentStatusText(detail.destinationClearanceDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="报关资料">{{ documentStatusText(detail.customsDeclarationDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="DO资料">{{ documentStatusText(detail.deliveryOrderDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="PU资料">{{ documentStatusText(detail.pickupOrderDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
|
2026-08-17 18:32:41 +08:00
|
|
|
|
</AppDialog>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
|
2026-08-13 11:57:44 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-08-14 20:59:19 +08:00
|
|
|
|
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
2026-08-15 17:04:00 +08:00
|
|
|
|
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
|
2026-08-14 20:59:19 +08:00
|
|
|
|
<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>
|
2026-08-17 12:31:27 +08:00
|
|
|
|
<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>
|
2026-08-14 20:59:19 +08:00
|
|
|
|
</el-form>
|
|
|
|
|
|
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
|
|
|
|
|
</AppDialog>
|
|
|
|
|
|
|
2026-08-18 16:09:43 +08:00
|
|
|
|
<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>
|
2026-08-14 20:59:19 +08:00
|
|
|
|
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
|
|
|
|
|
|
</AppDialog>
|
|
|
|
|
|
|
2026-08-12 16:31:46 +08:00
|
|
|
|
<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">
|
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-11 11:52:30 +08:00
|
|
|
|
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
|
2026-08-11 11:15:44 +08:00
|
|
|
|
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
2026-08-11 18:06:35 +08:00
|
|
|
|
<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>
|
2026-08-11 11:15:44 +08:00
|
|
|
|
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
|
2026-08-11 18:06:35 +08:00
|
|
|
|
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="请输入集装箱号,开始装柜前必填" /></el-form-item>
|
2026-08-15 17:04:00 +08:00
|
|
|
|
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜次号" :disabled="transportOnly" /></el-form-item>
|
2026-08-11 18:06:35 +08:00
|
|
|
|
<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>
|
2026-08-17 12:31:27 +08:00
|
|
|
|
<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>
|
2026-08-12 16:31:46 +08:00
|
|
|
|
<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>
|
2026-08-11 18:06:35 +08:00
|
|
|
|
<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>
|
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-15 11:12:22 +08:00
|
|
|
|
<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>
|
2026-08-11 18:06:35 +08:00
|
|
|
|
<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>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</el-form>
|
2026-08-12 16:31:46 +08:00
|
|
|
|
</AppDialog>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</AdminPage>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-14 20:59:19 +08:00
|
|
|
|
.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; }
|
2026-08-13 11:57:44 +08:00
|
|
|
|
.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; }
|
2026-08-12 16:31:46 +08:00
|
|
|
|
: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; }}
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</style>
|