diff --git a/frontend/src/stores/application.js b/frontend/src/stores/application.js index 31739e37..1de2b57c 100644 --- a/frontend/src/stores/application.js +++ b/frontend/src/stores/application.js @@ -7,7 +7,12 @@ export const useApplicationStore = defineStore('application', () => { const loading = ref(false) const myPage = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + // 列表当前生效的筛选条件。取消申请后会在原地重新拉取列表,那次拉取必须沿用 + // 用户选中的状态,否则列表会悄悄退回全量,而下拉框里还显示着选中的筛选值。 + const filters = ref({ status: '' }) + async function fetchMyApplications(status = '', pageNum = 1) { + filters.value = { status } loading.value = true try { const res = await applicationApi.getMy(status, pageNum, myPage.value.pageSize) @@ -22,13 +27,16 @@ export const useApplicationStore = defineStore('application', () => { async function createApplication(data) { const res = await applicationApi.create(data) + // 这处刻意清空筛选并回到第 1 页,和上面「沿用筛选」的规矩相反:刚提交的是 + // 待审批单,若沿用「已通过」之类的筛选就看不见它,用户会以为没提交成功。 + // 清空的是 store 这份状态,页面那份(下拉框)由 Application.vue 同步重置。 await fetchMyApplications('', 1) return res } async function cancelApplication(id) { const res = await applicationApi.cancel(id) - await fetchMyApplications('', myPage.value.currentPage) + await fetchMyApplications(filters.value.status, myPage.value.currentPage) return res } diff --git a/frontend/src/stores/application.test.js b/frontend/src/stores/application.test.js new file mode 100644 index 00000000..535f6ef4 --- /dev/null +++ b/frontend/src/stores/application.test.js @@ -0,0 +1,63 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { applicationApi } from '../api/application' +import { useApplicationStore } from './application' + +vi.mock('../api/application', () => ({ + applicationApi: { + getAll: vi.fn(), + getMy: vi.fn(), + create: vi.fn(), + cancel: vi.fn(), + approve: vi.fn(), + reject: vi.fn() + } +})) + +describe('application store 的筛选保持', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + applicationApi.getMy.mockResolvedValue({ data: { items: [], total: 0 } }) + applicationApi.create.mockResolvedValue({ data: {} }) + applicationApi.cancel.mockResolvedValue({ data: {} }) + }) + + // 取消申请会在原地重新拉取列表,那次拉取必须沿用用户选中的状态:否则列表会悄悄 + // 退回全量(已通过/已拒绝的单子全冒出来),而下拉框里还显示着「待审批」。 + it('取消申请后刷新列表要沿用当前的筛选条件', async () => { + const store = useApplicationStore() + + await store.fetchMyApplications('pending', 2) + applicationApi.getMy.mockClear() + + await store.cancelApplication(3) + + expect(applicationApi.getMy).toHaveBeenCalledWith('pending', 2, 20) + }) + + // 这处刻意与上面相反:刚提交的是待审批单,若沿用「已通过」之类的筛选就看不见它, + // 用户会以为没提交成功。所以清空筛选并回到第 1 页,让新单排在列表最前面。 + it('提交新申请后要清空筛选并回到第 1 页', async () => { + const store = useApplicationStore() + + await store.fetchMyApplications('approved', 3) + applicationApi.getMy.mockClear() + + await store.createApplication({ purpose: '测试' }) + + expect(applicationApi.getMy).toHaveBeenCalledWith('', 1, 20) + }) + + // 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。 + it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => { + const store = useApplicationStore() + + await store.fetchMyApplications('rejected', 1) + 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 1b8e8b1e..303fa2f5 100644 --- a/frontend/src/stores/approval.js +++ b/frontend/src/stores/approval.js @@ -7,7 +7,12 @@ export const useApprovalStore = defineStore('approval', () => { const loading = ref(false) const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + // 列表当前生效的筛选条件。审批通过/拒绝后会在原地重新拉取列表,那次拉取必须 + // 沿用用户选中的状态,否则列表会悄悄退回全量,而下拉框里还显示着选中的筛选值。 + const filters = ref({ status: '' }) + async function fetchPendingApplications(status = '', pageNum = 1) { + filters.value = { status } loading.value = true try { const res = await applicationApi.getAll(status, pageNum, page.value.pageSize) @@ -22,13 +27,13 @@ export const useApprovalStore = defineStore('approval', () => { async function approveApplication(id, data = {}) { const res = await applicationApi.approve(id, data) - await fetchPendingApplications('', page.value.currentPage) + await fetchPendingApplications(filters.value.status, page.value.currentPage) return res } async function rejectApplication(id, data = {}) { const res = await applicationApi.reject(id, data) - await fetchPendingApplications('', page.value.currentPage) + await fetchPendingApplications(filters.value.status, page.value.currentPage) return res } diff --git a/frontend/src/stores/approval.test.js b/frontend/src/stores/approval.test.js new file mode 100644 index 00000000..bfb3b88d --- /dev/null +++ b/frontend/src/stores/approval.test.js @@ -0,0 +1,70 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { applicationApi } from '../api/application' +import { useApprovalStore } from './approval' + +vi.mock('../api/application', () => ({ + applicationApi: { + getAll: vi.fn(), + getMy: vi.fn(), + create: vi.fn(), + cancel: vi.fn(), + approve: vi.fn(), + reject: vi.fn() + } +})) + +describe('approval store 的筛选保持', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + applicationApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + applicationApi.approve.mockResolvedValue({ data: {} }) + applicationApi.reject.mockResolvedValue({ data: {} }) + }) + + // 审批会在原地重新拉取列表,那次拉取必须沿用用户选中的状态:否则列表会悄悄退回 + // 全量(已通过/已拒绝的单子全冒出来),而下拉框里还显示着「待处理」。 + it('审批通过后刷新列表要沿用当前的筛选条件', async () => { + const store = useApprovalStore() + + await store.fetchPendingApplications('pending', 2) + applicationApi.getAll.mockClear() + + await store.approveApplication(9, { auto_outbound: true }) + + expect(applicationApi.getAll).toHaveBeenCalledWith('pending', 2, 20) + }) + + it('审批拒绝后刷新列表要沿用当前的筛选条件', async () => { + const store = useApprovalStore() + + await store.fetchPendingApplications('pending', 1) + applicationApi.getAll.mockClear() + + await store.rejectApplication(9, { reason: '库存不足' }) + + expect(applicationApi.getAll).toHaveBeenCalledWith('pending', 1, 20) + }) + + // 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。 + it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => { + const store = useApprovalStore() + + await store.fetchPendingApplications('approved', 1) + applicationApi.getAll.mockClear() + + await store.fetchPendingApplications() + + expect(applicationApi.getAll).toHaveBeenCalledWith('', 1, 20) + }) + + // 翻页本身也要带上筛选:漏掉的话第 2 页会突然冒出全量数据。 + it('翻页时带上当前的筛选条件', async () => { + const store = useApprovalStore() + + await store.fetchPendingApplications('approved', 3) + + expect(applicationApi.getAll).toHaveBeenCalledWith('approved', 3, 20) + }) +}) diff --git a/frontend/src/views/Application.test.js b/frontend/src/views/Application.test.js new file mode 100644 index 00000000..e8246ae5 --- /dev/null +++ b/frontend/src/views/Application.test.js @@ -0,0 +1,108 @@ +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus, { ElMessage } from 'element-plus' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import Application from './Application.vue' + +const applicationStore = vi.hoisted(() => ({ + myApplications: [], + loading: false, + myPage: { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }, + fetchMyApplications: vi.fn(), + createApplication: vi.fn(), + cancelApplication: vi.fn() +})) + +const materialsStore = vi.hoisted(() => ({ + materialOptions: [], + fetchMaterialOptions: vi.fn() +})) + +const stockStore = vi.hoisted(() => ({ + fetchStock: vi.fn(), + fetchStockByMaterialId: vi.fn() +})) + +vi.mock('../stores/application', () => ({ useApplicationStore: () => applicationStore })) +vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) +vi.mock('../stores/stock', () => ({ useStockStore: () => stockStore })) + +// 真实的 ElSelect 在 jsdom 下会因状态双向绑定反复触发自身更新而报 +// "Maximum recursive updates exceeded"。这里用替身直接派发 change, +// 模拟用户在状态下拉里选中某一项。 +const SelectStub = { + props: ['modelValue', 'placeholder', 'filterable', 'filterMethod', 'noDataText', 'disabled', 'clearable'], + emits: ['update:modelValue', 'change'], + methods: { + pick(value) { + this.$emit('update:modelValue', value) + this.$emit('change', value) + } + }, + template: '
' +} + +const OptionStub = { props: ['label', 'value'], template: '' } + +function mountApplication() { + return mount(Application, { + global: { + plugins: [ElementPlus], + stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub } + } + }) +} + +// 页面里有两处 ElSelect:申请表单里的物料选择,和「我的申请」里的状态筛选。 +function statusSelect(wrapper) { + return wrapper.findAllComponents(SelectStub) + .find(select => select.props('placeholder') === '全部状态') +} + +function setupState(wrapper) { + return wrapper.vm.$.setupState +} + +describe('提交申请后筛选条件与列表保持一致', () => { + let successSpy + + beforeEach(() => { + vi.clearAllMocks() + applicationStore.createApplication.mockResolvedValue({ data: {} }) + successSpy = vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) + }) + + test('状态下拉选中的值会传给列表查询', async () => { + const wrapper = mountApplication() + await flushPromises() + + statusSelect(wrapper).vm.pick('approved') + await flushPromises() + + expect(setupState(wrapper).statusFilter).toBe('approved') + expect(applicationStore.fetchMyApplications).toHaveBeenLastCalledWith('approved', 1) + wrapper.unmount() + }) + + // store 里提交后的那次刷新是刻意清空筛选的(好让刚提交的待审批单出现在列表里), + // 页面这份状态(下拉框)必须跟着一起回到「全部」:否则会出现下拉写着「已通过」、 + // 列表却是全量的表里不一,这次修复就是补的这一处。 + test('提交成功后下拉框的筛选要一起重置为全部', async () => { + const wrapper = mountApplication() + await flushPromises() + + statusSelect(wrapper).vm.pick('approved') + await flushPromises() + expect(setupState(wrapper).statusFilter).toBe('approved') + + setupState(wrapper).applyForm.purpose = '测试用途' + setupState(wrapper).applyForm.items = [ + { material_id: 7, materialSearchText: '', quantity: 1 } + ] + await setupState(wrapper).handleSubmit() + await flushPromises() + + expect(applicationStore.createApplication).toHaveBeenCalled() + expect(setupState(wrapper).statusFilter).toBe('') + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/Application.vue b/frontend/src/views/Application.vue index c85827b9..3a4791f8 100644 --- a/frontend/src/views/Application.vue +++ b/frontend/src/views/Application.vue @@ -212,6 +212,9 @@ async function handleSubmit() { applyForm.purpose = '' applyForm.items = [{ material_id: null, materialSearchText: '', quantity: 1 }] activeTab.value = 'my' + // store 里提交后的那次刷新会清空筛选(好让刚提交的待审批单出现在列表里), + // 下拉框得跟着一起回到「全部」,否则会出现下拉写着「已通过」、列表却是全量。 + statusFilter.value = '' } finally { submitLoading.value = false }