This commit is contained in:
2026-08-18 16:39:44 +08:00
parent 21249d1dde
commit 6ff77e92b2
9 changed files with 114 additions and 67 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Check, FileUp, Pencil } from '@lucide/vue'
import { ArrowLeft, 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'
@@ -22,16 +22,16 @@ const props = defineProps<{
customsSaving?: boolean
}>()
const emit = defineEmits<{ retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 106, slotName: 'actions', hideable: false, movable: false },
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 106, slotName: 'actions', hideable: false, movable: false }
const baseItemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 150 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: LoadingItem) => row.containerNo || '—' },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '—' },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 },
{ key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 },
@@ -55,6 +55,8 @@ const itemColumns: DataTableColumn[] = [
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
const canEditItemRemark = computed(() => props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || ''))
const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)
const blockingReasonLabels: Record<string, string> = {
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
@@ -111,14 +113,26 @@ function formatNumber(value?: number | null, digits = 3) {
<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="清单基本信息">
<section class="detail-overview surface-panel" aria-label="清单概览">
<header class="detail-page-header">
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<div class="detail-page-header__identity">
<h1>{{ title }}</h1>
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细、报关资料和柜次费用' }}</p>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<template v-if="canEditItemRemark">
<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>
</template>
<template v-else-if="mode === 'confirmed'">
<el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" type="primary" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button>
<el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button>
<StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" />
</template>
</div>
</header>
<section v-if="detail && summary" 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>
@@ -133,18 +147,17 @@ function formatNumber(value?: number | null, digits = 3) {
</template>
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
</section>
</template>
</section>
<LoadErrorState v-if="error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<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>
<div v-else-if="detail && summary" class="detail-layout">
<DetailPageLayout :ariaLabel="title">
<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"><div><h2>装柜货物明细</h2><p>仓库备注将随装柜完成资料下发海外仓</p></div><span>{{ detail.items.length }} 条</span></header>
<DataTable
:data="detail.items"
:columns="itemColumns"
@@ -154,9 +167,9 @@ function formatNumber(value?: number | null, digits = 3) {
empty-text="暂无装柜货物明细"
aria-label="装柜货物明细"
>
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
<template #actions="{ row }">
<el-button v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
<span v-else aria-label="只读">—</span>
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
</template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
@@ -192,28 +205,39 @@ function formatNumber(value?: number | null, digits = 3) {
<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-overview { overflow: hidden; }
.detail-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 14px 16px; }
.detail-page-header__back { margin-right: 2px; }
.detail-page-header__identity { min-width: 0; }
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
.detail-page-header__identity p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-page-header__actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.detail-page-header__actions :deep(.el-button + .el-button) { margin-left: 0; }
.detail-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); overflow: hidden; border-top: 1px solid var(--cc-border); }
.detail-summary > div { display: flex; min-width: 0; min-height: 68px; flex-direction: column; justify-content: center; gap: 5px; padding: 11px 14px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); background: #fff; }
.detail-summary > div:nth-child(5n) { border-right: 0; }
.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 strong { overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.detail-summary :deep(.status-badge) { width: fit-content; }
.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-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 16px; }
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.detail-section__header { display: flex; align-items: flex-start; 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__header p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-section__heading h2 { margin: 0; font-size: 15px; }
.detail-section__heading h2 span { margin-left: 6px; color: var(--cc-muted); font-size: 12px; font-weight: 400; }
.detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.35; }
.detail-section :deep(.data-table__toolbar) { min-height: 52px; padding: 7px 12px; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section :deep(.el-table th.el-table__cell) { padding: 7px 0; }
.detail-section :deep(.el-table td.el-table__cell) { padding: 6px 0; }
.detail-section :deep(.identifier-column .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.remark-cell { min-width: 0; line-height: 1.45; overflow-wrap: anywhere; }
.history-section { padding: 18px; }
.history-section { padding: 14px 16px; }
.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>