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

399 lines
16 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. 页签行右端的三个账号级批量按钮(全授权 / 全禁用 / 清空权限):挂在 Tabs 的
* rightExtra 插槽上,横跨三个页签、与选中哪个菜单 / 模块无关,改完即落库;
* 三者轻重不同,样式分三档(主色 / 描边 / 危险描边),不是三个同款;
* 「全授权 / 全禁用」管功能权限,「清空权限」连字段权限与数据范围一起清(先过确认框);
* 后两者的整表清除按 b_user_id 成删除条件提交,不逐行枚举主键(见最后一节);
* 3. 三个页签对应三张权限表的内容面板(切换页签不销毁,用 v-show 承载);
* 左右两栏都走 Splitter 分栏(栏宽可拖拽),模块树那一栏两个页签共用;
* 4. 即时落库:底部没有「保存 / 取消」按钮,任何改动都立刻按 diff 提交(见最后一节)。
*
* 面板自身的规则由 user-power-panel.spec.js 覆盖,这里不重复。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import UserPowerDrawer from '@/views/system/user/UserPowerDrawer.vue'
import UserPowerPanel from '@/views/system/user/UserPowerPanel.vue'
import UserFieldPowerPanel from '@/views/system/user/UserFieldPowerPanel.vue'
import UserDataPowerPanel from '@/views/system/user/UserDataPowerPanel.vue'
import { clearMessages } from '@/stores/i18n'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
}))
// 提示只按"有没有给"断言(提交失败等分支的表现就是提示一句),不测具体渲染
vi.mock('@/components/ui/message/message-manager.js', () => {
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
return { Message: m, default: m }
})
import { loadDataApi, saveObjectApi } from '@/services/api'
import { Message } from '@/components/ui/message/message-manager.js'
/** 按表返回的行:权限点与三张授权表各留一条,三个批量按钮的效果才有可验证的落差 */
const TABLE_ROWS = {
s_power: [
{ b_id: 'menu.partner', b_object_type: 'menu', b_object_id: 'partner', b_xh: 10, b_canuse: 1 },
{
b_id: 'module.v_b_othercompany',
b_object_type: 'module',
b_object_id: 'v_b_othercompany',
b_xh: 10,
b_canuse: 1,
},
{
b_id: 'action.v_b_othercompany.create',
b_object_type: 'module',
b_object_id: 'v_b_othercompany',
b_xh: 20,
b_canuse: 1,
},
],
s_user_power: [{ b_user_id: '001', b_power_id: 'menu.partner', b_canuse: 1 }],
s_user_field_power: [
{
b_user_id: '001',
b_module_id: 'v_b_othercompany',
b_field_id: 'b_name',
b_view: 0,
b_edit: 0,
b_query: 1,
b_export: 1,
b_canuse: 1,
},
],
s_user_data_power: [
{
b_user_id: '001',
b_module_id: 'v_b_othercompany',
b_operation: '*',
b_xh: 1,
b_scope_type: 'dept',
b_canuse: 1,
},
],
}
/** s_power 全量启用编码(含 menu.*):「全授权」要一次勾满这些 */
const ALL_POWER_IDS = TABLE_ROWS.s_power.map((row) => row.b_id)
/** 页签行右端的批量按钮:0 = 全授权,1 = 全禁用,2 = 清空权限 */
function actionButton(wrapper, index) {
return wrapper.findAll('.power-drawer__actions button')[index]
}
/** 命令式确认框(Modal.confirm 自建容器挂到 body,只能从 document 上找) */
function confirmPanel() {
return [...document.querySelectorAll('.fms-modal')].find((el) =>
el.textContent.includes('全部权限'),
)
}
/** 确认框底部按钮顺序:[取消, 确定] */
function confirmFooterButtons(panel) {
return [...panel.querySelectorAll('.fms-modal-footer button')]
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
clearMessages()
loadDataApi.mockReset()
saveObjectApi.mockClear()
// 只有本用例关心的表返回数据,其余表(菜单、模块、字段……)空数组即可
loadDataApi.mockImplementation((table) => Promise.resolve({ data: TABLE_ROWS[table] || [] }))
})
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('头部常规显示:账号标识标题 + 关闭按钮,页签在内容区', async () => {
const wrapper = await mountDrawer()
expect(wrapper.find('.fms-drawer-title').text()).toBe('分配权限 · 张三(001)')
expect(wrapper.find('.fms-drawer-close').exists()).toBe(true)
// header 让给账号标识:页签不在 header 里,在内容区顶部
expect(wrapper.find('.fms-drawer-header .tabs-nav').exists()).toBe(false)
expect(wrapper.find('.power-drawer__nav .tabs-nav').exists()).toBe(true)
// 账号级批量按钮也不占头部:它们与页签同行,在页签行右端
expect(wrapper.find('.fms-drawer-header button.button').exists()).toBe(false)
})
it('账号缺名称时标题只回退到编码', async () => {
const wrapper = mount(UserPowerDrawer, {
props: { open: true, user: { b_id: '001' } },
global: { stubs: { teleport: true } },
})
await flushPromises()
expect(wrapper.find('.fms-drawer-title').text()).toBe('分配权限 · 001')
})
it('三个页签对应三张权限表的内容面板', async () => {
const wrapper = await mountDrawer()
expect(wrapper.findAll('.tabs-tab').map((el) => el.text())).toEqual([
'功能权限',
'字段权限',
'数据范围',
])
// 功能权限自带一棵菜单树,是独立面板;字段权限 / 数据范围共用一个面板容器
// (同一棵模块树 + 各自的内容区),所以 data-pane 落在内容区上
expect(wrapper.findAll('[data-pane]').map((el) => el.attributes('data-pane'))).toEqual([
'power',
'field',
'data',
])
})
it('字段权限 / 数据范围:左模块树与右内容卡同样可拖拽调宽', async () => {
const wrapper = await mountDrawer()
// 模块树那一栏由抽屉渲染(两个页签共用),是内容区的直接子节点(功能权限面板自带一棵菜单树)
const moduleSplitter = wrapper.find('.power-drawer__body > .fms-splitter')
expect(moduleSplitter.exists()).toBe(true)
expect(moduleSplitter.find('.fms-splitter-bar').exists()).toBe(true)
const panels = moduleSplitter.findAll('.fms-splitter-panel')
expect(panels).toHaveLength(2)
expect(panels[0].find('.fms-tree-panel').exists()).toBe(true)
// 两个面板叠在右栏里(v-show 只显一个):切页签只换内容,不重挂树
expect(panels[1].findAll('[data-pane]').map((el) => el.attributes('data-pane'))).toEqual([
'field',
'data',
])
})
})
describe('分配权限抽屉:账号级批量操作', () => {
it('三个按钮在页签行右端:全授权 / 全禁用 / 清空权限', async () => {
const wrapper = await mountDrawer()
const actions = wrapper.find('.power-drawer__nav .power-drawer__actions')
expect(actions.exists()).toBe(true)
// 走 Tabs 的 rightExtra 插槽:按钮落在标签栏右端(.tabs-nav-extra),与页签同一行
expect(
wrapper
.find('.power-drawer__nav .tabs-nav .tabs-nav-extra .power-drawer__actions')
.exists(),
).toBe(true)
expect(actions.findAll('button').map((el) => el.text())).toEqual([
'全授权',
'全禁用',
'清空权限',
])
// 三者轻重不同,样式分三档:给(主色实心)/ 反向(描边)/ 破坏性(危险色描边)
const buttonTypes = actions.findAll('button').map((el) => el.classes())
expect(buttonTypes[0]).toContain('button-primary')
expect(buttonTypes[1]).toContain('button-outline')
expect(buttonTypes[2]).toContain('button-danger-outline')
// 作用范围写在 title 里(按钮文字只够放下动作名)
expect(actions.findAll('button').map((el) => el.attributes('title'))).toEqual([
'勾满该账号的全部功能权限(菜单入口、模块与业务动作)',
'取消该账号的全部功能权限(字段权限与数据范围不动)',
'清空该账号的功能权限、字段权限与数据范围',
])
})
it('全授权:一次勾满 s_power 的全部权限点,与选中哪个菜单无关', async () => {
const wrapper = await mountDrawer()
await actionButton(wrapper, 0).trigger('click')
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual(ALL_POWER_IDS)
// 字段权限与数据范围不是「功能」权限,全授权不动它们
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toHaveLength(1)
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toHaveLength(1)
})
it('全禁用:只取消功能权限,字段权限与数据范围不动', async () => {
const wrapper = await mountDrawer()
await actionButton(wrapper, 1).trigger('click')
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual([])
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toHaveLength(1)
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toHaveLength(1)
})
it('清空权限:确认前不动数据,确认后三类权限一起清空', async () => {
const wrapper = await mountDrawer()
// 三类都有数据,清空才有可验证的落差
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual(['menu.partner'])
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toHaveLength(1)
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toHaveLength(1)
await actionButton(wrapper, 2).trigger('click')
// 三类一次清到底是重操作:先问一次,确认前什么都不动
const panel = confirmPanel()
expect(panel).toBeTruthy()
expect(panel.textContent).toContain('确定清空「张三(001)」的全部权限?')
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual(['menu.partner'])
confirmFooterButtons(panel).at(-1).click()
await flushPromises()
await nextTick()
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual([])
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toEqual([])
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toEqual([])
})
})
describe('分配权限抽屉:改动即时落库', () => {
it('底部没有「保存 / 取消」按钮:改动不经过二次确认', async () => {
const wrapper = await mountDrawer()
// Drawer 只在给了 footer 插槽时才渲染底部条:没有保存流程,也没有取消按钮
expect(wrapper.find('.fms-drawer-footer').exists()).toBe(false)
expect(wrapper.find('.power-footer').exists()).toBe(false)
// 关闭入口只剩头部的关闭按钮
expect(wrapper.find('.fms-drawer-close').exists()).toBe(true)
})
it('全授权:改完立刻提交,只提交基线上没有的权限点(插入,不删原有的)', async () => {
const wrapper = await mountDrawer()
saveObjectApi.mockClear()
await actionButton(wrapper, 0).trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalledTimes(1)
const [requests] = saveObjectApi.mock.calls[0]
expect(requests.map((req) => req.table)).toEqual(['s_user_power'])
expect(requests[0].key_field).toBe('b_user_id,b_power_id')
// 顺序按 s_power 的加载顺序,与断言无关,排序后再比
expect(requests[0].inserts.map((row) => row.b_power_id).sort()).toEqual([
'action.v_b_othercompany.create',
'module.v_b_othercompany',
])
// 原有的菜单入口仍在勾选集合里,不产生删除
expect(requests[0].deletes).toBeUndefined()
})
it('全禁用:改完立刻提交,按账号整表撤销功能权限(不枚举主键行)', async () => {
const wrapper = await mountDrawer()
saveObjectApi.mockClear()
await actionButton(wrapper, 1).trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalledTimes(1)
const [requests] = saveObjectApi.mock.calls[0]
expect(requests.map((req) => req.table)).toEqual(['s_user_power'])
// 删除条件就是行里的列(b_user_id + 生效口径),不是逐行的主键值
expect(requests[0].deletes).toEqual([{ b_user_id: '001', b_canuse: 1 }])
expect(requests[0].inserts).toBeUndefined()
})
it('清空权限:确认后三张表在同一个请求列表里提交', async () => {
const wrapper = await mountDrawer()
saveObjectApi.mockClear()
await actionButton(wrapper, 2).trigger('click')
confirmFooterButtons(confirmPanel()).at(-1).click()
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalledTimes(1)
const [requests] = saveObjectApi.mock.calls[0]
expect(requests.map((req) => req.table)).toEqual([
's_user_power',
's_user_field_power',
's_user_data_power',
])
// 三类都按该账号整表清:条件相同(b_user_id + 生效口径),各自带上自己的主键列
expect(requests.map((req) => req.key_field)).toEqual([
'b_user_id,b_power_id',
'b_user_id,b_module_id,b_field_id',
'b_user_id,b_module_id,b_operation,b_xh',
])
expect(requests.map((req) => req.deletes)).toEqual([
[{ b_user_id: '001', b_canuse: 1 }],
[{ b_user_id: '001', b_canuse: 1 }],
[{ b_user_id: '001', b_canuse: 1 }],
])
expect(requests.map((req) => req.inserts)).toEqual([undefined, undefined, undefined])
})
it('提交在飞时又勾回来的权限:随整表清除那一批写回,不会在库里被删掉', async () => {
// 第一轮卡住不返回,制造"提交在飞"的窗口
let releaseFirst
saveObjectApi.mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseFirst = resolve
}),
)
const wrapper = await mountDrawer()
saveObjectApi.mockClear()
// 第一轮:全授权(这一轮快照里含原有的菜单入口勾选)
await actionButton(wrapper, 0).trigger('click')
await flushPromises()
// 在飞期间:先全禁用(登记整表清除),紧接着又把菜单入口勾回来
await actionButton(wrapper, 1).trigger('click')
await wrapper.findComponent(UserPowerPanel).vm.$emit('update:modelValue', ['menu.partner'])
await flushPromises()
releaseFirst({ code: 0, success: true })
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalledTimes(2)
const [requests] = saveObjectApi.mock.calls[1]
expect(requests[0].deletes).toEqual([{ b_user_id: '001', b_canuse: 1 }])
// 这个权限点在基线里本来就有,diff 看不出变化,必须随本批写回
expect(requests[0].inserts).toEqual([
{ b_user_id: '001', b_power_id: 'menu.partner', b_canuse: 1 },
])
})
it('重复点同一个按钮不会重复提交:差异已落库,下一轮直接早退', async () => {
const wrapper = await mountDrawer()
saveObjectApi.mockClear()
await actionButton(wrapper, 1).trigger('click')
await flushPromises()
await nextTick()
await actionButton(wrapper, 1).trigger('click')
await flushPromises()
await nextTick()
expect(saveObjectApi).toHaveBeenCalledTimes(1)
})
it('提交失败:保留界面上的改动并提示(下一次改动连着重发)', async () => {
saveObjectApi.mockRejectedValueOnce(new Error('网络异常'))
const wrapper = await mountDrawer()
saveObjectApi.mockClear()
await actionButton(wrapper, 1).trigger('click')
await flushPromises()
await nextTick()
// 失败不回退:面板上的勾选仍是"已清空",提示里说明这一批没有落库
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual([])
expect(Message.error).toHaveBeenCalledWith('权限保存失败(改动未落库):网络异常')
})
})