diff --git a/frontend/src/composables/useOrganizationSwitchGuard.js b/frontend/src/composables/useOrganizationSwitchGuard.js index 1e029d09..5b8cd075 100644 --- a/frontend/src/composables/useOrganizationSwitchGuard.js +++ b/frontend/src/composables/useOrganizationSwitchGuard.js @@ -25,5 +25,11 @@ export function useOrganizationSwitchGuard(isDirty) { } organizationStore.setOrganizationSwitchGuard(guard) - onUnmounted(() => organizationStore.setOrganizationSwitchGuard(null)) + onUnmounted(() => { + // 只有当前守卫仍是本组件注册的这一份时才撤销:若本页之后又被别的守卫页覆盖, + // 先卸载的一页不能把后注册者的守卫清掉。 + if (organizationStore.getOrganizationSwitchGuard() === guard) { + organizationStore.setOrganizationSwitchGuard(null) + } + }) } diff --git a/frontend/src/composables/useOrganizationSwitchGuard.test.js b/frontend/src/composables/useOrganizationSwitchGuard.test.js new file mode 100644 index 00000000..64af3250 --- /dev/null +++ b/frontend/src/composables/useOrganizationSwitchGuard.test.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { defineComponent } from 'vue' +import { authApi } from '../api/auth' +import { useOrganizationStore } from '../stores/organization' +import { useOrganizationSwitchGuard } from './useOrganizationSwitchGuard' +import { resetOrganizationContext } from '../utils/organizationContext' + +vi.mock('../api/auth', () => ({ + authApi: { + listOrganizations: vi.fn(), + switchOrganizationContext: vi.fn(), + createWebSocketTicket: vi.fn() + } +})) +vi.mock('../utils/companySocket', () => ({ + createCompanySocket: () => ({ connect: vi.fn(), close: vi.fn() }) +})) + +// 一个最小的“守卫页”:挂载即注册切换守卫。 +const Guarded = defineComponent({ + setup() { + useOrganizationSwitchGuard(() => true) + return () => null + } +}) + +describe('useOrganizationSwitchGuard 卸载撤销', () => { + beforeEach(() => { + localStorage.clear() + resetOrganizationContext() + setActivePinia(createPinia()) + }) + + // 先卸载的守卫页不能把后注册者的守卫清掉。 + test('先卸载的守卫页不会清掉后注册者的守卫', () => { + const first = mount(Guarded) + const second = mount(Guarded) + const org = useOrganizationStore() + expect(org.getOrganizationSwitchGuard()).not.toBeNull() + + first.unmount() + // second 仍在挂载:守卫必须保留 + expect(org.getOrganizationSwitchGuard()).not.toBeNull() + + second.unmount() + expect(org.getOrganizationSwitchGuard()).toBeNull() + }) +}) diff --git a/frontend/src/composables/useSavingGuard.js b/frontend/src/composables/useSavingGuard.js new file mode 100644 index 00000000..f9acda9b --- /dev/null +++ b/frontend/src/composables/useSavingGuard.js @@ -0,0 +1,23 @@ +// 页面直接调用 API 的写请求的“保存互斥”复用点。 +// +// 设计约定 3:保存请求执行期间禁止切换公司,避免写请求完成归属不明确。 +// 走 store 的写请求已在各 store 里 `beginSaving()/endSaving()`;本组合式函数给 +// “页面自己直接调 API 的写请求”(如仓库、伙伴、基础配置)提供同一套互斥: +// 用 runWithSaving 包住写请求,飞行期间组织 store 的 busy 为真,切换被拒。 +import { useOrganizationStore } from '../stores/organization' + +export function useSavingGuard() { + const organizationStore = useOrganizationStore() + + async function runWithSaving(task) { + organizationStore.beginSaving() + try { + return await task() + } finally { + // 成功或失败都要复位,避免忙标志被永久卡住、用户再也切不了公司。 + organizationStore.endSaving() + } + } + + return { runWithSaving } +} diff --git a/frontend/src/composables/useSavingGuard.test.js b/frontend/src/composables/useSavingGuard.test.js new file mode 100644 index 00000000..1d72b96d --- /dev/null +++ b/frontend/src/composables/useSavingGuard.test.js @@ -0,0 +1,83 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { authApi } from '../api/auth' +import { useOrganizationStore } from '../stores/organization' +import { useSavingGuard } from './useSavingGuard' +import { resetOrganizationContext } from '../utils/organizationContext' + +vi.mock('../api/auth', () => ({ + authApi: { + listOrganizations: vi.fn(), + switchOrganizationContext: vi.fn(), + createWebSocketTicket: vi.fn() + } +})) +vi.mock('../utils/companySocket', () => ({ + createCompanySocket: () => ({ connect: vi.fn(), close: vi.fn() }) +})) + +// 让当前账号以公司 1 完成一次初始化。 +async function enterCompany1() { + localStorage.setItem('wmms_user', JSON.stringify({ id: 1 })) + authApi.listOrganizations.mockResolvedValue({ + data: { organizations: [{ id: 1 }, { id: 2 }], default_organization_id: 1 } + }) + authApi.switchOrganizationContext.mockResolvedValueOnce({ data: { organization_id: 1 } }) + await useOrganizationStore().initialize() +} + +describe('useSavingGuard 保存互斥', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + resetOrganizationContext() + setActivePinia(createPinia()) + authApi.listOrganizations.mockReset() + authApi.switchOrganizationContext.mockReset() + }) + + // 页面直接调用 API 的写请求包进 runWithSaving 后,飞行期间 busy 为真,切换被拒。 + test('写请求飞行期间 busy 为真,切换公司被拒绝', async () => { + await enterCompany1() + const org = useOrganizationStore() + authApi.switchOrganizationContext.mockClear() + + const { runWithSaving } = useSavingGuard() + let resolveTask + const pending = runWithSaving(() => new Promise(resolve => { resolveTask = resolve })) + expect(org.busy).toBe(true) + + await org.switchOrganization(2) + + expect(authApi.switchOrganizationContext).not.toHaveBeenCalled() + expect(org.currentOrganizationId).toBe(1) + + resolveTask('ok') + expect(await pending).toBe('ok') + expect(org.busy).toBe(false) + }) + + test('写请求成功后复位保存互斥', async () => { + await enterCompany1() + const org = useOrganizationStore() + const { runWithSaving } = useSavingGuard() + + const result = await runWithSaving(() => Promise.resolve(42)) + + expect(result).toBe(42) + expect(org.saving).toBe(false) + expect(org.busy).toBe(false) + }) + + // 失败也要复位,否则忙标志会被永久卡住、用户再也切不了公司。 + test('写请求失败时也复位保存互斥', async () => { + await enterCompany1() + const org = useOrganizationStore() + const { runWithSaving } = useSavingGuard() + + await expect(runWithSaving(() => Promise.reject(new Error('boom')))).rejects.toThrow('boom') + + expect(org.saving).toBe(false) + expect(org.busy).toBe(false) + }) +}) diff --git a/frontend/src/stores/inbound.js b/frontend/src/stores/inbound.js index 9b1e20bf..5bcc3b5e 100644 --- a/frontend/src/stores/inbound.js +++ b/frontend/src/stores/inbound.js @@ -81,8 +81,17 @@ export const useInboundStore = defineStore('inbound', () => { } async function fetchInboundDetail(id) { - const res = await inboundApi.getById(id) - return res.data || res + // 捕获发起时的公司上下文快照:详情返回时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() + try { + const res = await inboundApi.getById(id) + // 公司已切换:丢弃旧公司的详情,返回 null(调用方据此放弃打开弹窗,不当作失败展示)。 + if (!isCurrentOrganizationContext(snapshot)) return null + return res.data || res + } catch (error) { + if (shouldDiscardResponse(error)) return null + throw error + } } // 公司范围变更时清空共享缓存:单据列表、分页与加载态。 diff --git a/frontend/src/stores/inbound.test.js b/frontend/src/stores/inbound.test.js index e4ba9f3c..be20e529 100644 --- a/frontend/src/stores/inbound.test.js +++ b/frontend/src/stores/inbound.test.js @@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { inboundApi } from '../api/inbound' import { useInboundStore } from './inbound' +import { commitOrganizationId, resetOrganizationContext } from '../utils/organizationContext' vi.mock('../api/inbound', () => ({ inboundApi: { @@ -62,3 +63,33 @@ describe('inbound store 的公司切换重置', () => { expect(store.loading).toBe(false) }) }) + +describe('inbound store 详情加载的公司上下文校验', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + resetOrganizationContext() + commitOrganizationId(1) + }) + + // 慢网或切换期间,旧公司的详情响应返回时必须丢弃,不落回页面。 + it('切换公司后旧的详情响应被丢弃', async () => { + let resolveDetail + inboundApi.getById.mockImplementationOnce(() => new Promise(resolve => { resolveDetail = resolve })) + const store = useInboundStore() + + const pending = store.fetchInboundDetail(7) + commitOrganizationId(2) + + resolveDetail({ data: { id: 7, handler: '张三', items: [{ material_code: 'OLD' }] } }) + + expect(await pending).toBeNull() + }) + + it('上下文未变时正常返回详情', async () => { + inboundApi.getById.mockResolvedValue({ data: { id: 7, handler: '张三' } }) + const store = useInboundStore() + + expect(await store.fetchInboundDetail(7)).toEqual({ id: 7, handler: '张三' }) + }) +}) diff --git a/frontend/src/stores/organization.js b/frontend/src/stores/organization.js index 3b79dae3..fa13f35c 100644 --- a/frontend/src/stores/organization.js +++ b/frontend/src/stores/organization.js @@ -90,6 +90,11 @@ export const useOrganizationStore = defineStore('organization', () => { switchGuard = typeof guard === 'function' ? guard : null } + // 供页面卸载时判断“当前守卫是否仍是自己注册的那一份”,避免先卸载的页面清掉后注册者的守卫。 + function getOrganizationSwitchGuard() { + return switchGuard + } + // 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。 const initialized = ref(false) // 最近一次 initialize 是否因临时故障失败:用于把“加载失败”与“确实没有可用公司”区分开, @@ -255,7 +260,7 @@ export const useOrganizationStore = defineStore('organization', () => { organizations, currentOrganizationId, currentWarehouseId, currentLocationId, currentWarehouse, contextVersion, switching, saving, busy, initialized, loadError, beginSaving, endSaving, - registerOrganizationReset, setOrganizationSwitchGuard, + registerOrganizationReset, setOrganizationSwitchGuard, getOrganizationSwitchGuard, initialize, switchOrganization, reset } }) diff --git a/frontend/src/stores/outbound.js b/frontend/src/stores/outbound.js index 40b9bd41..34ce452e 100644 --- a/frontend/src/stores/outbound.js +++ b/frontend/src/stores/outbound.js @@ -81,8 +81,17 @@ export const useOutboundStore = defineStore('outbound', () => { } async function fetchOutboundDetail(id) { - const res = await outboundApi.getById(id) - return res.data || res + // 捕获发起时的公司上下文快照:详情返回时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() + try { + const res = await outboundApi.getById(id) + // 公司已切换:丢弃旧公司的详情,返回 null(调用方据此放弃打开弹窗,不当作失败展示)。 + if (!isCurrentOrganizationContext(snapshot)) return null + return res.data || res + } catch (error) { + if (shouldDiscardResponse(error)) return null + throw error + } } // 公司范围变更时清空共享缓存:单据列表、分页与加载态。 diff --git a/frontend/src/stores/outbound.test.js b/frontend/src/stores/outbound.test.js new file mode 100644 index 00000000..bb1dde62 --- /dev/null +++ b/frontend/src/stores/outbound.test.js @@ -0,0 +1,65 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { outboundApi } from '../api/outbound' +import { useOutboundStore } from './outbound' +import { commitOrganizationId, resetOrganizationContext } from '../utils/organizationContext' + +vi.mock('../api/outbound', () => ({ + outboundApi: { + getAll: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } +})) + +describe('outbound store 详情加载的公司上下文校验', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + resetOrganizationContext() + commitOrganizationId(1) + }) + + // 慢网或切换期间,旧公司的详情响应返回时必须丢弃,不落回页面。 + it('切换公司后旧的详情响应被丢弃', async () => { + let resolveDetail + outboundApi.getById.mockImplementationOnce(() => new Promise(resolve => { resolveDetail = resolve })) + const store = useOutboundStore() + + const pending = store.fetchOutboundDetail(9) + commitOrganizationId(2) + + resolveDetail({ data: { id: 9, receiver: '旧公司', items: [] } }) + + expect(await pending).toBeNull() + }) + + it('上下文未变时正常返回详情', async () => { + outboundApi.getById.mockResolvedValue({ data: { id: 9, receiver: '仓A' } }) + const store = useOutboundStore() + + expect(await store.fetchOutboundDetail(9)).toEqual({ id: 9, receiver: '仓A' }) + }) +}) + +describe('outbound store 的公司切换重置', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('resetForOrganization 清空单据列表、分页与加载态', () => { + const store = useOutboundStore() + store.outboundList = [{ id: 1, outbound_no: 'OUT-001' }] + store.page = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 } + store.loading = true + + store.resetForOrganization() + + expect(store.outboundList).toEqual([]) + expect(store.page).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + expect(store.loading).toBe(false) + }) +}) diff --git a/frontend/src/views/Inbound.vue b/frontend/src/views/Inbound.vue index 9c088c10..3775ee05 100644 --- a/frontend/src/views/Inbound.vue +++ b/frontend/src/views/Inbound.vue @@ -214,17 +214,19 @@ function showAddModal() { } async function showEditModal(row) { - editingId.value = row.id - dialogTitle.value = '编辑入库单' // 列表接口不返回物料明细(InboundOrder.Items 带 omitempty),拿 row.items 会永远是空, // 表单里就看不到这张单当初入了什么。必须单独取一次详情。 - let order = row + let order try { order = await inboundStore.fetchInboundDetail(row.id) } catch (error) { ElMessage.error('加载入库单详情失败') return } + // 公司已切换:详情响应已作废(返回 null),放弃打开弹窗,也不提示失败。 + if (!order) return + editingId.value = row.id + dialogTitle.value = '编辑入库单' const items = order.items && order.items.length > 0 ? order.items.map(item => { const material = materialsStore.materialOptions.find(m => m.id === item.material_id) @@ -248,14 +250,17 @@ async function showEditModal(row) { } async function showDetail(row) { + let detail try { - const detail = await inboundStore.fetchInboundDetail(row.id) - currentRow.value = detail + detail = await inboundStore.fetchInboundDetail(row.id) } catch (error) { console.error('获取详情失败:', error) ElMessage.error(errorText(error, '获取详情失败')) - currentRow.value = { ...row, items: [] } + detail = { ...row, items: [] } } + // 公司已切换:详情响应已作废,不打开弹窗。 + if (!detail) return + currentRow.value = detail detailVisible.value = true } diff --git a/frontend/src/views/Outbound.vue b/frontend/src/views/Outbound.vue index 4ce876c9..b2f3bf6c 100644 --- a/frontend/src/views/Outbound.vue +++ b/frontend/src/views/Outbound.vue @@ -164,27 +164,30 @@ function showAddModal() { } async function showEditModal(row) { - isEdit.value = true - editId.value = row.id + let order try { - const res = await outboundStore.fetchOutboundDetail(row.id) - const order = res.data || res - form.receiver = order.receiver || '' - form.remark = order.remark || '' - form.items = order.items && order.items.length > 0 - ? order.items.map(item => { - const material = materialsStore.materialOptions.find(m => m.id === item.material_id) - // Fetch stock for existing items - stockStore.fetchStockByMaterialId(item.material_id).then(stock => { - itemStocks.value[item.material_id] = stock - }) - return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : `${item.material_code || ''} - ${item.material_name || ''}`, material_code: item.material_code, material_name: item.material_name, material_status: item.material_status, quantity: item.quantity } - }) - : [{ material_id: null, materialSearchText: '', quantity: 1 }] - dialogVisible.value = true + order = await outboundStore.fetchOutboundDetail(row.id) } catch (error) { ElMessage.error('加载出库单详情失败') + return } + // 公司已切换:详情响应已作废(返回 null),放弃打开弹窗,也不提示失败。 + if (!order) return + isEdit.value = true + editId.value = row.id + form.receiver = order.receiver || '' + form.remark = order.remark || '' + form.items = order.items && order.items.length > 0 + ? order.items.map(item => { + const material = materialsStore.materialOptions.find(m => m.id === item.material_id) + // Fetch stock for existing items + stockStore.fetchStockByMaterialId(item.material_id).then(stock => { + itemStocks.value[item.material_id] = stock + }) + return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : `${item.material_code || ''} - ${item.material_name || ''}`, material_code: item.material_code, material_name: item.material_name, material_status: item.material_status, quantity: item.quantity } + }) + : [{ material_id: null, materialSearchText: '', quantity: 1 }] + dialogVisible.value = true } function addItem() { @@ -288,14 +291,17 @@ async function confirmDelete() { } async function showDetail(row) { + let detail try { - const detail = await outboundStore.fetchOutboundDetail(row.id) - currentRow.value = detail + detail = await outboundStore.fetchOutboundDetail(row.id) } catch (error) { console.error('获取详情失败:', error) ElMessage.error('获取详情失败') - currentRow.value = { ...row, items: [] } + detail = { ...row, items: [] } } + // 公司已切换:详情响应已作废,不打开弹窗。 + if (!detail) return + currentRow.value = detail detailVisible.value = true } diff --git a/frontend/src/views/inventory/WarehouseSettings.test.js b/frontend/src/views/inventory/WarehouseSettings.test.js index 6a1e5c37..efd3648e 100644 --- a/frontend/src/views/inventory/WarehouseSettings.test.js +++ b/frontend/src/views/inventory/WarehouseSettings.test.js @@ -19,6 +19,17 @@ vi.mock('../../composables/useOrganizationSwitchGuard', () => ({ useOrganizationSwitchGuard: vi.fn() })) +// 页面直接调 API 的写请求走 useSavingGuard 的保存互斥;这里替身掉,避免测试环境需要 active Pinia。 +const savingGuard = vi.hoisted(() => ({ runWithSaving: vi.fn(task => task()) })) +vi.mock('../../composables/useSavingGuard', () => ({ + useSavingGuard: () => savingGuard +})) + +beforeEach(() => { + // restoreAllMocks 会清掉 vi.fn 的实现,这里每个用例重新装上直通实现。 + savingGuard.runWithSaving = vi.fn(task => task()) +}) + // 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报 // "Maximum recursive updates exceeded"。这里只关心状态下拉提交的内容,用轻量替身绕开。 // 与 MasterDataSettings.test.js 里的同款处理保持一致。 @@ -147,6 +158,8 @@ describe('仓库与库位表单必填校验', () => { expect(warnSpy).not.toHaveBeenCalled() expect(warehousesApi.create).toHaveBeenCalledTimes(1) + // 写请求必须包进保存互斥,飞行期间禁止切换公司 + expect(savingGuard.runWithSaving).toHaveBeenCalledTimes(1) wrapper.unmount() }) }) diff --git a/frontend/src/views/inventory/WarehouseSettings.vue b/frontend/src/views/inventory/WarehouseSettings.vue index 677df200..ca103368 100644 --- a/frontend/src/views/inventory/WarehouseSettings.vue +++ b/frontend/src/views/inventory/WarehouseSettings.vue @@ -63,6 +63,7 @@ import { ElMessage } from 'element-plus' import { warehousesApi } from '../../api/warehouses' import { replaceAllKeys } from '../../utils/replaceAllKeys' import { useOrganizationSwitchGuard } from '../../composables/useOrganizationSwitchGuard' +import { useSavingGuard } from '../../composables/useSavingGuard' const warehouses = ref([]) const loading = ref(false) @@ -71,6 +72,8 @@ const formVisible = ref(false) // 仓库/库位弹窗开着时切换公司会丢弃未保存内容,先确认。 useOrganizationSwitchGuard(() => formVisible.value) +// 本页直接调用仓库 API,写请求要用保存互斥包住,飞行期间禁止切换公司。 +const { runWithSaving } = useSavingGuard() const formType = ref('warehouse') // 编辑目标的 id,null 表示新增。刻意单独存放、不进 form: // form 上残留的 id 靠 Object.assign 是删不掉的,会导致「点新增却变成覆盖更新」。 @@ -122,14 +125,16 @@ async function save() { : { code: form.code.trim(), name: form.name.trim(), status: form.status } saving.value = true try { - if (formType.value === 'warehouse') { - if (isEdit.value) await warehousesApi.update(editingId.value, payload) - else await warehousesApi.create(payload) - } else if (isEdit.value) { - await warehousesApi.updateLocation(selectedWarehouseId.value, editingId.value, payload) - } else { - await warehousesApi.createLocation(selectedWarehouseId.value, payload) - } + await runWithSaving(async () => { + if (formType.value === 'warehouse') { + if (isEdit.value) await warehousesApi.update(editingId.value, payload) + else await warehousesApi.create(payload) + } else if (isEdit.value) { + await warehousesApi.updateLocation(selectedWarehouseId.value, editingId.value, payload) + } else { + await warehousesApi.createLocation(selectedWarehouseId.value, payload) + } + }) ElMessage.success('保存成功') // 关窗放在 await 成功之后:失败时弹窗留着,用户能改完重试 formVisible.value = false diff --git a/frontend/src/views/master-data/MasterDataSettings.test.js b/frontend/src/views/master-data/MasterDataSettings.test.js index a906efe5..149e9d78 100644 --- a/frontend/src/views/master-data/MasterDataSettings.test.js +++ b/frontend/src/views/master-data/MasterDataSettings.test.js @@ -10,6 +10,17 @@ vi.mock('../../api/masterData', () => ({ codeRulesApi: { list: vi.fn(), create: vi.fn(), update: vi.fn() } })) +// 页面直接调 API 的写请求走 useSavingGuard 的保存互斥;这里替身掉,避免测试环境需要 active Pinia。 +const savingGuard = vi.hoisted(() => ({ runWithSaving: vi.fn(task => task()) })) +vi.mock('../../composables/useSavingGuard', () => ({ + useSavingGuard: () => savingGuard +})) + +beforeEach(() => { + // restoreAllMocks 会清掉 vi.fn 的实现,这里每个用例重新装上直通实现。 + savingGuard.runWithSaving = vi.fn(task => task()) +}) + const STANDARD_PART_DESCRIPTION = '1.核心定义:国家/行业通用标准化零件,型号固定,无需绘图、无需任何二次加工。 2.包含说明:设备通用紧固、锁紧、防松类标准配件,采用全行业通用规格,无非标改造。 3.现场举例:内六角螺钉、沉头螺钉、组合螺钉。 4.边界区分:国标通用紧固配件统一归此类;非标定制紧固结构件归入 A04。' @@ -196,4 +207,19 @@ describe('基础配置表单必填校验', () => { expect(wrapper.text()).toContain('物料分类') wrapper.unmount() }) + + // 写请求必须包进保存互斥,飞行期间禁止切换公司。 + test('物料分类 tab:编码与名称填好后提交,写请求走保存互斥', async () => { + const wrapper = mountSettings() + await flushPromises() + await openCreateDialog(wrapper) + + await inputByLabel(wrapper, '编码').setValue('A03') + await inputByLabel(wrapper, '名称').setValue('新分类') + await clickSave(wrapper) + + expect(materialCategoriesApi.create).toHaveBeenCalledTimes(1) + expect(savingGuard.runWithSaving).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) }) diff --git a/frontend/src/views/master-data/MasterDataSettings.vue b/frontend/src/views/master-data/MasterDataSettings.vue index b0d763a9..9a99b5ca 100644 --- a/frontend/src/views/master-data/MasterDataSettings.vue +++ b/frontend/src/views/master-data/MasterDataSettings.vue @@ -116,6 +116,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import { codeRulesApi, materialCategoriesApi, unitsApi } from '../../api/masterData' import { replaceAllKeys } from '../../utils/replaceAllKeys' +import { useSavingGuard } from '../../composables/useSavingGuard' const tabs = [ { key: 'categories', label: '物料分类', api: materialCategoriesApi }, @@ -133,6 +134,9 @@ const current = computed(() => tabs.find(item => item.key === activeTab.value)) const rootCategories = computed(() => categories.value.filter(item => !item.parent_id)) const form = reactive(defaultForm()) +// 本页直接调用基础配置 API,写请求要用保存互斥包住,飞行期间禁止切换公司。 +const { runWithSaving } = useSavingGuard() + function defaultForm() { return { code: '', name: '', description: '', parent_id: null, category_id: null, prefix: '', sequence_length: 4, next_sequence: 1, precision_scale: 0, status: 'active' } } @@ -188,8 +192,10 @@ async function save() { } saving.value = true try { - if (editingId.value) await current.value.api.update(editingId.value, form) - else await current.value.api.create(form) + await runWithSaving(async () => { + if (editingId.value) await current.value.api.update(editingId.value, form) + else await current.value.api.create(form) + }) ElMessage.success('保存成功') dialogVisible.value = false await loadCurrent() diff --git a/frontend/src/views/partners/PartnerList.test.js b/frontend/src/views/partners/PartnerList.test.js index dffdf15a..b28e2ccd 100644 --- a/frontend/src/views/partners/PartnerList.test.js +++ b/frontend/src/views/partners/PartnerList.test.js @@ -17,6 +17,17 @@ vi.mock('../../composables/useOrganizationSwitchGuard', () => ({ useOrganizationSwitchGuard: vi.fn() })) +// 页面直接调 API 的写请求走 useSavingGuard 的保存互斥;这里替身掉,避免测试环境需要 active Pinia。 +const savingGuard = vi.hoisted(() => ({ runWithSaving: vi.fn(task => task()) })) +vi.mock('../../composables/useSavingGuard', () => ({ + useSavingGuard: () => savingGuard +})) + +beforeEach(() => { + // restoreAllMocks 会清掉 vi.fn 的实现,这里每个用例重新装上直通实现。 + savingGuard.runWithSaving = vi.fn(task => task()) +}) + // 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报 // "Maximum recursive updates exceeded"。这里只关心表单校验,用轻量替身绕开。 const SelectStub = { @@ -112,6 +123,8 @@ describe('伙伴表单必填校验', () => { expect(warnSpy).not.toHaveBeenCalled() expect(partnersApi.create).toHaveBeenCalledTimes(1) + // 写请求必须包进保存互斥,飞行期间禁止切换公司 + expect(savingGuard.runWithSaving).toHaveBeenCalledTimes(1) wrapper.unmount() }) diff --git a/frontend/src/views/partners/PartnerList.vue b/frontend/src/views/partners/PartnerList.vue index f0c94be8..149ee09a 100644 --- a/frontend/src/views/partners/PartnerList.vue +++ b/frontend/src/views/partners/PartnerList.vue @@ -66,6 +66,7 @@ import { ElMessage } from 'element-plus' import { partnersApi } from '../../api/partners' import { replaceAllKeys } from '../../utils/replaceAllKeys' import { useOrganizationSwitchGuard } from '../../composables/useOrganizationSwitchGuard' +import { useSavingGuard } from '../../composables/useSavingGuard' import BaseTable from '../../components/business/BaseTable.vue' const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } }) @@ -77,6 +78,8 @@ const editingId = ref(null) // 伙伴新增/编辑弹窗开着时切换公司会丢弃未保存内容,先确认。 useOrganizationSwitchGuard(() => dialogVisible.value) +// 本页直接调用伙伴 API,写请求要用保存互斥包住,飞行期间禁止切换公司。 +const { runWithSaving } = useSavingGuard() // 每页条数跟物料等列表页保持一致,避免同一个系统里两种翻页手感 const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 }) const statusField = computed(() => `${props.role}_status`) @@ -128,7 +131,15 @@ async function save() { if (!form.legal_name.trim()) { ElMessage.warning('请填写企业名称'); return } saving.value = true // 保存后停在第几页由 load 的默认参数决定:留在当前页,不把用户甩回第一页 - try { if (editingId.value) await partnersApi.update(editingId.value, payload()); else await partnersApi.create(payload()); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false } + try { + await runWithSaving(async () => { + if (editingId.value) await partnersApi.update(editingId.value, payload()) + else await partnersApi.create(payload()) + }) + ElMessage.success('保存成功') + dialogVisible.value = false + await load() + } finally { saving.value = false } } onMounted(() => load(1))