Files
admin-web/src/modules/loading-lists/components/LoadingListDetailDrawer.vue
T
2026-08-17 19:57:32 +08:00

210 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue'
import { Check, FileUp, Pencil } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
import { loadingListStatusLabel } from '../status'
import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
const props = defineProps<{
mode: LoadingListMode
loading: boolean
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
confirmSaving?: boolean
canManageCustoms?: boolean
customsSaving?: boolean
}>()
const emit = defineEmits<{ retry: []; edit: []; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 150 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 },
{ key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 },
{ key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 86, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 86, align: 'right' },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, align: 'right', slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, align: 'right', slotName: 'grossWeight' },
{ key: 'dimensionalWeightKg', label: '材积重(kg)', width: 115, align: 'right', slotName: 'dimensionalWeight' },
{ key: 'productQuantity', prop: 'productQuantity', label: '产品数', width: 96, align: 'right' },
{ key: 'unitsPerPackage', prop: 'unitsPerPackage', label: '每箱产品数', width: 110, align: 'right' },
{ key: 'fbaTrackingNo', prop: 'fbaTrackingNo', label: 'FBA 追踪编码', minWidth: 155 },
{ key: 'shipmentNo', prop: 'shipmentNo', label: '货件号', minWidth: 140 },
{ key: 'shippingMark', prop: 'shippingMark', label: '唛头', minWidth: 130 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 160, showOverflowTooltip: true },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160, showOverflowTooltip: true },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160, showOverflowTooltip: true },
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
]
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
const blockingReasonLabels: Record<string, string> = {
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
CONTAINER_NO_MISSING: '尚未录入集装箱号',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
OCEAN_BOOKING_NOT_LINKED: '尚未关联真实订舱',
OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱',
PDA_TASK_ALREADY_CREATED: 'PDA 任务已创建,无需重复确认',
PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认',
}
const confirmationStatusLabels: Record<string, string> = {
PENDING: '待确认', PARTIALLY_CONFIRMED: '部分票据已确认', CONFIRMED: '已确认',
}
const oceanBookingStatusLabels: Record<string, string> = {
CONFIRMED: '已确认', CHANGE_PENDING: '改船处理中', RELEASED: '已放舱', CANCELLED: '已取消',
}
const pdaTaskStatusLabels: Record<string, string> = {
PENDING: '待领取', CLAIMED: '已领取', LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
const blockingReasonText = computed(() => (preloadSummary.value?.readiness.blockingReasons || [])
.map((reason) => blockingReasonLabels[reason] || '存在未满足的确认条件')
.join(';'))
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const title = computed(() => {
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
const number = props.mode === 'preload'
? (summary.value as PreloadSummary).preloadNo
: 'loadingListNo' in summary.value ? summary.value.loadingListNo : ''
return `${props.mode === 'preload' ? '预装清单' : '装柜清单'} · ${number}`
})
function actionLabel(action: string) {
return ({
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
CONFIRM_PRELOAD: '确认预装',
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
} as Record<string, string>)[action] || '状态更新'
}
function formatDateTime(value?: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatNumber(value?: number | null, digits = 3) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
</script>
<template>
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
<LoadErrorState v-if="error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<div v-else-if="detail && summary" class="detail-layout">
<DetailPageLayout :ariaLabel="title">
<template #summary>
<section class="detail-summary" aria-label="清单基本信息">
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待录入' }}</strong></div>
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
<template v-if="preloadSummary">
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
<div><span>预装确认</span><StatusBadge :status="confirmationStatusLabels[preloadSummary.readiness.confirmationStatus] || '未知状态'" /></div>
<div><span>订舱状态</span><strong>{{ preloadSummary.readiness.oceanBookingStatus ? (oceanBookingStatusLabels[preloadSummary.readiness.oceanBookingStatus] || '未知状态') : '未关联' }}</strong></div>
<div><span>PDA 状态</span><strong>{{ preloadSummary.readiness.pdaTaskStatus ? (pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态') : (preloadSummary.readiness.readyForPda ? '可下发' : '未就绪') }}</strong></div>
<div class="detail-summary__wide"><span>客户</span><strong>{{ preloadSummary.customers.map((item) => item.customerName || '客户信息缺失').join('、') || '客户信息缺失' }}</strong></div>
</template>
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
</section>
</template>
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
<el-button type="success" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
</div>
<el-alert v-if="preloadSummary?.readiness.confirmationStatus === 'CONFIRMED'" type="success" :closable="false" show-icon title="整份预装计划已确认" :description="preloadSummary.readiness.pdaTaskStatus ? `PDA 任务状态:${pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态'}` : 'PDA 前置条件已满足,等待服务端生成任务。'" />
<el-alert v-else-if="preloadSummary && !preloadSummary.readiness.canConfirm" type="warning" :closable="false" show-icon title="当前暂不能确认预装" :description="blockingReasonText || '请刷新详情获取最新确认条件。'" />
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
<section class="detail-section surface-panel">
<header class="detail-section__header"><h2>装柜货物明细</h2><span>{{ detail.items.length }} 条</span></header>
<DataTable
:data="detail.items"
:columns="itemColumns"
stripe
preference-key="loading-list-detail-items"
row-key="itemId"
empty-text="暂无装柜货物明细"
aria-label="装柜货物明细"
>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
<template #dimensionalWeight="{ row }">{{ formatNumber(row.dimensionalWeightKg) }}</template>
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
</DataTable>
</section>
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" />
<section class="history-section surface-panel" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
<el-timeline v-else>
<el-timeline-item v-for="history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
<div class="history-entry">
<strong>{{ actionLabel(history.actionType) }}</strong>
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
<p>{{ history.operatorName || '操作人信息缺失' }}<template v-if="history.operationReason"> · {{ history.operationReason }}</template></p>
</div>
</el-timeline-item>
</el-timeline>
</section>
</DetailPageLayout>
</div>
</section>
</template>
<style scoped>
.loading-detail-content, .detail-layout { display: grid; min-width: 0; gap: 16px; }
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--cc-border); background: var(--cc-border); }
.detail-summary > div { display: flex; min-width: 0; min-height: 78px; flex-direction: column; justify-content: center; gap: 7px; padding: 14px 16px; background: #fff; }
.detail-summary span { color: var(--cc-muted); font-size: 12px; }
.detail-summary strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; }
.detail-summary small { color: var(--cc-muted); font-size: 11px; }
.detail-summary__wide { grid-column: span 2; }
.detail-actions { display: flex; justify-content: flex-end; }
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.detail-section__header { display: flex; align-items: baseline; gap: 10px; padding: 18px 18px 0; }
.detail-section__header h2 { margin: 0; font-size: 16px; }
.detail-section__header span { color: var(--cc-muted); font-size: 12px; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.history-section { padding: 18px; }
.history-section header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 18px; }
.history-section h2 { margin: 0; font-size: 16px; }
.history-section header span { color: var(--cc-muted); font-size: 12px; }
.history-entry { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.history-entry > span { color: var(--cc-muted); font-size: 12px; }
.history-entry p { width: 100%; margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 900px) { .detail-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 520px) { .detail-summary { grid-template-columns: 1fr; } .detail-summary__wide { grid-column: auto; } .detail-actions :deep(.el-button) { width: 100%; min-height: 44px; } }
</style>