style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import OceanBookingDocumentsPanel from './OceanBookingDocumentsPanel.vue'
|
||||
import { uploadOceanBookingDocument } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDocument } from '../types'
|
||||
import { invalidateOceanBookingDocument, uploadOceanBookingDocument } from '../api/platformOceanBookings'
|
||||
|
||||
vi.mock('../api/platformOceanBookings', () => ({
|
||||
downloadOceanBookingDocument: vi.fn(),
|
||||
@@ -93,3 +94,52 @@ describe('OceanBookingDocumentsPanel contract', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
function documentFixture(id: string): OceanBookingDocument {
|
||||
return { id, oceanBookingId: '123', fileId: `file-${id}`, documentType: 'BOOKING_CONFIRMATION', fileName: `${id}.pdf`, fileHash: null, sizeBytes: 128, contentType: 'application/pdf', valid: true, uploadedBy: 'internal-operator-id', uploadedByName: null, uploadedAt: '2026-10-19T10:00:00', invalidatedBy: null, invalidatedByName: null, invalidatedAt: null }
|
||||
}
|
||||
afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = '' })
|
||||
describe('OceanBookingDocumentsPanel confirmations', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
it('uses the booking number and preserves replacement upload/invalidation version chaining', async () => {
|
||||
const old = documentFixture('old')
|
||||
const uploaded = documentFixture('new')
|
||||
vi.mocked(uploadOceanBookingDocument).mockResolvedValue({ document: uploaded, bookingVersion: 3 })
|
||||
vi.mocked(invalidateOceanBookingDocument).mockResolvedValue({ documentId: old.id, bookingVersion: 4 })
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
const wrapper = mount(OceanBookingDocumentsPanel, { props: { bookingId: '123', bookingNo: 'OB-123', bookingVersion: 2, bookingStatus: 'DRAFT', documents: [old], canManage: true }, global: { plugins: [ElementPlus] } })
|
||||
expect(wrapper.text()).not.toContain('internal-operator-id')
|
||||
const file = new File(['new'], '替换资料.pdf', { type: 'application/pdf' })
|
||||
const input = wrapper.findAll('input[type="file"]')[0]!
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('替换资料.pdf'), '替换订舱资料 · OB-123', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认替换' }))
|
||||
expect(uploadOceanBookingDocument).toHaveBeenCalledExactlyOnceWith('123', 2, 'BOOKING_CONFIRMATION', file)
|
||||
expect(invalidateOceanBookingDocument).toHaveBeenCalledExactlyOnceWith('123', 'old', 3)
|
||||
expect(wrapper.emitted('updated')).toEqual([
|
||||
[[old, uploaded], 3],
|
||||
[[expect.objectContaining({ id: 'old', valid: false }), uploaded], 4],
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('leaves documents unchanged when invalidation is cancelled and uses the current version on confirmation', async () => {
|
||||
const old = documentFixture('old')
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel').mockResolvedValueOnce(undefined as never)
|
||||
vi.mocked(invalidateOceanBookingDocument).mockResolvedValue({ documentId: old.id, bookingVersion: 3 })
|
||||
const wrapper = mount(OceanBookingDocumentsPanel, { props: { bookingId: '123', bookingNo: 'OB-123', bookingVersion: 2, bookingStatus: 'DRAFT', documents: [old], canManage: true }, global: { plugins: [ElementPlus] } })
|
||||
const button = wrapper.findAll('button').find(item => item.text() === '作废')!
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(invalidateOceanBookingDocument).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirm).toHaveBeenLastCalledWith(expect.stringContaining('old.pdf'), '作废订舱资料 · OB-123', expect.objectContaining({ confirmButtonText: '确认作废' }))
|
||||
expect(invalidateOceanBookingDocument).toHaveBeenCalledExactlyOnceWith('123', 'old', 2)
|
||||
expect(wrapper.emitted('updated')).toEqual([[[expect.objectContaining({ id: 'old', valid: false })], 3]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadOceanBookingDocument, invalidateOceanBookingDocument, uploadOceanBookingDocument } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDocument, OceanBookingDocumentType, OceanBookingStatus } from '../types'
|
||||
|
||||
const props = defineProps<{ bookingId: string; bookingVersion: number; bookingStatus: OceanBookingStatus; documents: OceanBookingDocument[]; canManage: boolean; manageReason?: string }>()
|
||||
const props = defineProps<{ bookingId: string; bookingNo?: string | null; bookingVersion: number; bookingStatus: OceanBookingStatus; documents: OceanBookingDocument[]; canManage: boolean; manageReason?: string }>()
|
||||
const emit = defineEmits<{ updated: [documents: OceanBookingDocument[], bookingVersion: number] }>()
|
||||
const busyType = ref<OceanBookingDocumentType | ''>('')
|
||||
const busyDocumentId = ref('')
|
||||
@@ -22,6 +22,7 @@ const editable = computed(() => props.bookingStatus === 'DRAFT' && props.canMana
|
||||
const validDocuments = computed(() => props.documents.filter((item) => item.valid))
|
||||
const invalidDocuments = computed(() => props.documents.filter((item) => !item.valid))
|
||||
|
||||
function documentDialogTitle(action: string) { const code = props.bookingNo?.trim(); return code ? `${action} · ${code}` : action }
|
||||
function documentsOfType(type: OceanBookingDocumentType) { return validDocuments.value.filter((item) => item.documentType === type) }
|
||||
function fileSize(value: number | null) {
|
||||
if (value == null) return '大小未知'
|
||||
@@ -60,7 +61,7 @@ async function remove(item: OceanBookingDocument) {
|
||||
busyDocumentId.value = item.id
|
||||
error.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, '作废订舱资料', { customClass: 'admin-density-confirm', confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, documentDialogTitle('作废订舱资料'), { customClass: 'admin-density-confirm', confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' })
|
||||
await invalidate(item)
|
||||
ElMessage.success('订舱资料已作废')
|
||||
} catch (cause) {
|
||||
@@ -84,7 +85,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
error.value = null
|
||||
try {
|
||||
if (previous.length) {
|
||||
await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, '替换订舱资料', { customClass: 'admin-density-confirm', confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, documentDialogTitle('替换订舱资料'), { customClass: 'admin-density-confirm', confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' })
|
||||
}
|
||||
const uploaded = await uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)
|
||||
let documents = [...props.documents, uploaded.document]
|
||||
@@ -115,10 +116,10 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
<article v-for="type in types" :key="type.value" class="booking-document-card">
|
||||
<div class="booking-document-card__heading"><div><h3>{{ type.label }}</h3><p>{{ type.description }}</p><small class="booking-document-limit">任意格式,单文件不超过 50MB</small></div><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
|
||||
<el-empty v-if="!documentsOfType(type.value).length" description="暂无有效文件" :image-size="48" />
|
||||
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || item.uploadedBy }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions density-actions"><el-button class="density-action" size="small" plain :loading="busyDocumentId === item.id" @click="download(item)">下载</el-button><el-button data-tone="danger" class="density-action" v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)">作废</el-button></div></li></ul>
|
||||
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || '—' }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions density-actions"><el-button class="density-action" size="small" plain :loading="busyDocumentId === item.id" @click="download(item)">下载</el-button><el-button data-tone="danger" class="density-action" v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)">作废</el-button></div></li></ul>
|
||||
</article>
|
||||
</div>
|
||||
<details v-if="invalidDocuments.length" class="booking-document-history"><summary>查看已作废资料({{ invalidDocuments.length }})</summary><ul><li v-for="item in invalidDocuments" :key="item.id"><span>{{ item.fileName }}</span><small>{{ item.invalidatedByName || item.invalidatedBy || '系统' }} · {{ item.invalidatedAt || '—' }}</small></li></ul></details>
|
||||
<details v-if="invalidDocuments.length" class="booking-document-history"><summary>查看已作废资料({{ invalidDocuments.length }})</summary><ul><li v-for="item in invalidDocuments" :key="item.id"><span>{{ item.fileName }}</span><small>{{ item.invalidatedByName || '—' }} · {{ item.invalidatedAt || '—' }}</small></li></ul></details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OceanBookingPrintDialog from './OceanBookingPrintDialog.vue'
|
||||
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
|
||||
vi.mock('../api/platformOceanBookings', () => ({ downloadOceanBookingPrint: vi.fn() }))
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('OceanBookingPrintDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -32,6 +34,19 @@ describe('OceanBookingPrintDialog', () => {
|
||||
|
||||
expect(downloadOceanBookingPrint).toHaveBeenCalledWith('123', 'SHIPPING_INSTRUCTION')
|
||||
expect(document.body.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print')
|
||||
expect(document.body.querySelector('.el-dialog__title')?.textContent).toBe('出口货物托运单 · OB-123')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.el-dialog.is-fullscreen')).not.toBeNull()
|
||||
expect(document.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print')
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="退出全屏"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '返回选择')!.click()
|
||||
await flushPromises()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:booking-print')
|
||||
expect(wrapper.findComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('SHIPPING_INSTRUCTION')
|
||||
expect(downloadOceanBookingPrint).toHaveBeenCalledTimes(1)
|
||||
expect(document.querySelector('[aria-label="展开全屏"]')).toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -77,3 +92,56 @@ describe('OceanBookingPrintDialog', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('OceanBookingPrintDialog identity and resource lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:current-print'), revokeObjectURL: vi.fn() })
|
||||
})
|
||||
it('uses a neutral title and filename when the booking business number is missing', async () => {
|
||||
vi.mocked(downloadOceanBookingPrint).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
const downloads: string[] = []
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download) })
|
||||
const wrapper = mount(OceanBookingPrintDialog, { props: { modelValue: true, bookingId: 'internal-booking-id', bookingNo: ' ', allowed: true }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.el-dialog__title')?.textContent).toBe('选择打印单据')
|
||||
expect(document.body.textContent).not.toContain('internal-booking-id')
|
||||
await wrapper.findAll('.booking-print-option')[0]!.trigger('click')
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '预览 PDF')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '下载 PDF')!.click()
|
||||
expect(downloads).toEqual(['订舱单据-出口货物托运单.pdf'])
|
||||
expect(downloadOceanBookingPrint).toHaveBeenCalledExactlyOnceWith('internal-booking-id', 'SHIPPING_INSTRUCTION')
|
||||
expect(document.querySelector('iframe')?.title).not.toContain('internal-booking-id')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores a closed request that resolves after a new selection is previewed and releases the active URL', async () => {
|
||||
let resolveFirst!: (value: Blob) => void
|
||||
const current = new Blob(['current'], { type: 'application/pdf' })
|
||||
vi.mocked(downloadOceanBookingPrint).mockImplementationOnce(() => new Promise<Blob>(resolve => { resolveFirst = resolve })).mockResolvedValueOnce(current)
|
||||
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')
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '预览 PDF')!.click()
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: true })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.booking-print-option')[1]!.trigger('click')
|
||||
Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '预览 PDF')!.click()
|
||||
await flushPromises()
|
||||
resolveFirst(new Blob(['stale'], { type: 'application/pdf' }))
|
||||
await flushPromises()
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(URL.createObjectURL).mock.calls[0]?.[0]).toBe(current)
|
||||
expect(document.querySelector('.el-dialog__title')?.textContent).toBe('提单确认件 · OB-123')
|
||||
expect(document.querySelector('iframe')?.getAttribute('src')).toBe('blob:current-print')
|
||||
expect(downloadOceanBookingPrint).toHaveBeenNthCalledWith(1, '123', 'SHIPPING_INSTRUCTION')
|
||||
expect(downloadOceanBookingPrint).toHaveBeenNthCalledWith(2, '123', 'BILL_OF_LADING_CONFIRMATION')
|
||||
wrapper.unmount()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:current-print')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,13 +16,16 @@ const loading = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const printBlob = ref<Blob | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
let previewRequest = 0
|
||||
|
||||
const options: Array<{ value: OceanBookingPrintType; label: string; description: string }> = [
|
||||
{ value: 'SHIPPING_INSTRUCTION', label: '出口货物托运单', description: 'Sea Freight Shipping Instructions · PDF' },
|
||||
{ value: 'BILL_OF_LADING_CONFIRMATION', label: '提单确认件', description: '提单内容确认及打印 · PDF' },
|
||||
]
|
||||
const selectedOption = computed(() => options.find((item) => item.value === selectedType.value) || null)
|
||||
const previewTitle = computed(() => `${selectedOption.value?.label || '打印单据'} · ${props.bookingNo || props.bookingId}`)
|
||||
const bookingCode = computed(() => props.bookingNo?.trim() || '')
|
||||
const selectionTitle = computed(() => bookingCode.value ? `选择打印单据 · ${bookingCode.value}` : '选择打印单据')
|
||||
const previewTitle = computed(() => { const title = selectedOption.value?.label || '打印单据'; return bookingCode.value ? `${title} · ${bookingCode.value}` : title })
|
||||
|
||||
function releasePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
@@ -30,6 +33,7 @@ function releasePreview() {
|
||||
printBlob.value = null
|
||||
}
|
||||
function reset() {
|
||||
previewRequest += 1
|
||||
releasePreview()
|
||||
selectedType.value = ''
|
||||
error.value = null
|
||||
@@ -37,17 +41,19 @@ function reset() {
|
||||
}
|
||||
async function preview() {
|
||||
if (!props.allowed || !selectedType.value || loading.value) return
|
||||
const request = ++previewRequest
|
||||
loading.value = true
|
||||
error.value = null
|
||||
releasePreview()
|
||||
try {
|
||||
const blob = await downloadOceanBookingPrint(props.bookingId, selectedType.value)
|
||||
if (request !== previewRequest || !open.value) return
|
||||
printBlob.value = blob
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '打印单据加载失败,请稍后重试')
|
||||
if (request === previewRequest) error.value = getApiErrorDetails(cause, '打印单据加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (request === previewRequest) loading.value = false
|
||||
}
|
||||
}
|
||||
function backToSelection() {
|
||||
@@ -59,19 +65,20 @@ function download() {
|
||||
const url = URL.createObjectURL(printBlob.value)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `${props.bookingNo || props.bookingId}-${selectedOption.value.label}.pdf`
|
||||
anchor.download = `${bookingCode.value || '订舱单据'}-${selectedOption.value.label}.pdf`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
watch(open, (value) => { if (!value) reset() })
|
||||
onBeforeUnmount(releasePreview)
|
||||
onBeforeUnmount(() => { previewRequest += 1; releasePreview() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog"
|
||||
<AppDialog class="booking-print-dialog admin-density-dialog"
|
||||
v-model="open"
|
||||
:title="previewUrl ? previewTitle : '选择打印单据'"
|
||||
:title="previewUrl ? previewTitle : selectionTitle"
|
||||
:fullscreenable="Boolean(previewUrl)"
|
||||
width="min(1120px, calc(100vw - 48px))"
|
||||
:loading="loading"
|
||||
:show-confirm="false"
|
||||
@@ -79,18 +86,17 @@ onBeforeUnmount(releasePreview)
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert v-if="!allowed" title="暂无订舱单据打印权限" type="warning" :closable="false" show-icon />
|
||||
<template v-else-if="!previewUrl">
|
||||
<p class="booking-print-current">当前订舱:<strong>{{ bookingNo || bookingId }}</strong></p>
|
||||
<div class="booking-print-options" role="radiogroup" aria-label="打印单据类型">
|
||||
<label v-for="item in options" :key="item.value" class="booking-print-option" :class="{ 'is-selected': selectedType === item.value }">
|
||||
<el-radio v-model="selectedType" :value="item.value"><span class="sr-only">{{ item.label }}</span></el-radio>
|
||||
<FileText :size="22" />
|
||||
<span><strong>{{ item.label }}</strong><small>{{ item.description }}</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<section v-else-if="!previewUrl" class="density-dialog-section" data-tone="source">
|
||||
<p class="booking-print-current">当前订舱:<strong>{{ bookingCode || '—' }}</strong></p>
|
||||
<el-radio-group v-model="selectedType" class="booking-print-options" aria-label="打印单据类型">
|
||||
<el-radio v-for="item in options" :key="item.value" :value="item.value" class="booking-print-option">
|
||||
<FileText :size="18" />
|
||||
<span class="booking-print-label"><strong>{{ item.label }}</strong><small>{{ item.description }}</small></span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<LoadErrorState v-if="error" title="打印单据加载失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
</template>
|
||||
<div v-else class="booking-print-preview" :aria-label="previewTitle">
|
||||
</section>
|
||||
<div v-else class="density-dialog-section booking-print-preview" data-tone="source" :aria-label="previewTitle">
|
||||
<iframe :src="previewUrl" :title="`${previewTitle} PDF 预览`" />
|
||||
</div>
|
||||
<template #footer="{ close }">
|
||||
@@ -105,5 +111,21 @@ onBeforeUnmount(releasePreview)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-print-current{margin:0 0 var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-body)}.booking-print-current strong{color:var(--cc-text)}.booking-print-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5)}.booking-print-option{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:var(--admin-space-5);border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:var(--admin-surface);cursor:pointer;min-height:var(--admin-empty-height, 64px);padding:var(--admin-space-3) var(--admin-space-5)}.booking-print-option.is-selected{border-color:var(--cc-primary);background:var(--cc-primary-soft)}.booking-print-option span{display:grid;min-width:0;gap:var(--admin-space-1)}.booking-print-option strong{color:var(--cc-text);font-size:var(--admin-font-section)}.booking-print-option small{color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-print-preview{min-height:0;overflow:hidden;border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:#f3f5f8}.booking-print-preview iframe{display:block;width:100%;height:min(72vh,760px);border:0;background:var(--admin-surface)}.booking-print-footer{width:100%}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:720px){.booking-print-options{grid-template-columns:1fr}.booking-print-preview iframe{height:68vh}}
|
||||
.booking-print-current { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); }
|
||||
.booking-print-current strong { color: var(--cc-text); }
|
||||
.booking-print-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-5); width: 100%; }
|
||||
.booking-print-option { min-width: 0; height: auto; min-height: var(--admin-control-height); margin: 0; white-space: normal; }
|
||||
.booking-print-option :deep(.el-radio__label) { display: flex; align-items: center; gap: var(--admin-space-3); min-width: 0; white-space: normal; }
|
||||
.booking-print-option :deep(.el-radio__label) > svg { flex: 0 0 auto; }
|
||||
.booking-print-label { display: grid; min-width: 0; gap: var(--admin-space-1); }
|
||||
.booking-print-label strong { color: var(--cc-text); }
|
||||
.booking-print-label small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.booking-print-preview { min-height: 0; overflow: hidden; }
|
||||
.booking-print-preview iframe { display: block; width: 100%; height: min(72vh, 760px); border: 0; background: var(--admin-surface); }
|
||||
.booking-print-footer { width: 100%; }
|
||||
:global(.booking-print-dialog.el-dialog.is-fullscreen .el-dialog__body),
|
||||
:global(.booking-print-dialog.el-dialog.is-fullscreen .app-dialog__body) { display: flex; flex: 1; }
|
||||
:global(.booking-print-dialog.el-dialog.is-fullscreen .booking-print-preview) { display: flex; flex: 1; }
|
||||
:global(.booking-print-dialog.el-dialog.is-fullscreen .booking-print-preview iframe) { height: 100%; flex: 1; min-height: 0; }
|
||||
@media (max-width: 720px) { .booking-print-options { grid-template-columns: 1fr; } .booking-print-preview iframe { height: 68vh; } }
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../api/platformOceanBookings'
|
||||
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
|
||||
import OceanBookingDetailView from './OceanBookingDetailView.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const routerReplace = vi.hoisted(() => vi.fn())
|
||||
const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }))
|
||||
@@ -101,6 +102,23 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
|
||||
})
|
||||
|
||||
it('opens the booking-number confirmation with the edited facts before saving or releasing', async () => {
|
||||
const { wrapper, vm } = await mountView()
|
||||
vm.form.data.billOfLadingNo = 'EDITED-BL'
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '放舱')!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('确认放舱'))!
|
||||
expect(dialog.props('modelValue')).toBe(true)
|
||||
expect(dialog.props('title')).toBe('确认放舱 · OB-123')
|
||||
expect(wrapper.get('.release-summary').text()).toContain('EDITED-BL')
|
||||
expect(wrapper.text()).toContain('不能修改、不能删除')
|
||||
expect(wrapper.text()).not.toContain('处理数据库')
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
expect(releaseOceanBooking).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows the matching fixed logistics group instead of the underlying price-book channel', async () => {
|
||||
const { wrapper } = await mountView()
|
||||
expect(wrapper.text()).toContain('美森正班')
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'")
|
||||
expect(source).not.toContain('请先保存草稿后再放舱')
|
||||
expect(source).toContain('放舱前请填写${missing[1]}')
|
||||
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。')
|
||||
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。')
|
||||
expect(source).toContain('if (hasUnsavedDraftChanges.value)')
|
||||
expect(source).toContain('const saved = await persistDraft(false)')
|
||||
expect(source).toContain('releaseVersion = saved.version')
|
||||
|
||||
@@ -280,7 +280,7 @@ async function persistDraft(showSuccess: boolean) {
|
||||
const saved = isNew.value
|
||||
? await createOceanBooking({ expectedVersion: 0, data: payload })
|
||||
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
|
||||
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || saved.id}` : '订舱草稿已保存')
|
||||
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || '未生成订舱号'}` : '订舱草稿已保存')
|
||||
if (isNew.value) {
|
||||
workspace.removeByIds(['ocean-booking-create'])
|
||||
await router.replace('/ocean-bookings')
|
||||
@@ -347,11 +347,12 @@ async function submitRelease() {
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||||
finally { actionSaving.value = false }
|
||||
}
|
||||
function bookingDialogTitle(action: string) { const code = detail.value?.bookingNo?.trim(); return code ? `${action} · ${code}` : action }
|
||||
async function removeDraft() {
|
||||
if (!detail.value || !canDelete.value) return
|
||||
actionError.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', '删除草稿', { customClass: 'admin-density-confirm', confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', bookingDialogTitle('删除草稿'), { customClass: 'admin-density-confirm', confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
|
||||
actionSaving.value = true
|
||||
await deleteOceanBookingDraft(id.value, detail.value.version)
|
||||
ElMessage.success('订舱草稿已删除')
|
||||
@@ -464,7 +465,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||||
|
||||
<div v-if="detail" class="surface-panel"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
|
||||
|
||||
@@ -476,17 +477,17 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</DetailPageLayout>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。" type="warning" :closable="false" show-icon />
|
||||
<el-descriptions class="release-summary" :column="2" border>
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。" type="warning" :closable="false" show-icon />
|
||||
<section class="density-dialog-section" data-tone="summary"><el-descriptions class="release-summary density-descriptions" :column="2" border>
|
||||
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="航次">{{ form.data.voyageNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预计开船">{{ form.data.estimatedSailingAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预计到港">{{ form.data.estimatedArrivalAt || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-form>
|
||||
</el-descriptions></section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user