Files
admin-web/src/modules/container-loading/pages/ContainersView.vue
T

864 lines
69 KiB
Vue
Raw Normal View History

2026-08-10 15:36:53 +08:00
<script setup lang="ts">
2026-09-09 15:26:43 +08:00
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
2026-08-10 15:36:53 +08:00
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
2026-09-09 15:26:43 +08:00
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, Mail, MoreHorizontal, Pencil, Sparkles } from '@lucide/vue'
2026-09-09 14:32:04 +08:00
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
2026-08-10 15:36:53 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-14 12:50:42 +08:00
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
2026-08-10 15:36:53 +08:00
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
2026-08-11 18:06:35 +08:00
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
2026-08-15 11:12:22 +08:00
import { listSuppliers, type Supplier } from '@/modules/suppliers'
2026-09-09 15:26:43 +08:00
import { listOceanBookingLinkCandidates, listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-10 15:36:53 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-12 16:31:46 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-10 15:36:53 +08:00
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-10 15:36:53 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-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-09-09 15:26:43 +08:00
import { getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import {
2026-09-09 14:32:04 +08:00
getTerminalDrayageEmails,
sendTerminalDrayageEmail,
retryTerminalDrayageEmail,
type TerminalDrayageEmail,
getTerminalDrayage,
getTerminalDrayageHistory,
recordTerminalDrayageEmptyReturn,
recordTerminalDrayagePickup,
scheduleTerminalDrayagePickup,
selectTerminalDrayageFleet,
type TerminalDrayageAction,
type TerminalDrayageHistory,
type TerminalDrayageView,
} from '../api/terminalDrayages'
2026-09-09 15:26:43 +08:00
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
2026-08-10 15:36:53 +08:00
2026-08-14 12:50:42 +08:00
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
2026-08-19 09:42:12 +08:00
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
2026-08-10 15:36:53 +08:00
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
2026-08-10 15:36:53 +08:00
const router = useRouter()
const route = useRoute()
2026-08-10 15:36:53 +08:00
const access = useAccessPreviewStore()
const rows = ref<ContainerWorkbenchRow[]>([])
2026-08-14 12:50:42 +08:00
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
2026-08-10 15:36:53 +08:00
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
2026-08-11 18:06:35 +08:00
const destinationWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
2026-08-15 11:12:22 +08:00
const truckingSuppliers = ref<Supplier[]>([])
2026-09-09 18:29:47 +08:00
const terminalFleetOptions = ref<Supplier[]>([])
const terminalFleetLoading = ref(false)
2026-08-11 18:06:35 +08:00
const oceanBookings = ref<OceanBookingResource[]>([])
2026-08-10 15:36:53 +08:00
const loading = ref(false)
const warehouseLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const warehouseError = ref<ApiErrorDetails | null>(null)
2026-08-11 18:06:35 +08:00
const businessOptionsLoading = ref(false)
const businessOptionsError = ref('')
2026-08-10 15:36:53 +08:00
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE')
2026-08-14 12:50:42 +08:00
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
2026-08-10 15:36:53 +08:00
const applied = ref<Filters>({ ...filters.value })
const detail = ref<ContainerBookingResource | null>(null)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
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-25 19:56:37 +08:00
const unresolvedTruckingSupplierName = ref<string | null>(null)
const drayages = ref<Record<string, TerminalDrayageView>>({})
const drayageSavingIds = ref<string[]>([])
const drayageActionOpen = ref(false)
2026-09-09 14:32:04 +08:00
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>>('SCHEDULE_PICKUP')
const drayageTarget = ref<ContainerBookingResource | null>(null)
const drayageActionAt = ref('')
const drayageReason = ref('')
const drayageActionSaving = ref(false)
const drayageActionError = ref<ApiErrorDetails | null>(null)
const drayageHistoryOpen = ref(false)
const drayageHistory = ref<TerminalDrayageHistory[]>([])
const drayageHistoryLoading = ref(false)
const drayageHistoryError = ref<ApiErrorDetails | null>(null)
2026-09-09 14:32:04 +08:00
const emailHistoryOpen = ref(false)
const emailTarget = ref<ContainerBookingResource | null>(null)
const emails = ref<TerminalDrayageEmail[]>([])
const emailsLoading = ref(false)
const emailsError = ref<ApiErrorDetails | null>(null)
const emailDeliveryEnabled = ref(false)
const retryingEmail = ref('')
let emailSequence = 0
const emailColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'emailActions' },
{ key: 'kind', label: '类型', width: 100, formatter: (row: TerminalDrayageEmail) => row.kind === 'CANCEL_PICKUP' ? '取消提柜' : '请求提柜' },
{ key: 'fleetName', label: '车队', minWidth: 130 },
{ key: 'recipient', label: '收件邮箱', minWidth: 210 },
{ key: 'status', label: '发送状态', width: 100, formatter: (row: TerminalDrayageEmail) => ({ PENDING: '待发送', SENDING: '发送中', SENT: '已发送', FAILED: '失败待核对' })[row.status] },
{ key: 'content', label: '邮件内容', minWidth: 270, slotName: 'emailContent' },
{ key: 'failureReason', label: '失败说明', minWidth: 200 },
{ key: 'createdAt', label: '创建时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.createdAt) },
{ key: 'sentAt', label: '发送时间', width: 165, formatter: (row: TerminalDrayageEmail) => formatTime(row.sentAt) },
]
async function sendPickupEmail(row: ContainerWorkbenchRow) {
const current = drayageFor(row.id)
if (!current || !canDrayage(row, 'SEND_PICKUP_EMAIL') || drayageSavingIds.value.includes(row.id)) return
drayageSavingIds.value.push(row.id)
try {
replaceDrayage(await sendTerminalDrayageEmail(row.id, current.version))
ElMessage.success('提柜邮件已加入发送队列,可在邮件记录中查看结果')
await loadRows()
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '提交提柜邮件失败').message)
await loadRows()
} finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) }
}
async function openEmailHistory(row: ContainerBookingResource) {
const sequence = ++emailSequence
emailTarget.value = row; emailHistoryOpen.value = true; emailsLoading.value = true
emailsError.value = null; emails.value = []
try {
const result = await getTerminalDrayageEmails(row.id)
if (sequence !== emailSequence) return
emails.value = result.records; emailDeliveryEnabled.value = result.deliveryEnabled
} catch (error) {
if (sequence === emailSequence) emailsError.value = getApiErrorDetails(error, '邮件记录加载失败')
} finally { if (sequence === emailSequence) emailsLoading.value = false }
}
async function retryEmail(mail: TerminalDrayageEmail) {
const target = emailTarget.value
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
retryingEmail.value = mail.id
try {
await ElMessageBox.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 = '' }
}
2026-08-10 15:36:53 +08:00
let listSequence = 0
let detailSequence = 0
2026-08-25 19:56:37 +08:00
let linkSequence = 0
let warehousesLoaded = false
let warehouseOptionsPromise: Promise<void> | null = null
let partnersLoaded = false
let truckingSuppliersLoaded = false
let truckingSuppliersPromise: Promise<Supplier[]> | null = null
2026-09-09 18:29:47 +08:00
let terminalFleetSearchSequence = 0
let oceanBookingsLoaded = false
let businessOptionsPromise: Promise<void> | null = null
2026-08-10 15:36:53 +08:00
function emptyForm(): ContainerForm {
return {
id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null,
2026-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-25 19:56:37 +08:00
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null,
2026-08-10 15:36:53 +08:00
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
}
const form = reactive<ContainerForm>(emptyForm())
const rules: FormRules<ContainerForm> = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
2026-08-25 19:56:37 +08:00
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
2026-08-10 15:36:53 +08:00
}
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
const canView = computed(() => isDomestic.value ? access.can('platform:firstleg:container:list') : access.can('platform:firstleg:terminal-drayage:list'))
2026-08-10 15:36:53 +08:00
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
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 canPickupConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:pickup:confirm'))
const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:empty-return:confirm'))
2026-08-10 15:36:53 +08:00
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
2026-08-12 16:31:46 +08:00
const editorTitle = computed(() => form.id ? '编辑' : '新增')
2026-08-11 18:06:35 +08:00
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
2026-08-15 11:12:22 +08:00
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING'))
2026-08-11 18:06:35 +08:00
interface PortOption { value: string; code: string | null; name: string | null }
function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' }
function collectPorts(direction: 'departure' | 'destination'): PortOption[] {
const entries = oceanBookings.value.map((booking) => direction === 'departure'
? { code: booking.departurePortCode, name: booking.departurePortName }
: { code: booking.destinationPortCode, name: booking.destinationPortName })
const current = direction === 'departure'
? { code: form.departurePortCode, name: form.departurePortName }
: { code: form.destinationPortCode, name: form.destinationPortName }
entries.push(current)
const options = new Map<string, PortOption>()
for (const entry of entries) {
if (!entry.code && !entry.name) continue
const value = portValue(entry.code, entry.name)
options.set(value, { value, code: entry.code || null, name: entry.name || null })
}
return [...options.values()].sort((left, right) => (left.name || left.code || '').localeCompare(right.name || right.code || '', 'zh-CN'))
}
const departurePorts = computed(() => collectPorts('departure'))
const destinationPorts = computed(() => collectPorts('destination'))
const departurePortValue = computed(() => portValue(form.departurePortCode, form.departurePortName))
const destinationPortValue = computed(() => portValue(form.destinationPortCode, form.destinationPortName))
const shippingCompanyValue = computed(() => form.shippingCompanyId || (form.shippingCompanyName ? `name:${form.shippingCompanyName}` : ''))
const destinationWarehouseValue = computed(() => form.destinationWarehouseCode || (form.destinationWarehouseName ? `name:${form.destinationWarehouseName}` : ''))
2026-08-10 15:36:53 +08:00
const queryFields = computed<QueryField[]>(() => [
2026-08-14 14:27:03 +08:00
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
2026-08-25 17:43:00 +08:00
{ key: 'containerType', type: 'select', label: '柜型', width: 140, placeholder: '请选择箱型', options: CONTAINER_SPECIFICATIONS.map((item) => ({ label: item.type, value: item.type })), props: { 'aria-label': '柜型' } },
2026-09-04 15:54:36 +08:00
{ key: 'status', type: 'select', label: '装柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '装柜状态' } },
2026-08-10 15:36:53 +08:00
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const allColumns: DataTableColumn[] = [
2026-09-09 17:42:19 +08:00
{ key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
{ key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' },
2026-08-10 15:36:53 +08:00
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
2026-08-17 19:57:32 +08:00
{ key: 'planNo', prop: 'planNo', label: '装柜清单号', minWidth: 165, formatter: (row: ContainerBookingResource) => row.planNo || '—' },
2026-08-10 15:36:53 +08:00
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
2026-08-17 19:57:32 +08:00
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
2026-08-17 19:57:32 +08:00
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
2026-08-10 15:36:53 +08:00
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' },
{ key: 'pickupAppointmentAt', label: '预约提柜', width: 165, slotName: 'pickupAppointmentAt' },
{ key: 'actualPickupAt', label: '实际提柜', width: 165, slotName: 'actualPickupAt' },
{ key: 'estimatedEmptyReturnAt', label: '预计还柜', width: 165, slotName: 'estimatedEmptyReturnAt' },
{ key: 'actualEmptyReturnAt', label: '实际还柜', width: 165, slotName: 'actualEmptyReturnAt' },
2026-08-13 11:57:44 +08:00
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 },
2026-08-10 15:36:53 +08:00
{ key: 'route', label: '航线', minWidth: 220, slotName: 'route' },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 100 },
{ key: 'capacity', label: '容量上限', minWidth: 150, slotName: 'capacity' },
{ key: 'vessel', label: '船名/航次', minWidth: 180, slotName: 'vessel' },
2026-08-13 11:57:44 +08:00
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150 },
{ key: 'loadingAt', label: '装柜时间', minWidth: 160, slotName: 'loadingAt' },
2026-08-10 15:36:53 +08:00
{ key: 'estimatedSailingAt', label: '预计开船', minWidth: 160, slotName: 'estimatedSailingAt' },
2026-08-13 11:57:44 +08:00
{ key: 'actualSailingAt', label: '实际开船', minWidth: 160, slotName: 'actualSailingAt' },
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
2026-08-10 15:36:53 +08:00
]
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', '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'])
2026-09-09 13:51:14 +08:00
const columns = computed(() => allColumns
.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key)))
// 国内同时展示查看、运输信息、装柜流程和更多菜单,须为四个入口预留空间。
2026-09-09 13:54:43 +08:00
.map((column) => isDomestic.value && column.key === 'actions' ? { ...column, width: 290 } : column))
const drayageHistoryColumns: DataTableColumn[] = [
{ key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) },
{ key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` },
{ key: 'version', label: '版本', width: 100, formatter: (row: TerminalDrayageHistory) => `${row.fromVersion} → ${row.toVersion}` },
{ key: 'operatorName', label: '操作人', width: 130, formatter: (row: TerminalDrayageHistory) => row.operatorName || '—' },
{ key: 'createdAt', label: '操作时间', width: 170, formatter: (row: TerminalDrayageHistory) => formatTime(row.createdAt) },
{ key: 'reason', label: '原因', minWidth: 200 },
]
2026-08-10 15:36:53 +08:00
function optional(value: string | null) { return value?.trim() || null }
function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
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) || '—' }
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 || '—' }
2026-09-09 14:32:04 +08:00
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_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) {
2026-09-09 14:32:04 +08:00
const permission = action === 'SELECT_FLEET' || action === 'SEND_PICKUP_EMAIL' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : action === 'RECORD_PICKUP' ? canPickupConfirm.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)
}
2026-08-10 17:42:17 +08:00
function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
.find((value) => typeof value === 'string' && value.trim())
return reference ? `查看 ${reference}` : '查看货柜详情'
}
2026-08-10 15:36:53 +08:00
function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
2026-08-17 12:31:27 +08:00
const specification = containerSpecification(form.containerType)
if (specification) applyContainerSpecification(form, specification.type)
2026-08-25 19:56:37 +08:00
reconcileTruckingSupplier()
}
function reconcileTruckingSupplier() {
const supplierId = optional(form.terminalTruckingCompanyId)
const supplierName = optional(form.terminalTruckingCompany) || unresolvedTruckingSupplierName.value
const supplier = supplierId
? truckingCompanies.value.find((item) => item.id === supplierId)
: truckingCompanies.value.find((item) => item.name === supplierName)
selectedTruckingSupplierId.value = supplier?.id || ''
unresolvedTruckingSupplierName.value = supplier ? null : supplierName
if (supplier) {
form.terminalTruckingCompanyId = supplier.id
form.terminalTruckingCompany = supplier.name
} else if (!supplierName) {
form.terminalTruckingCompanyId = null
form.terminalTruckingCompany = null
} else {
form.terminalTruckingCompanyId = null
form.terminalTruckingCompany = supplierName
}
2026-08-10 15:36:53 +08:00
}
function payload(): ContainerSaveRequest {
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
2026-08-25 19:56:37 +08:00
const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
2026-08-10 15:36:53 +08:00
return {
containerNo: optional(detail.value?.containerNo || null),
2026-08-10 15:36:53 +08:00
warehouseId: form.warehouseId,
warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName),
billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo),
containerType: form.containerType,
shippingCompanyId: optional(form.shippingCompanyId), shippingCompanyName: optional(form.shippingCompanyName),
departurePortCode: optional(form.departurePortCode), departurePortName: optional(form.departurePortName),
destinationPortCode: optional(form.destinationPortCode), destinationPortName: optional(form.destinationPortName),
destinationWarehouseCode: optional(form.destinationWarehouseCode), destinationWarehouseName: optional(form.destinationWarehouseName),
2026-08-25 19:56:37 +08:00
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg,
terminalTruckingCompanyId: selectedTruckingSupplier?.id || null,
terminalTruckingCompany: selectedTruckingSupplier?.name || null,
2026-08-10 15:36:53 +08:00
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
}
}
2026-08-11 18:06:35 +08:00
function partnerLabel(partner: PlatformPartnerResource) {
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
}
2026-08-15 11:12:22 +08:00
function selectTruckingSupplier(supplierId: string) {
2026-08-25 19:56:37 +08:00
if (!supplierId && unresolvedTruckingSupplierName.value) return
2026-08-15 11:12:22 +08:00
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
selectedTruckingSupplierId.value = supplier?.id || ''
2026-08-25 19:56:37 +08:00
unresolvedTruckingSupplierName.value = null
form.terminalTruckingCompanyId = supplier?.id || null
2026-08-15 11:12:22 +08:00
form.terminalTruckingCompany = supplier?.name || null
}
2026-08-11 18:06:35 +08:00
function portLabel(port: PortOption) {
return port.name && port.code ? `${port.name}(${port.code})` : port.name || port.code || '未命名港口'
}
function selectShippingCompany(partnerId: string) {
if (partnerId.startsWith('name:')) {
form.shippingCompanyId = null
form.shippingCompanyName = partnerId.slice(5) || null
return
}
const partner = partners.value.find((item) => String(item.id) === partnerId)
form.shippingCompanyId = partnerId || null
form.shippingCompanyName = partner?.partnerName || null
}
function selectPort(direction: 'departure' | 'destination', value: string) {
const option = (direction === 'departure' ? departurePorts.value : destinationPorts.value).find((item) => item.value === value)
if (direction === 'departure') {
form.departurePortCode = option?.code || null
form.departurePortName = option?.name || null
} else {
form.destinationPortCode = option?.code || null
form.destinationPortName = option?.name || null
}
}
function selectDestinationWarehouse(warehouseCode: string) {
if (warehouseCode.startsWith('name:')) {
form.destinationWarehouseCode = null
form.destinationWarehouseName = warehouseCode.slice(5) || null
return
}
const warehouse = destinationWarehouses.value.find((item) => item.warehouseCode === warehouseCode)
form.destinationWarehouseCode = warehouseCode || null
form.destinationWarehouseName = warehouse?.warehouseName || null
}
2026-08-10 15:36:53 +08:00
async function loadWarehouses() {
if (warehousesLoaded) return
if (warehouseOptionsPromise) return warehouseOptionsPromise
warehouseOptionsPromise = (async () => {
warehouseLoading.value = true; warehouseError.value = null
try {
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
warehousesLoaded = true
}
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
})()
try { await warehouseOptionsPromise }
finally { warehouseOptionsPromise = null }
2026-08-10 15:36:53 +08:00
}
2026-08-14 12:50:42 +08:00
async function loadCustomers() {
customersLoading.value = true
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customers.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customers.value = []
} finally {
customersLoading.value = false
}
}
2026-08-11 18:06:35 +08:00
async function loadBusinessOptions() {
if (partnersLoaded && truckingSuppliersLoaded && oceanBookingsLoaded) return
if (businessOptionsPromise) return businessOptionsPromise
businessOptionsPromise = (async () => {
businessOptionsLoading.value = true
businessOptionsError.value = ''
const errors: string[] = []
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
partnersLoaded ? Promise.resolve(partners.value) : loadAllPartners(),
loadTruckingSuppliers(),
oceanBookingsLoaded ? Promise.resolve(oceanBookings.value) : loadAllOceanBookings(),
])
if (partnerResult.status === 'fulfilled') { partners.value = partnerResult.value; partnersLoaded = true }
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
if (supplierResult.status === 'fulfilled') {
if (form.id) reconcileTruckingSupplier()
}
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
if (bookingResult.status === 'fulfilled') { oceanBookings.value = bookingResult.value; oceanBookingsLoaded = true }
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
businessOptionsError.value = errors.join(';')
businessOptionsLoading.value = false
})()
try { await businessOptionsPromise }
finally { businessOptionsPromise = null }
}
async function loadTruckingSuppliers(): Promise<Supplier[]> {
if (truckingSuppliersLoaded) return truckingSuppliers.value
if (truckingSuppliersPromise) return truckingSuppliersPromise
truckingSuppliersPromise = (async () => {
const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })
const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
truckingSuppliers.value = records
truckingSuppliersLoaded = true
return records
})()
try { return await truckingSuppliersPromise }
finally { truckingSuppliersPromise = null }
}
2026-09-09 18:29:47 +08:00
async function searchTerminalFleets(keyword = '') {
const sequence = ++terminalFleetSearchSequence
terminalFleetLoading.value = true
try {
const result = await listSuppliers({
page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined,
})
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
} catch (error) {
if (sequence !== terminalFleetSearchSequence) return
terminalFleetOptions.value = []
ElMessage.error(getApiErrorDetails(error, '码头车队搜索失败').message)
} finally {
if (sequence === terminalFleetSearchSequence) terminalFleetLoading.value = false
}
}
function handleTerminalFleetVisible(open: boolean) {
if (open) void searchTerminalFleets()
else { terminalFleetSearchSequence++; terminalFleetLoading.value = false }
2026-08-11 18:06:35 +08:00
}
async function loadAllPartners() {
const firstPage = await listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' })
if (firstPage.pages <= 1) return firstPage.records
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listPlatformPartners({ page: index + 2, size: 200, status: 'ACTIVE' })))
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
async function loadAllOceanBookings() {
const firstPage = await listOceanBookings({ page: 1, size: 200 })
if (firstPage.pages <= 1) return firstPage.records
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200 })))
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
2026-09-09 15:26:43 +08:00
async function loadSimulationWarehouses() {
await loadWarehouses()
2026-09-09 15:26:43 +08:00
if (warehouseError.value) throw warehouseError.value
return warehouses.value
}
async function openLink(source: object) {
2026-08-25 19:56:37 +08:00
if (linkLoading.value) return
const row = source as ContainerBookingResource
2026-08-25 19:56:37 +08:00
const warehouseId = optional(row.warehouseId)
const channelCode = optional(row.channelCode)
if (!warehouseId || !channelCode || !optional(row.containerType)) {
2026-08-25 19:56:37 +08:00
linkTarget.value = row
linkCandidates.value = []
linkError.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
2026-08-25 19:56:37 +08:00
linkOpen.value = true
return
}
const sequence = ++linkSequence
linkTarget.value = row
linkCandidateKey.value = ''
2026-08-18 16:09:43 +08:00
linkContainerNo.value = ''
linkCandidates.value = []
linkError.value = null
linkOpen.value = true
linkLoading.value = true
try {
2026-08-25 19:56:37 +08:00
const result = await listOceanBookingLinkCandidates({
warehouseId, channelCode, containerType: row.containerType,
page: 1, size: 200,
})
if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return
linkCandidates.value = result.records.map((candidate) => ({
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
disabled: false,
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
}))
} catch (error) {
if (sequence === linkSequence) linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败')
} finally {
if (sequence === linkSequence) linkLoading.value = false
}
}
async function saveLink() {
const target = linkTarget.value
const candidate = linkCandidates.value.find((item) => item.key === linkCandidateKey.value)
const normalized = normalizedContainerNo(linkContainerNo.value)
const containerNo = normalized || null
linkContainerNo.value = normalized
2026-08-18 16:09:43 +08:00
if (!target || !candidate || candidate.disabled) { linkError.value = { message: '请选择规格一致且仍有剩余数量的柜型需求' }; return }
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 })
linkOpen.value = false
ElMessage.success(containerNo ? '订舱与真实集装箱号已关联,柜次号、装柜计划和明细保持不变' : '订舱已关联,真实集装箱号待司机提柜时录入')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
}
function replaceDrayage(value: TerminalDrayageView) {
drayages.value = { ...drayages.value, [value.containerBookingId]: value }
}
async function refreshDrayage(containerBookingId: string) {
const value = await getTerminalDrayage(containerBookingId)
replaceDrayage(value)
return value
}
async function selectRowFleet(source: object, supplierIdValue: unknown) {
const row = source as ContainerBookingResource
const current = drayageFor(row.id)
2026-09-09 18:29:47 +08:00
const supplierId = String(supplierIdValue || '')
const supplier = terminalFleetOptions.value.find((item) => item.id === supplierId)
|| truckingCompanies.value.find((item) => item.id === supplierId)
if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return
drayageSavingIds.value = [...drayageSavingIds.value, row.id]
try {
replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name }))
2026-09-09 17:42:19 +08:00
ElMessage.success(current.fleetSupplierId && current.fleetSupplierId !== supplier.id
? '码头车队已更新,新车队提柜邮件已入队;原车队已下发过邮件时同时加入取消邮件'
: '码头车队已更新')
2026-09-09 14:32:04 +08:00
await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '码头车队更新失败')
if (details.code === 'A0409') {
try { await refreshDrayage(row.id) } catch { /* 保留原始冲突证据 */ }
ElMessage.warning('运输信息已被其他人修改,已刷新当前柜最新状态')
} else ElMessage.error(details.message)
} finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) }
}
2026-09-09 14:32:04 +08:00
function openDrayageAction(row: ContainerBookingResource, action: Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>) {
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 = drayageAction.value === 'SCHEDULE_PICKUP'
? await scheduleTerminalDrayagePickup(row.id, { expectedVersion: current.version, pickupAppointmentAt: drayageActionAt.value, reason })
: drayageAction.value === 'RECORD_PICKUP'
2026-09-09 10:26:17 +08:00
? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason })
: 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 openDrayageHistory(row: ContainerBookingResource) {
drayageTarget.value = row; drayageHistory.value = []; drayageHistoryError.value = null; drayageHistoryOpen.value = true; drayageHistoryLoading.value = true
try {
const [current, history] = await Promise.all([getTerminalDrayage(row.id), getTerminalDrayageHistory(row.id)])
if (drayageTarget.value?.id !== row.id) return
replaceDrayage(current); drayageHistory.value = history
} catch (error) { if (drayageTarget.value?.id === row.id) drayageHistoryError.value = getApiErrorDetails(error, '码头拖车记录加载失败') }
finally { if (drayageTarget.value?.id === row.id) drayageHistoryLoading.value = false }
}
2026-08-10 15:36:53 +08:00
async function loadRows() {
const sequence = ++listSequence
loading.value = true; loadError.value = null
const [from, to] = applied.value.sailingRange || []
try {
const result = await listContainers({
2026-08-14 12:50:42 +08:00
page: page.value, size: pageSize.value, customerId: applied.value.customerId || undefined, keyword: applied.value.keyword.trim() || undefined,
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, scope: scope.value,
}, props.workbench)
2026-08-10 15:36:53 +08:00
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]] : []))
2026-08-10 15:36:53 +08:00
} catch (error) {
if (sequence !== listSequence) return
rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试')
} finally { if (sequence === listSequence) loading.value = false }
}
2026-08-10 18:12:23 +08:00
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
2026-08-18 17:40:40 +08:00
function openDetail(source: object) {
2026-08-10 15:36:53 +08:00
const row = source as ContainerBookingResource
void router.push({ name: props.workbench === 'DOMESTIC' ? 'domestic-container-detail' : 'overseas-container-detail', params: { containerId: row.id } })
2026-08-10 15:36:53 +08:00
}
2026-08-13 11:57:44 +08:00
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
if (command === 'link') { void openLink(row); return }
2026-08-18 17:40:40 +08:00
if (command === 'overview') { openDetail(row); return }
2026-09-09 14:32:04 +08:00
if (command === 'email-history') { void openEmailHistory(row); return }
if (command === 'drayage-history') { void openDrayageHistory(row); return }
2026-09-09 14:32:04 +08:00
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>); return }
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
2026-08-13 11:57:44 +08:00
}
2026-08-10 15:36:53 +08:00
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
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
}
2026-08-10 15:36:53 +08:00
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
}
async function save() {
2026-08-25 19:56:37 +08:00
if (unresolvedTruckingSupplierName.value) {
editorError.value = { message: '原拖车供应商已不可用,请重新选择合作中的拖车供应商' }
return
}
2026-08-10 15:36:53 +08:00
if (!(await formRef.value?.validate().catch(() => false))) return
saving.value = true
editorError.value = null
try {
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, '保存失败')
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)
}
2026-08-10 15:36:53 +08:00
} 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 },
})
}
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()
})
watch(scope, () => { if (page.value === 1) void loadRows(); else page.value = 1 })
2026-08-25 19:56:37 +08:00
watch(linkOpen, (open) => {
if (open) return
linkSequence += 1
linkLoading.value = false
})
onMounted(async () => {
await Promise.all([loadCustomers(), loadRows()])
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
if (!isDomestic.value || !linkContainerId || !canEdit.value) return
try {
const target = await getContainer(linkContainerId, 'DOMESTIC')
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !hasAction(target, 'EDIT_CONTAINER')) {
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
return
}
await openLink(target)
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
} finally {
void router.replace({ name: 'domestic-container-workbench' })
}
})
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="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
2026-08-10 16:24:54 +08:00
<template #toolbar>
<div class="container-query-panel">
<div class="container-scope-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>
2026-08-10 16:24:54 +08:00
</template>
<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>
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
2026-08-12 18:16:44 +08:00
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
2026-09-09 15:26:43 +08:00
<template #toolbar-actions><ContainerSimulationCreate v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
2026-09-09 14:32:04 +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><template v-if="isDomestic"><el-button v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑</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></template><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)"><Mail :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><template v-if="isOverseas"><el-dropdown-item command="email-history"><Mail :size="15" />邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents"><FileUp :size="15" />清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></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>
2026-09-09 18:29:47 +08:00
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }">{{ formatTime(drayageFor(row.id)?.pickupAppointmentAt) }}</template>
<template #actualPickupAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualPickupAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
2026-08-10 16:24:54 +08:00
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
2026-08-13 11:57:44 +08:00
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
2026-08-10 16:24:54 +08:00
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
2026-08-13 11:57:44 +08:00
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
2026-08-10 16:24:54 +08:00
</DataTable>
</section>
</ListPageLayout>
2026-08-10 15:36:53 +08:00
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @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="真实集装箱号(可选)"><el-input v-model="linkContainerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="linkContainerNo = normalizedContainerNo(linkContainerNo)" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" 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>
<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>
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
2026-08-19 09:42:12 +08:00
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
2026-08-11 18:06:35 +08:00
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="箱型(货柜类型)" prop="containerType"><el-select :model-value="form.containerType" placeholder="请选择箱型" @update:model-value="applyContainerSpecification(form, String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
2026-08-17 12:31:27 +08:00
<el-form-item label="货柜容量(m³)"><el-input :model-value="form.capacityCbm == null ? '—' : `${form.capacityCbm} m³`" disabled /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input :model-value="form.maxWeightKg == null ? '—' : `${form.maxWeightKg} kg`" disabled /></el-form-item>
<el-form-item label="拖车公司" prop="terminalTruckingCompany"><el-select :model-value="selectedTruckingSupplierId" filterable clearable placeholder="请选择拖车供应商" :loading="businessOptionsLoading" loading-text="拖车供应商加载中" no-data-text="暂无可用拖车供应商" @update:model-value="selectTruckingSupplier(String($event || ''))"><el-option v-if="form.terminalTruckingCompany && !selectedTruckingSupplierId" :label="`${form.terminalTruckingCompany}(需重新选择)`" value="" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" /></el-form-item>
2026-08-10 15:36:53 +08:00
</el-form>
2026-08-12 16:31:46 +08:00
</AppDialog>
<AppDialog v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
<el-form label-position="top"><el-form-item :label="drayageAction === 'SCHEDULE_PICKUP' ? '预约提柜时间' : drayageAction === 'RECORD_PICKUP' ? '实际提柜时间' : '实际还柜时间'" 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>
2026-09-09 14:32:04 +08:00
<AppDialog v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
<template #emailActions="{ row }"><el-button 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>
</AppDialog>
<AppDialog v-model="drayageHistoryOpen" title="码头拖车记录" 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>
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
</AppDialog>
2026-08-10 16:24:54 +08:00
</section>
</AdminPage>
2026-08-10 15:36:53 +08:00
</template>
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.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),.fleet-select { 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; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
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){.container-scope-tab{min-height:46px;padding:0 14px}: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>