2026-08-10 15:36:53 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed } from 'vue'
|
2026-08-10 16:24:54 +08:00
|
|
|
import { Pencil } from '@lucide/vue'
|
2026-08-10 15:36:53 +08:00
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
2026-08-10 16:24:54 +08:00
|
|
|
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
2026-08-10 15:36:53 +08:00
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
2026-08-12 16:31:46 +08:00
|
|
|
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
2026-08-11 19:35:28 +08:00
|
|
|
import { loadingListStatusLabel } from '../status'
|
2026-08-10 15:36:53 +08:00
|
|
|
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
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{ retry: []; edit: [] }>()
|
|
|
|
|
|
|
|
|
|
const itemColumns: DataTableColumn[] = [
|
|
|
|
|
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
|
|
|
|
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
|
|
|
|
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
|
|
|
|
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
|
|
|
|
{ key: 'product', label: '产品名称', minWidth: 190, slotName: 'product' },
|
|
|
|
|
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 },
|
|
|
|
|
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 86, align: 'right' },
|
|
|
|
|
{ key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 86, align: 'right' },
|
|
|
|
|
{ key: 'productQuantity', prop: 'productQuantity', label: '产品数', width: 96, align: 'right' },
|
|
|
|
|
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, align: 'right', slotName: 'grossWeight' },
|
|
|
|
|
{ key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' },
|
|
|
|
|
{ key: 'volumeCbm', label: '体积(m³)', width: 110, align: 'right', slotName: 'volume' },
|
|
|
|
|
{ key: 'fbaTrackingNo', prop: 'fbaTrackingNo', label: 'FBA 追踪编码', minWidth: 155 },
|
|
|
|
|
{ key: 'shipmentNo', prop: 'shipmentNo', label: '货件号', minWidth: 140 },
|
|
|
|
|
{ key: 'shippingMark', prop: 'shippingMark', label: '唛头', minWidth: 130 },
|
|
|
|
|
{ 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)
|
2026-08-12 16:31:46 +08:00
|
|
|
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 }))
|
|
|
|
|
})
|
2026-08-10 15:36:53 +08:00
|
|
|
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: '修改运输信息',
|
2026-08-11 11:52:30 +08:00
|
|
|
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
|
2026-08-12 09:25:55 +08:00
|
|
|
} as Record<string, string>)[action] || '状态更新'
|
2026-08-10 15:36:53 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
2026-08-12 16:31:46 +08:00
|
|
|
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
|
2026-08-10 15:36:53 +08:00
|
|
|
<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">
|
2026-08-10 16:24:54 +08:00
|
|
|
<DetailPageLayout :ariaLabel="title">
|
|
|
|
|
<template #summary>
|
2026-08-10 15:36:53 +08:00
|
|
|
<section class="detail-summary" aria-label="清单基本信息">
|
|
|
|
|
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
|
|
|
|
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
|
|
|
|
|
<div><span>柜号</span><strong>{{ summary.internalContainerNo || '—' }}</strong></div>
|
2026-08-11 19:35:28 +08:00
|
|
|
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
|
2026-08-10 15:36:53 +08:00
|
|
|
<template v-if="preloadSummary">
|
|
|
|
|
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
|
|
|
|
|
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
|
2026-08-12 10:59:49 +08:00
|
|
|
<div class="detail-summary__wide"><span>客户</span><strong>{{ preloadSummary.customers.map((item) => item.customerName || '客户信息缺失').join('、') || '客户信息缺失' }}</strong></div>
|
2026-08-10 15:36:53 +08:00
|
|
|
</template>
|
|
|
|
|
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
|
|
|
|
|
</section>
|
2026-08-10 16:24:54 +08:00
|
|
|
</template>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
2026-08-11 14:23:46 +08:00
|
|
|
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
|
2026-08-10 16:24:54 +08:00
|
|
|
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />编辑运输信息</el-button>
|
2026-08-10 15:36:53 +08:00
|
|
|
</div>
|
|
|
|
|
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
|
|
|
|
|
|
|
|
|
|
<section class="detail-section surface-panel">
|
2026-08-11 11:15:44 +08:00
|
|
|
<header class="detail-section__header"><h2>装柜货物明细</h2><span>{{ detail.items.length }} 条</span></header>
|
2026-08-10 15:36:53 +08:00
|
|
|
<DataTable
|
|
|
|
|
:data="detail.items"
|
|
|
|
|
:columns="itemColumns"
|
2026-08-12 18:16:44 +08:00
|
|
|
stripe
|
2026-08-10 15:36:53 +08:00
|
|
|
preference-key="loading-list-detail-items"
|
|
|
|
|
row-key="itemId"
|
|
|
|
|
empty-text="暂无装柜货物明细"
|
|
|
|
|
aria-label="装柜货物明细"
|
|
|
|
|
>
|
|
|
|
|
<template #product="{ row }"><strong>{{ row.chineseName || row.englishName || '—' }}</strong><small v-if="row.englishName && row.chineseName">{{ row.englishName }}</small></template>
|
|
|
|
|
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
|
|
|
|
|
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
|
|
|
|
|
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
|
|
|
|
|
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
|
|
|
|
|
</DataTable>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-08-12 17:29:35 +08:00
|
|
|
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" />
|
2026-08-12 16:31:46 +08:00
|
|
|
|
2026-08-10 15:36:53 +08:00
|
|
|
<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>
|
2026-08-11 19:35:28 +08:00
|
|
|
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
|
2026-08-12 10:59:49 +08:00
|
|
|
<p>{{ history.operatorName || '操作人信息缺失' }}<template v-if="history.operationReason"> · {{ history.operationReason }}</template></p>
|
2026-08-10 15:36:53 +08:00
|
|
|
</div>
|
|
|
|
|
</el-timeline-item>
|
|
|
|
|
</el-timeline>
|
|
|
|
|
</section>
|
2026-08-10 16:24:54 +08:00
|
|
|
</DetailPageLayout>
|
2026-08-10 15:36:53 +08:00
|
|
|
</div>
|
2026-08-12 16:31:46 +08:00
|
|
|
</section>
|
2026-08-10 15:36:53 +08:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-12 16:31:46 +08:00
|
|
|
.loading-detail-content, .detail-layout { display: grid; min-width: 0; gap: 16px; }
|
2026-08-10 15:36:53 +08:00
|
|
|
.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; }
|
2026-08-11 11:15:44 +08:00
|
|
|
.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; }
|
2026-08-10 15:36:53 +08:00
|
|
|
.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>
|