From ba6d0ff13f5d820a4d0d842c7277d67834057b49 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 18:53:06 +0800 Subject: [PATCH] fix(fleet): guard workspace navigation with unsaved pool drafts --- .../components/CapacityDialogClosing.test.ts | 55 +++++++++++++++++-- .../pages/CapacityOfferDetailView.vue | 5 +- .../pages/CapacityOffersBehavior.test.ts | 2 +- .../useFleetDraftGuard.ts | 21 ++++++- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts index 3cb86770..07ad3b5b 100644 --- a/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts +++ b/src/modules/overseas-master-data/components/CapacityDialogClosing.test.ts @@ -2,19 +2,23 @@ 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 } from 'vue' +import { createMemoryHistory, createRouter, RouterView } from 'vue-router' import CapacityInquiryDialog from './CapacityInquiryDialog.vue' import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue' +import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue' import type { CapacityOffer } from '../capacityOfferTypes' import type { FleetTrip } from '../tripTypes' import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' -import { listCapacityOffers } from '../api/capacityOffers' +import { getCapacityOffer, listCapacityOffers } from '../api/capacityOffers' +import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace' vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) -vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: 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(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }) }) -afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = '' }) +beforeEach(() => { vi.clearAllMocks(); 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' @@ -56,3 +60,46 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i accept.click(); await flushPromises() expect(wrapper.emitted('update:modelValue')).toEqual([[false]]) }) + +it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => { + const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm') + 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: CapacityOfferDetailView, 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() + 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).toHaveBeenCalledExactlyOnceWith('CO-CLOSE') + 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).toHaveBeenCalledExactlyOnceWith('CO-CLOSE') + 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') +}) diff --git a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue index 1d29c378..b4fdaf8a 100644 --- a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue +++ b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue @@ -50,7 +50,10 @@ function accept(value: InquiryResult) { const commands = useCapacityInquiryCommands('capacity-offer-command', accept) async function action(key: string, row: CapacityInquiry) { if (key === 'confirm' || key === 'cancel') await commands.run(row, key); else { editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true } } function create() { if (!canCreate.value) return; editing.value = null; readonly.value = false; editorOpen.value = true } -watch(() => route.params.offerNo, () => { detail.value = null; editorOpen.value = false; void load() }, { immediate: true }) +watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => { + if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return + detail.value = null; editorOpen.value = false; void load() +}, { immediate: true }) watch([page, size], loadInquiries) onBeforeUnmount(() => { sequence++; inquirySequence++ }) diff --git a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts index 419720bf..d5acee01 100644 --- a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts +++ b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts @@ -10,7 +10,7 @@ import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork' import * as api from '../api/capacityOffers' import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes' import type { FleetTrip } from '../tripTypes' -const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, route: { params: { offerNo: 'CO-1' } }, push: vi.fn() })) +const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write }) })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } })) diff --git a/src/modules/overseas-master-data/useFleetDraftGuard.ts b/src/modules/overseas-master-data/useFleetDraftGuard.ts index 8b033ba1..3a7fc5ed 100644 --- a/src/modules/overseas-master-data/useFleetDraftGuard.ts +++ b/src/modules/overseas-master-data/useFleetDraftGuard.ts @@ -1,23 +1,38 @@ import { computed, getCurrentInstance, reactive, ref, type Ref } from 'vue' import { ElMessageBox } from 'element-plus' +import { onBeforeRouteLeave } from 'vue-router' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' +import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace' -/** Veto native closing before Element Plus changes its internal visibility; route/tab exits use the shared registry. */ +/** Veto native closing and supplement cached route switches; retain the shared registry for other exits. */ export function useFleetDraftGuard(key: string, open: Ref, model: object, busy: Ref, uncertain: Ref) { const baseline = ref(''), checking = ref(false) const snapshot = computed(() => JSON.stringify(model)) const registration = useUnsavedWorkRegistration({ key: `${key}:${getCurrentInstance()?.uid}`, label: computed(() => '拼车表单'), isOpen: computed(() => open.value || uncertain.value), model: reactive({ form: model, uncertain }), requiredPermission: computed(() => null) }) function capture() { baseline.value = snapshot.value; if (!uncertain.value) registration.captureBaseline() } - async function mayClose() { + async function mayClose(leaving = false) { if (busy.value || checking.value) return false if (snapshot.value !== baseline.value || uncertain.value) { checking.value = true - try { await ElMessageBox.confirm(uncertain.value ? '上次提交结果尚未确定,原请求将保留。关闭后可重新打开并核对或重试,是否关闭?' : '表单有未保存内容,确认放弃本次修改?', '关闭表单', { customClass: 'admin-density-confirm', confirmButtonText: '确认关闭', cancelButtonText: '继续填写' }) } + try { await ElMessageBox.confirm( + uncertain.value ? `上次提交结果尚未确定,原请求将保留。${leaving ? '离开' : '关闭'}后可重新打开并核对或重试,是否继续?` : leaving ? '表单有未保存内容,离开后将关闭表单并放弃本次修改。是否离开?' : '表单有未保存内容,确认放弃本次修改?', + leaving ? '离开页面' : '关闭表单', + { customClass: 'admin-density-confirm', confirmButtonText: leaving ? uncertain.value ? '保留请求并离开' : '放弃并离开' : '确认关闭', cancelButtonText: '继续填写' }, + ) } catch { return false } finally { checking.value = false } } return true } async function beforeClose(done: () => void) { if (await mayClose()) done() } + if (getCurrentInstance()?.proxy?.$route) { + onBeforeRouteLeave(async (to, from) => { + // The global guard protects destructive navigation; it intentionally skips cached workspace switches. + if (!isWorkspaceRoute(to) || workspaceRouteId(to) === workspaceRouteId(from) || !open.value && !uncertain.value) return true + if (!await mayClose(true)) return false + open.value = false + return true + }) + } return { capture, beforeClose, dirty: computed(() => snapshot.value !== baseline.value) } }