From 49babd731a9f095f7ecc7a99f415c1edbd15ef7d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 31 Aug 2026 21:05:03 +0800 Subject: [PATCH] test(admin): harden booking document workflows --- .../OceanBookingDocumentsPanel.test.ts | 26 +++++++++++++++++++ .../components/OceanBookingDocumentsPanel.vue | 25 +++++++----------- .../OceanBookingPrintDialog.test.ts | 22 ++++++++++++++++ .../pages/OceanBookingDetailView.test.ts | 9 ++++++- .../pages/OceanBookingDetailView.vue | 25 +++++++++--------- 5 files changed, 79 insertions(+), 28 deletions(-) diff --git a/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.test.ts b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.test.ts index f210a114..a5a206f9 100644 --- a/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.test.ts +++ b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.test.ts @@ -66,4 +66,30 @@ describe('OceanBookingDocumentsPanel contract', () => { expect(source).toContain('订舱资料上传失败,请稍后重试') expect(source).toContain('订舱资料下载失败,请稍后重试') }) + + it('suppresses duplicate file mutations and protects list-only access', () => { + expect(source).toContain('if (busyDocumentId.value || busyType.value) return') + expect(source).toContain('if (busyDocumentId.value || busyType.value || !editable.value) return') + expect(source).toContain('!file || !editable.value || busyType.value || busyDocumentId.value') + expect(source).toContain(':disabled="Boolean(busyType || busyDocumentId)"') + }) + + it('keeps long Unicode active and invalidated file names readable', () => { + const fileName = '客户资料-中文-🚢-é-'.repeat(16) + '.数据包' + const document = { + id: 'doc-1', oceanBookingId: '123', fileId: 'file-1', documentType: 'BILL_OF_LADING' as const, + fileName, fileHash: null, sizeBytes: 12, contentType: null, valid: true, + uploadedBy: '7', uploadedByName: '操作员', uploadedAt: '2026-08-31', + invalidatedBy: null, invalidatedByName: null, invalidatedAt: null, + } + const wrapper = mount(OceanBookingDocumentsPanel, { + props: { bookingId: '123', bookingVersion: 2, bookingStatus: 'DRAFT', documents: [document], canManage: false, manageReason: '暂无订舱资料维护权限' }, + global: { plugins: [ElementPlus] }, + }) + expect(wrapper.get('.booking-document-list strong').attributes('title')).toBe(fileName) + expect(wrapper.find('input[type="file"]').exists()).toBe(false) + expect(wrapper.text()).not.toContain('作废') + expect(source).toContain('.booking-document-history li span{min-width:0;overflow-wrap:anywhere}') + wrapper.unmount() + }) }) diff --git a/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue index 215a9414..98161bd5 100644 --- a/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue +++ b/src/modules/ocean-bookings/components/OceanBookingDocumentsPanel.vue @@ -37,6 +37,7 @@ function saveBlob(blob: Blob, name: string) { URL.revokeObjectURL(url) } async function download(item: OceanBookingDocument) { + if (busyDocumentId.value || busyType.value) return busyDocumentId.value = item.id error.value = null try { @@ -54,18 +55,15 @@ async function invalidate(item: OceanBookingDocument, expectedVersion = props.bo return { documents: nextDocuments, version: result.bookingVersion } } async function remove(item: OceanBookingDocument) { - try { - await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, '作废订舱资料', { confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' }) - } catch (cause) { - if (cause === 'cancel' || cause === 'close') return - throw cause - } + if (busyDocumentId.value || busyType.value || !editable.value) return busyDocumentId.value = item.id error.value = null try { + await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, '作废订舱资料', { confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' }) await invalidate(item) ElMessage.success('订舱资料已作废') } catch (cause) { + if (cause === 'cancel' || cause === 'close') return error.value = getApiErrorDetails(cause, '订舱资料作废失败,请稍后重试') } finally { busyDocumentId.value = '' @@ -75,23 +73,18 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) { const input = event.target as HTMLInputElement const file = input.files?.[0] input.value = '' - if (!file || !editable.value) return + if (!file || !editable.value || busyType.value || busyDocumentId.value) return if (file.size > maxFileBytes) { error.value = { message: '单个订舱资料文件不能超过 50MB,请重新选择' } return } const previous = documentsOfType(type) - if (previous.length) { - try { - await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, '替换订舱资料', { confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' }) - } catch (cause) { - if (cause === 'cancel' || cause === 'close') return - throw cause - } - } busyType.value = type error.value = null try { + if (previous.length) { + await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, '替换订舱资料', { confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' }) + } const uploaded = await uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file) let documents = [...props.documents, uploaded.document] let version = uploaded.bookingVersion @@ -104,6 +97,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) { } ElMessage.success(previous.length ? '订舱资料已替换' : '订舱资料已上传') } catch (cause) { + if (cause === 'cancel' || cause === 'close') return error.value = getApiErrorDetails(cause, '订舱资料上传失败,请稍后重试') } finally { busyType.value = '' @@ -129,4 +123,5 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) { diff --git a/src/modules/ocean-bookings/components/OceanBookingPrintDialog.test.ts b/src/modules/ocean-bookings/components/OceanBookingPrintDialog.test.ts index 801ff8a8..02f33242 100644 --- a/src/modules/ocean-bookings/components/OceanBookingPrintDialog.test.ts +++ b/src/modules/ocean-bookings/components/OceanBookingPrintDialog.test.ts @@ -54,4 +54,26 @@ describe('OceanBookingPrintDialog', () => { expect(document.body.textContent).toContain('trace-print') failed.unmount() }) + + it('suppresses duplicate preview clicks and restores loading after completion', async () => { + let resolvePrint!: (blob: Blob) => void + vi.mocked(downloadOceanBookingPrint).mockImplementation(() => new Promise((resolve) => { resolvePrint = resolve })) + const wrapper = mount(OceanBookingPrintDialog, { + props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true }, + global: { plugins: [ElementPlus] }, + attachTo: document.body, + }) + await flushPromises() + await wrapper.findAll('.booking-print-option')[0]!.trigger('click') + const preview = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.includes('预览 PDF'))! + preview.click() + preview.click() + await flushPromises() + expect(downloadOceanBookingPrint).toHaveBeenCalledTimes(1) + expect(preview.disabled).toBe(true) + resolvePrint(new Blob(['pdf'], { type: 'application/pdf' })) + await flushPromises() + expect(document.body.querySelector('iframe')).not.toBeNull() + wrapper.unmount() + }) }) diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts index 09b8e539..8dabfd79 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts @@ -92,7 +92,7 @@ describe('OceanBookingDetailView draft and release contract', () => { expect(source).toContain('当前船公司暂无可用订舱代理') expect(source).toContain(':label="supplier.name"') expect(source).toContain('filterable clearable') - expect(source).toContain(':disabled="!editable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable"') + expect(source).toContain(':disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable"') expect(source).toContain(':disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError"') }) @@ -115,6 +115,13 @@ describe('OceanBookingDetailView draft and release contract', () => { expect(source).toContain('打印单据') }) + it('keeps a DRAFT detail read-only when the user lacks write permission', () => { + expect(source).toContain('const formWritable = computed(() => canSave.value)') + expect(source).toContain(':disabled="!formWritable" class="booking-groups"') + expect(source).toContain('v-if="formWritable" class="container-actions"') + expect(source).toContain(':disabled="!formWritable"') + }) + it('carries the existing read-only CarrierScheduleQuery on the booking detail page', () => { expect(source).toContain('CarrierScheduleQueryPanel') expect(source).toContain("from '@/modules/port-schedules'") diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue index c11f3362..8af887f5 100644 --- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue +++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue @@ -104,6 +104,7 @@ const savedDraftSignature = ref('') const rules: FormRules = {} const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT') const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit')) +const formWritable = computed(() => canSave.value) const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete')) const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release')) const canPrint = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:list')) @@ -203,7 +204,7 @@ async function loadBookingAgents(carrierCode: OceanBookingCarrierCode) { const options = await listOceanBookingAgents(carrierCode) if (serial !== bookingAgentRequestSerial || carrierCode !== form.data.carrierCode) return bookingAgents.value = options - if (editable.value && shouldClearBookingAgent(form.data.bookingAgentId, options)) clearBookingAgent() + if (formWritable.value && shouldClearBookingAgent(form.data.bookingAgentId, options)) clearBookingAgent() } catch (cause) { if (serial !== bookingAgentRequestSerial) return bookingAgents.value = [] @@ -391,14 +392,14 @@ onMounted(() => { void load(); void loadOptions() }) 重新加载选项 - +

基本信息

- {{ channel.channelName }}{{ channel.channelCode }}
{{ channelsError }}重新加载
该仓库暂无有效物流渠道,请更换仓库或联系管理员。
+ {{ channel.channelName }}{{ channel.channelCode }}
{{ channelsError }}重新加载
该仓库暂无有效物流渠道,请更换仓库或联系管理员。
重新加载
@@ -429,14 +430,14 @@ onMounted(() => { void load(); void loadOptions() }) - + - - - + + + {{ requirement.capacityCbm }} m³ {{ requirement.maxWeightKg }} kg {{ requirement.remainingQuantity ?? requirement.quantity }} @@ -444,7 +445,7 @@ onMounted(() => { void load(); void loadOptions() })
-
新增柜型需求{{ form.data.containerRequirements.length }} / 20 项
+
新增柜型需求{{ form.data.containerRequirements.length }} / 20 项
@@ -456,10 +457,10 @@ onMounted(() => { void load(); void loadOptions() })

{{ partyTypes[index]?.label || party.partyType }}

- - - - + + + +