From fbd0bc1341d18947fbeb69ea111d3c7c78adce81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Tue, 22 Sep 2026 14:10:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=A1=E6=89=B9/=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E5=90=8E=E5=88=97=E8=A1=A8=E7=9A=84=E7=AD=9B=E9=80=89=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E8=A2=AB=E4=B8=A2=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写操作后原地重新拉取列表时,status 参数被写死成 '':列表悄悄退回全量(已通过、 已拒绝的单子全冒出来),而页面下拉框仍显示着用户选中的筛选值。用户以为看到的 是筛选结果,实际不是。store 层一直有这个缺陷却没被拦住,因为页面那层没有测试。 修法照 stock.js 已有的约定(stockFilters 配合 stock.test.js 的断言):store 存一份 filters,写操作原地刷新时取出来用。 - approval.js:approve / reject 沿用筛选 - application.js:cancel 沿用筛选 - application.js:create 保持清空筛选并回第 1 页——刚提交的是待审批单,若沿用 「已通过」之类的筛选就看不见它,用户会以为没提交成功 - Application.vue:提交成功后同步把下拉框重置为「全部」,否则 store 清了筛选而 下拉没清,同样是表里不一 测试补 9 条(approval/application 两个 store + Application 页面层)。全部做过变异 验证:把实现改回原样后对应断言确实变红,且只有该变红的那几条变红。 --- frontend/src/stores/application.js | 10 ++- frontend/src/stores/application.test.js | 63 ++++++++++++++ frontend/src/stores/approval.js | 9 +- frontend/src/stores/approval.test.js | 70 +++++++++++++++ frontend/src/views/Application.test.js | 108 ++++++++++++++++++++++++ frontend/src/views/Application.vue | 3 + 6 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 frontend/src/stores/application.test.js create mode 100644 frontend/src/stores/approval.test.js create mode 100644 frontend/src/views/Application.test.js 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 }