/** * 功能权限面板(用户管理 → 分配权限 → 功能权限)。 * * 数据加载与保存由父级 UserPowerDrawer 负责,面板自身只做这几件事,测试只锁这几件: * 1. 打开时的初始状态:只展开「全部分类」虚拟根,业务目录收起、不选中任何菜单; * 2. 菜单选中语义:重复点击已选中的菜单不取消选中,只有点「全部分类」才清空; * 3. 右侧结构:页头(菜单名 + 入口勾选框 + 本页进度)+ 一张「模块 / 动作 / 已选」三列表。 * 模块级勾选在「模块」列、动作勾选在「动作」列,层级靠列而不是靠嵌套容器; * 目录不渲染入口勾选框、没配权限点的模块不渲染,都不出空态文案; * 4. 面板顶部工具栏的「全授权 / 全禁用」作用于该账号的全部功能权限(s_power 全量), * 与左侧选中哪个菜单无关; * 5. 勾选结果经 v-model 回传,三层是「保护性单向联动」: * 勾动作补模块使用与入口,模块级勾选框全给全撤,关入口收回该菜单下的全部授权。 * * 勾选框是 ui/Checkbox(label 包原生 input),测试直接驱动 input 的 change —— * jsdom 不保证 label 点击会转发给内部控件,走 change 才是组件真实监听的入口。 */ import { describe, it, expect, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import UserPowerPanel from '@/views/system/user/UserPowerPanel.vue' import { clearMessages } from '@/stores/i18n' /** 菜单树夹具:顺序模拟 s_menu 的 b_depth / b_xh 排序(父目录在前) */ const MENU_TREE = [ { b_id: 'base', b_parent_id: null, b_name: '基础资料', b_menu_type: 'directory', b_xh: 10 }, { b_id: 'system', b_parent_id: null, b_name: '系统管理', b_menu_type: 'directory', b_xh: 20 }, { b_id: 'partner', b_parent_id: 'base', b_name: '合作伙伴', b_menu_type: 'page', b_xh: 10 }, // 外链菜单:不绑模块,入口就是唯一的授权单元 { b_id: 'portal', b_parent_id: 'base', b_name: '外部系统', b_menu_type: 'external', b_xh: 20 }, { b_id: 'user', b_parent_id: 'system', b_name: '用户管理', b_menu_type: 'page', b_xh: 10 }, ] const MENU_MODULES = { // 合作伙伴菜单绑两个模块,动作集不同 → 验证动作各自成组、互不影响 partner: [ { b_menu_id: 'partner', b_module_id: 'v_b_othercompany', b_xh: 10 }, { b_menu_id: 'partner', b_module_id: 'v_b_contact', b_xh: 20 }, ], // 用户管理绑两个模块,其中币种(b_money)没配权限点 → 验证「未配置权限点的模块不渲染」 user: [ { b_menu_id: 'user', b_module_id: 'v_b_user', b_xh: 10 }, { b_menu_id: 'user', b_module_id: 'b_money', b_xh: 20 }, ], } const MODULE_BY_ID = new Map([ ['v_b_othercompany', { b_id: 'v_b_othercompany', b_name: '合作伙伴' }], ['v_b_contact', { b_id: 'v_b_contact', b_name: '联系人' }], ['v_b_user', { b_id: 'v_b_user', b_name: '用户管理' }], ['b_money', { b_id: 'b_money', b_name: '币种' }], ]) /** 每个模块的权限点:access 是「模块使用」(并入模块级勾选框),其余是「动作」列 */ const POWER_BY_MODULE = { v_b_othercompany: [ { b_id: 'module.v_b_othercompany', b_name: '模块使用', b_action: 'access' }, { b_id: 'action.v_b_othercompany.create', b_name: '新增', b_action: 'create' }, { b_id: 'action.v_b_othercompany.update', b_name: '修改', b_action: 'update' }, { b_id: 'action.v_b_othercompany.audit', b_name: '审核', b_action: 'audit' }, ], // 联系人只有新增 / 删除:动作列也只剩这两项 v_b_contact: [ { b_id: 'module.v_b_contact', b_name: '模块使用', b_action: 'access' }, { b_id: 'action.v_b_contact.create', b_name: '新增', b_action: 'create' }, { b_id: 'action.v_b_contact.delete', b_name: '删除', b_action: 'delete' }, ], v_b_user: [{ b_id: 'module.v_b_user', b_name: '模块使用', b_action: 'access' }], } /** s_power 全量启用编码(含 menu.*),父级加载后传给面板,供「全授权」使用 */ const ALL_POWER_IDS = [ 'menu.partner', 'menu.portal', 'menu.user', 'module.v_b_othercompany', 'action.v_b_othercompany.create', 'action.v_b_othercompany.update', 'action.v_b_othercompany.audit', 'module.v_b_contact', 'action.v_b_contact.create', 'action.v_b_contact.delete', 'module.v_b_user', ] /** 合作伙伴模块(v_b_othercompany)的权限点全量:模块级勾选框勾上时就该是这些 */ const OTHERCOMPANY_ALL = [ 'module.v_b_othercompany', 'action.v_b_othercompany.create', 'action.v_b_othercompany.update', 'action.v_b_othercompany.audit', ] beforeEach(() => { localStorage.clear() sessionStorage.clear() clearMessages() }) function mountPanel(props = {}) { return mount(UserPowerPanel, { props: { modelValue: [], menuTree: MENU_TREE, menuModules: MENU_MODULES, moduleById: MODULE_BY_ID, powerByModule: POWER_BY_MODULE, allPowerIds: ALL_POWER_IDS, ...props, }, }) } /** 选中一个菜单(打开时不再默认选中,多数用例只需把右侧切到目标菜单) */ async function mountWithMenu(menuId, props = {}) { const wrapper = mountPanel(props) wrapper.vm.selectedMenuId = menuId await nextTick() return wrapper } /** 按标题文本找树行(每次重新查询,避免拿着已过期的 DOM 包装器做断言) */ function findRow(wrapper, text) { return wrapper.findAll('.tree-node').find((row) => row.text().includes(text)) } /** 模块行的模块名清单(按行顺序) */ function moduleNames(wrapper) { return wrapper.findAll('.power-row__module .checkbox-label').map((el) => el.text()) } /** 页头(菜单名 + 入口)的入口勾选框(目录没有入口权限点时返回 undefined) */ function entryCheck(wrapper) { const found = wrapper.find('.power-head__entry') return found.exists() ? found : undefined } /** 第 index 个模块行的模块级勾选框 */ function moduleCheck(wrapper, index = 0) { return wrapper.findAll('.power-row__module .checkbox')[index] } /** 勾选框形态的开关总数:页头入口 1 个 + 每行模块 1 个(动作勾选框在「动作」列,不计入) */ function useChecks(wrapper) { return wrapper.findAll('.power-head__entry, .power-row__module .checkbox') } /** 第 index 行行尾的「已选」计数 */ function rowCount(wrapper, index = 0) { return wrapper.findAll('.power-row .power-col--count')[index] } /** 某个模块行(按模块名定位,比按下标稳) */ function moduleRow(wrapper, moduleName) { return wrapper .findAll('.power-row') .find((row) => row.find('.power-row__module .checkbox-label').text() === moduleName) } /** * 动作勾选框:按「模块名 + 动作名」定位。 * 动作只有「动作」列有,所以按名字找比按下标找稳,动作数量变化也不影响。 */ function actionCheck(wrapper, moduleName, actionName) { return moduleRow(wrapper, moduleName) .findAll('.power-row__actions .checkbox') .find((el) => el.find('.checkbox-label').text() === actionName) } /** 某个模块的动作名清单(按铺排顺序) */ function actionNames(wrapper, moduleName) { return moduleRow(wrapper, moduleName) .findAll('.power-row__actions .checkbox-label') .map((el) => el.text()) } /** 驱动 ui/Checkbox 内部的原生 input:勾上 / 取消 */ function setCheck(checkbox, checked = true) { return checkbox.find('.checkbox-input').setValue(checked) } /** 面板顶部工具栏按钮:0 = 全授权,1 = 全禁用 */ function toolbarButtons(wrapper) { return wrapper.findAll('.power-toolbar__actions button') } /** 回传的勾选结果(取最后一次 emit) */ function lastEmitted(wrapper) { const events = wrapper.emitted('update:modelValue') return events[events.length - 1][0] } describe('功能权限面板:打开时的初始状态', () => { it('只展开「全部分类」,业务目录收起、不选中任何菜单', async () => { const wrapper = mountPanel({ menuTree: [] }) await nextTick() await wrapper.setProps({ menuTree: MENU_TREE }) await nextTick() expect(wrapper.vm.selectedMenuId).toBe('') // 业务目录的展开键不由面板补写:虚拟根的展开由 FmsTree 承担 expect(wrapper.vm.expandedKeys).toEqual([]) expect(findRow(wrapper, '全部分类')).toBeTruthy() expect(findRow(wrapper, '基础资料')).toBeTruthy() // 目录收起 → 其下的页面菜单不可见 expect(findRow(wrapper, '合作伙伴')).toBeUndefined() expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单') }) }) describe('功能权限面板:菜单选中语义', () => { it('重复点击已选中的菜单:保持选中,不跳回「全部分类」', async () => { const wrapper = mountPanel() await nextTick() // 展开目录 → 选中「合作伙伴」→ 再点一次(ui/Tree 会传出空 keys,即「取消选中」) await findRow(wrapper, '基础资料').find('.tree-node-switcher').trigger('click') await nextTick() await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click') await nextTick() expect(wrapper.vm.selectedMenuId).toBe('partner') await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click') await nextTick() expect(wrapper.vm.selectedMenuId).toBe('partner') expect(findRow(wrapper, '合作伙伴').classes()).toContain('is-selected') expect(findRow(wrapper, '全部分类').classes()).not.toContain('is-selected') }) it('点「全部分类」虚拟根:清空选中,右侧回到空态提示', async () => { const wrapper = mountPanel() await nextTick() await findRow(wrapper, '基础资料').find('.tree-node-switcher').trigger('click') await nextTick() await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click') await nextTick() expect(wrapper.vm.selectedMenuId).toBe('partner') await findRow(wrapper, '全部分类').find('.tree-node-content').trigger('click') await nextTick() expect(wrapper.vm.selectedMenuId).toBe('') expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单') }) it('点另一个菜单:选中切到该菜单', async () => { const wrapper = mountPanel() await nextTick() // 「用户管理」在收起的「系统管理」下:先展开目录再点 await findRow(wrapper, '系统管理').find('.tree-node-switcher').trigger('click') await nextTick() await findRow(wrapper, '用户管理').find('.tree-node-content').trigger('click') await nextTick() expect(wrapper.vm.selectedMenuId).toBe('user') expect(moduleNames(wrapper)).toEqual(['用户管理']) }) }) describe('功能权限面板:右侧结构', () => { it('页头:菜单名做标题 + 入口勾选框 + 本页进度', async () => { const wrapper = await mountWithMenu('partner') expect(wrapper.find('.power-head__name').text()).toBe('合作伙伴') expect(entryCheck(wrapper).find('.checkbox-label').text()).toBe('可访问') expect(wrapper.find('.power-head__meta').text()).toBe('本页 2 个模块,已授权 0 个') // 页头里只有菜单名、勾选框与进度,没有多余的说明句 expect(wrapper.find('.power-head').text()).toBe('合作伙伴 可访问 本页 2 个模块,已授权 0 个') }) it('本页进度:至少给了一项权限的模块才计入已授权', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'], }) expect(wrapper.find('.power-head__meta').text()).toBe('本页 2 个模块,已授权 1 个') }) it('入口的连带影响只进 title,不常显在页头', async () => { const wrapper = await mountWithMenu('partner') expect(entryCheck(wrapper).attributes('title')).toBe( '可访问「合作伙伴」(关闭会收回该页面下的全部模块授权)', ) }) it('表格:模块 / 动作 / 已选三列,一个模块一行', async () => { const wrapper = await mountWithMenu('partner') expect(wrapper.find('.power-grid__head').text()).toBe('模块动作已选') expect(moduleNames(wrapper)).toEqual(['合作伙伴', '联系人']) expect(wrapper.findAll('.power-row')).toHaveLength(2) // 勾选框形态的开关:页头入口 1 个 + 模块级 2 个(动作勾选框在「动作」列,不计入) expect(useChecks(wrapper)).toHaveLength(3) }) it('动作按模块分行:每行的动作只属于该行模块,互不挤占', async () => { const wrapper = await mountWithMenu('partner') expect(actionNames(wrapper, '合作伙伴')).toEqual(['新增', '修改', '审核']) expect(actionNames(wrapper, '联系人')).toEqual(['新增', '删除']) }) it('动作勾选框挂 title(模块名 + 动作名),名称超长被省略时可查看全称', async () => { const wrapper = await mountWithMenu('partner') expect(actionCheck(wrapper, '合作伙伴', '新增').attributes('title')).toBe('合作伙伴 新增') }) it('行尾「已选 x/y」= 该模块已授权的动作数 / 动作总数', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['module.v_b_othercompany', 'action.v_b_othercompany.create'], }) // 「模块使用」不计入已选:它并入模块级勾选框,与「动作」列不是一回事 expect(rowCount(wrapper, 0).text()).toBe('1/3') expect(rowCount(wrapper, 1).text()).toBe('0/2') }) it('只有模块使用、没有动作的模块不显示计数', async () => { const wrapper = await mountWithMenu('user') expect(moduleNames(wrapper)).toEqual(['用户管理']) expect(rowCount(wrapper, 0).text()).toBe('') }) it('模块级勾选框三态:全选 / 半选 / 未选', async () => { const wrapper = await mountWithMenu('partner') // 全选:模块使用 + 全部动作都在 await wrapper.setProps({ modelValue: OTHERCOMPANY_ALL }) expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-checked') expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-indeterminate') // 半选:进了这个模块,但只给了部分动作 await wrapper.setProps({ modelValue: ['module.v_b_othercompany', 'action.v_b_othercompany.create'], }) expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-checked') expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-indeterminate') // 未选 await wrapper.setProps({ modelValue: [] }) expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-checked') expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-indeterminate') }) it('模块级勾选框的 title 说清点它会全给还是全撤', async () => { const wrapper = await mountWithMenu('partner') expect(moduleCheck(wrapper, 0).attributes('title')).toBe('全选「合作伙伴」的 4 项权限') await wrapper.setProps({ modelValue: OTHERCOMPANY_ALL }) expect(moduleCheck(wrapper, 0).attributes('title')).toBe('取消「合作伙伴」的全部授权') }) it('已授权的入口渲染为打开态', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner'] }) expect(wrapper.vm.menuPowerCode).toBe('menu.partner') expect(entryCheck(wrapper).classes()).toContain('checkbox-checked') }) it('目录:不渲染入口勾选框(没有 menu.* 权限点),只在页头说明原因', async () => { const wrapper = await mountWithMenu('base') expect(useChecks(wrapper)).toHaveLength(0) // 目录没有可勾的入口:页头只显示名称,不出现勾选框 expect(entryCheck(wrapper)).toBeUndefined() expect(wrapper.find('.power-head__name').text()).toBe('基础资料') expect(wrapper.find('.power-head__note').text()).toBe('目录不需要单独授权') }) it('外链菜单:没有可授权模块,连表头都不渲染,入口勾选框独立可用', async () => { const wrapper = await mountWithMenu('portal') expect(moduleNames(wrapper)).toEqual([]) expect(wrapper.findAll('.power-row')).toHaveLength(0) expect(wrapper.find('.power-grid').exists()).toBe(false) expect(wrapper.find('.power-head__meta').exists()).toBe(false) await setCheck(entryCheck(wrapper)) expect(lastEmitted(wrapper)).toEqual(['menu.portal']) }) it('页面没绑模块(自定义页):只剩入口勾选框,勾选即全部权限', async () => { const wrapper = await mountWithMenu('partner', { menuModules: {} }) expect(wrapper.find('.power-grid').exists()).toBe(false) expect(useChecks(wrapper)).toHaveLength(1) await setCheck(entryCheck(wrapper)) expect(lastEmitted(wrapper)).toEqual(['menu.partner']) }) it('未配置权限点的模块不渲染,也不显示空态文案', async () => { const wrapper = await mountWithMenu('user') // 「币种」绑定了但没配权限点:不渲染成行,也不出任何提示 expect(moduleNames(wrapper)).toEqual(['用户管理']) // 只有模块使用权限点的模块没有动作勾选框 expect(wrapper.findAll('.power-row__actions .checkbox')).toHaveLength(0) expect(wrapper.find('.power-panel__main').text()).not.toContain('未配置权限点') }) }) describe('功能权限面板:全授权 / 全禁用', () => { it('工具栏在面板顶部:说明 + 两个按钮', async () => { const wrapper = mountPanel() await nextTick() expect(wrapper.find('.power-toolbar__label').text()).toBe('该账号的全部功能权限') expect(toolbarButtons(wrapper).map((el) => el.text())).toEqual(['全授权', '全禁用']) }) it('全授权:一次勾满 s_power 的全部权限点', async () => { const wrapper = await mountWithMenu('partner') await toolbarButtons(wrapper)[0].trigger('click') await nextTick() expect(lastEmitted(wrapper)).toEqual(ALL_POWER_IDS) }) it('全禁用:清空该账号的全部功能权限', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ALL_POWER_IDS }) await toolbarButtons(wrapper)[1].trigger('click') await nextTick() expect(lastEmitted(wrapper)).toEqual([]) }) it('工具栏不依赖选中菜单:未选中菜单时同样可用', async () => { const wrapper = mountPanel() await nextTick() expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单') await toolbarButtons(wrapper)[0].trigger('click') await nextTick() expect(lastEmitted(wrapper)).toEqual(ALL_POWER_IDS) }) }) describe('功能权限面板:三层联动', () => { it('勾模块级勾选框:一次给全该模块的权限点,并补上菜单入口', async () => { const wrapper = await mountWithMenu('partner') await setCheck(moduleCheck(wrapper)) await nextTick() expect(lastEmitted(wrapper)).toEqual([...OTHERCOMPANY_ALL, 'menu.partner']) }) it('半选时点模块级勾选框:补齐到全选,不产生重复项', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['module.v_b_othercompany', 'action.v_b_othercompany.create'], }) await setCheck(moduleCheck(wrapper)) await nextTick() expect(lastEmitted(wrapper)).toEqual([...OTHERCOMPANY_ALL, 'menu.partner']) }) it('取消模块级勾选框:收回该模块的全部权限点,不动菜单入口', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner', ...OTHERCOMPANY_ALL], }) await setCheck(moduleCheck(wrapper), false) await nextTick() expect(lastEmitted(wrapper)).toEqual(['menu.partner']) }) it('勾单个动作:自动补上模块使用与菜单入口', async () => { const wrapper = await mountWithMenu('partner') await setCheck(actionCheck(wrapper, '合作伙伴', '新增')) await nextTick() expect(lastEmitted(wrapper)).toEqual([ 'action.v_b_othercompany.create', 'module.v_b_othercompany', 'menu.partner', ]) }) it('取消单个动作:不动模块使用与入口,模块级勾选框落到半选', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'], }) await setCheck(actionCheck(wrapper, '合作伙伴', '新增'), false) await nextTick() expect(lastEmitted(wrapper)).toEqual(['menu.partner', 'module.v_b_othercompany']) expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-indeterminate') expect(rowCount(wrapper, 0).text()).toBe('0/3') }) it('开菜单入口:只补入口本身,不给任何模块权限', async () => { const wrapper = await mountWithMenu('partner') await setCheck(entryCheck(wrapper)) await nextTick() expect(lastEmitted(wrapper)).toEqual(['menu.partner']) }) it('关菜单入口:连带收回该菜单下的全部模块与动作授权', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'], }) await setCheck(entryCheck(wrapper), false) await nextTick() expect(lastEmitted(wrapper)).toEqual([]) }) it('勾选态回显:各行动作各管各的,只勾动作时模块级是半选', async () => { const wrapper = await mountWithMenu('partner', { modelValue: ['action.v_b_othercompany.create'], }) expect(actionCheck(wrapper, '合作伙伴', '新增').classes()).toContain('checkbox-checked') expect(actionCheck(wrapper, '合作伙伴', '修改').classes()).not.toContain('checkbox-checked') // 同名动作在别的模块行里不受影响 expect(actionCheck(wrapper, '联系人', '新增').classes()).not.toContain('checkbox-checked') // 只勾了动作、没勾模块使用 → 模块级勾选框半选 expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-indeterminate') }) })