Files
admin-web/src/modules/container-loading/pages/ContainersView.vue
T
2026-10-20 14:52:06 +08:00

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