diff --git a/frontend/src/composables/useOrganizationSwitchGuard.js b/frontend/src/composables/useOrganizationSwitchGuard.js new file mode 100644 index 00000000..1e029d09 --- /dev/null +++ b/frontend/src/composables/useOrganizationSwitchGuard.js @@ -0,0 +1,29 @@ +// 页面级“切换公司前的未保存内容确认”复用点。 +// +// 设计约定 3:编辑中切换公司要提示“切换公司将放弃未保存内容”,用户取消则保持当前公司。 +// 页面把自己的“脏”判定传进来,本组合式函数向组织 store 注册一个守卫: +// 脏 → 弹确认框,确认返回 true(放行切换),取消返回 false(保持当前公司); +// 不脏 → 直接放行。 +// 组件卸载时自动撤销守卫,避免守卫指向已卸载页面的状态。 +import { onUnmounted } from 'vue' +import { ElMessageBox } from 'element-plus' +import { useOrganizationStore } from '../stores/organization' + +export function useOrganizationSwitchGuard(isDirty) { + const organizationStore = useOrganizationStore() + + const guard = () => { + if (typeof isDirty !== 'function' || !isDirty()) return true + return ElMessageBox.confirm('切换公司将放弃未保存内容', '提示', { + confirmButtonText: '放弃并切换', + cancelButtonText: '取消', + type: 'warning' + }) + .then(() => true) + // 点取消或按 ESC 关掉都会 reject:视为用户取消切换。 + .catch(() => false) + } + + organizationStore.setOrganizationSwitchGuard(guard) + onUnmounted(() => organizationStore.setOrganizationSwitchGuard(null)) +} diff --git a/frontend/src/stores/application.js b/frontend/src/stores/application.js index 1de2b57c..c5c2a24c 100644 --- a/frontend/src/stores/application.js +++ b/frontend/src/stores/application.js @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { applicationApi } from '../api/application' +import { useOrganizationStore } from './organization' +import { + captureOrganizationContext, + isCurrentOrganizationContext, + shouldDiscardResponse +} from '../utils/organizationContext' export const useApplicationStore = defineStore('application', () => { const myApplications = ref([]) @@ -13,35 +19,63 @@ export const useApplicationStore = defineStore('application', () => { async function fetchMyApplications(status = '', pageNum = 1) { filters.value = { status } + // 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() loading.value = true try { const res = await applicationApi.getMy(status, pageNum, myPage.value.pageSize) + if (!isCurrentOrganizationContext(snapshot)) return myApplications.value = res.data?.items || res.data || [] myPage.value.total = res.data?.total || myApplications.value.length myPage.value.currentPage = pageNum myPage.value.totalPages = Math.ceil(myPage.value.total / myPage.value.pageSize) || 1 + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } async function createApplication(data) { - const res = await applicationApi.create(data) - // 这处刻意清空筛选并回到第 1 页,和上面「沿用筛选」的规矩相反:刚提交的是 - // 待审批单,若沿用「已通过」之类的筛选就看不见它,用户会以为没提交成功。 - // 清空的是 store 这份状态,页面那份(下拉框)由 Application.vue 同步重置。 - await fetchMyApplications('', 1) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await applicationApi.create(data) + // 这处刻意清空筛选并回到第 1 页,和上面「沿用筛选」的规矩相反:刚提交的是 + // 待审批单,若沿用「已通过」之类的筛选就看不见它,用户会以为没提交成功。 + // 清空的是 store 这份状态,页面那份(下拉框)由 Application.vue 同步重置。 + await fetchMyApplications('', 1) + return res + } finally { + organization.endSaving() + } } async function cancelApplication(id) { - const res = await applicationApi.cancel(id) - await fetchMyApplications(filters.value.status, myPage.value.currentPage) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await applicationApi.cancel(id) + await fetchMyApplications(filters.value.status, myPage.value.currentPage) + return res + } finally { + organization.endSaving() + } } + // 公司范围变更时清空共享缓存:我的申请、筛选、分页与加载态。 + function resetForOrganization() { + myApplications.value = [] + loading.value = false + myPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + filters.value = { status: '' } + } + + useOrganizationStore().registerOrganizationReset(resetForOrganization) + return { myApplications, loading, myPage, - fetchMyApplications, createApplication, cancelApplication + fetchMyApplications, createApplication, cancelApplication, resetForOrganization } }) diff --git a/frontend/src/stores/application.test.js b/frontend/src/stores/application.test.js index 535f6ef4..fed1bb22 100644 --- a/frontend/src/stores/application.test.js +++ b/frontend/src/stores/application.test.js @@ -61,3 +61,30 @@ describe('application store 的筛选保持', () => { expect(applicationApi.getMy).toHaveBeenCalledWith('', 1, 20) }) }) + +describe('application store 的公司切换重置', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + applicationApi.getMy.mockResolvedValue({ data: { items: [], total: 0 } }) + }) + + it('resetForOrganization 清空我的申请、筛选与分页', async () => { + const store = useApplicationStore() + store.myApplications = [{ id: 1 }] + store.myPage = { currentPage: 3, pageSize: 20, total: 60, totalPages: 3 } + store.loading = true + await store.fetchMyApplications('approved', 2) + + store.resetForOrganization() + + expect(store.myApplications).toEqual([]) + expect(store.myPage).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + expect(store.loading).toBe(false) + + // 筛选清空:切换公司后的查询不带旧公司选的筛选值。 + applicationApi.getMy.mockClear() + await store.fetchMyApplications() + expect(applicationApi.getMy).toHaveBeenCalledWith('', 1, 20) + }) +}) diff --git a/frontend/src/stores/approval.js b/frontend/src/stores/approval.js index 303fa2f5..93af61ba 100644 --- a/frontend/src/stores/approval.js +++ b/frontend/src/stores/approval.js @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { applicationApi } from '../api/application' +import { useOrganizationStore } from './organization' +import { + captureOrganizationContext, + isCurrentOrganizationContext, + shouldDiscardResponse +} from '../utils/organizationContext' export const useApprovalStore = defineStore('approval', () => { const applications = ref([]) @@ -13,32 +19,60 @@ export const useApprovalStore = defineStore('approval', () => { async function fetchPendingApplications(status = '', pageNum = 1) { filters.value = { status } + // 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() loading.value = true try { const res = await applicationApi.getAll(status, pageNum, page.value.pageSize) + if (!isCurrentOrganizationContext(snapshot)) return applications.value = res.data?.items || res.data || [] page.value.total = res.data?.total || applications.value.length page.value.currentPage = pageNum page.value.totalPages = Math.ceil(page.value.total / page.value.pageSize) || 1 + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } async function approveApplication(id, data = {}) { - const res = await applicationApi.approve(id, data) - await fetchPendingApplications(filters.value.status, page.value.currentPage) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await applicationApi.approve(id, data) + await fetchPendingApplications(filters.value.status, page.value.currentPage) + return res + } finally { + organization.endSaving() + } } async function rejectApplication(id, data = {}) { - const res = await applicationApi.reject(id, data) - await fetchPendingApplications(filters.value.status, page.value.currentPage) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await applicationApi.reject(id, data) + await fetchPendingApplications(filters.value.status, page.value.currentPage) + return res + } finally { + organization.endSaving() + } } + // 公司范围变更时清空共享缓存:待审批列表、筛选、分页与加载态。 + function resetForOrganization() { + applications.value = [] + loading.value = false + page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + filters.value = { status: '' } + } + + useOrganizationStore().registerOrganizationReset(resetForOrganization) + return { applications, loading, page, - fetchPendingApplications, approveApplication, rejectApplication + fetchPendingApplications, approveApplication, rejectApplication, resetForOrganization } }) diff --git a/frontend/src/stores/approval.test.js b/frontend/src/stores/approval.test.js index bfb3b88d..dd5431f9 100644 --- a/frontend/src/stores/approval.test.js +++ b/frontend/src/stores/approval.test.js @@ -68,3 +68,29 @@ describe('approval store 的筛选保持', () => { expect(applicationApi.getAll).toHaveBeenCalledWith('approved', 3, 20) }) }) + +describe('approval store 的公司切换重置', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + applicationApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + }) + + it('resetForOrganization 清空待审批列表、筛选与分页', async () => { + const store = useApprovalStore() + store.applications = [{ id: 1 }] + store.page = { currentPage: 3, pageSize: 20, total: 60, totalPages: 3 } + store.loading = true + await store.fetchPendingApplications('pending', 2) + + store.resetForOrganization() + + expect(store.applications).toEqual([]) + expect(store.page).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + expect(store.loading).toBe(false) + + applicationApi.getAll.mockClear() + await store.fetchPendingApplications() + expect(applicationApi.getAll).toHaveBeenCalledWith('', 1, 20) + }) +}) diff --git a/frontend/src/stores/inbound.js b/frontend/src/stores/inbound.js index 0e88f438..9b1e20bf 100644 --- a/frontend/src/stores/inbound.js +++ b/frontend/src/stores/inbound.js @@ -2,6 +2,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { inboundApi } from '../api/inbound' import { useStockStore } from './stock' +import { useOrganizationStore } from './organization' +import { + captureOrganizationContext, + isCurrentOrganizationContext, + shouldDiscardResponse +} from '../utils/organizationContext' export const useInboundStore = defineStore('inbound', () => { const inboundList = ref([]) @@ -9,17 +15,23 @@ export const useInboundStore = defineStore('inbound', () => { const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) async function fetchInboundList(pageNum = 1) { + // 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() loading.value = true try { const pageSize = page.value.pageSize const offset = (pageNum - 1) * pageSize const res = await inboundApi.getAll({ limit: pageSize, offset }) + if (!isCurrentOrganizationContext(snapshot)) return inboundList.value = res.data?.items || res.data || [] page.value.total = res.data?.total || inboundList.value.length page.value.currentPage = pageNum page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1 + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } @@ -30,24 +42,42 @@ export const useInboundStore = defineStore('inbound', () => { } async function createInbound(data) { - const res = await inboundApi.create(data) - invalidateStock() - await fetchInboundList(1) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await inboundApi.create(data) + invalidateStock() + await fetchInboundList(1) + return res + } finally { + organization.endSaving() + } } async function updateInbound(id, data) { - const res = await inboundApi.update(id, data) - invalidateStock() - await fetchInboundList(page.value.currentPage) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await inboundApi.update(id, data) + invalidateStock() + await fetchInboundList(page.value.currentPage) + return res + } finally { + organization.endSaving() + } } async function deleteInbound(id) { - const res = await inboundApi.delete(id) - invalidateStock() - await fetchInboundList(page.value.currentPage) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await inboundApi.delete(id) + invalidateStock() + await fetchInboundList(page.value.currentPage) + return res + } finally { + organization.endSaving() + } } async function fetchInboundDetail(id) { @@ -55,8 +85,18 @@ export const useInboundStore = defineStore('inbound', () => { return res.data || res } + // 公司范围变更时清空共享缓存:单据列表、分页与加载态。 + function resetForOrganization() { + inboundList.value = [] + loading.value = false + page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + } + + useOrganizationStore().registerOrganizationReset(resetForOrganization) + return { inboundList, loading, page, - fetchInboundList, createInbound, updateInbound, deleteInbound, fetchInboundDetail + fetchInboundList, createInbound, updateInbound, deleteInbound, fetchInboundDetail, + resetForOrganization } }) diff --git a/frontend/src/stores/inbound.test.js b/frontend/src/stores/inbound.test.js index df1a907a..e4ba9f3c 100644 --- a/frontend/src/stores/inbound.test.js +++ b/frontend/src/stores/inbound.test.js @@ -42,3 +42,23 @@ describe('inbound store details', () => { ]) }) }) + +describe('inbound store 的公司切换重置', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('resetForOrganization 清空单据列表、分页与加载态', () => { + const store = useInboundStore() + store.inboundList = [{ id: 1, inbound_no: 'IN-001' }] + store.page = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 } + store.loading = true + + store.resetForOrganization() + + expect(store.inboundList).toEqual([]) + expect(store.page).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + expect(store.loading).toBe(false) + }) +}) diff --git a/frontend/src/stores/logs.js b/frontend/src/stores/logs.js index 7fb0c3f5..e3c01a02 100644 --- a/frontend/src/stores/logs.js +++ b/frontend/src/stores/logs.js @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { logsApi } from '../api/logs' +import { + captureOrganizationContext, + isCurrentOrganizationContext, + shouldDiscardResponse +} from '../utils/organizationContext' +import { useOrganizationStore } from './organization' export const useLogsStore = defineStore('logs', () => { const logs = ref([]) @@ -9,6 +15,8 @@ export const useLogsStore = defineStore('logs', () => { const filters = ref({ operationType: '', startDate: '', endDate: '' }) async function fetchLogs(pageNum = 1) { + // 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() loading.value = true try { const pageSize = page.value.pageSize @@ -29,12 +37,17 @@ export const useLogsStore = defineStore('logs', () => { } } const res = await logsApi.getAll(params) + // 公司已切换:丢弃旧公司的日志,绝不覆盖新公司的列表。 + if (!isCurrentOrganizationContext(snapshot)) return logs.value = res.data?.items || res.data || [] page.value.total = res.data?.total || logs.value.length page.value.currentPage = pageNum page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1 + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } @@ -46,8 +59,18 @@ export const useLogsStore = defineStore('logs', () => { filters.value = { operationType: '', startDate: '', endDate: '' } } + // 公司范围变更时清空共享缓存:日志、筛选、分页与加载态。 + function resetForOrganization() { + logs.value = [] + loading.value = false + page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + filters.value = { operationType: '', startDate: '', endDate: '' } + } + + useOrganizationStore().registerOrganizationReset(resetForOrganization) + return { logs, loading, page, filters, - fetchLogs, setFilters, resetFilters + fetchLogs, setFilters, resetFilters, resetForOrganization } }) diff --git a/frontend/src/stores/logs.test.js b/frontend/src/stores/logs.test.js index 5101651f..a3ffa42c 100644 --- a/frontend/src/stores/logs.test.js +++ b/frontend/src/stores/logs.test.js @@ -64,3 +64,30 @@ describe('logs store 查询参数', () => { expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 }) }) }) + +describe('logs store 的公司切换重置', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + logsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + }) + + it('resetForOrganization 清空日志、筛选与分页', async () => { + const store = useLogsStore() + store.logs = [{ id: 1 }] + store.page = { currentPage: 3, pageSize: 20, total: 60, totalPages: 3 } + store.loading = true + store.setFilters({ operationType: 'CREATE', materialId: 7 }) + + store.resetForOrganization() + + expect(store.logs).toEqual([]) + expect(store.page).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + expect(store.loading).toBe(false) + + // 筛选一并清空:切换公司后的查询不带旧公司选的筛选项。 + logsApi.getAll.mockClear() + await store.fetchLogs() + expect(logsApi.getAll).toHaveBeenCalledWith({ limit: 20, offset: 0 }) + }) +}) diff --git a/frontend/src/stores/materials.js b/frontend/src/stores/materials.js index bbbefdf1..9435f0db 100644 --- a/frontend/src/stores/materials.js +++ b/frontend/src/stores/materials.js @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { materialsApi } from '../api/materials' +import { + captureOrganizationContext, + isCurrentOrganizationContext, + shouldDiscardResponse +} from '../utils/organizationContext' +import { useOrganizationStore } from './organization' export const useMaterialsStore = defineStore('materials', () => { const materials = ref([]) @@ -36,22 +42,32 @@ export const useMaterialsStore = defineStore('materials', () => { async function fetchMaterials(page = 1, search = '', status = 'all') { filters.value = { search, status } + // 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() loading.value = true try { const pageSize = pagination.value.pageSize const offset = (page - 1) * pageSize const res = await materialsApi.getAll({ search, status, limit: pageSize, offset }) + // 公司已切换:丢弃旧公司的响应,绝不覆盖新公司的列表。 + if (!isCurrentOrganizationContext(snapshot)) return materials.value = res.data?.items || res.data || [] pagination.value.total = res.data?.total || materials.value.length pagination.value.currentPage = page pagination.value.totalPages = Math.ceil(pagination.value.total / pageSize) || 1 + } catch (error) { + // 切换公司导致的作废/取消不是失败:静默丢弃,不显示为失败。 + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + // 旧请求作废时不清理 loading,避免覆盖新公司请求正在展示的加载态。 + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } // 接口单页上限是 100,循环翻页把符合条件的物料全部取回来。 async function fetchOptionsByStatus(status) { + const snapshot = captureOrganizationContext() const pageSize = 100 let offset = 0 let total = 0 @@ -59,6 +75,8 @@ export const useMaterialsStore = defineStore('materials', () => { do { const res = await materialsApi.getAll({ status, limit: pageSize, offset }) + // 公司已切换:返回空数组,绝不让半份旧公司选项被提交到新公司。 + if (!isCurrentOrganizationContext(snapshot)) return [] const items = res.data?.items || res.data || [] options.push(...items) total = res.data?.total ?? options.length @@ -69,51 +87,96 @@ export const useMaterialsStore = defineStore('materials', () => { } async function fetchMaterialOptions() { - materialOptions.value = await fetchOptionsByStatus('active') + const snapshot = captureOrganizationContext() + const options = await fetchOptionsByStatus('active') + if (!isCurrentOrganizationContext(snapshot)) return + materialOptions.value = options } async function fetchHistoricalMaterialOptions() { - historicalMaterialOptions.value = await fetchOptionsByStatus('all') + const snapshot = captureOrganizationContext() + const options = await fetchOptionsByStatus('all') + if (!isCurrentOrganizationContext(snapshot)) return + historicalMaterialOptions.value = options } async function refreshMaterialOptions() { await Promise.all([fetchMaterialOptions(), fetchHistoricalMaterialOptions()]) } + // 公司范围变更时清空共享缓存:列表、两份选项、筛选、分页与加载态。 + function resetForOrganization() { + materials.value = [] + materialOptions.value = [] + historicalMaterialOptions.value = [] + loading.value = false + pagination.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + filters.value = { search: '', status: 'all' } + } + + // 把本 store 的清理注册给组织 store:切换公司/登出时由它统一调用。 + useOrganizationStore().registerOrganizationReset(resetForOrganization) + async function createMaterial(data) { - const res = await materialsApi.create(data) - // 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的物料不一定 - // 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。列表按 id 倒序,回到 - // 第 1 页正好能看见它。清空的是 store 这份状态,页面那份(搜索框)由 Materials.vue - // 同步重置。 - await fetchMaterials(1, '', 'all') - return res + const organization = useOrganizationStore() + // 写请求飞行中把组织上下文标记为“忙”,禁止切换公司,避免归属不明。 + organization.beginSaving() + try { + const res = await materialsApi.create(data) + // 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的物料不一定 + // 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。列表按 id 倒序,回到 + // 第 1 页正好能看见它。清空的是 store 这份状态,页面那份(搜索框)由 Materials.vue + // 同步重置。 + await fetchMaterials(1, '', 'all') + return res + } finally { + organization.endSaving() + } } async function updateMaterial(id, data) { - const res = await materialsApi.update(id, data) - await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await materialsApi.update(id, data) + await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) + return res + } finally { + organization.endSaving() + } } async function obsoleteMaterial(id) { - const res = await materialsApi.obsolete(id) - await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) - // 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新 - await refreshMaterialOptions() - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await materialsApi.obsolete(id) + await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) + // 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新 + await refreshMaterialOptions() + return res + } finally { + organization.endSaving() + } } async function restoreMaterial(id) { - const res = await materialsApi.restore(id) - await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) - await refreshMaterialOptions() - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await materialsApi.restore(id) + await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) + await refreshMaterialOptions() + return res + } finally { + organization.endSaving() + } } return { materials, materialOptions, historicalMaterialOptions, loading, pagination, categoryMap, getCategoryDisplay, fetchMaterials, fetchMaterialOptions, fetchHistoricalMaterialOptions, + resetForOrganization, createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial } }) diff --git a/frontend/src/stores/materials.test.js b/frontend/src/stores/materials.test.js index 02a22a3a..e285da31 100644 --- a/frontend/src/stores/materials.test.js +++ b/frontend/src/stores/materials.test.js @@ -2,6 +2,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { materialsApi } from '../api/materials' import { useMaterialsStore } from './materials' +import { commitOrganizationId, resetOrganizationContext } from '../utils/organizationContext' vi.mock('../api/materials', () => ({ materialsApi: { @@ -195,3 +196,91 @@ describe('materials store 的筛选保持', () => { }) }) }) + +describe('materials store 的公司切换重置与竞态', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + resetOrganizationContext() + commitOrganizationId(1) + materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + }) + + // resetForOrganization 清空共享缓存:列表、两份选项、分页与加载态。 + it('resetForOrganization 清空列表、选项与分页', async () => { + const store = useMaterialsStore() + store.materials = [{ id: 1, material_code: 'A-001' }] + store.materialOptions = [{ id: 1 }] + store.historicalMaterialOptions = [{ id: 2 }] + store.pagination = { currentPage: 3, pageSize: 50, total: 120, totalPages: 3 } + store.loading = true + + store.resetForOrganization() + + expect(store.materials).toEqual([]) + expect(store.materialOptions).toEqual([]) + expect(store.historicalMaterialOptions).toEqual([]) + expect(store.pagination).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + expect(store.loading).toBe(false) + }) + + // A 公司的延迟响应在切到 B 之后返回:用捕获的 {organizationId, version} 判定作废, + // 绝不把 A 的列表覆盖到 B 上。 + it('A→B 时 A 的延迟响应不覆盖 B', async () => { + let resolveA + materialsApi.getAll.mockImplementationOnce( + () => new Promise(resolve => { resolveA = resolve }) + ) + const store = useMaterialsStore() + + const pendingA = store.fetchMaterials(1, 'A') + // 切到 B:公司 ID 变为 2,版本递增。 + commitOrganizationId(2) + + resolveA({ data: { items: [{ id: 99, material_code: 'A-OLD' }], total: 1 } }) + await pendingA + + expect(store.materials).toEqual([]) + }) + + // A→B→A 时仅比较公司 ID 不够:第一次 A 的响应版本已过期,仍要丢弃。 + it('A→B→A 时第一次 A 的延迟响应仍然丢弃', async () => { + let resolveA + materialsApi.getAll.mockImplementationOnce( + () => new Promise(resolve => { resolveA = resolve }) + ) + const store = useMaterialsStore() + + const pendingA = store.fetchMaterials(1, 'first') + commitOrganizationId(2) + commitOrganizationId(1) // 又切回 A + + resolveA({ data: { items: [{ id: 88, material_code: 'STALE' }], total: 1 } }) + await pendingA + + expect(store.materials).toEqual([]) + }) + + // 旧请求的 finally 不得清掉新请求正在展示的 loading。 + it('旧请求返回时若新请求仍在飞行,不覆盖新请求的 loading', async () => { + let resolveA + let resolveB + materialsApi.getAll + .mockImplementationOnce(() => new Promise(resolve => { resolveA = resolve })) + .mockImplementationOnce(() => new Promise(resolve => { resolveB = resolve })) + const store = useMaterialsStore() + + const pendingA = store.fetchMaterials(1, 'A') + commitOrganizationId(2) + const pendingB = store.fetchMaterials(1, 'B') + + resolveA({ data: { items: [], total: 0 } }) + await pendingA + // B 仍在飞行:loading 必须保持 true(A 的 finally 不该清掉它) + expect(store.loading).toBe(true) + + resolveB({ data: { items: [], total: 0 } }) + await pendingB + expect(store.loading).toBe(false) + }) +}) diff --git a/frontend/src/stores/organization.js b/frontend/src/stores/organization.js index 91461705..3b79dae3 100644 --- a/frontend/src/stores/organization.js +++ b/frontend/src/stores/organization.js @@ -64,6 +64,32 @@ export const useOrganizationStore = defineStore('organization', () => { const saving = ref(false) // 任何会导致“公司上下文变更或归属不明”的操作进行中都算忙:切换中或保存中。 const busy = computed(() => switching.value || saving.value) + // 组织范围变更(切换公司、进入空状态、登出)时,各业务 store 注册的清理回调统一在此调用。 + // 业务 store 在自己的 setup 里 registerOrganizationReset(resetForOrganization), + // 由组织 store 充当协调者,避免“页面各自记得手动清理”造成遗漏,也避免业务 store 反向导入组织 store 成环。 + const organizationResetters = new Set() + + function registerOrganizationReset(resetter) { + if (typeof resetter !== 'function') return () => {} + organizationResetters.add(resetter) + return () => organizationResetters.delete(resetter) + } + + // 复制一份再遍历:回调内部若再注册/注销也不会破坏本次遍历。 + function resetOrganizationScopedStores() { + for (const resetter of [...organizationResetters]) { + resetter() + } + } + + // 切换公司前的“未保存内容”确认钩子:由当前页面注册,返回 false 表示用户取消切换。 + // 未注册时行为与原来完全一致(直接切换),不影响已有语义。 + let switchGuard = null + + function setOrganizationSwitchGuard(guard) { + switchGuard = typeof guard === 'function' ? guard : null + } + // 是否已完成一次公司列表加载,路由屏障据此决定是否需要先初始化。 const initialized = ref(false) // 最近一次 initialize 是否因临时故障失败:用于把“加载失败”与“确实没有可用公司”区分开, @@ -99,6 +125,8 @@ export const useOrganizationStore = defineStore('organization', () => { applyWarehouseContext(data?.warehouse || null) syncVersion() if (organizationId) writeStoredOrganizationId(organizationId) + // 公司已确定:先清空各业务 store 的共享缓存(列表/详情/筛选/分页),页面随后重新加载。 + resetOrganizationScopedStores() // 当前公司已确定:关闭旧公司连接并为新公司取得票据重连。connect 不阻塞、不抛错。 organizationSocket.connect() } @@ -110,6 +138,8 @@ export const useOrganizationStore = defineStore('organization', () => { currentOrganizationId.value = null applyWarehouseContext(null) syncVersion() + // 空状态同样清空业务缓存,避免没有可用公司时页面还留着上一个公司(或上一标签页)的数据。 + resetOrganizationScopedStores() // 无可用公司:不建立实时连接。 organizationSocket.close() } @@ -182,8 +212,14 @@ export const useOrganizationStore = defineStore('organization', () => { // 保存飞行中切换会让保存的响应被版本判定作废并被静默吞掉,用户既看不到成功也看不到失败。 if (busy.value) return if (!organizationId || organizationId === currentOrganizationId.value) return + // 切换前先进入“切换中”,既覆盖确认框停留期间的重入,也让切换器在确认期间保持不可操作。 switching.value = true try { + // 未保存内容确认:页面注册的守卫返回 false 表示用户取消,保持当前公司不变。 + if (switchGuard) { + const allowed = await switchGuard(organizationId) + if (!allowed) return + } const res = await authApi.switchOrganizationContext( organizationId, currentOrganizationId.value ?? 0 @@ -209,6 +245,9 @@ export const useOrganizationStore = defineStore('organization', () => { saving.value = false initialized.value = false loadError.value = false + // 账号变化也不能把上一个账号(或上一个公司)的业务缓存留给下个会话。 + resetOrganizationScopedStores() + switchGuard = null syncVersion() } @@ -216,6 +255,7 @@ export const useOrganizationStore = defineStore('organization', () => { organizations, currentOrganizationId, currentWarehouseId, currentLocationId, currentWarehouse, contextVersion, switching, saving, busy, initialized, loadError, beginSaving, endSaving, + registerOrganizationReset, setOrganizationSwitchGuard, initialize, switchOrganization, reset } }) diff --git a/frontend/src/stores/organization.test.js b/frontend/src/stores/organization.test.js index efc03784..fd3cb991 100644 --- a/frontend/src/stores/organization.test.js +++ b/frontend/src/stores/organization.test.js @@ -370,3 +370,83 @@ describe('公司级实时连接接线', () => { expect(socketMock.close).toHaveBeenCalled() }) }) + +// 组织范围变更时的业务 store 清理注册表,以及切换前的“未保存内容”确认守卫。 +describe('公司范围变更的清理注册与切换守卫', () => { + beforeEach(() => { + localStorage.clear() + sessionStorage.clear() + resetOrganizationContext() + setActivePinia(createPinia()) + authApi.listOrganizations.mockReset() + authApi.switchOrganizationContext.mockReset() + socketMock.connect.mockReset() + socketMock.close.mockReset() + }) + + async function enterCompany1(store) { + loginAs(1) + authApi.listOrganizations.mockResolvedValue(orgList([{ id: 1 }, { id: 2 }], 1)) + authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(1)) + await store.initialize() + } + + test('切换公司时调用注册的重置回调', async () => { + const store = useOrganizationStore() + await enterCompany1(store) + const reset = vi.fn() + store.registerOrganizationReset(reset) + + authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2)) + await store.switchOrganization(2) + + expect(reset).toHaveBeenCalledTimes(1) + expect(store.currentOrganizationId).toBe(2) + }) + + test('登出(reset)时也调用注册的重置回调', async () => { + const store = useOrganizationStore() + await enterCompany1(store) + const reset = vi.fn() + store.registerOrganizationReset(reset) + + store.reset() + + expect(reset).toHaveBeenCalledTimes(1) + }) + + test('守卫返回 false 时取消切换,保持当前公司且不发起校验', async () => { + const store = useOrganizationStore() + await enterCompany1(store) + authApi.switchOrganizationContext.mockClear() + + store.setOrganizationSwitchGuard(async () => false) + await store.switchOrganization(2) + + expect(store.currentOrganizationId).toBe(1) + expect(authApi.switchOrganizationContext).not.toHaveBeenCalled() + // 取消后不能把“切换中”标志卡住 + expect(store.switching).toBe(false) + }) + + test('守卫返回 true 时按原流程切换', async () => { + const store = useOrganizationStore() + await enterCompany1(store) + + store.setOrganizationSwitchGuard(async () => true) + authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2)) + await store.switchOrganization(2) + + expect(store.currentOrganizationId).toBe(2) + }) + + test('没有注册守卫时行为不变,直接切换', async () => { + const store = useOrganizationStore() + await enterCompany1(store) + + authApi.switchOrganizationContext.mockResolvedValueOnce(switchResult(2)) + await store.switchOrganization(2) + + expect(store.currentOrganizationId).toBe(2) + }) +}) diff --git a/frontend/src/stores/outbound.js b/frontend/src/stores/outbound.js index 66ea64e2..40b9bd41 100644 --- a/frontend/src/stores/outbound.js +++ b/frontend/src/stores/outbound.js @@ -2,6 +2,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { outboundApi } from '../api/outbound' import { useStockStore } from './stock' +import { useOrganizationStore } from './organization' +import { + captureOrganizationContext, + isCurrentOrganizationContext, + shouldDiscardResponse +} from '../utils/organizationContext' export const useOutboundStore = defineStore('outbound', () => { const outboundList = ref([]) @@ -9,17 +15,23 @@ export const useOutboundStore = defineStore('outbound', () => { const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) async function fetchOutboundList(pageNum = 1) { + // 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() loading.value = true try { const pageSize = page.value.pageSize const offset = (pageNum - 1) * pageSize const res = await outboundApi.getAll({ limit: pageSize, offset }) + if (!isCurrentOrganizationContext(snapshot)) return outboundList.value = res.data?.items || res.data || [] page.value.total = res.data?.total || outboundList.value.length page.value.currentPage = pageNum page.value.totalPages = Math.ceil(page.value.total / pageSize) || 1 + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } @@ -30,24 +42,42 @@ export const useOutboundStore = defineStore('outbound', () => { } async function createOutbound(data) { - const res = await outboundApi.create(data) - invalidateStock() - await fetchOutboundList(1) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await outboundApi.create(data) + invalidateStock() + await fetchOutboundList(1) + return res + } finally { + organization.endSaving() + } } async function updateOutbound(id, data) { - const res = await outboundApi.update(id, data) - invalidateStock() - await fetchOutboundList(page.value.currentPage) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await outboundApi.update(id, data) + invalidateStock() + await fetchOutboundList(page.value.currentPage) + return res + } finally { + organization.endSaving() + } } async function deleteOutbound(id) { - const res = await outboundApi.delete(id) - invalidateStock() - await fetchOutboundList(page.value.currentPage) - return res + const organization = useOrganizationStore() + organization.beginSaving() + try { + const res = await outboundApi.delete(id) + invalidateStock() + await fetchOutboundList(page.value.currentPage) + return res + } finally { + organization.endSaving() + } } async function fetchOutboundDetail(id) { @@ -55,8 +85,18 @@ export const useOutboundStore = defineStore('outbound', () => { return res.data || res } + // 公司范围变更时清空共享缓存:单据列表、分页与加载态。 + function resetForOrganization() { + outboundList.value = [] + loading.value = false + page.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + } + + useOrganizationStore().registerOrganizationReset(resetForOrganization) + return { outboundList, loading, page, - fetchOutboundList, createOutbound, updateOutbound, deleteOutbound, fetchOutboundDetail + fetchOutboundList, createOutbound, updateOutbound, deleteOutbound, fetchOutboundDetail, + resetForOrganization } }) diff --git a/frontend/src/stores/stock.js b/frontend/src/stores/stock.js index ea9b6a8a..50c4e848 100644 --- a/frontend/src/stores/stock.js +++ b/frontend/src/stores/stock.js @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { stockApi } from '../api/stock' +import { + captureOrganizationContext, + isCurrentOrganizationContext, + shouldDiscardResponse +} from '../utils/organizationContext' +import { useOrganizationStore } from './organization' export const useStockStore = defineStore('stock', () => { const stockList = ref([]) @@ -20,27 +26,39 @@ export const useStockStore = defineStore('stock', () => { async function fetchStock(page = 1, search = '', category = '') { stockFilters.value = { search, category } + // 捕获发起时的公司上下文快照:响应回来时用它判断是否已被切换作废。 + const snapshot = captureOrganizationContext() loading.value = true try { const pageSize = stockPage.value.pageSize const offset = (page - 1) * pageSize const res = await stockApi.getAll({ limit: pageSize, offset, search, category }) + // 公司已切换:丢弃旧公司的库存,绝不覆盖新公司的列表。 + if (!isCurrentOrganizationContext(snapshot)) return stockList.value = res.data?.items || res.data || [] stockPage.value.total = res.data?.total || 0 stockPage.value.currentPage = page + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } async function fetchAllStock() { + const snapshot = captureOrganizationContext() loading.value = true try { const res = await stockApi.getAll({ limit: 1000, offset: 0 }) + if (!isCurrentOrganizationContext(snapshot)) return stockList.value = res.data?.items || res.data || [] stockPage.value.total = res.data?.total || 0 + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } @@ -50,8 +68,11 @@ export const useStockStore = defineStore('stock', () => { if (stockCache.value[materialId] !== undefined) { return stockCache.value[materialId] } + const snapshot = captureOrganizationContext() try { const res = await stockApi.getById(materialId) + // 公司已切换:不把旧公司的库存写进新公司的缓存。 + if (!isCurrentOrganizationContext(snapshot)) return null const detail = res.data ?? null stockCache.value[materialId] = detail return detail @@ -76,20 +97,26 @@ export const useStockStore = defineStore('stock', () => { } async function fetchStockWarning(page = 1) { + const snapshot = captureOrganizationContext() loading.value = true try { const pageSize = warningPage.value.pageSize const offset = (page - 1) * pageSize const res = await stockApi.getWarning({ limit: pageSize, offset }) + if (!isCurrentOrganizationContext(snapshot)) return warningList.value = res.data?.items || res.data || [] warningPage.value.total = res.data?.total || 0 warningPage.value.currentPage = page + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } async function fetchStockLogs(materialId, page = 1) { + const snapshot = captureOrganizationContext() loading.value = true try { const pageSize = logsPage.value.pageSize @@ -97,29 +124,65 @@ export const useStockStore = defineStore('stock', () => { const res = materialId ? await stockApi.getLogs(materialId, { limit: pageSize, offset }) : await stockApi.getAllLogs({ limit: pageSize, offset }) + if (!isCurrentOrganizationContext(snapshot)) return logs.value = res.data?.items || res.data || [] logsPage.value.total = res.data?.total || 0 logsPage.value.currentPage = page + } catch (error) { + if (shouldDiscardResponse(error)) return + throw error } finally { - loading.value = false + if (isCurrentOrganizationContext(snapshot)) loading.value = false } } async function updateWarningStock(materialId, warningStock, enabled = true) { - await stockApi.updateWarning(materialId, warningStock, enabled) - // 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。 - delete stockCache.value[materialId] + const organization = useOrganizationStore() + // 写请求飞行中把组织上下文标记为“忙”,禁止切换公司,避免归属不明。 + organization.beginSaving() + try { + await stockApi.updateWarning(materialId, warningStock, enabled) + // 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。 + delete stockCache.value[materialId] + await Promise.all([ + fetchStock(stockPage.value.currentPage, stockFilters.value.search, stockFilters.value.category), + fetchStockWarning(warningPage.value.currentPage) + ]) + } finally { + organization.endSaving() + } + } + + // 实时库存事件到达时由 Stock 页面调用:沿用当前筛选与页码刷新列表与预警, + // 不退回全量,也不把用户甩回第一页。 + async function refreshStock() { await Promise.all([ fetchStock(stockPage.value.currentPage, stockFilters.value.search, stockFilters.value.category), fetchStockWarning(warningPage.value.currentPage) ]) } + // 公司范围变更时清空共享缓存:库存、预警、流水、单物料缓存、筛选、分页与加载态。 + function resetForOrganization() { + stockList.value = [] + warningList.value = [] + logs.value = [] + loading.value = false + stockPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + warningPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + logsPage.value = { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 } + stockFilters.value = { search: '', category: '' } + stockCache.value = {} + } + + useOrganizationStore().registerOrganizationReset(resetForOrganization) + return { stockList, warningList, logs, loading, - stockPage, warningPage, logsPage, + stockPage, warningPage, logsPage, stockFilters, fetchStock, fetchAllStock, fetchStockByMaterialId, fetchStockDetailByMaterialId, - fetchStockWarning, fetchStockLogs, clearStockCache, + fetchStockWarning, fetchStockLogs, clearStockCache, refreshStock, + resetForOrganization, updateWarningStock } }) diff --git a/frontend/src/stores/stock.test.js b/frontend/src/stores/stock.test.js index a157af73..c706570e 100644 --- a/frontend/src/stores/stock.test.js +++ b/frontend/src/stores/stock.test.js @@ -123,3 +123,41 @@ describe('stock store warning settings', () => { expect(await stockStore.fetchStockByMaterialId(7)).toBe('500') }) }) + +describe('stock store 的公司切换重置', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + stockApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + stockApi.getWarning.mockResolvedValue({ data: { items: [], total: 0 } }) + }) + + it('resetForOrganization 清空库存、预警、流水、筛选、分页与单物料缓存', async () => { + const store = useStockStore() + store.stockList = [{ material_id: 1 }] + store.warningList = [{ material_id: 2 }] + store.logs = [{ id: 3 }] + store.stockPage = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 } + store.warningPage = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 } + store.logsPage = { currentPage: 2, pageSize: 20, total: 40, totalPages: 2 } + store.stockFilters = { search: '轴承', category: 'A01' } + store.loading = true + + stockApi.getById.mockResolvedValue({ data: { material_id: 7, total_on_hand: '5' } }) + await store.fetchStockDetailByMaterialId(7) + stockApi.getById.mockClear() + + store.resetForOrganization() + + expect(store.stockList).toEqual([]) + expect(store.warningList).toEqual([]) + expect(store.logs).toEqual([]) + expect(store.stockPage).toEqual({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + expect(store.stockFilters).toEqual({ search: '', category: '' }) + expect(store.loading).toBe(false) + + // 单物料缓存也被清空:再次查询必须重新请求,而不是命中旧公司的缓存。 + await store.fetchStockDetailByMaterialId(7) + expect(stockApi.getById).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/stores/ui.js b/frontend/src/stores/ui.js index 1ee3ee7c..52b0afc4 100644 --- a/frontend/src/stores/ui.js +++ b/frontend/src/stores/ui.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' +import { useOrganizationStore } from './organization' export const useUiStore = defineStore('ui', () => { const loading = ref(false) @@ -15,8 +16,17 @@ export const useUiStore = defineStore('ui', () => { sidebarCollapsed.value = !sidebarCollapsed.value } + // 公司范围变更时清掉可能卡住的全局 loading 遮罩,避免旧公司某次请求留下的遮罩 + // 挡在新公司页面上。侧栏折叠是用户的界面偏好,不属于公司数据,保持不变。 + function resetForOrganization() { + loading.value = false + loadingText.value = '' + } + + useOrganizationStore().registerOrganizationReset(resetForOrganization) + return { loading, loadingText, sidebarCollapsed, - setLoading, toggleSidebar + setLoading, toggleSidebar, resetForOrganization } }) diff --git a/frontend/src/utils/organizationContext.js b/frontend/src/utils/organizationContext.js index f8dbed5a..04edef6e 100644 --- a/frontend/src/utils/organizationContext.js +++ b/frontend/src/utils/organizationContext.js @@ -55,6 +55,18 @@ export function isOrganizationContextSuperseded(error) { return error?.name === 'OrganizationContextSupersededError' } +// 切换公司时共享 AbortController 会取消旧公司在途的读请求,axios 抛出 CanceledError。 +// 这类取消不是失败:store 的加载方法据此静默吞掉,不向上抛出、也不显示为失败。 +export function isCancelledRequest(error) { + return error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError' +} + +// 加载方法统一用来判断“这个错误是否属于切换公司造成的作废/取消”:是则静默丢弃, +// 既不提交状态也不当作失败向上抛。合并两个判定,避免每个 store 各写一遍。 +export function shouldDiscardResponse(error) { + return isOrganizationContextSuperseded(error) || isCancelledRequest(error) +} + // 登出或切换账号时清空上下文,避免下游把旧公司的 ID 继续发出去。 export function resetOrganizationContext() { abortReadRequests() diff --git a/frontend/src/views/Application.test.js b/frontend/src/views/Application.test.js index e8246ae5..95302ae5 100644 --- a/frontend/src/views/Application.test.js +++ b/frontend/src/views/Application.test.js @@ -25,6 +25,10 @@ const stockStore = vi.hoisted(() => ({ vi.mock('../stores/application', () => ({ useApplicationStore: () => applicationStore })) vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore })) +// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。 +vi.mock('../composables/useOrganizationSwitchGuard', () => ({ + useOrganizationSwitchGuard: vi.fn() +})) // 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报 // "Maximum recursive updates exceeded"。这里用替身直接派发 change, diff --git a/frontend/src/views/Application.vue b/frontend/src/views/Application.vue index 3a4791f8..3041a4eb 100644 --- a/frontend/src/views/Application.vue +++ b/frontend/src/views/Application.vue @@ -110,6 +110,7 @@ import { useMaterialsStore } from '../stores/materials' import { useStockStore } from '../stores/stock' import BaseTable from '../components/business/BaseTable.vue' import StatusBadge from '../components/business/StatusBadge.vue' +import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard' import { ElMessage, ElMessageBox } from 'element-plus' import { searchMaterialOptions } from '../utils/materialSearch' import { formatDate } from '../utils/datetime' @@ -127,6 +128,11 @@ const applyForm = reactive({ items: [{ material_id: null, materialSearchText: '', quantity: 1 }] }) +// 申请表单填了用途或选了物料就是未保存内容,切换公司会丢弃,先确认。 +useOrganizationSwitchGuard( + () => applyForm.purpose.trim() !== '' || applyForm.items.some(item => !!item.material_id) +) + // Per-item stock cache (item -> stock count) const itemStockMap = reactive(new Map()) diff --git a/frontend/src/views/Approval.test.js b/frontend/src/views/Approval.test.js index 1efa2168..bfbfb28f 100644 --- a/frontend/src/views/Approval.test.js +++ b/frontend/src/views/Approval.test.js @@ -13,6 +13,10 @@ const approvalStore = vi.hoisted(() => ({ })) vi.mock('../stores/approval', () => ({ useApprovalStore: () => approvalStore })) +// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。 +vi.mock('../composables/useOrganizationSwitchGuard', () => ({ + useOrganizationSwitchGuard: vi.fn() +})) function findButton(wrapper, text) { return wrapper.findAll('button').find(button => button.text().includes(text)) diff --git a/frontend/src/views/Approval.vue b/frontend/src/views/Approval.vue index 20992c9c..eea02dd8 100644 --- a/frontend/src/views/Approval.vue +++ b/frontend/src/views/Approval.vue @@ -88,6 +88,7 @@ import { ref, reactive, onMounted } from 'vue' import { useApprovalStore } from '../stores/approval' import BaseTable from '../components/business/BaseTable.vue' import StatusBadge from '../components/business/StatusBadge.vue' +import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard' import { ElMessage } from 'element-plus' import { formatDate } from '../utils/datetime' @@ -98,6 +99,9 @@ const approveDialogVisible = ref(false) const rejectDialogVisible = ref(false) const currentApplication = ref(null) +// 审批/拒绝弹窗开着时切换公司会丢掉正在填写的内容,先确认。 +useOrganizationSwitchGuard(() => approveDialogVisible.value || rejectDialogVisible.value) + // 审批默认勾选自动出库:通过即扣减库存并生成出库单,省去再去出库页手工建单。 // 初始值和每次打开弹窗时的重置都引用它,避免默认值改动时只改一处。 const AUTO_OUTBOUND_DEFAULT = true diff --git a/frontend/src/views/Inbound.test.js b/frontend/src/views/Inbound.test.js index 7bb461cf..3c12398a 100644 --- a/frontend/src/views/Inbound.test.js +++ b/frontend/src/views/Inbound.test.js @@ -29,6 +29,10 @@ vi.mock('../stores/inbound', () => ({ useInboundStore: () => inboundStore })) vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore })) vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) +// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。 +vi.mock('../composables/useOrganizationSwitchGuard', () => ({ + useOrganizationSwitchGuard: vi.fn() +})) // 真实的 ElSelect 在 jsdom 下会因 filter-method 反复触发自身更新而报 // "Maximum recursive updates exceeded",这里只关心表单校验,用轻量替身绕开。 diff --git a/frontend/src/views/Inbound.vue b/frontend/src/views/Inbound.vue index ea6f0404..9c088c10 100644 --- a/frontend/src/views/Inbound.vue +++ b/frontend/src/views/Inbound.vue @@ -138,6 +138,7 @@ import { useAuthStore } from '../stores/auth' import BaseTable from '../components/business/BaseTable.vue' import StatusBadge from '../components/business/StatusBadge.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue' +import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard' import { ElMessage } from 'element-plus' import { errorText } from '../utils/errorText' import { formatDate } from '../utils/datetime' @@ -156,6 +157,9 @@ const editingId = ref(null) const deleteId = ref(null) const currentRow = ref(null) +// 单据弹窗开着时切换公司会丢弃未保存内容,先确认。 +useOrganizationSwitchGuard(() => dialogVisible.value) + // 明细行的初始结构。预警阈值和启用状态分别放在 warning_stock / warning_enabled 上, // 并共用一个"用户是否改过"的标记:只有真正被改动过的明细才会把预警字段提交给后端, // 避免编辑旧入库单时把物料当前的阈值和启用状态覆盖回表单里的旧数。 diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js index 6f459c36..581f0344 100644 --- a/frontend/src/views/Materials.test.js +++ b/frontend/src/views/Materials.test.js @@ -22,6 +22,10 @@ const materialsStore = vi.hoisted(() => ({ vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) +// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。 +vi.mock('../composables/useOrganizationSwitchGuard', () => ({ + useOrganizationSwitchGuard: vi.fn() +})) vi.mock('../api/masterData', () => ({ materialCategoriesApi: { list: vi.fn() }, unitsApi: { list: vi.fn() }, @@ -525,3 +529,30 @@ describe('停用分类下的物料编辑', () => { wrapper.unmount() }) }) + +describe('物料页无可用分类时的配置入口提示', () => { + test('没有可用分类时,新建弹窗给出具体配置入口', async () => { + materialCategoriesApi.list.mockResolvedValue({ data: [] }) + unitsApi.list.mockResolvedValue({ data: [] }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + + expect(wrapper.text()).toContain('本公司尚未配置物料分类') + expect(wrapper.text()).toContain('基础配置') + wrapper.unmount() + }) + + test('有可用分类时不显示配置入口提示', async () => { + materialCategoriesApi.list.mockResolvedValue({ data: categories }) + unitsApi.list.mockResolvedValue({ data: [] }) + + const wrapper = mountMaterials() + await flushPromises() + await openAddDialog(wrapper) + + expect(wrapper.text()).not.toContain('本公司尚未配置物料分类') + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 4571da55..af17e1aa 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -67,6 +67,15 @@ + +
@@ -161,6 +170,7 @@ import { computed, ref, reactive, onMounted } from 'vue' import { useMaterialsStore } from '../stores/materials' import { useAuthStore } from '../stores/auth' +import { useOrganizationSwitchGuard } from '../composables/useOrganizationSwitchGuard' import BaseTable from '../components/business/BaseTable.vue' import DeleteConfirm from '../components/business/DeleteConfirm.vue' import AlertTip from '../components/business/AlertTip.vue' @@ -171,6 +181,7 @@ import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../ import { searchMaterialOptions } from '../utils/materialSearch' import { errorText } from '../utils/errorText' import { formatDate } from '../utils/datetime' +import { getOrganizationId } from '../utils/organizationContext' // 物料详情的展示沿用原先不带秒的格式 const MATERIAL_DATE_OPTIONS = { @@ -408,9 +419,14 @@ async function handleSave() { if (file) { const uploadForm = new FormData() uploadForm.append('image', file) + // 这里绕开了 axios 拦截器,组织头得手工补上,否则图片上传会缺少 X-Organization-ID。 + const organizationId = getOrganizationId() const res = await fetch('/api/upload', { method: 'POST', - headers: { 'Authorization': `Bearer ${authStore.token}` }, + headers: { + 'Authorization': `Bearer ${authStore.token}`, + ...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {}) + }, body: uploadForm }) if (!res.ok) { @@ -466,6 +482,13 @@ async function handleRestore(id) { } } +// 切换公司会重新挂载页面,正在编辑的弹窗连同其中的改动都会丢失。有未保存内容时先确认, +// 用户取消则保持当前公司不变(守卫返回 false)。 +const hasUnsavedChanges = computed( + () => dialogVisible.value && (isCreating.value || isEditing.value) +) +useOrganizationSwitchGuard(() => hasUnsavedChanges.value) + diff --git a/frontend/src/views/partners/PartnerList.test.js b/frontend/src/views/partners/PartnerList.test.js index 245215d2..dffdf15a 100644 --- a/frontend/src/views/partners/PartnerList.test.js +++ b/frontend/src/views/partners/PartnerList.test.js @@ -12,6 +12,11 @@ vi.mock('../../api/partners', () => ({ } })) +// 切换公司的未保存确认由组织 store 承载;这里只关心伙伴页本身,替身掉守卫注册。 +vi.mock('../../composables/useOrganizationSwitchGuard', () => ({ + useOrganizationSwitchGuard: vi.fn() +})) + // 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报 // "Maximum recursive updates exceeded"。这里只关心表单校验,用轻量替身绕开。 const SelectStub = { diff --git a/frontend/src/views/partners/PartnerList.vue b/frontend/src/views/partners/PartnerList.vue index 034c1795..f0c94be8 100644 --- a/frontend/src/views/partners/PartnerList.vue +++ b/frontend/src/views/partners/PartnerList.vue @@ -65,6 +65,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { partnersApi } from '../../api/partners' import { replaceAllKeys } from '../../utils/replaceAllKeys' +import { useOrganizationSwitchGuard } from '../../composables/useOrganizationSwitchGuard' import BaseTable from '../../components/business/BaseTable.vue' const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } }) @@ -73,6 +74,9 @@ const loading = ref(false) const saving = ref(false) const dialogVisible = ref(false) const editingId = ref(null) + +// 伙伴新增/编辑弹窗开着时切换公司会丢弃未保存内容,先确认。 +useOrganizationSwitchGuard(() => dialogVisible.value) // 每页条数跟物料等列表页保持一致,避免同一个系统里两种翻页手感 const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 }) const statusField = computed(() => `${props.role}_status`)