diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js index 581f0344..70287966 100644 --- a/frontend/src/views/Materials.test.js +++ b/frontend/src/views/Materials.test.js @@ -26,6 +26,17 @@ vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) vi.mock('../composables/useOrganizationSwitchGuard', () => ({ useOrganizationSwitchGuard: vi.fn() })) + +// 图片上传等直接写请求走 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()) +}) vi.mock('../api/masterData', () => ({ materialCategoriesApi: { list: vi.fn() }, unitsApi: { list: vi.fn() }, @@ -556,3 +567,43 @@ describe('物料页无可用分类时的配置入口提示', () => { wrapper.unmount() }) }) + +describe('物料图片上传的保存互斥', () => { + beforeEach(() => { + materialCategoriesApi.list.mockResolvedValue({ data: categories }) + unitsApi.list.mockResolvedValue({ data: [] }) + codeRulesApi.next.mockReset() + codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) + materialsStore.createMaterial.mockReset().mockResolvedValue({ data: {} }) + vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) + }) + + // 上传绕开 axios、直接 fetch,是保存物料流程里的直接写请求。必须走保存互斥, + // 否则上传飞行期间组织 store 的 saving 仍为 false,能切公司,归属不明确。 + test('带图片保存时,上传请求走保存互斥', async () => { + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + await pickCategories(wrapper) + setupState(wrapper).form.name = '带图物料' + setupState(wrapper).imageInput = { files: [new File(['x'], 'a.png', { type: 'image/png' })] } + + const originalFetch = global.fetch + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { url: '/uploads/a.png' } }) + }) + global.fetch = fetchSpy + + try { + await setupState(wrapper).handleSave() + } finally { + global.fetch = originalFetch + } + + expect(fetchSpy).toHaveBeenCalled() + expect(materialsStore.createMaterial).toHaveBeenCalled() + expect(savingGuard.runWithSaving).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index af17e1aa..bdacf5b3 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -171,6 +171,7 @@ import { computed, ref, reactive, onMounted } from 'vue' import { useMaterialsStore } from '../stores/materials' import { useAuthStore } from '../stores/auth' import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard' +import { useSavingGuard } from '../composables/useSavingGuard' import BaseTable from '../components/business/BaseTable.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue' import AlertTip from '../components/business/AlertTip.vue' @@ -194,6 +195,9 @@ const MATERIAL_DATE_OPTIONS = { const materialsStore = useMaterialsStore() const authStore = useAuthStore() +// 图片上传是本页唯一直接发起的写请求(其余写请求都走 materialsStore 自身的保存互斥); +// 上传要用保存互斥包住,飞行期间禁止切换公司。 +const { runWithSaving } = useSavingGuard() const searchKeyword = ref('') const materialSearchText = ref('') @@ -421,14 +425,16 @@ async function handleSave() { uploadForm.append('image', file) // 这里绕开了 axios 拦截器,组织头得手工补上,否则图片上传会缺少 X-Organization-ID。 const organizationId = getOrganizationId() - const res = await fetch('/api/upload', { + // 上传是保存物料流程中的直接写请求:包进保存互斥,飞行期间禁止切换公司; + // runWithSaving 内部是 try/finally,上传失败也会复位,不会把 busy 永久卡住。 + const res = await runWithSaving(() => fetch('/api/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${authStore.token}`, ...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {}) }, body: uploadForm - }) + })) if (!res.ok) { ElMessage.error('图片上传失败') return