diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts index 4d2be963..3b9a9b5c 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts +++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts @@ -47,6 +47,21 @@ describe('ContainerOverviewPanel', () => { expect(wrapper.text()).not.toContain('9007199254740999') }) + it('renders four compact document table rows with the operation column first', () => { + 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(wrapper.findAll('.document-table tbody tr')).toHaveLength(4) + expect(wrapper.findAll('.document-item')).toHaveLength(0) + expect(wrapper.find('.document-table-wrap').exists()).toBe(true) + const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))! + expect(deliveryRow.text()).toContain('do.pdf') + expect(deliveryRow.text()).toContain('操作员') + expect(deliveryRow.text()).toContain('2026-08-13 10:00') + expect(deliveryRow.text()).toContain('已上传') + expect(deliveryRow.find('.document-file-name').attributes('title')).toBe('do.pdf') + }) + it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => { const open = vi.spyOn(window, 'open').mockImplementation(() => null) const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index 7a3d7f47..3a79a67b 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -10,6 +10,7 @@ const props = defineProps<{ overview: ContainerOverview canManageDocuments: boolean canManageCustoms: boolean + canDownloadCustoms?: boolean canManageException: boolean documentBusy?: boolean customsCompleting?: boolean @@ -30,10 +31,17 @@ const documentTypes = [ ] as const const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item]))) -const downloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER']) +const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER']) const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION']) +function canDownloadDocument(documentType: string) { + return containerDownloadableTypes.has(documentType) || (documentType === 'CUSTOMS_DECLARATION' && props.canDownloadCustoms) +} function canUploadDocument(documentType: string) { - return downloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms + return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms +} +function hasDocumentAction(documentType: string) { + const document = documentsByType.value.get(documentType) + return Boolean((document && canDownloadDocument(documentType)) || (canUploadDocument(documentType) && !document?.completed)) } function documentStatusText(documentType: string) { const document = documentsByType.value.get(documentType) @@ -41,6 +49,16 @@ function documentStatusText(documentType: string) { if (!document) return '第一步上传附件,第二步确认报关完成' return document.completed ? '已确认报关完成' : '附件已上传,待确认报关完成' } +function documentStatusLabel(documentType: string) { + const document = documentsByType.value.get(documentType) + if (!document) return '未上传' + return document.completed ? '已完成' : '已上传' +} +function documentStatusType(documentType: string) { + const document = documentsByType.value.get(documentType) + if (!document) return 'info' + return document.completed ? 'success' : 'primary' +} const pdaProgress = computed(() => { const pda = props.overview.pda if (!pda || pda.itemCount <= 0) return 0 @@ -113,11 +131,18 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })

业务资料

