From bcee877278a0d8dfa9c2568ba1e558243ee54a36 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:40:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=89=A9=E6=96=99/=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=A2=9E=E5=88=A0=E6=94=B9=E5=90=8E=E5=88=97=E8=A1=A8=E7=9A=84?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=85=B3=E9=94=AE=E5=AD=97=E8=A2=AB=E4=B8=A2?= =?UTF-8?q?=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 写操作后原地重新拉取列表时,search 参数被写死成缺省的空串:搜索框里还显示着 用户输入的关键字,列表却退回全量。用户会把全量列表当成筛选结果,接下来的作废/ 删除操作就打在被误认的那一条上。store 层一直有这个缺陷却没被拦住,因为页面 那层没有测试。 修法照 stock.js / approval.js 已有的约定(store 存一份 filters,写操作原地刷新 时取出来用): - materials.js:编辑/作废/恢复沿用 search 和 status - users.js:编辑/删除沿用 search - 新增走反方向,清空筛选并回第 1 页:新建的项不一定匹配当前关键字,沿用搜索 就看不见它,用户会以为没建成功。物料按 id 倒序,回第 1 页正好能看见 - Materials.vue / Users.vue:新增成功后把搜索框一起清空,否则 store 清了筛选而 搜索框没清,同样是表里不一 测试补 14 条(两个 store + 两个页面)。全部做过变异验证:把实现改回原样后, 6 条 store 断言和 2 条页面断言确实变红,且只有它们变红。 --- frontend/src/stores/materials.js | 18 +++- frontend/src/stores/materials.test.js | 123 +++++++++++++++++++++++++- frontend/src/stores/users.js | 15 +++- frontend/src/stores/users.test.js | 90 +++++++++++++++++++ frontend/src/views/Materials.test.js | 64 ++++++++++++++ frontend/src/views/Materials.vue | 3 + frontend/src/views/Users.test.js | 66 +++++++++++++- frontend/src/views/Users.vue | 3 + 8 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 frontend/src/stores/users.test.js diff --git a/frontend/src/stores/materials.js b/frontend/src/stores/materials.js index 21d791db..bbbefdf1 100644 --- a/frontend/src/stores/materials.js +++ b/frontend/src/stores/materials.js @@ -12,6 +12,11 @@ export const useMaterialsStore = defineStore('materials', () => { const loading = ref(false) const pagination = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + // 列表当前生效的筛选条件。编辑/作废/恢复后会在原地重新拉取列表,那次拉取必须沿用 + // 它们,否则列表会悄悄退回全量,而搜索框里还显示着用户输入的关键字——用户会把 + // 全量列表当成筛选结果,接着的作废操作就打在误认的那一条上。 + const filters = ref({ search: '', status: 'all' }) + const categoryMap = { 'A01': '标准件', 'A02': '成品件', 'A03': '简易加工件', 'A04': '精密加工件', 'A05': '钣金件', 'A06': '塑胶件', 'A07': '其他', @@ -30,6 +35,7 @@ export const useMaterialsStore = defineStore('materials', () => { } async function fetchMaterials(page = 1, search = '', status = 'all') { + filters.value = { search, status } loading.value = true try { const pageSize = pagination.value.pageSize @@ -76,19 +82,23 @@ export const useMaterialsStore = defineStore('materials', () => { async function createMaterial(data) { const res = await materialsApi.create(data) - await fetchMaterials(1) + // 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的物料不一定 + // 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。列表按 id 倒序,回到 + // 第 1 页正好能看见它。清空的是 store 这份状态,页面那份(搜索框)由 Materials.vue + // 同步重置。 + await fetchMaterials(1, '', 'all') return res } async function updateMaterial(id, data) { const res = await materialsApi.update(id, data) - await fetchMaterials(pagination.value.currentPage) + await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) return res } async function obsoleteMaterial(id) { const res = await materialsApi.obsolete(id) - await fetchMaterials(pagination.value.currentPage) + await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) // 作废会改变物料在「有效」和「全部」两份选项里的归属,两份都要刷新 await refreshMaterialOptions() return res @@ -96,7 +106,7 @@ export const useMaterialsStore = defineStore('materials', () => { async function restoreMaterial(id) { const res = await materialsApi.restore(id) - await fetchMaterials(pagination.value.currentPage) + await fetchMaterials(pagination.value.currentPage, filters.value.search, filters.value.status) await refreshMaterialOptions() return res } diff --git a/frontend/src/stores/materials.test.js b/frontend/src/stores/materials.test.js index 39b45abf..02a22a3a 100644 --- a/frontend/src/stores/materials.test.js +++ b/frontend/src/stores/materials.test.js @@ -5,7 +5,11 @@ import { useMaterialsStore } from './materials' vi.mock('../api/materials', () => ({ materialsApi: { - getAll: vi.fn() + getAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + obsolete: vi.fn(), + restore: vi.fn() } })) @@ -74,3 +78,120 @@ describe('materials store selection options', () => { expect(store.materialOptions).toEqual([]) }) }) + +describe('materials store 的筛选保持', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + materialsApi.create.mockResolvedValue({ data: {} }) + materialsApi.update.mockResolvedValue({ data: {} }) + materialsApi.obsolete.mockResolvedValue({ data: {} }) + materialsApi.restore.mockResolvedValue({ data: {} }) + }) + + // 编辑/作废/恢复都在原地重新拉取列表,那次拉取必须沿用搜索框里的关键字:否则列表 + // 会悄悄退回全量,而搜索框里还显示着用户输入的内容——用户会把全量列表当成筛选结果, + // 接着的作废操作就打在误认的那一条上。 + it('编辑物料后刷新列表要沿用当前的搜索关键字', async () => { + const store = useMaterialsStore() + + await store.fetchMaterials(2, '螺丝') + materialsApi.getAll.mockClear() + + await store.updateMaterial(7, { name: '内六角螺丝' }) + + expect(materialsApi.getAll).toHaveBeenCalledWith({ + search: '螺丝', + status: 'all', + limit: 20, + offset: 20 + }) + }) + + // 作废和恢复除了列表还要刷两份选项,所以 getAll 会被调用多次,只看第一次(列表那次)。 + it('作废物料后刷新列表要沿用当前的搜索关键字', async () => { + const store = useMaterialsStore() + + await store.fetchMaterials(1, '螺丝') + materialsApi.getAll.mockClear() + + await store.obsoleteMaterial(7) + + expect(materialsApi.getAll).toHaveBeenNthCalledWith(1, { + search: '螺丝', + status: 'all', + limit: 20, + offset: 0 + }) + }) + + it('恢复物料后刷新列表要沿用当前的搜索关键字', async () => { + const store = useMaterialsStore() + + await store.fetchMaterials(1, '螺丝') + materialsApi.getAll.mockClear() + + await store.restoreMaterial(7) + + expect(materialsApi.getAll).toHaveBeenNthCalledWith(1, { + search: '螺丝', + status: 'all', + limit: 20, + offset: 0 + }) + }) + + // 状态筛选目前还没有页面在用,但 store 的接口本来就有这一维,原地刷新时漏掉它会 + // 埋下同一个坑:以后页面加上状态下拉,列表又会悄悄退回全量。 + it('刷新时一并沿用当前的状态筛选', async () => { + const store = useMaterialsStore() + + await store.fetchMaterials(1, '', 'obsolete') + materialsApi.getAll.mockClear() + + await store.updateMaterial(7, { name: '内六角螺丝' }) + + expect(materialsApi.getAll).toHaveBeenCalledWith({ + search: '', + status: 'obsolete', + limit: 20, + offset: 0 + }) + }) + + // 这处刻意与上面相反:新建的物料不一定匹配当前关键字,沿用搜索就看不见它,用户会 + // 以为没建成功。列表按 id 倒序,回到第 1 页正好能看见它。 + it('新建物料后要清空筛选并回到第 1 页', async () => { + const store = useMaterialsStore() + + await store.fetchMaterials(3, '螺丝', 'active') + materialsApi.getAll.mockClear() + + await store.createMaterial({ name: '新物料' }) + + expect(materialsApi.getAll).toHaveBeenCalledWith({ + search: '', + status: 'all', + limit: 20, + offset: 0 + }) + }) + + // 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。 + it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => { + const store = useMaterialsStore() + + await store.fetchMaterials(2, '螺丝', 'obsolete') + materialsApi.getAll.mockClear() + + await store.fetchMaterials() + + expect(materialsApi.getAll).toHaveBeenCalledWith({ + search: '', + status: 'all', + limit: 20, + offset: 0 + }) + }) +}) diff --git a/frontend/src/stores/users.js b/frontend/src/stores/users.js index 23b30451..a180801b 100644 --- a/frontend/src/stores/users.js +++ b/frontend/src/stores/users.js @@ -7,7 +7,13 @@ export const useUsersStore = defineStore('users', () => { const loading = ref(false) const page = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) + // 列表当前生效的筛选条件。编辑/删除后会在原地重新拉取列表,那次拉取必须沿用它们, + // 否则列表会悄悄退回全量,而搜索框里还显示着用户输入的关键字——用户会把全量列表 + // 当成筛选结果,接着的删除操作就打在误认的那一条上。 + const filters = ref({ search: '' }) + async function fetchUsers(pageNum = 1, search = '') { + filters.value = { search } loading.value = true try { const pageSize = page.value.pageSize @@ -24,19 +30,22 @@ export const useUsersStore = defineStore('users', () => { async function createUser(data) { const res = await usersApi.create(data) - await fetchUsers(1) + // 这处刻意清空筛选并回到第 1 页,和下面「沿用筛选」的规矩相反:新建的用户不一定 + // 匹配当前关键字,沿用搜索就看不见它,用户会以为没建成功。清空的是 store 这份 + // 状态,页面那份(搜索框)由 Users.vue 同步重置。 + await fetchUsers(1, '') return res } async function updateUser(id, data) { const res = await usersApi.update(id, data) - await fetchUsers(page.value.currentPage) + await fetchUsers(page.value.currentPage, filters.value.search) return res } async function deleteUser(id) { const res = await usersApi.delete(id) - await fetchUsers(page.value.currentPage) + await fetchUsers(page.value.currentPage, filters.value.search) return res } diff --git a/frontend/src/stores/users.test.js b/frontend/src/stores/users.test.js new file mode 100644 index 00000000..9ac741db --- /dev/null +++ b/frontend/src/stores/users.test.js @@ -0,0 +1,90 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { usersApi } from '../api/users' +import { useUsersStore } from './users' + +vi.mock('../api/users', () => ({ + usersApi: { + getAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } +})) + +describe('users store 的筛选保持', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + usersApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } }) + usersApi.create.mockResolvedValue({ data: {} }) + usersApi.update.mockResolvedValue({ data: {} }) + usersApi.delete.mockResolvedValue({ data: {} }) + }) + + // 编辑/删除都在原地重新拉取列表,那次拉取必须沿用搜索框里的用户名:否则列表会悄悄 + // 退回全量,而搜索框里还显示着用户输入的内容——用户会把全量列表当成筛选结果,接着 + // 的删除操作就打在误认的那一条上。 + it('编辑用户后刷新列表要沿用当前的搜索关键字', async () => { + const store = useUsersStore() + + await store.fetchUsers(2, 'zhangsan') + usersApi.getAll.mockClear() + + await store.updateUser(7, { username: 'zhangsan', role_ids: [2] }) + + expect(usersApi.getAll).toHaveBeenCalledWith({ + limit: 20, + offset: 20, + search: 'zhangsan' + }) + }) + + it('删除用户后刷新列表要沿用当前的搜索关键字', async () => { + const store = useUsersStore() + + await store.fetchUsers(1, 'zhangsan') + usersApi.getAll.mockClear() + + await store.deleteUser(7) + + expect(usersApi.getAll).toHaveBeenCalledWith({ + limit: 20, + offset: 0, + search: 'zhangsan' + }) + }) + + // 这处刻意与上面相反:新建的用户不一定匹配当前关键字,沿用搜索就看不见它,用户会 + // 以为没建成功。 + it('新建用户后要清空筛选并回到第 1 页', async () => { + const store = useUsersStore() + + await store.fetchUsers(3, 'zhangsan') + usersApi.getAll.mockClear() + + await store.createUser({ username: 'lisi', role_ids: [2] }) + + expect(usersApi.getAll).toHaveBeenCalledWith({ + limit: 20, + offset: 0, + search: '' + }) + }) + + // 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。 + it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => { + const store = useUsersStore() + + await store.fetchUsers(2, 'zhangsan') + usersApi.getAll.mockClear() + + await store.fetchUsers() + + expect(usersApi.getAll).toHaveBeenCalledWith({ + limit: 20, + offset: 0, + search: '' + }) + }) +}) diff --git a/frontend/src/views/Materials.test.js b/frontend/src/views/Materials.test.js index 8c590fb2..26e41306 100644 --- a/frontend/src/views/Materials.test.js +++ b/frontend/src/views/Materials.test.js @@ -71,6 +71,10 @@ function codeInput(wrapper) { return wrapper.find('.material-code-field input') } +function setupState(wrapper) { + return wrapper.vm.$.setupState +} + async function openAddDialog(wrapper) { await findButton(wrapper, '新增物料').trigger('click') await flushPromises() @@ -231,3 +235,63 @@ 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() + materialsStore.updateMaterial.mockReset() + materialsStore.createMaterial.mockResolvedValue({ data: {} }) + materialsStore.updateMaterial.mockResolvedValue({ data: {} }) + vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) + }) + + // 页面里第一个 ElSelect 就是物料搜索框。 + async function searchBy(wrapper, keyword) { + await wrapper.findAllComponents(SelectStub)[0].vm.pick(keyword) + await flushPromises() + } + + // store 里新建后的那次刷新是刻意清空筛选的(好让刚建的物料出现在列表里),页面这份 + // 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着关键字、列表却是全量的 + // 表里不一,用户会把全量列表当成筛选结果。 + test('新增物料成功后搜索框要清空', async () => { + const wrapper = mountMaterials() + await flushPromises() + + await searchBy(wrapper, '螺丝') + expect(setupState(wrapper).searchKeyword).toBe('螺丝') + + await openAddDialog(wrapper) + await pickCategories(wrapper) + setupState(wrapper).form.name = '内六角螺丝' + await setupState(wrapper).handleSave() + await flushPromises() + + expect(materialsStore.createMaterial).toHaveBeenCalled() + expect(setupState(wrapper).searchKeyword).toBe('') + wrapper.unmount() + }) + + // 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。 + test('编辑物料成功后搜索框要保留', async () => { + const wrapper = mountMaterials() + await flushPromises() + + await searchBy(wrapper, '螺丝') + + setupState(wrapper).showEditModal({ + id: 7, category_id: 12, material_code: 'A010201', name: '螺丝' + }) + await flushPromises() + await setupState(wrapper).handleSave() + await flushPromises() + + expect(materialsStore.updateMaterial).toHaveBeenCalled() + expect(setupState(wrapper).searchKeyword).toBe('螺丝') + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 5103889f..f3e1481d 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -375,6 +375,9 @@ async function handleSave() { } else { await materialsStore.createMaterial(formData) ElMessage.success('物料创建成功') + // store 里新建后的那次刷新会清空筛选(好让刚建的物料出现在列表里),搜索框得 + // 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。 + searchKeyword.value = '' } dialogVisible.value = false } catch (e) { diff --git a/frontend/src/views/Users.test.js b/frontend/src/views/Users.test.js index 0358eeba..87a07095 100644 --- a/frontend/src/views/Users.test.js +++ b/frontend/src/views/Users.test.js @@ -1,5 +1,5 @@ import { flushPromises, mount } from '@vue/test-utils' -import ElementPlus from 'element-plus' +import ElementPlus, { ElMessage } from 'element-plus' import { beforeEach, describe, expect, test, vi } from 'vitest' import Users from './Users.vue' import { organizationsApi } from '../api/organizations' @@ -36,6 +36,15 @@ vi.mock('../api/organizations', () => ({ } })) +function mountUsers() { + return mount(Users, { + global: { + plugins: [ElementPlus], + stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub } + } + }) +} + describe('用户管理角色', () => { beforeEach(() => { usersStore.users = [{ @@ -91,3 +100,58 @@ describe('用户管理角色', () => { wrapper.unmount() }) }) + +describe('新增用户后列表与搜索框保持一致', () => { + beforeEach(() => { + usersStore.fetchUsers.mockReset() + usersStore.createUser.mockReset() + usersStore.updateUser.mockReset() + usersStore.createUser.mockResolvedValue({ data: {} }) + usersStore.updateUser.mockResolvedValue({ data: {} }) + organizationsApi.listRoles.mockResolvedValue({ data: [] }) + vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) + }) + + // store 里新建后的那次刷新是刻意清空筛选的(好让刚建的用户出现在列表里),页面这份 + // 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着用户名、列表却是全量的 + // 表里不一,用户会把全量列表当成筛选结果。 + test('新增用户成功后搜索框要清空', async () => { + const wrapper = mountUsers() + await flushPromises() + const state = wrapper.vm.$.setupState + + await wrapper.find('.search-bar input').setValue('zhangsan') + await flushPromises() + expect(state.searchKeyword).toBe('zhangsan') + + state.form.username = 'lisi' + state.form.password = '123456' + state.form.role_ids = [2] + await state.handleSave() + await flushPromises() + + expect(usersStore.createUser).toHaveBeenCalled() + expect(state.searchKeyword).toBe('') + wrapper.unmount() + }) + + // 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。 + test('编辑用户成功后搜索框要保留', async () => { + const wrapper = mountUsers() + await flushPromises() + const state = wrapper.vm.$.setupState + + await wrapper.find('.search-bar input').setValue('zhangsan') + await flushPromises() + + state.editingId = 7 + state.form.username = 'zhangsan' + state.form.role_ids = [2] + await state.handleSave() + await flushPromises() + + expect(usersStore.updateUser).toHaveBeenCalled() + expect(state.searchKeyword).toBe('zhangsan') + wrapper.unmount() + }) +}) diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index a3f4b946..7b337018 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -182,6 +182,9 @@ async function handleSave() { await usersStore.updateUser(editingId.value, data) } else { await usersStore.createUser(data) + // store 里新建后的那次刷新会清空筛选(好让刚建的用户出现在列表里),搜索框得 + // 跟着一起清空,否则会出现搜索框写着关键字、列表却是全量的表里不一。 + searchKeyword.value = '' } ElMessage.success('保存成功') dialogVisible.value = false