/** * 分配权限抽屉(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('权限保存失败(改动未落库):网络异常') }) })