Files
workspace/code/fms/fms-vue/tests/views/user-power-panel.spec.js
T
2026-09-21 17:33:45 +08:00

539 lines
22 KiB
JavaScript
Raw 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. 右侧结构:页头(菜单名 + 入口勾选框 + 本页进度)+ 一张「模块 / 动作 / 已选」三列表。
* 模块级勾选在「模块」列、动作勾选在「动作」列,层级靠列而不是靠嵌套容器;
* 目录不渲染入口勾选框、没配权限点的模块不渲染,都不出空态文案;
* 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')
})
})