import ElementPlus, { ElMessageBox } from 'element-plus' import { createPinia } from 'pinia' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { defineComponent, ref } from 'vue' import { createMemoryHistory, createRouter, RouterView } from 'vue-router' import CapacityInquiryDialog from './CapacityInquiryDialog.vue' import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue' import type { CapacityOffer } from '../capacityOfferTypes' import type { FleetTrip } from '../tripTypes' import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' import { getCapacityOffer, listCapacityOffers } from '../api/capacityOffers' import { getTrip, getTripCapacityPlan } from '../api/trips' import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('../api/trips', () => ({ getTrip: vi.fn(), getTripCapacityPlan: vi.fn(), getTripLoading: vi.fn(), confirmTripCapacityPlan: vi.fn(), confirmTripLoading: vi.fn(), updateTripPlan: vi.fn() })) vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), listCapacityInquiries: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn(), commandCapacityInquiry: vi.fn() })) const offer: CapacityOffer = { offerNo: 'CO-CLOSE', tripNo: 'TR-CLOSE', status: 'OPEN', recordVersion: 1, tripRecordVersion: 2, warehouseId: '9', warehouseName: '测试仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待核对', actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } } const wrappers: VueWrapper[] = [] beforeEach(() => { vi.clearAllMocks(); vi.mocked(getTrip).mockResolvedValue({ trip: { tripNo: offer.tripNo, recordVersion: 2, plannedDepartureAt: offer.plannedDepartureAt, stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } } as Awaited>); vi.mocked(getTripCapacityPlan).mockResolvedValue({ tripNo: offer.tripNo, recordVersion: 2, cargo: [], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) }) afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = ''; vi.restoreAllMocks() }) function mountDialog(kind: 'inquiry' | 'settings') { const pinia = createPinia() const wrapper = kind === 'inquiry' ? mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [pinia, ElementPlus] } }) : mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus, createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] })] } }) wrappers.push(wrapper) return { wrapper, pinia } } async function continueEditing() { const message = [...document.querySelectorAll('.el-message-box')].at(-1)! expect(message).not.toBeNull() const button = [...message.querySelectorAll('button')].find(node => node.textContent?.trim() === '继续填写')! expect(button).toBeTruthy(); button.click(); await flushPromises() await new Promise(resolve => setTimeout(resolve, 50)); await flushPromises() } it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited input visible through cancel, Escape and X vetoes', async kind => { const { wrapper, pinia } = mountDialog(kind) if (kind === 'settings') await wrapper.setProps({ modelValue: true }) await flushPromises() await wrapper.get('details summary').trigger('click') const input = wrapper.find(kind === 'inquiry' ? 'input[placeholder="填写货物名称(确认承运前必填)"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]') expect(input.exists()).toBe(true) await input.setValue('UI-未保存检查'); await flushPromises() const assertPreserved = () => { expect(wrapper.find('.app-dialog').isVisible()).toBe(true) expect(input.element.isConnected).toBe(true) expect(input.element.value).toBe('UI-未保存检查') expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(useUnsavedWorkStore(pinia).findBlocking(new Set(), true)).toHaveLength(1) } await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises() assertPreserved(); await continueEditing(); assertPreserved() input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises() assertPreserved(); await continueEditing(); assertPreserved() await wrapper.get('.el-dialog__headerbtn').trigger('click'); await flushPromises() assertPreserved(); await continueEditing(); assertPreserved() await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '取消')!.trigger('click'); await flushPromises() const message = [...document.querySelectorAll('.el-message-box')].at(-1)! const accept = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '确认关闭') as HTMLButtonElement accept.click(); await flushPromises() expect(wrapper.emitted('update:modelValue')).toEqual([[false]]) }) it('preserves route-leave protection in the retained native inquiry dialog without duplicating the global prompt', async () => { const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm') // 登记页面入口已隐藏;保留组件自身与真实路由的未保存保护回归。 const InquiryHost = defineComponent({ components: { CapacityInquiryDialog }, setup: () => ({ offer, open: ref(false) }), template: '' }) const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '

运力列表

' }, meta: { requiresAuth: true } }, { path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: InquiryHost, meta: { requiresAuth: true } }, { path: '/outside', name: 'outside', component: { template: '

外部页面

' }, meta: { workspaceTab: false } }, ] }) // Match the application's intentional policy: cross-workspace switching skips the global prompt. router.beforeEach(async (to, from) => { const fromId = workspaceRouteId(from) if (!fromId || isWorkspaceRoute(to) && workspaceRouteId(to) !== fromId || !registry.findForWorkspace(fromId).length) return true try { await ElMessageBox.confirm('当前页面有未保存内容', '全局离开确认', { confirmButtonText: '放弃并继续', cancelButtonText: '继续填写' }); registry.removeWorkspaces([fromId]); return true } catch { return false } }) const Host = defineComponent({ components: { RouterView }, template: '' }) await router.push('/pool'); await router.isReady() const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper) await router.push('/pool/CO-CLOSE'); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises() await wrapper.get('details summary').trigger('click') const input = wrapper.get('input[placeholder="填写货物名称(确认承运前必填)"]') await input.setValue('UI-修复后保留输入'); await flushPromises() input.element.focus(); await input.trigger('keydown', { key: 'Escape', code: 'Escape' }); await flushPromises(); await continueEditing() confirms.mockClear() router.back(); await flushPromises() expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail') expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true) await continueEditing() expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail') expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).not.toHaveBeenCalled() confirms.mockClear(); router.back(); await flushPromises() const message = [...document.querySelectorAll('.el-message-box')].at(-1)! const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement expect(leave).toBeTruthy(); leave.click(); await flushPromises() expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers') expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).not.toHaveBeenCalled() await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises() await wrapper.get('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises() confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises() expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认') await continueEditing(); await navigation expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail') })