import { flushPromises, mount } from '@vue/test-utils' import ElementPlus, { ElMessage } from 'element-plus' import { defineComponent, ref } from 'vue' import { beforeEach, describe, expect, test, vi } from 'vitest' import Users from './Users.vue' import { organizationsApi } from '../api/organizations' const RoleSelectStub = { props: { modelValue: Array, multiple: Boolean }, emits: ['update:modelValue'], template: '
' } const RoleOptionStub = { props: ['label', 'value', 'disabled'], template: '{{ label }}' } const usersStore = vi.hoisted(() => ({ users: [], loading: false, page: { currentPage: 1, pageSize: 20, total: 0, totalPages: 1 }, fetchUsers: vi.fn(), createUser: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn() })) vi.mock('../stores/users', () => ({ useUsersStore: () => usersStore })) vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) vi.mock('../api/organizations', () => ({ organizationsApi: { listRoles: vi.fn() } })) function mountUsers() { return mount(Users, { global: { plugins: [ElementPlus], stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub } } }) } describe('用户管理角色', () => { beforeEach(() => { usersStore.users = [{ id: 7, username: 'zhangsan', description: '测试用户', created_at: '2026-09-16T09:00:00+08:00', roles: [ { id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] }, { id: 3, code: 'purchaser', name: '采购人员', enabled: true, permissions: [] } ] }] usersStore.fetchUsers.mockReset() organizationsApi.listRoles.mockResolvedValue({ data: [ { id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] }, { id: 3, code: 'purchaser', name: '采购人员', enabled: true, permissions: [] } ] }) }) test('列表显示用户的所有中文角色名称', async () => { const wrapper = mount(Users, { global: { plugins: [ElementPlus], stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub } } }) await flushPromises() expect(wrapper.text()).toContain('仓库管理员') expect(wrapper.text()).toContain('采购人员') expect(wrapper.text()).not.toContain('普通用户') wrapper.unmount() }) test('新增和编辑使用角色与权限中的多选角色', async () => { const wrapper = mount(Users, { global: { plugins: [ElementPlus], stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub } } }) await flushPromises() const addButton = wrapper.findAll('button').find(button => button.text().includes('新增用户')) await addButton.trigger('click') await flushPromises() const roleSelect = wrapper.findComponent(RoleSelectStub) expect(roleSelect.props('multiple')).toBe(true) expect(wrapper.text()).toContain('仓库管理员') expect(wrapper.text()).toContain('采购人员') wrapper.unmount() }) // 列表里同样要标出来:停用角色的 tag 和启用的一模一样,管理员会以为这个用户还在 // 通过它拿权限,实际上 GenerateToken 已经把它过滤掉了。 test('列表里已停用的角色也带后缀,启用的不带', async () => { usersStore.users = [{ ...usersStore.users[0], roles: [ { id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] }, { id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] } ] }] const wrapper = mountUsers() await flushPromises() expect(wrapper.text()).toContain('采购人员(已停用)') expect(wrapper.text()).toContain('仓库管理员') expect(wrapper.text()).not.toContain('仓库管理员(已停用)') 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() }) }) // 已停用角色在下拉里怎么呈现,直接决定"编辑用户"这条路走不走得通:保存是全量替换 // 角色,前端若摘不掉那个停用角色、后端又要求角色全部启用,这个用户就永远存不回去。 // 现在后端已放行"本来就持有的停用角色",前端这两条是配套:既让人摘得掉,也不让人 // 新分配一个停用角色。 describe('已停用角色的选择状态', () => { const disabledRole = { id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] } const unassignedDisabledRole = { id: 4, code: 'obsolete_role', name: '作废角色', enabled: false, permissions: [] } beforeEach(() => { // 用户 7 正持有已停用的角色 3;角色 4 同样停用但该用户没有 usersStore.users = [{ id: 7, username: 'zhangsan', description: '测试用户', created_at: '2026-09-16T09:00:00+08:00', roles: [disabledRole] }] usersStore.updateUser.mockReset() usersStore.updateUser.mockResolvedValue({ data: {} }) organizationsApi.listRoles.mockResolvedValue({ data: [ { id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] }, disabledRole, unassignedDisabledRole ] }) }) async function openEdit(wrapper) { const state = wrapper.vm.$.setupState state.showEditModal(usersStore.users[0]) await flushPromises() return state } function findOption(wrapper, value) { return wrapper.findAllComponents(RoleOptionStub).find(option => option.props('value') === value) } test('用户已持有的停用角色不禁用,否则它摘不掉', async () => { const wrapper = mountUsers() await flushPromises() const state = await openEdit(wrapper) expect(state.form.role_ids).toEqual([3]) // disabled 跟着 form.role_ids 走:已选中就不禁用,tag 上才有关闭按钮 expect(findOption(wrapper, 3).props('disabled')).toBe(false) wrapper.unmount() }) test('未持有的停用角色仍然禁用,不能通过编辑新分配', async () => { const wrapper = mountUsers() await flushPromises() await openEdit(wrapper) expect(findOption(wrapper, 4).props('disabled')).toBe(true) wrapper.unmount() }) test('停用的角色在下拉里标注出来', async () => { const wrapper = mountUsers() await flushPromises() await openEdit(wrapper) expect(findOption(wrapper, 3).props('label')).toBe('采购人员(已停用)') expect(findOption(wrapper, 2).props('label')).toBe('仓库管理员') wrapper.unmount() }) }) // 上面几条用的是 stub,只能证明 :disabled 这个 prop 算对了;而"管理员究竟能不能把那个 // 停用角色摘掉"取决于 element-plus 内部——multiple 模式下 tag 的关闭按钮绑在 option 的 // isDisabled 上(select2.mjs 的 closable: !selectDisabled && !item.isDisabled)。这里是 // 用真实组件把这条依赖验一遍,否则 Users.vue 的修复只是纸面上成立。 describe('element-plus 多选下停用角色 tag 的可关闭性', () => { const roles = [ { id: 2, name: 'warehouse_admin', enabled: true }, { id: 3, name: 'purchaser', enabled: false } ] // 只铺一个最小场景:已选中那个停用的角色 3 function buildSelect(disabledExpression) { return defineComponent({ template: ` `, setup() { return { ids: ref([3]), roles } } }) } async function closeIconCount(disabledExpression) { const wrapper = mount(buildSelect(disabledExpression), { global: { plugins: [ElementPlus] } }) await flushPromises() return wrapper.findAll('.el-tag__close').length } test('Users.vue 的写法:已选中的停用角色 tag 保留关闭按钮', async () => { expect(await closeIconCount('!role.enabled && !ids.includes(role.id)')).toBe(1) }) test('静态 disabled 的写法:同一个 tag 没有关闭按钮(原死锁现场)', async () => { expect(await closeIconCount('!role.enabled')).toBe(0) }) })