Files
workspace/code/fms/fms-vue/tests/views/user-power-panel.spec.js
2026-09-22 22:41:36 +08:00

518 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 功能权限面板(用户管理 → 分配权限 → 功能权限)。
*
* 数据加载与保存由父级 UserPowerDrawer 负责,面板自身只做这几件事,测试只锁这几件:
* 1. 打开时的初始状态:只展开「全部分类」虚拟根,业务目录收起、不选中任何菜单;
* 2. 菜单选中语义:重复点击已选中的菜单不取消选中,只有点「全部分类」才清空;
* 3. 右侧结构:左右两栏用 Splitter 分栏(左树栏可拖拽调宽),
* 右栏是页头(菜单名 + 入口勾选框 + 右端 x/y 进度)+ 一个模块一块的授权块列表。
* 块头是模块(模块级勾选框 + 「已选 x/y」):勾选框表示"这个数据给没给"——有任意
* 一项权限即勾选、一项都没有才未勾选(不做半选),点一下即给模块使用 / 整块收回;
* 块内是它的动作,层级靠块头的上下关系;
* 没配权限点的模块不渲染;目录不渲染入口勾选框,说明放内容区中间而不是页头;
* 本页没有可配置模块时内容区中间给一句说明(目录与外链 / 自定义页文案不同);
* 4. 勾选结果经 v-model 回传,三级是「逐级给、连带收」:
* 勾下级自动补上级(操作 → 模块使用 → 入口);勾上级不替下级勾选;
* 取消上级连带收回下级(关入口收回本页全部,取消模块收回该模块全部权限点)。
*
* 勾选框是 ui/Checkbox(label 包原生 input),测试直接驱动 input 的 change ——
* jsdom 不保证 label 点击会转发给内部控件,走 change 才是组件真实监听的入口。
*
* 账号级的批量操作(全授权 / 全禁用 / 清空权限)不在本面板,由 user-power-drawer.spec.js 覆盖。
*/
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' }],
}
/** 合作伙伴模块(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,
...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__head .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__head .checkbox')[index]
}
/** 勾选框形态的开关总数:页头入口 1 个 + 每块模块 1 个(动作勾选框在块内,不计入) */
function useChecks(wrapper) {
return wrapper.findAll('.power-head__entry, .power-row__head .checkbox')
}
/** 第 index 个授权块块头的「已选」计数(没有动作的模块不渲染该节点) */
function rowCount(wrapper, index = 0) {
return wrapper.findAll('.power-row .power-row__count')[index]
}
/** 某个授权块(按模块名定位,比按下标稳) */
function moduleRow(wrapper, moduleName) {
return wrapper
.findAll('.power-row')
.find((row) => row.find('.power-row__head .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)
}
/** 回传的勾选结果(取最后一次 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('左右两栏用 Splitter 分栏:左栏菜单树可拖拽调宽,右栏吃满剩余宽度', async () => {
const wrapper = await mountPanel()
await nextTick()
expect(wrapper.find('.power-panel__body.fms-splitter').exists()).toBe(true)
expect(wrapper.find('.power-panel__body .fms-splitter-bar').exists()).toBe(true)
const panels = wrapper.find('.power-panel__body').findAll('.fms-splitter-panel')
expect(panels).toHaveLength(2)
expect(panels[0].find('.fms-tree-panel').exists()).toBe(true)
expect(panels[1].find('.power-panel__main').exists()).toBe(true)
})
it('页头:菜单名做标题 + 入口勾选框 + 右端的授权进度', async () => {
const wrapper = await mountWithMenu('partner')
expect(wrapper.find('.power-head__name').text()).toBe('合作伙伴')
expect(entryCheck(wrapper).find('.checkbox-label').text()).toBe('可访问')
// 进度常显的只是两个数字,完整说法在 title 里
expect(wrapper.find('.power-head__meta').text()).toBe('0/2')
expect(wrapper.find('.power-head__meta').attributes('title')).toBe(
'本页 2 个模块,已授权 0 个',
)
// 页头里只有菜单名、勾选框与进度,没有多余的说明句
expect(wrapper.find('.power-head').text()).toBe('合作伙伴 可访问 0/2')
})
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('1/2')
})
it('入口的连带影响只进 title,不常显在页头', async () => {
const wrapper = await mountWithMenu('partner')
expect(entryCheck(wrapper).attributes('title')).toBe(
'可访问「合作伙伴」(打开只给入口,本页模块与操作要自己勾;关闭会全部收回)',
)
})
it('授权块:块头是模块(勾选框 + 已选),块内是该模块的动作', async () => {
const wrapper = await mountWithMenu('partner')
expect(moduleNames(wrapper)).toEqual(['合作伙伴', '联系人'])
expect(wrapper.findAll('.power-row')).toHaveLength(2)
// 模块在上、动作在下:块头(.power-row__head)里的勾选框只有模块级那一个
expect(wrapper.findAll('.power-row__head .checkbox')).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)).toBeUndefined()
})
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: ['action.v_b_othercompany.create'] })
expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-checked')
// 只留了模块使用、一个动作都没给:这个数据仍算有权限 → 勾选
await wrapper.setProps({ modelValue: ['module.v_b_othercompany'] })
expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-checked')
expect(moduleCheck(wrapper, 0).classes()).not.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('给「合作伙伴」模块权限(不含操作)')
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').text()).toBe('基础资料')
expect(wrapper.find('.power-panel__state').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)
expect(wrapper.find('.power-panel__state').text()).toBe('本页没有可配置的模块')
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(wrapper.find('.power-panel__state').text()).toBe('本页没有可配置的模块')
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('未配置权限点')
// 本页仍有可配置模块 → 内容区不出现整体的空态说明
expect(wrapper.find('.power-panel__state').exists()).toBe(false)
})
})
describe('功能权限面板:三层联动', () => {
it('勾模块级勾选框:只给该模块的模块使用权限,并补上菜单入口', async () => {
const wrapper = await mountWithMenu('partner')
await setCheck(moduleCheck(wrapper))
await nextTick()
// 勾上级不替下级勾选:只给「模块使用」,下面的操作要自己勾
expect(lastEmitted(wrapper)).toEqual(['module.v_b_othercompany', 'menu.partner'])
})
it('已授权的模块点勾选框:收回该模块全部权限', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
// 勾选框显示的是"这个数据给没给"(已给 → 勾选),点一下就是它的反面:整块收回
await setCheck(moduleCheck(wrapper), false)
await nextTick()
expect(lastEmitted(wrapper)).toEqual([])
})
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-checked')
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-checked')
expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-indeterminate')
})
})