test(admin): harden booking document workflows

This commit is contained in:
2026-08-31 21:05:03 +08:00
parent 623dfce255
commit 49babd731a
5 changed files with 79 additions and 28 deletions
@@ -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()
})
})
@@ -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) {
<style scoped>
.booking-documents{padding:16px}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:16px}.booking-documents__heading{margin-bottom:14px}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:16px}.booking-documents__heading p,.booking-document-card__heading p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.booking-document-limit{display:block;margin-top:5px;color:var(--cc-muted);font-size:11px}.booking-document-types{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.booking-document-card{min-width:0;padding:14px;border:1px solid var(--cc-border);border-radius:8px}.booking-document-card h3{font-size:14px}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:6px;min-height:32px;padding:0 12px;border:1px solid var(--cc-primary);border-radius:6px;color:var(--cc-primary);font-size:12px;cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:8px;margin:12px 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;padding:9px 10px;border-radius:6px;background:var(--el-fill-color-lighter)}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:11px}.booking-document-actions{flex:0 0 auto;gap:6px}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:14px;color:var(--cc-muted);font-size:12px}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:12px;padding:7px 10px;border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
.booking-document-history li span{min-width:0;overflow-wrap:anywhere}
</style>
@@ -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<Blob>((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()
})
})