Merge remote-tracking branch 'origin/release/test' into dev

# Conflicts:
#	src/modules/container-loading/pages/ContainersView.vue
This commit is contained in:
2026-09-09 15:42:42 +08:00
22 changed files with 677 additions and 123 deletions
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
import { useRoute, useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, Mail, MoreHorizontal, Pencil, Sparkles } from '@lucide/vue'
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 { listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import { listOceanBookingLinkCandidates, 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'
@@ -18,8 +19,12 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import { getContainer, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
import {
getTerminalDrayageEmails,
sendTerminalDrayageEmail,
retryTerminalDrayageEmail,
type TerminalDrayageEmail,
getTerminalDrayage,
getTerminalDrayageHistory,
recordTerminalDrayageEmptyReturn,
@@ -30,7 +35,7 @@ import {
type TerminalDrayageHistory,
type TerminalDrayageView,
} from '../api/terminalDrayages'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
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 }
@@ -65,13 +70,6 @@ const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const simulationOpen = ref(false)
const simulationSaving = ref(false)
const simulationError = ref<ApiErrorDetails | null>(null)
const simulationChannels = ref<OceanLogisticsChannel[]>([])
const simulationChannelsLoading = ref(false)
const simulationFormRef = ref<FormInstance>()
const simulationForm = reactive<ContainerSimulationCreateRequest>({ channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
const linkOpen = ref(false)
const linkLoading = ref(false)
const linkSaving = ref(false)
@@ -85,7 +83,7 @@ const unresolvedTruckingSupplierName = ref<string | null>(null)
const drayages = ref<Record<string, TerminalDrayageView>>({})
const drayageSavingIds = ref<string[]>([])
const drayageActionOpen = ref(false)
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET'>>('SCHEDULE_PICKUP')
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>>('SCHEDULE_PICKUP')
const drayageTarget = ref<ContainerBookingResource | null>(null)
const drayageActionAt = ref('')
const drayageReason = ref('')
@@ -95,6 +93,66 @@ 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('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { 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 linkSequence = 0
@@ -140,12 +198,6 @@ const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING'))
const simulationRules: FormRules<ContainerSimulationCreateRequest> = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
}
interface PortOption { value: string; code: string | null; name: string | null }
function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' }
function collectPorts(direction: 'departure' | 'destination'): PortOption[] {
@@ -179,7 +231,7 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const allColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 140, 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 || '—' },
@@ -210,7 +262,10 @@ const allColumns: DataTableColumn[] = [
]
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'])
const columns = computed(() => allColumns.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key))))
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)}` },
@@ -227,10 +282,10 @@ function documentStatusText(status: ContainerDocumentStatus | null | undefined)
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_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认还柜' } as Record<TerminalDrayageAction, string>)[action] }
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) {
const permission = action === 'SELECT_FLEET' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : action === 'RECORD_PICKUP' ? canPickupConfirm.value : canEmptyReturnConfirm.value
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) {
@@ -435,39 +490,10 @@ async function loadAllOceanBookings() {
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 openSimulation() {
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
simulationChannels.value = []
simulationError.value = null
simulationOpen.value = true
async function loadSimulationWarehouses() {
await loadWarehouses()
if (warehouseError.value) simulationError.value = warehouseError.value
}
function selectSimulationContainerType(containerType: string) {
applyContainerSpecification(simulationForm, containerType)
}
async function selectSimulationWarehouse(value: unknown) {
simulationForm.warehouseId = String(value || '')
simulationForm.channelCode = ''
simulationChannels.value = []
simulationError.value = null
if (!simulationForm.warehouseId) return
simulationChannelsLoading.value = true
try { simulationChannels.value = await listWarehouseLogisticsChannels(simulationForm.warehouseId) }
catch (error) { simulationError.value = getApiErrorDetails(error, '物流渠道加载失败') }
finally { simulationChannelsLoading.value = false }
}
async function saveSimulation() {
if (!(await simulationFormRef.value?.validate().catch(() => false))) return
simulationSaving.value = true
simulationError.value = null
try {
await createContainerSimulation({ ...simulationForm })
simulationOpen.value = false
ElMessage.success('模拟柜已创建,可先排货后关联真实订舱柜')
await loadRows()
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
finally { simulationSaving.value = false }
if (warehouseError.value) throw warehouseError.value
return warehouses.value
}
async function openLink(source: object) {
if (linkLoading.value) return
@@ -542,7 +568,8 @@ async function selectRowFleet(source: object, supplierIdValue: unknown) {
drayageSavingIds.value = [...drayageSavingIds.value, row.id]
try {
replaceDrayage(await selectTerminalDrayageFleet(row.id, { expectedVersion: current.version, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name }))
ElMessage.success('码头车队已更新')
ElMessage.success(current.fleetSupplierId && current.fleetSupplierId !== supplier.id ? '码头车队已更新,取消及提柜邮件已加入发送队列' : '码头车队已更新')
await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '码头车队更新失败')
if (details.code === 'A0409') {
@@ -551,7 +578,7 @@ async function selectRowFleet(source: object, supplierIdValue: unknown) {
} else ElMessage.error(details.message)
} finally { drayageSavingIds.value = drayageSavingIds.value.filter((id) => id !== row.id) }
}
function openDrayageAction(row: ContainerBookingResource, action: Exclude<TerminalDrayageAction, 'SELECT_FLEET'>) {
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
@@ -616,8 +643,9 @@ 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'>); 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 } })
}
async function openEdit(source: object) {
@@ -732,8 +760,8 @@ onMounted(async () => {
</template>
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="isDomestic && canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><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-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="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 #toolbar-actions><ContainerSimulationCreate v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><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>
@@ -756,17 +784,6 @@ onMounted(async () => {
</section>
</ListPageLayout>
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
<el-form-item label="固定规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /></el-form-item>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @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>
@@ -804,6 +821,16 @@ onMounted(async () => {
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
</AppDialog>
<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>