feat: split domestic and overseas container workbenches

This commit is contained in:
2026-09-08 19:57:42 +08:00
parent 296a8246ad
commit 8910e88563
26 changed files with 365 additions and 219 deletions
@@ -8,7 +8,7 @@ import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 },
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
@@ -47,13 +47,19 @@ describe('ContainerOverviewPanel', () => {
expect(wrapper.text()).not.toContain('9007199254740999')
})
it('renders three compact non-customs document rows with the operation column first', () => {
it('renders two file rows and one inline PU code row without a nested heading', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态'])
expect(headers).toEqual(['操作', '资料类型', '资料内容', '更新人', '更新时间', '状态'])
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(3)
expect(wrapper.findAll('.document-item')).toHaveLength(0)
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
expect(wrapper.text()).not.toContain('业务资料')
const pickupInput = wrapper.find('input[aria-label="PU码"]')
expect((pickupInput.element as HTMLInputElement).value).toBe('00123456')
await pickupInput.setValue('998877')
await wrapper.find('.pickup-code-row button').trigger('click')
expect(wrapper.emitted('savePickupCode')?.[0]).toEqual(['998877'])
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
expect(deliveryRow.text()).toContain('do.pdf')
expect(deliveryRow.text()).toContain('操作员')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -12,23 +12,27 @@ const props = defineProps<{
canManageCustoms: boolean
canManageException: boolean
documentBusy?: boolean
pickupCodeBusy?: boolean
sections?: Array<'exception' | 'documents' | 'pda'>
}>()
const emit = defineEmits<{
uploadDocument: [documentType: string]
downloadDocument: [document: ContainerOverviewDocument]
savePickupCode: [pickupCode: string]
manageException: []
}>()
const documentTypes = [
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
] as const
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
const containerDownloadableTypes = new Set(['DELIVERY_ORDER'])
const pickupCodeInput = ref('')
watch(() => props.overview.container.pickupCode, (value) => { pickupCodeInput.value = value || '' }, { immediate: true })
const pickupCodeValid = computed(() => /^[0-9]{1,64}$/.test(pickupCodeInput.value))
function canDownloadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType)
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
@@ -119,11 +123,10 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
</section>
<section v-if="visibleSections.has('documents')" class="overview-section" aria-label="货柜业务资料">
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
<div class="document-table-wrap">
<table class="document-table">
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>资料内容</th><th>更新人</th><th>更新时间</th><th>状态</th></tr></thead>
<tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong></td>
@@ -131,6 +134,13 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
</tr><tr class="pickup-code-row">
<td class="document-table__actions"><el-button v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td>
<td><strong>PU 资料</strong></td>
<td><el-input v-model="pickupCodeInput" :disabled="!canManageDocuments" maxlength="64" inputmode="numeric" placeholder="请输入数字 PU 码" aria-label="PU码" @input="pickupCodeInput = pickupCodeInput.replace(/\D/g, '')" /></td>
<td>{{ overview.container.pickupCodeUpdatedByName || '—' }}</td>
<td>{{ formatTime(overview.container.pickupCodeUpdatedAt) }}</td>
<td><el-tag size="small" :type="overview.container.pickupCode ? 'success' : 'info'">{{ overview.container.pickupCode ? '已填写' : '未填写' }}</el-tag></td>
</tr></tbody>
</table>
</div>
@@ -149,12 +159,13 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<style scoped>
.overview-panel { display: grid; gap: 12px; }
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
.overview-section--documents{padding:0;border:0}
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.overview-section h3 { margin: 0; font-size: 15px; }
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
.exception-management{padding-top:10px;padding-bottom:10px}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:14px}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{overflow:hidden;max-width:360px;text-overflow:ellipsis;white-space:nowrap}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:12px}.exception-management__actions{display:flex;align-items:center;gap:8px}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:12px}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:8px;align-items:center;min-width:680px;padding:6px 4px}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.exception-history__empty{margin:0!important;padding:7px 4px;border-top:1px solid var(--cc-border);font-size:12px!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
.metric-grid span { color: var(--cc-muted); font-size: 12px; }