This commit is contained in:
2026-09-22 13:51:58 +08:00
parent aefcd2565f
commit fed257a720
8 changed files with 174 additions and 45 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, Check, ChevronRight, 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'
@@ -18,10 +18,11 @@ const props = defineProps<{
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
canPushPda?: boolean
pdaAssigneeSelected?: boolean
dense?: boolean
embedded?: boolean
canEditTransport?: boolean
transportNote?: string
confirmSaving?: boolean
}>()
@@ -101,6 +102,13 @@ const visibleHistories = computed(() => {
return true
})
})
const historyExpanded = ref(props.mode !== 'preload')
let lastHistoryPlanId: string | number | undefined
watch(() => props.detail?.summary.planId, (id) => {
if (id == null) return
if (lastHistoryPlanId !== undefined && id !== lastHistoryPlanId) historyExpanded.value = props.mode !== 'preload'
lastHistoryPlanId = id
})
const blockingReasonLabels: Record<string, string> = {
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
@@ -156,13 +164,13 @@ function formatNumber(value?: number | null, digits = 3) {
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<div v-if="!embedded" class="detail-page-header__identity">
<h1>{{ title }}</h1>
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
<p v-if="mode !== 'preload'">查看已完成装柜明细和柜次费用</p>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
<template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
<el-button v-if="canPushPda" type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected" @click="emit('confirmPreload')"><Check :size="16" />确认预装并推送 PDA</el-button>
</template>
</div>
</header>
@@ -192,7 +200,6 @@ function formatNumber(value?: number | null, digits = 3) {
<strong>暂不能确认预装:</strong>
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
</div>
<div v-if="preloadSummary" class="readiness-note transport-note" role="status"><strong>真实集装箱号:</strong><span>{{ transportNote }}</span></div>
<section class="detail-section">
<DataTable
@@ -204,7 +211,7 @@ 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 #toolbar-start><div class="detail-section__heading"><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><slot v-if="mode === 'preload'" name="pda-operator" /></div></template>
<template #actions="{ row }">
<el-button :class="{ 'density-action': mode === 'preload' || dense }" size="small" :type="mode === 'preload' ? undefined : 'primary'" :plain="mode !== 'preload'" :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的备注`" @click="emit('editRemarks', row as LoadingItem)"><Pencil v-if="mode !== 'preload'" :size="14" />编辑备注</el-button>
</template>
@@ -222,9 +229,9 @@ function formatNumber(value?: number | null, digits = 3) {
</DataTable>
</section>
<section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
<el-table :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
<section class="history-section" :class="{ 'is-expanded': historyExpanded }" aria-label="操作历史">
<header><button type="button" class="history-section__toggle" :aria-expanded="historyExpanded" aria-controls="loading-history-table" @click="historyExpanded = !historyExpanded"><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span><ChevronRight :size="16" class="history-section__arrow" :class="{ 'is-expanded': historyExpanded }" aria-hidden="true" /></button></header>
<el-table v-if="historyExpanded" id="loading-history-table" :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
<el-table-column label="事件" width="140"><template #default="{ row }">{{ actionLabel(row.actionType) }}</template></el-table-column>
<el-table-column label="状态变化" min-width="200"><template #default="{ row }"><span v-if="row.fromStatus || row.toStatus">{{ loadingListStatusLabel(row.fromStatus) }} → {{ loadingListStatusLabel(row.toStatus) }}</span><span v-else>—</span></template></el-table-column>
@@ -262,16 +269,21 @@ function formatNumber(value?: number | null, digits = 3) {
.readiness-note strong { flex: 0 0 auto; color: var(--el-color-warning-dark-2); }
.readiness-note span { min-width: 0; color: var(--cc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section, .history-section { min-width: 0; overflow: hidden; padding: var(--admin-space-6, 16px) 0 0; border-top: 1px solid var(--cc-border); }
.detail-section__heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.detail-section__heading h2 { margin: 0; font-size: 15px; }
.detail-section__heading h2 span { margin-left: var(--admin-space-2, 6px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }
.detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); line-height: 1.35; }
.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: var(--admin-space-2, 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 header { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); margin-bottom: 18px; }
.history-section header { margin: 0; }
.history-section.is-expanded header { margin-bottom: 18px; }
.history-section__toggle { display: inline-flex; align-items: center; gap: var(--admin-space-4, 10px); padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; }
.history-section__toggle:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; border-radius: 2px; }
.history-section__arrow { color: var(--cc-muted); transition: transform 0.2s ease; }
.history-section__arrow.is-expanded { transform: rotate(90deg); }
.history-section h2 { margin: 0; font-size: var(--admin-font-title, 16px); }
.history-section header span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.loading-density { gap: var(--admin-space-5, 12px); padding: var(--admin-space-5, 12px); border-radius: var(--admin-radius, 4px); }
@@ -287,8 +299,8 @@ function formatNumber(value?: number | null, digits = 3) {
.loading-density .detail-layout :deep(.detail-page-layout__content) { gap: var(--admin-space-5, 12px); }
.loading-density .detail-section, .loading-density .history-section { padding-top: var(--admin-space-3, 8px); }
.loading-density .detail-section__heading h2, .loading-density .history-section h2 { font-size: var(--admin-font-section, 14px); }
.loading-density .history-section header { margin-bottom: var(--admin-space-3, 8px); }
.loading-density .detail-section__heading p, .loading-density small { font-size: var(--admin-font-caption, 12px); }
.loading-density .history-section.is-expanded header { margin-bottom: var(--admin-space-3, 8px); }
.loading-density small { font-size: var(--admin-font-caption, 12px); }
.loading-density .readiness-note { align-items: baseline; }
.loading-density .readiness-note span { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
@media (max-width: 720px) { .loading-density .detail-summary { grid-template-columns: 1fr; } .loading-density .detail-summary__wide { grid-column: auto; } }
@@ -116,11 +116,11 @@ describe('LoadingListsView', () => {
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
expect(confirmedDetailSource).toContain('if (sequence !== loadSequence) return')
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => {')
})
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected"')
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
@@ -185,7 +185,7 @@ describe('LoadingListsView', () => {
it('keeps customer remarks read-only while exposing three editable collaboration remarks', () => {
expect(detailSource).toContain('来源:客户下单')
expect(detailSource).toContain('来源:订单内部备注')
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).toContain("emit('editRemarks', row as LoadingItem)")
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
expect(detailSource).not.toContain("emit('editCustomerRemark'")