20260922224136

This commit is contained in:
oneao committed 2026-09-22 22:41:36 +08:00
1 parent c8dad8d4a6
commit 0abe3706c8
113 files changed
+5057 -2771

No files matched your search

@@ -57,8 +57,8 @@ describe('fms-module-list components', () => {
expect(item.classes()).toContain('fms-form-item')
expect(item.classes()).not.toContain('fms-form-item-vertical')
expect(item.find('.fms-form-item-label').text()).toBe('b_name')
// 定宽标签会在短标签后拖出一段空白,查询区按内容收缩
expect(item.find('.fms-form-item-label').attributes('style')).toContain('width: auto')
// 查询区标签定宽(短标签也占满),同一行各条件的控件左缘因此对齐
expect(item.find('.fms-form-item-label').attributes('style')).toContain('width: 72px')
zh.unmount()
// 切到非中文:竖排,label 在上
@@ -76,7 +76,7 @@ describe('fms-module-list components', () => {
},
})
expect(one.find('.query-grid').element.style.gridTemplateColumns).toBe(
'repeat(1, minmax(0, 200px))',
'repeat(1, minmax(0, 288px))',
)
one.unmount()
@@ -90,7 +90,7 @@ describe('fms-module-list components', () => {
},
})
expect(four.find('.query-grid').element.style.gridTemplateColumns).toBe(
'repeat(4, minmax(0, 200px))',
'repeat(4, minmax(0, 288px))',
)
four.unmount()
})
@@ -1,84 +0,0 @@
/**
* 模块绑定弹层(ModulePickerModal):模块树默认全部收起。
*
* 回归点:弹层曾用 default-expanded-keys 展开第一层根节点,一打开就铺开整棵树;
* 现在不设该 prop,初始只渲染根节点,子节点由用户按需逐层点开。
* 折叠态下子树不渲染(tree-node.vue 用 v-if 而非 v-show),因此可直接数 treeitem。
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ModulePickerModal from '@/views/module/menu-management/ModulePickerModal.vue'
const TREE = [
{
b_id: 'base',
b_module_type: 'module',
b_name: '基础资料',
children: [
{
b_id: 'base_otherdata',
b_module_type: 'module',
b_name: '其他数据',
children: [{ b_id: 'b_port', b_module_type: 'data', b_name: '港口' }],
},
],
},
{
b_id: 'system',
b_module_type: 'module',
b_name: '系统',
children: [{ b_id: 'b_user', b_module_type: 'data', b_name: '用户' }],
},
]
const MODULE_TYPES = {
base: 'module',
base_otherdata: 'module',
b_port: 'data',
system: 'module',
b_user: 'data',
}
async function mountModal(props = {}) {
const wrapper = mount(ModulePickerModal, {
props: {
open: true,
treeData: TREE,
checkedKeys: [],
moduleTypes: MODULE_TYPES,
...props,
},
// Modal 用 Teleport 挂到 body;打桩后内容留在组件树内,便于断言
global: { stubs: { teleport: true } },
})
await flushPromises()
await nextTick()
return wrapper
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe('模块绑定弹层:树默认收起', () => {
it('初始只渲染根节点,没有任何节点处于展开态', async () => {
const wrapper = await mountModal()
const items = wrapper.findAll('[role="treeitem"]')
expect(items).toHaveLength(TREE.length)
expect(items.map((item) => item.attributes('aria-expanded'))).toEqual([
'false',
'false',
])
})
it('折叠态下子节点不渲染', async () => {
const wrapper = await mountModal()
expect(wrapper.text()).toContain('基础资料')
expect(wrapper.text()).not.toContain('其他数据')
expect(wrapper.text()).not.toContain('港口')
})
})
@@ -0,0 +1,331 @@
/**
* 模块权限面板(ModulePowerPanel):
* - 权限库取数:只在「权限」页签可见(active)时查一次,按共用口径去重(@/utils/powerCodes
* .buildPowerLibrarySql)——标准动作不进库,否则导入会与模块使用权限撞编码;
* - 库行:名称 / 动作 / 使用模块数;已在本模块的动作置灰(勾不上);
* - 导入:按动作追加派生好的权限行(只改草稿:_rowKey / 编码 / 性质 / 资源键 / 顺序由面板物化),
* 清空选中并提示;选中的动作本模块都已存在时不追加、只提示;
* - 切换模块:库重取一次并清空选中;库查询失败留空,不打断手打动作。
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { registerFeature, useAreaSelection } from 'stk-table-vue'
import ModulePowerPanel from '@/views/module/module-management/ModulePowerPanel.vue'
import { buildPowerLibrarySql } from '@/utils/powerCodes'
import { createTestPinia } from '../helpers/app.js'
// 与 main.js 一致:表格可编辑(单元格点击进编辑)依赖 stk-table 的 area-selection feature
registerFeature(useAreaSelection)
// 权限库走只读 SQL 接口,mock 这一个接缝即可
vi.mock('@/services/api', () => ({ loadDataBySqlApi: vi.fn() }))
// 命令式 Message 在 jsdom 下会建 DOM 容器,按测试约定打桩
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 { loadDataBySqlApi } from '@/services/api'
import { Message } from '@/components/ui/message/message-manager.js'
// jsdom 无布局:StkTable 在 clientWidth 为 0 时启用虚拟列裁剪,补成宽容器
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() {
return 5000
},
})
})
/** 权限库里的动作(跨模块去重后只留动作与一份名称) */
const LIBRARY = [
{ b_action: 'audit', b_name: '审核' },
{ b_action: 'print', b_name: '打印' },
{ b_action: 'settle', b_name: '结算' },
]
/** 当前模块已有的权限点:模块使用 + 审核(每次取新副本:面板会往行上补 _rowKey) */
function powersFixture() {
return [
{
b_id: 'module.order',
b_name: '订单',
b_i18n: 'module.order',
b_action: 'access',
b_type: 'module',
b_object_type: 'module',
b_object_id: 'order',
b_canuse: 1,
b_xh: 10,
},
{
b_id: 'action.order.audit',
b_name: '审核',
b_i18n: 'action.audit',
b_action: 'audit',
b_type: 'action',
b_object_type: 'module',
b_object_id: 'order',
b_canuse: 1,
b_xh: 20,
},
]
}
async function mountPanel(props = {}) {
const pinia = createTestPinia()
const wrapper = mount(ModulePowerPanel, {
props: {
moduleId: 'order',
moduleName: '订单',
active: true,
loading: false,
modelValue: powersFixture(),
...props,
},
global: { plugins: [pinia] },
attachTo: document.body,
})
await flushPromises()
await nextTick()
return wrapper
}
/** 库表的行文本(按区块取:左侧主表也有表格) */
function libraryRowTexts(wrapper) {
return wrapper
.findAll('.mm-power__section--library .stk-tbody-main tr')
.map((row) => row.text())
}
/** 库表的行复选框(选择列的表头也有一枚,取 tbody 里的才是行) */
function libraryCheckboxes(wrapper) {
return wrapper.findAll('.mm-power__section--library .stk-tbody-main .fms-checkbox input')
}
/** 最后一次回写给父级的权限点草稿(没有写回时为 null) */
function lastPowers(wrapper) {
const events = wrapper.emitted('update:modelValue') || []
return events.length ? events[events.length - 1][0] : null
}
beforeEach(() => {
document.body.innerHTML = ''
localStorage.clear()
sessionStorage.clear()
loadDataBySqlApi.mockReset()
loadDataBySqlApi.mockResolvedValue({ data: [] })
Message.success.mockClear()
Message.info.mockClear()
})
describe('模块权限面板:权限库取数', () => {
it('页签可见时按去重口径取一次库:同动作合并、排除标准动作', async () => {
await mountPanel()
expect(loadDataBySqlApi).toHaveBeenCalledWith(buildPowerLibrarySql())
const sql = loadDataBySqlApi.mock.calls[0][0]
expect(sql).toContain('group by [b_action]')
// 标准动作的编码是 module.{模块编码},导入会与模块使用权限撞编码:不进库
expect(sql).toContain(
"and [b_action] not in ('access', 'read', 'create', 'update', 'delete', 'export')",
)
expect(sql).toContain("where [b_object_type] = 'module' and [b_canuse] = 1")
})
it('页签不可见时不取库(面板是 v-show 常驻挂载)', async () => {
await mountPanel({ active: false })
expect(loadDataBySqlApi).not.toHaveBeenCalled()
})
it('切到该页签才取库', async () => {
const wrapper = await mountPanel({ active: false })
await wrapper.setProps({ active: true })
await flushPromises()
expect(loadDataBySqlApi).toHaveBeenCalledTimes(1)
})
it('库查询失败:留空不打断编辑(仍可手打动作)', async () => {
loadDataBySqlApi.mockRejectedValue(new Error('boom'))
const wrapper = await mountPanel()
expect(libraryRowTexts(wrapper)).toEqual([])
expect(wrapper.vm.libraryLoading).toBe(false)
})
})
describe('模块权限面板:库行与置灰', () => {
it('列出库里的动作:名称 + 动作两列(不带使用模块数)', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = await mountPanel()
await flushPromises()
const rows = libraryRowTexts(wrapper)
expect(rows).toHaveLength(3)
expect(rows[0]).toContain('审核')
expect(rows[0]).toContain('audit')
expect(rows[1]).toContain('打印')
expect(rows[1]).toContain('print')
expect(wrapper.find('.mm-power__section--library').text()).not.toContain('模块数')
})
it('库表铺满右栏(auto-fit-width):右栏拖窄也不出现横向滚动', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = await mountPanel()
await flushPromises()
// 左主表按列宽展示,右库表 auto-fit-width:表宽跟随容器(库内会强制关掉列宽拖拽)
const tables = wrapper.findAllComponents({ name: 'FmsTable' })
expect(tables).toHaveLength(2)
expect(tables[1].props('autoFitWidth')).toBe(true)
expect(wrapper.find('.mm-power__section--library .fms-table').classes()).toContain(
'fms-table--auto-fit-width',
)
// 名称固定一档窄宽,动作(最后一列)不指定宽度:由它自适应吃剩余
const [nameCol, actionCol] = wrapper.vm.libraryColumns
expect(nameCol.width).toBe(88)
expect(actionCol.width).toBeUndefined()
})
it('已在本模块的动作置灰,未用过的可勾选', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = await mountPanel()
await flushPromises()
// audit 已在本模块(powers 里有),print / settle 没有
expect(libraryCheckboxes(wrapper).map((box) => box.element.disabled)).toEqual([
true,
false,
false,
])
})
})
describe('模块权限面板:权限点表(定宽 / 密集)', () => {
it('列宽收紧到 <700,且允许拖拽回写(表按列宽定宽,不自铺满整栏)', async () => {
const wrapper = await mountPanel()
const totalWidth = wrapper.vm.columns.reduce((sum, col) => sum + (col.width || 0), 0)
expect(totalWidth).toBeLessThan(700)
// colResizable + v-model:columns:表格按列宽定宽,拖过的列宽可回写
const powerTable = wrapper.findAllComponents({ name: 'FmsTable' })[0]
expect(powerTable.props('colResizable')).toBe(true)
})
})
describe('模块权限面板:导入', () => {
it('导入选中动作:追加派生好的行、清空选中并提示', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = await mountPanel()
await flushPromises()
await libraryCheckboxes(wrapper)[1].setValue(true)
await nextTick()
expect(wrapper.vm.librarySelectedKeys).toEqual(['action:print'])
wrapper.vm.importSelectedActions()
await nextTick()
const next = lastPowers(wrapper)
expect(next).toHaveLength(3)
expect(next[2]).toMatchObject({
b_action: 'print',
b_name: '打印',
b_id: 'action.order.print',
b_i18n: 'action.print',
b_type: 'action',
b_object_type: 'module',
b_object_id: 'order',
b_canuse: 1,
})
// 行键(表格行身份)与显示顺序都在导入时就地物化
expect(next[2]._rowKey).toBeTruthy()
expect(next[2].b_xh).toBe(30)
expect(wrapper.vm.librarySelectedKeys).toEqual([])
expect(Message.success).toHaveBeenCalledWith('已导入 1 个权限点,保存模块后生效')
})
it('一次导入多个:按库行顺序追加,编号连续', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = await mountPanel()
await flushPromises()
await libraryCheckboxes(wrapper)[1].setValue(true)
await libraryCheckboxes(wrapper)[2].setValue(true)
await nextTick()
wrapper.vm.importSelectedActions()
await nextTick()
expect(lastPowers(wrapper).map((row) => row.b_action)).toEqual([
'access',
'audit',
'print',
'settle',
])
expect(lastPowers(wrapper).map((row) => row.b_id)).toEqual([
'module.order',
'action.order.audit',
'action.order.print',
'action.order.settle',
])
})
it('选中的动作本模块都已存在:不写回草稿,只提示一句', async () => {
loadDataBySqlApi.mockResolvedValue({ data: [LIBRARY[0]] })
const wrapper = await mountPanel()
await flushPromises()
const emits = wrapper.emitted('update:modelValue') || []
// 库里该行是禁用态(勾不上),直接驱动内部状态覆盖兜底分支
wrapper.vm.librarySelectedKeys = ['action:audit']
wrapper.vm.importSelectedActions()
await nextTick()
expect(wrapper.emitted('update:modelValue') || []).toHaveLength(emits.length)
expect(Message.info).toHaveBeenCalledWith('选中的动作本模块都已存在')
expect(Message.success).not.toHaveBeenCalled()
})
it('导入的行可再次被批量删除(行键与选中集合联动)', async () => {
loadDataBySqlApi.mockResolvedValue({ data: [LIBRARY[1]] })
const wrapper = await mountPanel()
await flushPromises()
wrapper.vm.librarySelectedKeys = ['action:print']
wrapper.vm.importSelectedActions()
await nextTick()
const added = lastPowers(wrapper)[2]
wrapper.vm.selectedRowKeys = [String(added._rowKey)]
wrapper.vm.removeSelectedRows()
await nextTick()
expect(lastPowers(wrapper).map((row) => row.b_action)).toEqual(['access', 'audit'])
expect(wrapper.vm.selectedRowKeys).toEqual([])
})
})
describe('模块权限面板:切换模块', () => {
it('切模块:库重取一次,选中清空', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = await mountPanel()
await flushPromises()
await libraryCheckboxes(wrapper)[1].setValue(true)
await nextTick()
expect(wrapper.vm.librarySelectedKeys).toEqual(['action:print'])
await wrapper.setProps({ moduleId: 'other' })
await flushPromises()
expect(loadDataBySqlApi).toHaveBeenCalledTimes(2)
expect(wrapper.vm.librarySelectedKeys).toEqual([])
})
})
@@ -0,0 +1,571 @@
/**
* 绑定模块面板(ModuleBindPanel):菜单配置右侧的常驻面板。
*
* 回归点:
* 1) 树默认全部收起(不设 default-expanded-keys),子节点由用户按需逐层点开;
* 折叠态下子树不渲染(tree-node 用 v-if 而非 v-show),因此可直接数 treeitem。
* 2) 勾分类节点级联勾选其下全部模块,但回写给父级的只有 data / virtual 编码 ——
* module 类型只是模块树上的分类,不参与权限,写进 s_menu_module 会留下脏关系行。
* 3) 目录 / 外链菜单不绑定模块(不渲染可勾的树,只留一句说明)。
* 4) 外部值变化(切换菜单 / 重载)时,树上的勾选跟随重置。
* 5) 权限点:点模块名按 b_object_id 查 s_power 并用表格列出;分类节点与 virtual
* 模块不挂权限点,不发查询、只给说明;切换菜单时连草稿一起清空。
* 6) 就地增删(同「模块管理 → 权限」):新增先在草稿里加一行空动作行再进单元格编辑,
* 填完动作按共用口径(@/utils/powerCodes)派生编码 / 性质 / 资源键 / 默认名,
* 编码撞车时清掉编码并提示;启用与删除只改草稿(回写给父级),落库由菜单的保存统一提交。
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { registerFeature, useAreaSelection } from 'stk-table-vue'
import ModuleBindPanel from '@/views/module/menu-management/ModuleBindPanel.vue'
import { clearMessages } from '@/stores/i18n'
import { createTestPinia } from '../helpers/app.js'
// 与 main.js 一致:表格可编辑(单元格点击进编辑)依赖 stk-table 的 area-selection feature
registerFeature(useAreaSelection)
// 权限点 / 权限库都直接取数(面板自带的查询),mock 这一个接缝即可
vi.mock('@/services/api', () => ({ loadDataApi: vi.fn(), loadDataBySqlApi: vi.fn() }))
// 命令式 Message 在 jsdom 下会建 DOM 容器,按测试约定打桩
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 { buildPowerLibrarySql } from '@/utils/powerCodes'
import { loadDataApi, loadDataBySqlApi } from '@/services/api'
import { Message } from '@/components/ui/message/message-manager.js'
// 权限点表是 FmsTable(stk-table):jsdom 无布局,容器 clientWidth 为 0 时库会
// 启用虚拟列裁剪只渲染首列——patch 成宽容器,让全部列真实渲染(同 module-fields-panel 用例)
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() {
return 5000
},
})
})
const TREE = [
{
b_id: 'base',
b_module_type: 'module',
b_name: '基础资料',
children: [
{
b_id: 'base_otherdata',
b_module_type: 'module',
b_name: '其他数据',
children: [{ b_id: 'b_port', b_module_type: 'data', b_name: '港口' }],
},
],
},
{
b_id: 'system',
b_module_type: 'module',
b_name: '系统',
children: [{ b_id: 'b_user', b_module_type: 'data', b_name: '用户' }],
},
]
const MODULE_TYPES = {
base: 'module',
base_otherdata: 'module',
b_port: 'data',
system: 'module',
b_user: 'data',
}
/** 库中 b_port 的权限点:模块使用(启用)+ 审核(停用) */
const POWERS = [
{
b_id: 'module.b_port',
b_name: '模块使用',
b_i18n: 'module.b_port',
b_action: 'access',
b_type: 'module',
b_object_type: 'module',
b_object_id: 'b_port',
b_canuse: 1,
b_xh: 10,
},
{
b_id: 'action.b_port.audit',
b_name: '审核',
b_i18n: 'action.audit',
b_action: 'audit',
b_type: 'action',
b_object_type: 'module',
b_object_id: 'b_port',
b_canuse: 0,
b_xh: 20,
},
]
/** 权限库里的动作(跨模块去重后只留动作与一份名称) */
const LIBRARY = [
{ b_action: 'audit', b_name: '审核' },
{ b_action: 'print', b_name: '打印' },
]
function mountPanel(props = {}) {
// attachTo:FmsTable(stk-table)要真实 DOM 尺寸,脱离文档流时量不到宽高
const wrapper = mount(ModuleBindPanel, {
props: {
treeData: TREE,
moduleTypes: MODULE_TYPES,
menuType: 'page',
modelValue: [],
// 权限点草稿走 v-model:power-drafts:回写值要真的回到 prop,面板才继续拿得到草稿
powerDrafts: {},
'onUpdate:powerDrafts': (value) => wrapper.setProps({ powerDrafts: value }),
...props,
},
attachTo: document.body,
})
return wrapper
}
/** 按文案取按钮(工具栏按钮没有更稳定的属性可用) */
function buttonByText(wrapper, text) {
return wrapper.findAll('button').find((btn) => btn.text().includes(text))
}
/**
* 左权限点表的启用勾选框(树上的勾选框是 ui/Checkbox,类名不同)。
* 按区块取:右栏权限库的选择列(含表头那枚)也是 .fms-checkbox。
*/
function powerCheckboxes(wrapper) {
return wrapper.findAll('.mm-bind-panel__powers .fms-checkbox input')
}
/** 取最后一次回写给父级的权限点草稿 */
function lastDrafts(wrapper) {
const events = wrapper.emitted('update:powerDrafts')
return events[events.length - 1][0]
}
/** 驱动 ui/Checkbox 内部的原生 input:勾上 / 取消 */
function setCheck(wrapper, index, checked = true) {
return wrapper.findAll('input.checkbox-input')[index].setValue(checked)
}
/** 可勾选行的勾选态(顺序与 [role="treeitem"] 一致) */
function checkedStates(wrapper) {
return wrapper.findAll('input.checkbox-input').map((box) => box.element.checked)
}
/** 回写给父级的模块编码(取最后一次 emit) */
function lastEmitted(wrapper) {
const events = wrapper.emitted('update:modelValue')
return events[events.length - 1][0]
}
/** 权限库区块的行文本(左权限点表也有表格,按区块取) */
function libraryRowTexts(wrapper) {
return wrapper
.findAll('.mm-bind-panel__powers-lib .stk-tbody-main tr')
.map((row) => row.text())
}
/** 权限库区块的行复选框(选择列的表头也有一枚,取 tbody 里的才是行) */
function libraryCheckboxes(wrapper) {
return wrapper.findAll('.mm-bind-panel__powers-lib .stk-tbody-main .fms-checkbox input')
}
/** 按节点名取树行([role="treeitem"]) */
function nodeRow(wrapper, name) {
return wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(name))
}
/** 展开非叶子节点(箭头是行内唯一的 role="button") */
async function expandNode(wrapper, name) {
await nodeRow(wrapper, name).find('[role="button"]').trigger('click')
await nextTick()
}
/** 点节点标题:选中即列出该模块的权限点 */
async function clickNode(wrapper, name) {
await nodeRow(wrapper, name).find('.tree-node-content').trigger('click')
await nextTick()
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
clearMessages()
// FmsTable 内取 appStore(深浅色主题);用 createTestPinia 保证 persist 插件真实注册
createTestPinia()
loadDataApi.mockReset()
loadDataApi.mockResolvedValue({ data: [] })
loadDataBySqlApi.mockReset()
loadDataBySqlApi.mockResolvedValue({ data: [] })
Message.success.mockClear()
Message.info.mockClear()
Message.warning.mockClear()
})
describe('绑定模块面板:树默认收起', () => {
it('初始只渲染根节点,没有任何节点处于展开态', () => {
const wrapper = mountPanel()
const items = wrapper.findAll('[role="treeitem"]')
expect(items).toHaveLength(TREE.length)
expect(items.map((item) => item.attributes('aria-expanded'))).toEqual(['false', 'false'])
})
it('折叠态下子节点不渲染', () => {
const wrapper = mountPanel()
expect(wrapper.text()).toContain('基础资料')
expect(wrapper.text()).not.toContain('其他数据')
expect(wrapper.text()).not.toContain('港口')
})
})
describe('绑定模块面板:勾选与回写', () => {
it('勾分类节点级联整棵子树,回写只保留 data 模块编码', async () => {
const wrapper = mountPanel()
await setCheck(wrapper, 0)
await nextTick()
// 分类节点(base / base_otherdata)不落库,只回写 data 模块
expect(lastEmitted(wrapper)).toEqual(['b_port'])
})
it('子模块全勾时分类节点呈全选态,点它取消整棵子树并回写空数组', async () => {
const wrapper = mountPanel({ modelValue: ['b_port'] })
await setCheck(wrapper, 0, false)
await nextTick()
expect(lastEmitted(wrapper)).toEqual([])
// 再点回来:级联勾选整棵子树,回写仍只有 data 模块
await setCheck(wrapper, 0)
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['b_port'])
})
it('外部值变化(切换菜单)时树上的勾选跟随重置', async () => {
const wrapper = mountPanel()
// 展开「系统」露出子节点「用户」:此时只有未勾选的根节点
await expandNode(wrapper, '系统')
expect(checkedStates(wrapper)).toEqual([false, false, false])
await wrapper.setProps({ modelValue: ['b_user'] })
await nextTick()
expect(checkedStates(wrapper)).toEqual([false, true, true])
})
})
/** 展开到「港口」这一层(b_port 是唯一的 data 叶子) */
async function openPort(wrapper) {
await expandNode(wrapper, '基础资料')
await expandNode(wrapper, '其他数据')
await clickNode(wrapper, '港口')
await flushPromises()
}
/** 点「新增权限」:草稿里多一行空动作行 */
async function addPowerRow(wrapper) {
await buttonByText(wrapper, '新增权限').trigger('click')
await nextTick()
}
/**
* 驱动单元格变更:jsdom 不实现布局,单元格编辑器挂载与命中不可靠,
* 直接触发组件真实监听的 change(row, col, value)——同 module-fields-panel 用例的做法。
*/
function changeCell(wrapper, row, dataIndex, value) {
wrapper.findComponent({ name: 'FmsTable' }).vm.$emit('change', row, { dataIndex }, value)
}
describe('绑定模块面板:权限点(点模块名查看)', () => {
it('按模块编码查权限点,用表格列出(启停态反映到勾选框)', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
const wrapper = mountPanel()
await openPort(wrapper)
expect(loadDataApi).toHaveBeenCalledWith(
's_power',
"b_object_type = 'module' AND b_object_id = N'b_port'",
'b_xh ASC, b_id ASC',
)
expect(wrapper.findAll('.stk-tbody-main tr')).toHaveLength(2)
expect(wrapper.text()).toContain('权限名称')
expect(wrapper.text()).toContain('模块使用')
expect(wrapper.text()).toContain('action.b_port.audit')
expect(powerCheckboxes(wrapper).map((el) => el.element.checked)).toEqual([true, false])
})
it('点分类节点:不发查询,只提示分类节点没有权限点', async () => {
const wrapper = mountPanel()
await clickNode(wrapper, '基础资料')
expect(loadDataApi).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('分类节点没有权限点')
})
it('同一模块来回点只查一次(草稿即缓存)', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
const wrapper = mountPanel()
await openPort(wrapper)
await clickNode(wrapper, '基础资料')
await clickNode(wrapper, '港口')
await flushPromises()
expect(loadDataApi).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('module.b_port')
})
it('切换菜单:清掉选中的模块与权限点草稿', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
const wrapper = mountPanel({ menuId: 'otherdata' })
await openPort(wrapper)
expect(wrapper.text()).toContain('module.b_port')
await wrapper.setProps({ menuId: 'base' })
await nextTick()
expect(wrapper.text()).not.toContain('module.b_port')
expect(wrapper.text()).toContain('点击左侧模块')
expect(lastDrafts(wrapper)).toEqual({})
})
})
describe('绑定模块面板:权限点就地增删', () => {
it('模块没有权限点时也能新增:先加一行空动作行,等填动作', async () => {
const wrapper = mountPanel()
await openPort(wrapper)
expect(wrapper.findAll('.fms-action-btn')).toHaveLength(0)
await addPowerRow(wrapper)
const rows = lastDrafts(wrapper).b_port.rows
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
b_id: '',
b_action: '',
b_type: 'action',
b_object_type: 'module',
b_object_id: 'b_port',
b_canuse: 1,
})
expect(rows[0]._rowKey).toBeTruthy()
expect(wrapper.findAll('.stk-tbody-main tr')).toHaveLength(1)
// 未保存标记:改动只在草稿里,由菜单的「保存」统一落库
expect(wrapper.text()).toContain('未保存')
})
it('填动作:按共用口径派生编码 / 性质 / 资源键 / 默认名', async () => {
const wrapper = mountPanel()
await openPort(wrapper)
await addPowerRow(wrapper)
changeCell(wrapper, lastDrafts(wrapper).b_port.rows[0], 'b_action', 'audit')
await nextTick()
expect(lastDrafts(wrapper).b_port.rows[0]).toMatchObject({
b_id: 'action.b_port.audit',
b_name: '审核',
b_i18n: 'action.audit',
b_action: 'audit',
b_type: 'action',
b_object_id: 'b_port',
})
expect(wrapper.text()).toContain('action.b_port.audit')
})
it('动作编码撞车:清掉该行编码(不落库)并提示', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
const wrapper = mountPanel()
await openPort(wrapper)
await addPowerRow(wrapper)
// 新增行(最后一行)填一个库里已存在的动作 → 编码撞车
changeCell(wrapper, lastDrafts(wrapper).b_port.rows.at(-1), 'b_action', 'audit')
await nextTick()
expect(Message.warning).toHaveBeenCalledWith('该动作已存在,重复行不会保存')
expect(lastDrafts(wrapper).b_port.rows.at(-1)).toMatchObject({ b_action: 'audit', b_id: '' })
})
it('启用勾选框:按 _rowKey 把勾选结果写回草稿(不就地改显示副本)', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
const wrapper = mountPanel()
await openPort(wrapper)
await powerCheckboxes(wrapper)[1].setValue(true)
await nextTick()
const rows = lastDrafts(wrapper).b_port.rows
expect(rows.find((row) => row.b_id === 'action.b_port.audit').b_canuse).toBe(1)
expect(rows.find((row) => row.b_id === 'module.b_port').b_canuse).toBe(1)
expect(wrapper.text()).toContain('未保存')
})
it('删除:文字按钮只从草稿里去掉,不回库', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
const wrapper = mountPanel()
await openPort(wrapper)
// 操作列是文字按钮;删掉第二行(审核),第一行「模块使用」保留
await wrapper.findAll('.fms-action-btn')[1].trigger('click')
await nextTick()
expect(lastDrafts(wrapper).b_port.rows.map((row) => row.b_id)).toEqual(['module.b_port'])
expect(wrapper.findAll('.stk-tbody-main tr')).toHaveLength(1)
expect(wrapper.text()).toContain('未保存')
})
})
describe('绑定模块面板:权限区表头与表宽', () => {
it('不再渲染「权限点 / 模块名 / 条数」表头行', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
const wrapper = mountPanel()
await openPort(wrapper)
expect(wrapper.find('.mm-bind-panel__powers-head').exists()).toBe(false)
// 权限区只剩工具栏(新增权限)+ 表格:模块名由左侧树写明了,这里不重复
expect(wrapper.find('.mm-bind-panel__powers').text()).not.toContain('港口')
expect(wrapper.find('.mm-bind-panel__powers').text()).toContain('新增权限')
})
it('权限点表按列宽定宽:列宽收紧到 <500,且允许拖拽回写(自适应)', async () => {
const wrapper = mountPanel()
await openPort(wrapper)
const totalWidth = wrapper.vm.powerColumns.reduce((sum, col) => sum + (col.width || 0), 0)
expect(totalWidth).toBeLessThan(500)
// colResizable + v-model:columns:表格按列宽定宽(不自铺满整栏),拖过的列宽可回写
const powerTable = wrapper.findAllComponents({ name: 'FmsTable' })[0]
expect(powerTable.props('colResizable')).toBe(true)
})
})
describe('绑定模块面板:权限库(历史动作导入)', () => {
it('选中 data 模块后按去重口径取库,列出名称与动作', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = mountPanel()
await openPort(wrapper)
expect(loadDataBySqlApi).toHaveBeenCalledWith(buildPowerLibrarySql())
// 名称固定一档窄宽,动作(最后一列)不指定宽度:由它自适应吃剩余
const [nameCol, actionCol] = wrapper.vm.libraryColumns
expect(nameCol.width).toBe(88)
expect(actionCol.width).toBeUndefined()
const rows = libraryRowTexts(wrapper)
expect(rows).toHaveLength(2)
expect(rows[0]).toContain('审核')
expect(rows[0]).toContain('audit')
expect(rows[1]).toContain('打印')
expect(rows[1]).toContain('print')
})
it('没选模块 / 选中分类节点时不出权限库,也不发查询', async () => {
const wrapper = mountPanel()
// 未选模块:权限点区只有一句说明,右栏整块不渲染
expect(wrapper.find('.mm-bind-panel__powers-lib').exists()).toBe(false)
await clickNode(wrapper, '基础资料')
expect(wrapper.find('.mm-bind-panel__powers-lib').exists()).toBe(false)
expect(loadDataBySqlApi).not.toHaveBeenCalled()
})
it('已在本模块的动作置灰,未用过的可勾选', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = mountPanel()
await openPort(wrapper)
// audit 已在 b_port 的权限点里(POWERS),print 没有
expect(libraryCheckboxes(wrapper).map((box) => box.element.disabled)).toEqual([true, false])
})
it('导入选中动作:按共用口径派生行追进草稿,清空选中并提示', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = mountPanel()
await openPort(wrapper)
await libraryCheckboxes(wrapper)[1].setValue(true)
await nextTick()
expect(wrapper.vm.librarySelectedKeys).toEqual(['action:print'])
wrapper.vm.importSelectedActions()
await nextTick()
const rows = lastDrafts(wrapper).b_port.rows
expect(rows).toHaveLength(3)
expect(rows[2]).toMatchObject({
b_id: 'action.b_port.print',
b_name: '打印',
b_i18n: 'action.print',
b_action: 'print',
b_type: 'action',
b_object_type: 'module',
b_object_id: 'b_port',
b_canuse: 1,
// 顺序号接着草稿末尾排(原有 10 / 20)
b_xh: 30,
})
expect(wrapper.vm.librarySelectedKeys).toEqual([])
expect(Message.success).toHaveBeenCalledWith('已导入 1 个权限点,随菜单保存一起落库')
})
it('选中的动作本模块都已存在:不写回草稿,只提示一句', async () => {
loadDataApi.mockResolvedValue({ data: POWERS })
loadDataBySqlApi.mockResolvedValue({ data: [LIBRARY[0]] })
const wrapper = mountPanel()
await openPort(wrapper)
const drafts = wrapper.emitted('update:powerDrafts') || []
// 库里该行是禁用态(勾不上),直接驱动内部状态覆盖兜底分支
wrapper.vm.librarySelectedKeys = ['action:audit']
wrapper.vm.importSelectedActions()
await nextTick()
expect(wrapper.emitted('update:powerDrafts') || []).toHaveLength(drafts.length)
expect(Message.info).toHaveBeenCalledWith('选中的动作本模块都已存在')
expect(Message.success).not.toHaveBeenCalled()
})
it('换模块:库重取一次,选中清空', async () => {
loadDataBySqlApi.mockResolvedValue({ data: LIBRARY })
const wrapper = mountPanel()
await openPort(wrapper)
await libraryCheckboxes(wrapper)[1].setValue(true)
await nextTick()
expect(wrapper.vm.librarySelectedKeys).toEqual(['action:print'])
// 换到另一个 data 模块(系统 / 用户)
await expandNode(wrapper, '系统')
await clickNode(wrapper, '用户')
expect(loadDataBySqlApi).toHaveBeenCalledTimes(2)
expect(wrapper.vm.librarySelectedKeys).toEqual([])
})
})
describe('绑定模块面板:非页面菜单', () => {
it('目录菜单不渲染可勾的树,只给一句说明', () => {
const wrapper = mountPanel({ menuType: 'directory' })
expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(0)
expect(wrapper.text()).toContain('目录不绑定模块')
})
it('外链菜单不渲染可勾的树,只给一句说明', () => {
const wrapper = mountPanel({ menuType: 'external' })
expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(0)
expect(wrapper.text()).toContain('外链不绑定模块')
})
})
@@ -1,15 +1,26 @@
/**
* 分配权限抽屉(UserPowerDrawer):头部结构。
* 分配权限抽屉(UserPowerDrawer):头部结构 + 账号级批量操作。
*
* 数据加载与保存由本组件负责(面板只读展示),测试只锁结构两件事:
* 数据加载与保存由本组件负责(面板只读展示),测试只锁这几件事:
* 1. 头部常规显示:左侧账号标识标题 + 右侧关闭按钮,页签在内容区顶部;
* 2. 三个页签对应三张权限表的内容面板(切换页签不销毁,用 v-show 承载)。
* 2. 页签行右端的三个账号级批量按钮(全授权 / 全禁用 / 清空权限):挂在 Tabs 的
* rightExtra 插槽上,横跨三个页签、与选中哪个菜单 / 模块无关,改完即落库;
* 三者轻重不同,样式分三档(主色 / 描边 / 危险描边),不是三个同款;
* 「全授权 / 全禁用」管功能权限,「清空权限」连字段权限与数据范围一起清(先过确认框);
* 后两者的整表清除按 b_user_id 成删除条件提交,不逐行枚举主键(见最后一节);
* 3. 三个页签对应三张权限表的内容面板(切换页签不销毁,用 v-show 承载);
* 左右两栏都走 Splitter 分栏(栏宽可拖拽),模块树那一栏两个页签共用;
* 4. 即时落库:底部没有「保存 / 取消」按钮,任何改动都立刻按 diff 提交(见最后一节)。
*
* 面板自身的规则由 user-power-panel.spec.js 覆盖(含「全授权 / 全禁用」),这里不重复。
* 面板自身的规则由 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', () => ({
@@ -17,14 +28,87 @@ vi.mock('@/services/api', () => ({
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
}))
import { loadDataApi } from '@/services/api'
// 提示只按"有没有给"断言(提交失败等分支的表现就是提示一句),不测具体渲染
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()
loadDataApi.mockImplementation(() => Promise.resolve({ data: [] }))
saveObjectApi.mockClear()
// 只有本用例关心的表返回数据,其余表(菜单、模块、字段……)空数组即可
loadDataApi.mockImplementation((table) => Promise.resolve({ data: TABLE_ROWS[table] || [] }))
})
async function mountDrawer() {
@@ -46,7 +130,7 @@ describe('分配权限抽屉:头部', () => {
// 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)
})
@@ -76,4 +160,239 @@ describe('分配权限抽屉:头部', () => {
'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('权限保存失败(改动未落库):网络异常')
})
})
@@ -4,20 +4,21 @@
* 数据加载与保存由父级 UserPowerDrawer 负责,面板自身只做这几件事,测试只锁这几件:
* 1. 打开时的初始状态:只展开「全部分类」虚拟根,业务目录收起、不选中任何菜单;
* 2. 菜单选中语义:重复点击已选中的菜单不取消选中,只有点「全部分类」才清空;
* 3. 右侧结构:页头(菜单名 + 入口勾选框 + 右端 x/y 进度)+ 一个模块一块的授权块列表。
* 3. 右侧结构:左右两栏用 Splitter 分栏(左树栏可拖拽调宽),
* 右栏是页头(菜单名 + 入口勾选框 + 右端 x/y 进度)+ 一个模块一块的授权块列表。
* 块头是模块(模块级勾选框 + 「已选 x/y」):勾选框表示"这个数据给没给"——有任意
* 一项权限即勾选、一项都没有才未勾选(不做半选),点一下即给模块使用 / 整块收回;
* 块内是它的动作,层级靠块头的上下关系;
* 没配权限点的模块不渲染;目录不渲染入口勾选框,说明放内容区中间而不是页头;
* 本页没有可配置模块时内容区中间给一句说明(目录与外链 / 自定义页文案不同);
* 4. 面板顶部工具栏的「全授权 / 全禁用」作用于该账号的全部功能权限(s_power 全量),
* 与左侧选中哪个菜单无关;
* 5. 勾选结果经 v-model 回传,三级是「逐级给、连带收」:
* 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'
@@ -72,21 +73,6 @@ const POWER_BY_MODULE = {
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',
@@ -109,7 +95,6 @@ function mountPanel(props = {}) {
menuModules: MENU_MODULES,
moduleById: MODULE_BY_ID,
powerByModule: POWER_BY_MODULE,
allPowerIds: ALL_POWER_IDS,
...props,
},
})
@@ -183,11 +168,6 @@ 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')
@@ -265,6 +245,19 @@ describe('功能权限面板:菜单选中语义', () => {
})
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')
@@ -426,45 +419,6 @@ describe('功能权限面板:右侧结构', () => {
})
})
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')