20260920220520

This commit is contained in:
oneao committed 2026-09-20 22:05:20 +08:00
1 parent 6f03d92425
commit b3364fe6dc
44 files changed
+2313 -2737

No files matched your search

+86 -41
View File
@@ -1,7 +1,12 @@
/**
* menus store(s_menu 动态菜单):按 menu.* 授权裁剪可见菜单 +
* 路由地址 → 菜单反查(路由守卫用它兜住手输地址)。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { useMenuStore } from '@/stores/menus'
import { usePermissionStore } from '@/stores/permissions'
import { createTestPinia } from './helpers/app.js'
// mock 后端数据接口,避免真实网络请求
vi.mock('@/services/api', () => ({
@@ -14,26 +19,38 @@ import { loadDataApi } from '@/services/api'
// 因此这里用业务菜单构造数据,验证菜单 store 本身无任何编码/账号特判。
const mockMenus = [
{
b_id: '1',
b_code: 'base',
b_id: 'base',
b_name: '基础数据',
b_menu_type: 'directory',
b_parent_id: null,
b_canuse: 1,
},
{
b_id: '2',
b_code: 'contact',
b_id: 'contact',
b_name: '往来单位',
b_menu_type: 'page',
b_parent_id: '1',
b_parent_id: 'base',
b_route: '/base/contact',
b_canuse: 1,
},
{
b_id: 'system',
b_name: '系统管理',
b_menu_type: 'directory',
b_parent_id: null,
b_canuse: 1,
},
{
b_id: 'user',
b_name: '用户管理',
b_menu_type: 'page',
b_parent_id: 'system',
b_route: '/system/user',
b_canuse: 1,
},
{
// 没有任何子项的目录:可见性裁剪后不应渲染
b_id: '3',
b_code: 'empty-dir',
b_id: 'empty-dir',
b_name: '空目录',
b_menu_type: 'directory',
b_parent_id: null,
@@ -41,14 +58,18 @@ const mockMenus = [
},
]
function mockSModuleData(menus) {
loadDataApi.mockImplementation((view) =>
Promise.resolve({
data: view === 's_menu' ? menus : [],
code: 0,
success: true,
}),
)
function mockData({ menus = mockMenus, powers = [], userPowers = [] } = {}) {
loadDataApi.mockImplementation((table) => {
const data =
table === 's_menu'
? menus
: table === 's_power'
? powers
: table === 's_user_power'
? userPowers
: []
return Promise.resolve({ data, code: 0, success: true })
})
}
function setUser(account, id) {
@@ -59,61 +80,64 @@ function setUser(account, id) {
/** 冲刷微任务:让 setSession 触发的 watch(clear) 先执行 */
const flush = () => Promise.resolve()
function collectCodes(nodes) {
const codes = []
function collectIds(nodes) {
const ids = []
for (const node of nodes) {
codes.push(String(node.b_code))
if (node.children) codes.push(...collectCodes(node.children))
ids.push(String(node.b_id))
if (node.children) ids.push(...collectIds(node.children))
}
return codes
return ids
}
beforeEach(() => {
setActivePinia(createPinia())
createTestPinia()
loadDataApi.mockReset()
})
describe('menus store(s_menu 动态菜单)', () => {
it('普通用户:全部启用菜单按树渲染,空目录剪枝(无编码特判)', async () => {
mockSModuleData(mockMenus)
it('普通用户:只渲染有 menu.* 授权的页面菜单,空目录剪枝', async () => {
mockData({
powers: [{ b_id: 'menu.contact' }, { b_id: 'menu.user' }],
// 只授权联系人:用户管理虽在 s_power 里,但用户没有该授权 → 系统管理目录随之消失
userPowers: [{ b_user_id: '123', b_power_id: 'menu.contact', b_canuse: 1 }],
})
setUser('u1', 123)
await flush()
const menus = useMenuStore()
await menus.load()
const codes = collectCodes(menus.visibleMenus)
expect(codes).toContain('base')
expect(codes).toContain('contact')
// 目录没有可见子菜单时不渲染
expect(codes).not.toContain('empty-dir')
const perm = usePermissionStore()
const menus = useMenuStore()
await Promise.all([perm.load(), menus.load()])
expect(collectIds(menus.visibleMenus)).toEqual(['base', 'contact'])
})
it('管理员特判不在菜单 store:g3soft 与普通用户渲染一致', async () => {
it('g3soft 提权:不查授权也全部可见(管理员不裁剪)', async () => {
// 模块中心的管控在路由守卫(meta.adminOnly)与侧栏渲染(仅 g3soft 追加固定入口),
// 菜单 store 对所有登录主体一视同仁
mockSModuleData(mockMenus)
// 菜单 store 对管理员只跳过授权裁剪,不做编码特判
mockData()
setUser('g3soft', 1)
await flush()
const menus = useMenuStore()
await menus.load()
const codes = collectCodes(menus.visibleMenus)
expect(codes).toHaveLength(2)
expect(codes).toEqual(expect.arrayContaining(['base', 'contact']))
const perm = usePermissionStore()
const menus = useMenuStore()
await Promise.all([perm.load(), menus.load()])
expect(collectIds(menus.visibleMenus)).toEqual(['base', 'contact', 'system', 'user'])
})
it('同一主体重复 load 不重复发请求', async () => {
mockSModuleData(mockMenus)
mockData()
setUser('u1', 123)
await flush()
const menus = useMenuStore()
await menus.load()
await menus.load()
// 只请求 s_menu(权限 store 未参与加载)
expect(loadDataApi).toHaveBeenCalledTimes(1)
})
it('切换账号:缓存清空并重新加载', async () => {
mockSModuleData(mockMenus)
mockData()
setUser('u1', 123)
await flush()
const menus = useMenuStore()
@@ -133,3 +157,24 @@ describe('menus store(s_menu 动态菜单)', () => {
expect(menus.rawMenus.length).toBe(mockMenus.length)
})
})
describe('menus store:路由反查(findMenuByRoute)', () => {
it('命中菜单路由与其子路径,非菜单路由返回 null', async () => {
mockData()
setUser('u1', 123)
await flush()
const menus = useMenuStore()
await menus.load()
expect(menus.findMenuByRoute('/base/contact')?.b_id).toBe('contact')
// 详情页等子路径同样算作该菜单的入口
expect(menus.findMenuByRoute('/base/contact/123')?.b_id).toBe('contact')
expect(menus.findMenuByRoute('/base/contact/?x=1')?.b_id).toBe('contact')
// 工作台、演示页、模块中心都不在 s_menu
expect(menus.findMenuByRoute('/dashboard')).toBeNull()
expect(menus.findMenuByRoute('/demo')).toBeNull()
expect(menus.findMenuByRoute('/module/module-management')).toBeNull()
// 目录没有路由,不会被匹配到
expect(menus.findMenuByRoute('/base')).toBeNull()
})
})
+101 -46
View File
@@ -1,7 +1,14 @@
/**
* permissions store(新表口径,设计文档 §14)。
*
* 数据来源:s_module(启用模块)+ s_power(启用权限点)+ s_user_power(用户授权)。
* 判断口径:白名单(无授权即无权限);标准操作由 module.* 统一表达,
* 业务动作走 action.{模块}.{动作};菜单入口走 menu.*。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { usePermissionStore } from '@/stores/permissions'
import { createTestPinia } from './helpers/app.js'
// mock 后端数据接口,避免真实网络请求
vi.mock('@/services/api', () => ({
@@ -11,58 +18,69 @@ vi.mock('@/services/api', () => ({
import { loadDataApi } from '@/services/api'
const mockModules = [
{ b_id: 'v_b_contact', b_name: '联系人', b_module_type: 'data', b_parent_id: 'base' },
]
const mockPowers = [
{ b_id: 'menu.contact', b_type: 'menu', b_object_type: 'menu', b_object_id: 'contact' },
{
b_id: '1',
b_code: 'm1',
b_name: 'M1',
b_canuse: 1,
b_canmenu: 1,
b_module_type: 'directory',
b_parent_id: null,
b_icon: '',
b_route: '',
b_id: 'module.v_b_contact',
b_type: 'module',
b_object_type: 'module',
b_object_id: 'v_b_contact',
},
{
b_id: 'action.v_b_contact.audit',
b_type: 'action',
b_object_type: 'module',
b_object_id: 'v_b_contact',
},
]
const mockUserModules = [{ b_id: '1', b_module_id: '1', b_user_id: '123' }]
const mockUserPowers = []
const mockPowers = []
function mockApiResponses() {
loadDataApi.mockImplementation((view) => {
const data =
view === 's_module'
? mockModules
: view === 'b_user_module'
? mockUserModules
: view === 'b_user_power'
? mockUserPowers
: view === 's_module_power'
? mockPowers
: []
return Promise.resolve({ data, code: 0, success: true })
})
/** 用户 123:能进联系人菜单、能用联系人模块,但没有审核动作权限 */
const mockUserPowers = [
{ b_user_id: '123', b_power_id: 'menu.contact', b_canuse: 1 },
{ b_user_id: '123', b_power_id: 'module.v_b_contact', b_canuse: 1 },
// s_power 中不存在的权限点不算数(停用/已删除)
{ b_user_id: '123', b_power_id: 'module.b_money', b_canuse: 1 },
]
function mockApiResponses(overrides = {}) {
const dataByTable = {
s_module: mockModules,
s_power: mockPowers,
s_user_power: mockUserPowers,
...overrides,
}
loadDataApi.mockImplementation((table) =>
Promise.resolve({ data: dataByTable[table] || [], code: 0, success: true }),
)
}
function setLoggedInUser() {
function setLoggedInUser(account = 'u1', id = 123) {
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 123, account: 'u1', name: 'U' } }, 'ORG')
auth.setSession({ token: 't', user: { id, account, name: 'U' } }, 'ORG')
}
// 冲刷微任务:让 setSession 触发的 watch(clear) 先执行,隔离“手动 clear”的断言
const flush = () => Promise.resolve()
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
sessionStorage.clear()
createTestPinia()
loadDataApi.mockReset()
})
describe('permissions store', () => {
it('load 成功后写入 modules 且记录 loadedPrincipalKey', async () => {
describe('permissions store:加载', () => {
it('load 成功后写入 modules 与授权码,并记录 loadedPrincipalKey', async () => {
mockApiResponses()
setLoggedInUser()
const perm = usePermissionStore()
await perm.load()
expect(perm.modules.length).toBe(1)
// s_power 中不存在的授权被过滤
expect(perm.grantedPowerCodes).toEqual(['menu.contact', 'module.v_b_contact'])
})
it('同一主体重复 load 不重复发请求(按主体去重)', async () => {
@@ -71,8 +89,8 @@ describe('permissions store', () => {
const perm = usePermissionStore()
await perm.load()
await perm.load()
// 首次发 4 个接口调用;第二次因主体已加载直接 return
expect(loadDataApi).toHaveBeenCalledTimes(4)
// 首次发 3 个接口调用(s_module / s_power / s_user_power);第二次因主体已加载直接 return
expect(loadDataApi).toHaveBeenCalledTimes(3)
})
it('登录场景:load 进行中触发 clear(主体相同)不作废结果', async () => {
@@ -95,27 +113,64 @@ describe('permissions store', () => {
const perm = usePermissionStore()
const p = perm.load() // 主体 ORG:123 进行中
// 模拟切换账号:authStore 变为另一主体,watch 触发 clear
const auth = useAuthStore()
auth.setSession({ token: 't2', user: { id: 999, account: 'u2', name: 'U2' } }, 'ORG2')
setLoggedInUser('u2', 999)
perm.clear()
await p
// 旧请求被作废:结果丢弃,modules 仍为空(且未触发新主体的 load)
expect(perm.modules.length).toBe(0)
})
it('g3soft 提权:仅加载全部启用模块', async () => {
loadDataApi.mockImplementation((view) =>
Promise.resolve({
data: view === 's_module' ? mockModules : [],
code: 0,
success: true,
}),
)
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 1, account: 'g3soft', name: 'G' } }, 'ORG')
it('g3soft 提权:仅加载全部启用模块,不查授权表', async () => {
mockApiResponses()
setLoggedInUser('g3soft', 1)
const perm = usePermissionStore()
await perm.load()
expect(perm.isG3soft).toBe(true)
expect(perm.modules.length).toBe(1)
expect(perm.grantedPowerCodes).toEqual([])
expect(loadDataApi).toHaveBeenCalledTimes(1)
expect(loadDataApi).toHaveBeenCalledWith('s_module', 'b_canuse = 1', 'b_id ASC')
})
})
describe('permissions store:判断口径', () => {
async function loadedStore() {
mockApiResponses()
setLoggedInUser()
const perm = usePermissionStore()
await perm.load()
return perm
}
it('canEnterMenu:有 menu.* 授权才能进入该菜单', async () => {
const perm = await loadedStore()
expect(perm.canEnterMenu('contact')).toBe(true)
expect(perm.canEnterMenu('user')).toBe(false)
})
it('canAccess:有 module.* 授权才能使用该模块', async () => {
const perm = await loadedStore()
expect(perm.canAccess('v_b_contact')).toBe(true)
// 未授权模块 / 不存在的模块一律拒绝
expect(perm.canAccess('b_money')).toBe(false)
expect(perm.canAccess('not-exist')).toBe(false)
})
it('canPower:标准操作由模块使用权限统一表达,业务动作需单独授权', async () => {
const perm = await loadedStore()
expect(perm.canPower('v_b_contact', 'create')).toBe(true)
expect(perm.canPower('v_b_contact', 'delete')).toBe(true)
expect(perm.canPower('v_b_contact', 'audit')).toBe(false)
expect(perm.canPower('b_money', 'create')).toBe(false)
})
it('g3soft 一切放行,且不需要任何授权数据', async () => {
mockApiResponses()
setLoggedInUser('g3soft', 1)
const perm = usePermissionStore()
await perm.load()
expect(perm.canAccess('b_money')).toBe(true)
expect(perm.canPower('b_money', 'audit')).toBe(true)
expect(perm.canEnterMenu('user')).toBe(true)
})
})
@@ -5,11 +5,13 @@
*/
import { describe, expect, it } from 'vitest'
import {
GROUP_CODE_MAX_LENGTH,
collectGroupIds,
nextGroupId,
normalizeGroupIds,
parseSchema,
schemaToJsonText,
validateGroupCode,
} from '@/utils/schemaJson'
const group = (over = {}) => ({ type: 'group', title: '分组', children: [], ...over })
@@ -89,3 +91,72 @@ describe('normalizeGroupIds', () => {
expect(() => normalizeGroupIds('view', parseSchema('view', '{bad json'))).not.toThrow()
})
})
describe('validateGroupCode', () => {
const REQUIRED = '分组编码不能为空'
const FORMAT = '分组编码须以小写字母开头,且只能包含小写字母、数字和下划线'
const LENGTH = `分组编码长度需为 2-${GROUP_CODE_MAX_LENGTH} 个字符`
it('空值报不能为空', () => {
expect(validateGroupCode('')).toBe(REQUIRED)
expect(validateGroupCode(' ')).toBe(REQUIRED)
expect(validateGroupCode(null)).toBe(REQUIRED)
expect(validateGroupCode(undefined)).toBe(REQUIRED)
})
it('长度需落在 2 到上限之间(含边界)', () => {
expect(validateGroupCode('a')).toBe(LENGTH)
expect(validateGroupCode('a'.repeat(GROUP_CODE_MAX_LENGTH + 1))).toBe(LENGTH)
expect(validateGroupCode('ab')).toBe(true)
expect(validateGroupCode('a'.repeat(GROUP_CODE_MAX_LENGTH))).toBe(true)
})
it('格式与模块编码同构:小写字母开头,只含小写字母、数字与下划线', () => {
for (const code of ['1abc', 'Abc', 'a-b', 'a b', '分组']) {
expect(validateGroupCode(code)).toBe(FORMAT)
}
})
it('与本配置已有编码冲突时提示已被使用', () => {
const message = validateGroupCode('basic', { existingIds: ['basic'] })
expect(message).toContain('basic')
expect(message).toContain('已被本配置的其他分组使用')
expect(message).not.toContain('多语言')
})
it('与多语言资源里残留的编码冲突时给出可区分的提示', () => {
const message = validateGroupCode('group2', { reservedIds: ['group2'] })
expect(message).toContain('多语言')
})
it('合法自定义编码通过', () => {
expect(validateGroupCode('basic_info')).toBe(true)
expect(validateGroupCode('cw_fee_detail')).toBe(true)
})
it('group{n} 形式不冲突即允许', () => {
expect(validateGroupCode('group7', { existingIds: ['group1'], reservedIds: ['group2'] })).toBe(
true,
)
})
it('编码集合同时兼容数组与 Set', () => {
expect(validateGroupCode('basic', { existingIds: new Set(['basic']) })).toBe(
validateGroupCode('basic', { existingIds: ['basic'] }),
)
})
it('输入首尾空白归一后再比对与判定', () => {
expect(validateGroupCode(' basic ', { existingIds: ['basic'] })).toContain('basic')
expect(validateGroupCode(' ok_code ')).toBe(true)
})
it('保留字 ungrouped 不可占用(结构树虚拟未分组节点用 g:ungrouped)', () => {
expect(validateGroupCode('ungrouped')).toContain('系统保留字')
})
it('长度上限可覆盖', () => {
expect(validateGroupCode('a'.repeat(11), { maxLength: 10 })).toContain('2-10')
expect(validateGroupCode('a'.repeat(10), { maxLength: 10 })).toBe(true)
})
})
@@ -1,9 +1,14 @@
/**
* 回归用例:菜单管理 - 绑定模块(s_menu_module)的加载与保存。
* 回归用例:菜单管理 - 随菜单保存同步的两份子表数据。
*
* s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id),
* 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。
* 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。
* 1) 绑定模块(s_menu_module)
* s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id),
* 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。
* 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。
*
* 2) 菜单入口权限点(s_power, b_object_type = 'menu')
* 页面 / 外链菜单各派生一行 menu.{菜单编码}(b_action = 'access',名称/顺序/启用跟随菜单),
* 目录没有;保存时按菜单行与该菜单的加载基线 diff;删除菜单时连同授权行一并清理。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
@@ -98,6 +103,8 @@ const modules = [
/** 库中 s_menu_module 的现有行(每个用例前重置) */
let boundRows = []
/** 库中该菜单的 s_power(menu.*) 行(每个用例前重置) */
let powerRows = []
/** 从查询条件里取 N'xxx' 字面量(mock 按 id 过滤用;'1 = 1' 等条件返回 null) */
function literalOf(condition) {
@@ -106,6 +113,7 @@ function literalOf(condition) {
beforeEach(() => {
boundRows = [{ b_menu_id: 'otherdata', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
powerRows = []
loadDataApi.mockReset()
saveObjectApi.mockClear()
loadDataApi.mockImplementation((view, condition) => {
@@ -121,6 +129,12 @@ beforeEach(() => {
data: boundRows.filter((row) => !id || row.b_menu_id === id),
})
}
if (view === 's_power') {
const id = literalOf(condition)
return Promise.resolve({
data: powerRows.filter((row) => !id || row.b_object_id === id),
})
}
return Promise.resolve({ data: [] })
})
})
@@ -193,3 +207,111 @@ describe('菜单管理:绑定模块(s_menu_module)', () => {
expect(request.deletes).toEqual([{ b_menu_id: 'base', b_module_id: 'b_port' }])
})
})
describe('菜单管理:入口权限点(s_power, menu.*)', () => {
/** 库中该菜单的入口权限点行(名称故意与菜单不同,用于验证保存会同步) */
function makePowerRow(over = {}) {
return {
b_id: 'menu.otherdata',
b_name: '其他数据',
b_i18n: 'menu.otherdata',
b_type: 'menu',
b_object_type: 'menu',
b_object_id: 'otherdata',
b_action: 'access',
b_canuse: 1,
b_xh: 10,
...over,
}
}
it('选中页面菜单时按 b_object_id 加载入口权限点基线', async () => {
powerRows = [makePowerRow()]
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'otherdata'
await flushPromises()
expect(loadDataApi).toHaveBeenCalledWith(
's_power',
"b_object_type = 'menu' and b_object_id = N'otherdata'",
'b_id ASC',
)
expect(wrapper.vm.menuPower_org?.b_id).toBe('menu.otherdata')
})
it('新增页面菜单:自动派生 menu.{编码} 权限点行', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'otherdata'
await flushPromises()
// 改菜单名,保证有可提交的差异
wrapper.vm.maindata.b_name = '其他数据(新)'
await wrapper.vm.handleSave()
const request = lastRequestOf('s_power')
expect(request.key_field).toBe('b_id')
expect(request.inserts).toHaveLength(1)
expect(request.inserts[0]).toMatchObject({
b_id: 'menu.otherdata',
b_name: '其他数据(新)',
b_i18n: 'menu.otherdata',
b_type: 'menu',
b_object_type: 'menu',
b_object_id: 'otherdata',
b_action: 'access',
b_canuse: 1,
b_xh: 10,
})
})
it('保存页面菜单:入口权限点的名称跟随菜单同步更新', async () => {
powerRows = [makePowerRow({ b_name: '旧名称' })]
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'otherdata'
await flushPromises()
expect(wrapper.vm.menuPower_org?.b_name).toBe('旧名称')
// 菜单本身没有改动,只有权限点名称与菜单不一致 → 也应同步
await wrapper.vm.handleSave()
const request = lastRequestOf('s_power')
expect(request.updates).toHaveLength(1)
expect(request.updates[0]).toMatchObject({ b_id: 'menu.otherdata', b_name: '其他数据' })
})
it('目录菜单没有入口权限点:无差异时不提交任何保存请求', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.menuSelectedId = 'base'
await flushPromises()
await wrapper.vm.handleSave()
// 目录不派生权限点;菜单 / 绑定 / 多语言都无差异 → 不调用保存接口
expect(saveObjectApi).not.toHaveBeenCalled()
})
it('删除页面菜单:入口权限点与授权行一并清理', async () => {
const { wrapper } = await mountPage(MenuManagement)
await flushPromises()
wrapper.vm.deleteMenu('otherdata')
await flushPromises()
await wrapper.vm.confirmState.onOk()
await flushPromises()
const requests = saveObjectApi.mock.calls.at(-1)[0]
expect(requests.find((request) => request.table === 's_power').deletes).toEqual([
{ b_id: 'menu.otherdata' },
])
const granted = requests.find((request) => request.table === 's_user_power')
expect(granted.key_field).toBe('b_power_id')
expect(granted.deletes).toEqual([{ b_power_id: 'menu.otherdata' }])
// 菜单主表最后删
expect(requests.at(-1)).toMatchObject({ table: 's_menu' })
})
})
@@ -0,0 +1,65 @@
/**
* 分配权限抽屉(UserPowerDrawer):头部结构。
*
* 数据加载与保存由本组件负责(面板只读展示),测试只锁头部两件事:
* 1. 头部不渲染标题:只有页签一行 + ui/Drawer 的关闭按钮,没有其它元素;
* 2. 三个页签对应三张权限表的内容面板(切换页签不销毁,用 v-show 承载)。
*
* 面板自身的规则由 user-power-panel.spec.js 覆盖(含「全授权 / 全禁用」),这里不重复。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserPowerDrawer from '@/views/system/user/UserPowerDrawer.vue'
import { clearMessages } from '@/stores/i18n'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
}))
import { loadDataApi } from '@/services/api'
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
clearMessages()
loadDataApi.mockReset()
loadDataApi.mockImplementation(() => Promise.resolve({ data: [] }))
})
async function mountDrawer() {
const wrapper = mount(UserPowerDrawer, {
props: { open: true, user: { b_id: '001', b_name: '张三' } },
// Drawer 用 Teleport 到 body;打桩后内容留在原地,选择器才能命中
global: { stubs: { teleport: true } },
})
await flushPromises()
return wrapper
}
describe('分配权限抽屉:头部', () => {
it('头部只有页签一行:无标题、无批量按钮,关闭按钮由 ui/Drawer 提供', async () => {
const wrapper = await mountDrawer()
expect(wrapper.find('.fms-drawer-title').exists()).toBe(false)
expect(wrapper.find('.fms-drawer-extra .tabs-nav').exists()).toBe(true)
expect(wrapper.find('.fms-drawer-close').exists()).toBe(true)
// 账号级批量按钮不在头部(在功能权限面板的工具栏里)
expect(wrapper.find('.fms-drawer-header button.button').exists()).toBe(false)
})
it('三个页签对应三张权限表的内容面板', async () => {
const wrapper = await mountDrawer()
expect(wrapper.findAll('.tabs-tab').map((el) => el.text())).toEqual([
'功能权限',
'字段权限',
'数据范围',
])
expect(wrapper.findAll('.power-pane').map((el) => el.attributes('data-pane'))).toEqual([
'power',
'field',
'data',
])
})
})
@@ -0,0 +1,389 @@
/**
* 功能权限面板(用户管理 → 分配权限 → 功能权限)。
*
* 数据加载与保存由父级 UserPowerDrawer 负责,面板自身只做这几件事,测试只锁这几件:
* 1. 打开时的初始状态:只展开「全部分类」虚拟根,业务目录收起、不选中任何菜单;
* 2. 菜单选中语义:重复点击已选中的菜单不取消选中,只有点「全部分类」才清空;
* 3. 右侧两个分区:菜单访问(入口开关)+ 模块权限
* (模块名 | 模块使用开关 | 操作权限 chip | 行尾全选与计数)。
* 目录不渲染入口开关、没配权限点的模块不渲染,都不出空态文案;
* 4. 面板顶部工具栏的「全授权 / 全禁用」作用于该账号的全部功能权限(s_power 全量),
* 与左侧选中哪个菜单无关;
* 5. 勾选结果经 v-model 回传,三层是「保护性单向联动」:
* 勾动作补模块使用与入口,勾模块补入口,关模块清该模块动作,
* 关入口收回该菜单下的全部授权。
*/
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_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_user', { b_id: 'v_b_user', b_name: '用户管理' }],
['b_money', { b_id: 'b_money', b_name: '币种' }],
])
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' },
],
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',
'module.v_b_user',
]
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 sectionTitles(wrapper) {
return wrapper.findAll('.power-section__title').map((el) => el.text())
}
/** 模块权限区的行名清单(跳过「菜单访问」分区的那一行) */
function moduleNames(wrapper) {
const section = wrapper.findAll('.power-section')[1]
return section ? section.findAll('.power-row__name').map((el) => el.text()) : []
}
/** 「菜单访问」分区的入口开关(目录没有入口权限点时返回 undefined) */
function entrySwitch(wrapper) {
return wrapper.findAll('.power-section')[0]?.find('[role="switch"]')
}
/** 模块权限区的模块使用开关(第 index 行) */
function moduleSwitch(wrapper, index = 0) {
const section = wrapper.findAll('.power-section')[1]
return section ? section.findAll('[role="switch"]')[index] : undefined
}
/** 面板顶部工具栏按钮: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(sectionTitles(wrapper)).toEqual(['菜单访问', '模块权限'])
expect(wrapper.findAll('.power-section')[0].find('.power-row__name').text()).toBe('合作伙伴')
expect(moduleNames(wrapper)).toEqual(['合作伙伴'])
// 开关只有两处:菜单访问 + 模块使用
expect(wrapper.findAll('[role="switch"]')).toHaveLength(2)
// chip 只剩业务动作,模块使用不再混在里面
expect(wrapper.findAll('.power-chip').map((el) => el.text())).toEqual(['新增'])
})
it('已授权的入口渲染为打开态', async () => {
const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner'] })
expect(wrapper.vm.menuPowerCode).toBe('menu.partner')
expect(entrySwitch(wrapper).attributes('aria-checked')).toBe('true')
})
it('目录:不渲染入口开关(没有 menu.* 权限点),只给一行说明', async () => {
const wrapper = await mountWithMenu('base')
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(wrapper.findAll('[role="switch"]')).toHaveLength(0)
expect(wrapper.find('.power-panel__hint').text()).toContain('目录只是分组节点')
})
it('外链菜单:没有可授权模块,入口开关独立可用', async () => {
const wrapper = await mountWithMenu('portal')
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(moduleNames(wrapper)).toEqual([])
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.portal'])
})
it('页面没绑模块(自定义页):只剩入口开关,勾选即全部权限', async () => {
const wrapper = await mountWithMenu('partner', { menuModules: {} })
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(wrapper.findAll('[role="switch"]')).toHaveLength(1)
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('未配置权限点的模块不渲染,也不显示空态文案', async () => {
const wrapper = await mountWithMenu('user')
// 「币种」绑定了但没配权限点:不渲染成行,也不出任何提示
expect(moduleNames(wrapper)).toEqual(['用户管理'])
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 moduleSwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['module.v_b_othercompany', 'menu.partner'])
})
it('勾操作权限:自动补上模块使用与菜单入口', async () => {
const wrapper = await mountWithMenu('partner')
await wrapper.findAll('.power-chip')[0].trigger('click')
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 moduleSwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('开菜单入口:只补入口本身,不给任何模块权限', async () => {
const wrapper = await mountWithMenu('partner')
await entrySwitch(wrapper).trigger('click')
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 entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([])
})
it('取消单个操作权限:不动模块使用与入口', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
await wrapper.findAll('.power-chip')[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner', 'module.v_b_othercompany'])
})
it('勾选态回显:操作权限 chip 与模块使用开关各管各的', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['action.v_b_othercompany.create'],
})
expect(wrapper.findAll('.power-chip')[0].classes()).toContain('is-checked')
expect(wrapper.findAll('.power-chip')[0].attributes('aria-checked')).toBe('true')
// 模块使用未授权 → 开关是关的
expect(moduleSwitch(wrapper).attributes('aria-checked')).toBe('false')
})
it('行尾「全选」:补齐该模块的权限点并联动入口', async () => {
const wrapper = await mountWithMenu('partner')
await wrapper.find('.power-row__toggle').trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([
'module.v_b_othercompany',
'action.v_b_othercompany.create',
'menu.partner',
])
})
})