fix: 补齐详情加载的上下文校验与保存互斥

- 入出库详情加载提交前校验公司 ID 与上下文版本,旧公司响应静默丢弃
- 新增 runWithSaving 包裹器,仓库、伙伴、基础配置等直接调接口的页面纳入保存互斥
- 切换守卫卸载时仅撤销自己注册的那一份
This commit is contained in:
文睿 张 2026-10-09 14:25:46 +08:00
parent 3674076377
commit 89368ba3f8
17 changed files with 409 additions and 43 deletions

View File

@ -25,5 +25,11 @@ export function useOrganizationSwitchGuard(isDirty) {
}
organizationStore.setOrganizationSwitchGuard(guard)
onUnmounted(() => organizationStore.setOrganizationSwitchGuard(null))
onUnmounted(() => {
// 只有当前守卫仍是本组件注册的这一份时才撤销:若本页之后又被别的守卫页覆盖,
// 先卸载的一页不能把后注册者的守卫清掉。
if (organizationStore.getOrganizationSwitchGuard() === guard) {
organizationStore.setOrganizationSwitchGuard(null)
}
})
}

View File

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

View File

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

View File

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

View File

@ -81,8 +81,17 @@ export const useInboundStore = defineStore('inbound', () => {
}
async function fetchInboundDetail(id) {
// 捕获发起时的公司上下文快照:详情返回时用它判断是否已被切换作废。
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
}
}
// 公司范围变更时清空共享缓存:单据列表、分页与加载态。

View File

@ -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: '张三' })
})
})

View File

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

View File

@ -81,8 +81,17 @@ export const useOutboundStore = defineStore('outbound', () => {
}
async function fetchOutboundDetail(id) {
// 捕获发起时的公司上下文快照:详情返回时用它判断是否已被切换作废。
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
}
}
// 公司范围变更时清空共享缓存:单据列表、分页与加载态。

View File

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

View File

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

View File

@ -164,11 +164,17 @@ function showAddModal() {
}
async function showEditModal(row) {
let order
try {
order = await outboundStore.fetchOutboundDetail(row.id)
} catch (error) {
ElMessage.error('加载出库单详情失败')
return
}
// 公司已切换:详情响应已作废(返回 null),放弃打开弹窗,也不提示失败。
if (!order) return
isEdit.value = true
editId.value = row.id
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
@ -182,9 +188,6 @@ async function showEditModal(row) {
})
: [{ material_id: null, materialSearchText: '', quantity: 1 }]
dialogVisible.value = true
} catch (error) {
ElMessage.error('加载出库单详情失败')
}
}
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
}
</script>

View File

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

View File

@ -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,6 +125,7 @@ async function save() {
: { code: form.code.trim(), name: form.name.trim(), status: form.status }
saving.value = true
try {
await runWithSaving(async () => {
if (formType.value === 'warehouse') {
if (isEdit.value) await warehousesApi.update(editingId.value, payload)
else await warehousesApi.create(payload)
@ -130,6 +134,7 @@ async function save() {
} else {
await warehousesApi.createLocation(selectedWarehouseId.value, payload)
}
})
ElMessage.success('保存成功')
// 关窗放在 await 成功之后:失败时弹窗留着,用户能改完重试
formVisible.value = false

View File

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

View File

@ -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 {
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()

View File

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

View File

@ -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))
</script>