fix: 补齐详情加载的上下文校验与保存互斥
- 入出库详情加载提交前校验公司 ID 与上下文版本,旧公司响应静默丢弃 - 新增 runWithSaving 包裹器,仓库、伙伴、基础配置等直接调接口的页面纳入保存互斥 - 切换守卫卸载时仅撤销自己注册的那一份
This commit is contained in:
parent
3674076377
commit
89368ba3f8
|
|
@ -25,5 +25,11 @@ export function useOrganizationSwitchGuard(isDirty) {
|
|||
}
|
||||
|
||||
organizationStore.setOrganizationSwitchGuard(guard)
|
||||
onUnmounted(() => organizationStore.setOrganizationSwitchGuard(null))
|
||||
onUnmounted(() => {
|
||||
// 只有当前守卫仍是本组件注册的这一份时才撤销:若本页之后又被别的守卫页覆盖,
|
||||
// 先卸载的一页不能把后注册者的守卫清掉。
|
||||
if (organizationStore.getOrganizationSwitchGuard() === guard) {
|
||||
organizationStore.setOrganizationSwitchGuard(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
50
frontend/src/composables/useOrganizationSwitchGuard.test.js
Normal file
50
frontend/src/composables/useOrganizationSwitchGuard.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
23
frontend/src/composables/useSavingGuard.js
Normal file
23
frontend/src/composables/useSavingGuard.js
Normal 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 }
|
||||
}
|
||||
83
frontend/src/composables/useSavingGuard.test.js
Normal file
83
frontend/src/composables/useSavingGuard.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 公司范围变更时清空共享缓存:单据列表、分页与加载态。
|
||||
|
|
|
|||
|
|
@ -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: '张三' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 公司范围变更时清空共享缓存:单据列表、分页与加载态。
|
||||
|
|
|
|||
65
frontend/src/stores/outbound.test.js
Normal file
65
frontend/src/stores/outbound.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user