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) }
}