{{ overview.documents.length }} 份
-
-
-
{{ item.label }}{{ documentsByType.get(item.type)?.fileName || '未上传' }}{{ documentStatusText(item.type) }}
-
下载{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}确认报关完成
-
+
+ + + + + + + + + + +
操作资料类型文件名上传人上传时间状态
下载{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}确认报关完成—
{{ item.label }}{{ documentStatusText(item.type) }}{{ documentsByType.get(item.type)?.fileName || '—' }}{{ documentsByType.get(item.type)?.uploadedByName || '—' }}{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}{{ documentStatusLabel(item.type) }}
@@ -148,18 +173,12 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() }) .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; } -.document-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-top: 1px solid var(--cc-border); } -.document-item:nth-child(-n+2){border-top:0}.compact-document-list{column-gap:18px;row-gap:0} -.document-item > div:first-child { display: grid; min-width: 0; gap: 4px; } -.document-item span { overflow: hidden; color: var(--cc-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } -.document-actions { display: flex; flex-shrink: 0; gap: 6px; } +.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)} .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; } .metric-grid strong { font-size: 18px; } .photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:10px 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:#fff;color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:8px;color:var(--cc-muted);font-size:12px}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px}.photo-caption small{white-space:nowrap}.compact-empty{margin:6px 0;color:var(--cc-muted);font-size:12px}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:8px;padding:6px 0;border-top:1px solid var(--cc-border);font-size:12px}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap} @media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}} -@media (max-width: 720px) { .document-grid { grid-template-columns: 1fr; }.document-item:nth-child(2){border-top:1px solid var(--cc-border)} .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} } -@media (max-width: 420px) { .document-item { align-items: stretch; flex-direction: column; } .document-actions :deep(.el-button) { flex: 1; } } +@media (max-width: 720px) { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} } diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index c084b99d..8dce8c5a 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -2,8 +2,8 @@ import ElementPlus, { ElMessageBox } from 'element-plus' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' -import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' -import { completeLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' +import { downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' +import { completeLoadingDeclaration, downloadLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' import { ApiResponseError } from '@/shared/api/http' @@ -12,7 +12,7 @@ const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) })) vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
', mounted: () => { carrierMounts.value += 1 } } })) -vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn() })) +vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn() })) vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(), @@ -40,21 +40,25 @@ const outboundDetail = { function mountView(customs = true, outbound = true) { accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage') + || permission === 'platform:firstleg:loading-list:list' || (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission))) return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } }) } -function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! } +function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! } function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! } describe('ContainerDetailView', () => { beforeEach(() => { vi.clearAllMocks() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) carrierMounts.value = 0 vi.mocked(getContainer).mockResolvedValue(detail) vi.mocked(getContainerOverview).mockResolvedValue(overview as never) vi.mocked(getContainerClearance).mockResolvedValue(null) - vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never) + vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never) vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never) + vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs'])) vi.mocked(uploadContainerDocument).mockResolvedValue({} as never) vi.mocked(uploadContainerClearance).mockResolvedValue({} as never) vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail) @@ -63,7 +67,7 @@ describe('ContainerDetailView', () => { it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => { const wrapper = mountView(); await flushPromises() - await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('国内出口报关附件') const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' }) ;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file }) @@ -76,14 +80,14 @@ describe('ContainerDetailView', () => { it('shows a readable error when the container has no loading plan', async () => { vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never) const wrapper = mountView(); await flushPromises() - await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises() expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件') expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount() }) it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => { const wrapper = mountView(false); await flushPromises() - expect(documentArticle(wrapper, '国内出口报关附件').find('button').exists()).toBe(false) + expect(documentArticle(wrapper, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载']) expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false) expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true) expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true) @@ -98,6 +102,43 @@ describe('ContainerDetailView', () => { wrapper.unmount() }) + it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises() + expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502') + expect(downloadContainerDocument).not.toHaveBeenCalled() + expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(click).toHaveBeenCalledOnce() + expect(URL.revokeObjectURL).toHaveBeenCalled() + wrapper.unmount() + }) + + it('keeps customs download after completion while hiding update and completion actions', async () => { + vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never) + const wrapper = mountView(); await flushPromises() + const row = documentArticle(wrapper, '国内出口报关附件') + expect(row.findAll('button').map((button) => button.text())).toEqual(['下载']) + expect(row.text()).toContain('已完成') + wrapper.unmount() + }) + + it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => { + vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable')) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises() + const file = new File(['customs-new'], 'customs-new.pdf', { type: 'application/pdf' }) + ;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file }) + await (wrapper.vm as unknown as { saveDocument: () => Promise }).saveDocument(); await flushPromises() + const row = documentArticle(wrapper, '国内出口报关附件') + expect(row.text()).toContain('customs-new.pdf') + expect(row.text()).toContain('操作员') + expect(row.text()).toContain('2026-08-26 12:00') + expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果') + expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件') + wrapper.unmount() + }) + it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => { const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index fed74000..2235baf9 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' -import { completeLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' +import { completeLoadingDeclaration, downloadLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types' import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' @@ -34,6 +34,7 @@ const access = useAccessPreviewStore() const containerId = computed(() => String(route.params.containerId || '')) const canEdit = computed(() => access.can('platform:firstleg:container:edit')) const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage')) +const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list')) const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view')) const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm')) const loading = ref(false) @@ -49,7 +50,8 @@ const documentSaving = ref(false) const documentDownloading = ref(false) const documentError = ref(null) const customsCompleting = ref(false) -const customsCompletionError = ref(null) +const customsActionError = ref(null) +const documentRefreshWarning = ref(null) const clearanceOpen = ref(false) const clearanceDocument = ref(null) const clearanceFile = ref(null) @@ -164,16 +166,33 @@ function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || function removeDocumentFile() { documentFile.value = null } async function saveDocument() { if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return } - documentSaving.value = true; documentError.value = null + documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null try { const expectedVersion = selectedDocument.value?.version || 0 if (documentType.value === 'CUSTOMS_DECLARATION') { if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件') - await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件') + const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件') + if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息') + const optimistic: ContainerOverviewDocument = { + id: uploaded.id || selectedDocument.value?.id || uploaded.fileId, + documentType: 'CUSTOMS_DECLARATION', fileId: uploaded.fileId, fileName: uploaded.fileName, + fileHash: uploaded.fileHash, uploadedBy: uploaded.uploadedBy, uploadedByName: uploaded.uploadedByName, + uploadedAt: uploaded.uploadedAt, version: uploaded.version, completed: Boolean(uploaded.completedAt), + } + overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== 'CUSTOMS_DECLARATION'), optimistic] } + try { + const refreshed = await getContainerOverview(containerId.value) + if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed + else { + overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] } + documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' } + } + } + catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') } } else { await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`) + overview.value = await getContainerOverview(containerId.value) } - overview.value = await getContainerOverview(containerId.value) documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`) } catch (cause) { documentError.value = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) } finally { documentSaving.value = false } @@ -185,31 +204,44 @@ async function refreshCustomsFacts() { async function completeCustomsDeclaration(document: ContainerOverviewDocument) { if (!canManageCustoms.value || customsCompleting.value || document.completed) return if (!overview.value?.loadingPlanId || !detail.value) { - customsCompletionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' } + customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' } return } - customsCompleting.value = true; customsCompletionError.value = null + customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null try { await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' }) } catch { customsCompleting.value = false; return } try { await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成') - await refreshCustomsFacts() + try { await refreshCustomsFacts() } + catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') } ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验') } catch (cause) { const details = getApiErrorDetails(cause, '确认报关完成失败') if (details.code === 'A0409') { try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ } } - customsCompletionError.value = details + customsActionError.value = details } finally { customsCompleting.value = false } } async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) } async function downloadDocument(item: ContainerOverviewDocument) { - if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return + if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(item.documentType)) return + if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) { + customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' } + return + } documentDownloading.value = true - try { await saveBlob(await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') } - catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) } + try { + const blob = item.documentType === 'CUSTOMS_DECLARATION' + ? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId) + : await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId) + await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始') + } catch (cause) { + const details = getApiErrorDetails(cause, '资料下载失败') + if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details + ElMessage.error(details.message) + } finally { documentDownloading.value = false } } function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true } @@ -261,8 +293,9 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op