20260921173344
This commit is contained in:
1 parent
b3364fe6dc
commit
ac710c1801
5 files changed
+1000
-407
No files matched your search
@@ -6,15 +6,15 @@
|
||||
* s_user_field_power 字段权限(按模块 × 字段的 查看 / 编辑 / 查询 / 导出)
|
||||
* s_user_data_power 数据范围(按模块 × 操作的范围类型与条件)
|
||||
*
|
||||
* 布局:右侧 Drawer,头部只有一行页签(不渲染标题),关闭按钮由 ui/Drawer 固定在最右端。
|
||||
* 布局:右侧 Drawer。头部是常规形态 —— 左侧账号标识(给谁配)、右侧关闭按钮;
|
||||
* 三个页签在内容区顶部,与模块管理页的配置区同一形态(页签只管导航,
|
||||
* 内容由下方 v-show 面板承载,切换页签不销毁已挂载的面板)。
|
||||
* 账号级的「全授权 / 全禁用」在功能权限面板自己的工具栏里(见 UserPowerPanel),
|
||||
* 不占头部,也不会在切到其它页签时仍显示。
|
||||
* 页签用 nav-only(Tabs 只管导航),内容由下方 v-show 面板承载 —— 与模块管理的
|
||||
* 配置区同一形态,切换页签不销毁已挂载的面板。
|
||||
*
|
||||
* 当前「功能权限」已接入;字段权限与数据范围仍为空面板。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Button, Drawer, Message, Tabs } from '@/components/ui'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import UserPowerPanel from './UserPowerPanel.vue'
|
||||
@@ -27,6 +27,21 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:open'])
|
||||
|
||||
/* ---------- 头部账号标识 ---------- */
|
||||
/** 当前账号:用户名 + 编码(名称缺失时只用编码) */
|
||||
const userLabel = computed(() => {
|
||||
const name = String(props.user?.b_name ?? '').trim()
|
||||
const id = String(props.user?.b_id ?? '').trim()
|
||||
if (name && id) return `${name}(${id})`
|
||||
return name || id || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 抽屉标题。列表行操作点进来后,抽屉里再没有第二条账号线索(页签与面板都只有模块、
|
||||
* 菜单、权限点),关掉抽屉才能核对是给谁配的权限,所以把账号提到头部。
|
||||
*/
|
||||
const drawerTitle = computed(() => (userLabel.value ? `分配权限 · ${userLabel.value}` : '分配权限'))
|
||||
|
||||
/* ---------- 页签 ---------- */
|
||||
const activeTab = ref('power')
|
||||
|
||||
@@ -198,15 +213,15 @@ async function save() {
|
||||
<template>
|
||||
<Drawer
|
||||
:open="open"
|
||||
:title="drawerTitle"
|
||||
placement="right"
|
||||
width="min(900px, 96%)"
|
||||
:mask-closable="false"
|
||||
@update:open="emit('update:open', $event)"
|
||||
@cancel="close"
|
||||
>
|
||||
<!-- 头部只有页签这一行(不渲染标题):关闭按钮由 ui/Drawer 固定在最右端;
|
||||
账号级批量操作(全授权 / 全禁用)在功能权限面板自己的工具栏里 -->
|
||||
<template #extra>
|
||||
<div class="power-drawer">
|
||||
<!-- 页签在内容区顶部而不是 header:header 要正常显示账号标识 -->
|
||||
<Tabs
|
||||
v-model="activeTab"
|
||||
nav-only
|
||||
@@ -214,9 +229,7 @@ async function save() {
|
||||
:items="tabItems"
|
||||
aria-label="权限分类"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="power-drawer">
|
||||
<div class="power-drawer__body">
|
||||
<div v-show="activeTab === 'power'" class="power-pane" data-pane="power">
|
||||
<UserPowerPanel
|
||||
@@ -244,19 +257,21 @@ async function save() {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 抽屉内容区纵向铺满:面板区自行滚动。
|
||||
/* 抽屉内容区纵向铺满:页签一行 + 面板区(面板自行滚动)。
|
||||
body 的 padding 已由 :has() 规则清零(左右两栏要贴边滚动),间距在这里补回 */
|
||||
.power-drawer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px 20px;
|
||||
padding: 10px 20px 12px;
|
||||
}
|
||||
|
||||
/* 页签占满头部剩余宽度:标签栏的底线正好充当头部与内容的分隔线 */
|
||||
/* 页签一行:不参与伸缩(面板要吃掉剩余高度),与面板之间留出间距 */
|
||||
.power-drawer__nav {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.power-drawer__body {
|
||||
@@ -279,39 +294,25 @@ async function save() {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 非 scoped:header 由 ui Drawer 渲染,而 Drawer 根节点是 Teleport,
|
||||
<!-- 非 scoped:header 与 body 由 ui Drawer 渲染,而 Drawer 根节点是 Teleport,
|
||||
class 无法透传(scoped 与外部 class 都命不中),因此用内容锚点 .power-drawer
|
||||
配合 :has() 定位本抽屉的面板(与 FmsFileModal / ui/select 同一做法)。
|
||||
只有本抽屉的内容才会渲染 .power-drawer,其它抽屉不受影响。 -->
|
||||
<style lang="scss">
|
||||
/* 头部只服务页签一行:上下留白压到最小;底线由 ui/Drawer 的 header 承担
|
||||
(整条贯穿,含关闭按钮下方),页签自带的底线去掉,避免两条平行线叠在一起 */
|
||||
.fms-drawer:has(.power-drawer) {
|
||||
--fms-drawer-header-padding: 6px 20px 0;
|
||||
}
|
||||
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-header .tabs-nav {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 页签上移到头部后,标题组与附加区都要能撑满:标签栏才能占满整行、
|
||||
底线成为完整的分隔线,批量按钮也才能被推到右端 */
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-header-main,
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-extra {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* 标题(账号标识)单行省略:账号名 + 编码可能很长,换行会把头部撑高 */
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 高度链起点:.fms-drawer-body 是普通块容器(ui Drawer 的 scoped 样式,改不了),
|
||||
.power-drawer 的 flex:1 在块容器里不生效、高度塌成 auto,导致面板 height:100% 全链失效。
|
||||
这里把它变成 flex 容器,让 .power-drawer 能吃满剩余高度。 */
|
||||
这里把它变成 flex 容器,让 .power-drawer 能吃满剩余高度;
|
||||
同时清掉 body 自带的 padding —— 它与「左右两栏贴边滚动」冲突,间距收敛到面板内部自理。 */
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* body 自带的 padding 与「左右两栏贴边」冲突,收敛到面板内部自理 */
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-body {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -8,10 +8,30 @@
|
||||
* s_power 权限点(menu.* 菜单入口 / module.* 模块使用 / action.* 业务动作)
|
||||
* s_user_power 勾选结果(用户 × 权限点,主键 b_user_id + b_power_id)
|
||||
*
|
||||
* 布局:左右两栏各占满高度、各自滚动。右栏是两个分区:
|
||||
* 分区一 = 当前菜单的入口权限(menu.{编码},一个开关);
|
||||
* 分区二 = 该菜单绑定模块的权限点列表(模块使用开关 + 业务动作 chip)。
|
||||
* 账号级的「全授权 / 全禁用」在抽屉头部(父级 UserPowerDrawer),不在这两个分区里。
|
||||
* 布局:左右两栏各占满高度、各自滚动。右栏 = 页头 + 一张「模块 / 动作 / 已选」三列表:
|
||||
* 页头 = 当前菜单名(标题)+ 入口权限 menu.{编码},固定在顶部不随内容滚动 ——
|
||||
* 入口是"页面级"属性,与"页面里能用哪些模块"不是同一层;
|
||||
* 表 = 一行一个模块,模块级勾选框落在「模块」列,动作勾选框落在「动作」列。
|
||||
*
|
||||
* 三层权限的层级靠"列"表达,不靠嵌套容器:
|
||||
* 之前每个模块是一个带底色块头的方块、动作在块内铺排,三层权限就都是同一个
|
||||
* "方框 + 文字"的形态,只靠底色深浅区分,十几个模块连成一片时读不出层级。
|
||||
* 改成列之后,「模块」列只有模块级勾选、「动作」列只有动作勾选,各级权限各占一列,
|
||||
* 扫一眼就知道某一行属于哪个模块、这个模块给了几项动作。
|
||||
*
|
||||
* 行尾「已选 x/y」= 该模块已授权的动作数 / 动作总数(不含模块使用本身),
|
||||
* 不展开也能量化进度;只有模块使用、没有动作的模块不显示计数。
|
||||
*
|
||||
* 模块级勾选框的语义是"该模块全给 / 全撤"(模块使用 + 全部动作),
|
||||
* 半选态表示"进了这个模块,但只给了部分动作":
|
||||
* - 勾上 = 一次补齐该模块的全部权限点,并联动菜单入口;
|
||||
* - 取消 = 收回该模块的全部权限点(不留"有动作、没模块"的死配置)。
|
||||
* 因此不再单设行尾「全选 / 清空」—— 它与模块级勾选框作用对象完全重合,
|
||||
* 两个控件做同一件事只会让人不知道该点哪个。只想给部分动作时,
|
||||
* 先点模块级勾选框全给、再逐个取消不要的动作即可。
|
||||
*
|
||||
* 勾选框统一用 ui/Checkbox(原生 input + 自带半选 / 禁用 / 键盘焦点),
|
||||
* 不自己拼方框与对勾 —— 选中态、半选态、聚焦环、禁用态只有一处定义。
|
||||
*
|
||||
* 两层的关系(《模块关系与权限边界重构计划》§3.2):进得了菜单不等于能用菜单里的模块,
|
||||
* 所以授权仍分两层落库;但交互上入口跟随模块 —— 勾任一模块 / 动作自动补入口,
|
||||
@@ -22,8 +42,7 @@
|
||||
* (选中权限编码数组),差异比对与保存由父级负责。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { Button, Spin, Switch } from '@/components/ui'
|
||||
import { Button, Checkbox, Spin } from '@/components/ui'
|
||||
import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue'
|
||||
import { buildSortedTree, filterTreeByText, findTreeNode } from '@/utils/tree'
|
||||
import { MODULE_ACCESS_ACTION } from '@/utils/powerCodes'
|
||||
@@ -80,7 +99,7 @@ function onMenuSelect(keys, info) {
|
||||
selectedMenuId.value = String(keys[0])
|
||||
}
|
||||
|
||||
/* ---------- 右侧:菜单访问权限(根节点)+ 属下的模块与权限点 ---------- */
|
||||
/* ---------- 右侧:页头(菜单入口)+ 模块 / 动作表 ---------- */
|
||||
|
||||
/**
|
||||
* 当前菜单的入口权限编码:menu.{菜单编码}(s_power 中 b_object_type = 'menu',
|
||||
@@ -107,7 +126,7 @@ const menuType = computed(() => String(menuNode.value?.b_menu_type || ''))
|
||||
/**
|
||||
* 能否单独授权入口:只有页面 / 外链在 s_power 里派生 menu.{编码}
|
||||
* (menu-management 的 MENU_POWER_MENU_TYPES),目录只是分组、没有权限点 ——
|
||||
* 目录若渲染开关,勾上会往 s_user_power 写一条 s_power 里不存在的编码。
|
||||
* 目录若渲染勾选框,勾上会往 s_user_power 写一条 s_power 里不存在的编码。
|
||||
*/
|
||||
const canAuthorizeEntry = computed(() => menuType.value === 'page' || menuType.value === 'external')
|
||||
|
||||
@@ -124,70 +143,17 @@ function isChecked(powerId) {
|
||||
return checkedSet.value.has(String(powerId))
|
||||
}
|
||||
|
||||
/** 整组(整模块)全选 / 全不选;全选时同样联动补上菜单入口 */
|
||||
function toggleModule(row) {
|
||||
const ids = row.powers.map((power) => String(power.b_id))
|
||||
const next = checkedPowerIds.value.map(String).filter((id) => !ids.includes(id))
|
||||
if (!row.allChecked) {
|
||||
next.push(...ids)
|
||||
ensureMenuEntry(next)
|
||||
}
|
||||
checkedPowerIds.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块使用开关(保护性单向联动,只保证不出现「有动作权限、没有模块权限」的死配置):
|
||||
* - 打开:开模块本身,并顺带补上菜单入口(入口跟随模块,见 ensureMenuEntry);
|
||||
* - 关闭:连带清掉该模块下的操作权限(不然勾着也不生效);
|
||||
* 动作权限由用户另外勾,要一键全给用行尾的「全选」。
|
||||
*/
|
||||
function toggleModuleAccess(group) {
|
||||
const accessId = String(group.accessPower?.b_id || '')
|
||||
if (!accessId) return
|
||||
const next = checkedPowerIds.value.map(String)
|
||||
const index = next.indexOf(accessId)
|
||||
if (index < 0) {
|
||||
next.push(accessId)
|
||||
ensureMenuEntry(next)
|
||||
checkedPowerIds.value = next
|
||||
return
|
||||
}
|
||||
const actionIds = group.actionPowers.map((power) => String(power.b_id))
|
||||
next.splice(index, 1)
|
||||
checkedPowerIds.value = next.filter((id) => !actionIds.includes(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作权限 chip:勾上任意一个动作时,自动补上同模块的「模块使用」与菜单入口
|
||||
* (动作以模块权限为前提、模块以入口为前提,否则是勾了不生效的死配置);
|
||||
* 取消动作则不动模块开关 —— 可能只是不做这个动作,模块本身还要用。
|
||||
*/
|
||||
function toggleActionPower(group, power) {
|
||||
const id = String(power.b_id)
|
||||
const next = checkedPowerIds.value.map(String)
|
||||
const index = next.indexOf(id)
|
||||
if (index >= 0) {
|
||||
next.splice(index, 1)
|
||||
} else {
|
||||
next.push(id)
|
||||
const accessId = String(group.accessPower?.b_id || '')
|
||||
if (accessId && !next.includes(accessId)) next.push(accessId)
|
||||
ensureMenuEntry(next)
|
||||
}
|
||||
checkedPowerIds.value = next
|
||||
}
|
||||
|
||||
/** 模块使用权限点(b_action = 'access'):每个模块最多一个,在行内呈现为开关 */
|
||||
/** 模块使用权限点(b_action = 'access'):每个模块最多一个,不进「动作」列 */
|
||||
function isModuleAccessPower(power) {
|
||||
return String(power?.b_action || '').trim() === MODULE_ACCESS_ACTION
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块行:模块使用 + 它的操作权限。
|
||||
* 表格行:一个模块一行。
|
||||
*
|
||||
* 一个模块的权限点分两类,界面上必须分得开(设计文档 §14.1 / §3.3):
|
||||
* - 模块使用(b_action = 'access'):能不能用这个模块 —— 一个开关;
|
||||
* - 操作权限(其余业务动作):能用它做哪些动作 —— 一组 chip。
|
||||
* 一个模块的权限点分两类(设计文档 §14.1 / §3.3):
|
||||
* - 模块使用(b_action = 'access'):能不能用这个模块,并入模块级勾选框(与全部动作一起全给全撤);
|
||||
* - 动作权限(其余业务动作):能用它做哪些动作,渲染在「动作」列。
|
||||
* 只保留「配了权限点」的模块;没配权限点的模块直接不渲染,也不出空态文案。
|
||||
*/
|
||||
const groups = computed(() => {
|
||||
@@ -196,27 +162,64 @@ const groups = computed(() => {
|
||||
.map((moduleId) => {
|
||||
const module = props.moduleById.get?.(moduleId) || null
|
||||
const powers = props.powerByModule[moduleId] || []
|
||||
const checked = powers.filter((power) => checkedSet.value.has(String(power.b_id))).length
|
||||
// 动作名在这里 translate 一次(模板直接用 label):勾选框的文案与 title
|
||||
// 都要用同一个名字,两处各写一遍表达式不划算。
|
||||
const actionPowers = powers
|
||||
.filter((power) => !isModuleAccessPower(power))
|
||||
.map((power) => ({
|
||||
...power,
|
||||
label: translate(power.b_i18n, power.b_name || power.b_action),
|
||||
}))
|
||||
// 模块级勾选框要覆盖「模块使用 + 全部动作」,所以判定用的是权限点总数,
|
||||
// 与「动作」列展示的动作数(actionTotal)不是一回事
|
||||
const checkedCount = powers.filter((power) => checkedSet.value.has(String(power.b_id))).length
|
||||
return {
|
||||
moduleId,
|
||||
moduleName: translate(module?.b_i18n, module?.b_name || moduleId),
|
||||
powers,
|
||||
accessPower: powers.find(isModuleAccessPower) || null,
|
||||
actionPowers: powers.filter((power) => !isModuleAccessPower(power)),
|
||||
checked,
|
||||
allChecked: powers.length > 0 && checked === powers.length,
|
||||
actionPowers,
|
||||
actionChecked: actionPowers.filter((power) => checkedSet.value.has(String(power.b_id)))
|
||||
.length,
|
||||
actionTotal: actionPowers.length,
|
||||
allChecked: powers.length > 0 && checkedCount === powers.length,
|
||||
someChecked: checkedCount > 0,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.powers.length > 0)
|
||||
})
|
||||
|
||||
/* ---------- 入口联动与分区批量 ---------- */
|
||||
/** 页头进度:本页模块总数与「至少给了权限」的模块数,不展开也能看出配了多少 */
|
||||
const pageSummary = computed(() => {
|
||||
if (!groups.value.length) return ''
|
||||
const granted = groups.value.filter((group) => group.someChecked).length
|
||||
return `本页 ${groups.value.length} 个模块,已授权 ${granted} 个`
|
||||
})
|
||||
|
||||
/** 当前菜单下所有模块的权限点编码(关入口 / 分区清空时连带收回) */
|
||||
/** 模块级勾选框的悬停说明:说清点它会发生什么(全给还是全撤) */
|
||||
function moduleTitle(group) {
|
||||
return group.allChecked
|
||||
? `取消「${group.moduleName}」的全部授权`
|
||||
: `全选「${group.moduleName}」的 ${group.powers.length} 项权限`
|
||||
}
|
||||
|
||||
/* ---------- 入口联动与批量 ---------- */
|
||||
|
||||
/** 当前菜单下所有模块的权限点编码(关入口时连带收回) */
|
||||
const allMenuPowerIds = computed(() =>
|
||||
groups.value.flatMap((group) => group.powers.map((power) => String(power.b_id))),
|
||||
)
|
||||
|
||||
/**
|
||||
* 页头入口的悬停说明。关掉入口会连带收回该页面的模块与动作授权(入口关了这些授权
|
||||
* 都不生效)—— 这条只说明"关掉时会发生什么",属于操作补充信息,不常显:
|
||||
* 常显它等于把页面级的一个属性写成一段警告,正是这块之前做得过"重"的原因。
|
||||
*/
|
||||
const entryTitle = computed(() => {
|
||||
const base = `可访问「${menuName.value}」`
|
||||
return groups.value.length ? `${base}(关闭会收回该页面下的全部模块授权)` : base
|
||||
})
|
||||
|
||||
/**
|
||||
* 入口联动:勾任一模块 / 动作时,把该菜单的入口权限一并补上。
|
||||
*
|
||||
@@ -232,7 +235,7 @@ function ensureMenuEntry(ids) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 「菜单访问」分区的入口开关。与「模块使用 → 动作」同构的保护性联动:
|
||||
* 页头「可访问」开关。与模块级勾选框同构的保护性单向联动:
|
||||
* - 打开:只补入口本身(可以先开入口、再慢慢配模块);
|
||||
* - 关闭:连带收回该菜单下所有模块与动作权限 —— 入口关了这些授权都不生效,
|
||||
* 留着只会变成看不见的僵尸数据,也避免重开时误以为「已经清过了」。
|
||||
@@ -252,6 +255,42 @@ function toggleMenuAccess() {
|
||||
checkedPowerIds.value = next.filter((id) => !scoped.includes(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块级勾选框:该模块全给 / 全撤。
|
||||
* 半选(进了模块但只给了部分动作)时点一下 = 补齐到全选,与勾选框组的常规语义一致。
|
||||
* 全给时顺带补菜单入口 —— 入口跟随模块,见 ensureMenuEntry。
|
||||
*/
|
||||
function toggleModule(group) {
|
||||
const ids = group.powers.map((power) => String(power.b_id))
|
||||
const next = checkedPowerIds.value.map(String).filter((id) => !ids.includes(id))
|
||||
if (!group.allChecked) {
|
||||
next.push(...ids)
|
||||
ensureMenuEntry(next)
|
||||
}
|
||||
checkedPowerIds.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个动作权限:勾上任意一个动作时,自动补上同模块的「模块使用」与菜单入口
|
||||
* (动作以模块权限为前提、模块以入口为前提,否则是勾了不生效的死配置);
|
||||
* 取消动作则不动模块使用 —— 可能只是不做这个动作,模块本身还要用
|
||||
* (此时模块级勾选框落到半选态)。
|
||||
*/
|
||||
function toggleActionPower(group, power) {
|
||||
const id = String(power.b_id)
|
||||
const next = checkedPowerIds.value.map(String)
|
||||
const index = next.indexOf(id)
|
||||
if (index >= 0) {
|
||||
next.splice(index, 1)
|
||||
} else {
|
||||
next.push(id)
|
||||
const accessId = String(group.accessPower?.b_id || '')
|
||||
if (accessId && !next.includes(accessId)) next.push(accessId)
|
||||
ensureMenuEntry(next)
|
||||
}
|
||||
checkedPowerIds.value = next
|
||||
}
|
||||
|
||||
/* ---------- 账号级批量:全授权 / 全禁用 ---------- */
|
||||
/**
|
||||
* 作用于该账号的**全部功能权限**(s_power 里所有启用权限点,含所有菜单入口、
|
||||
@@ -294,88 +333,78 @@ function disableAll() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右:菜单访问 + 模块权限(占满高度,本栏自己滚动) -->
|
||||
<!-- 右:页头(菜单入口,固定)+ 模块 / 动作表(滚动) -->
|
||||
<div class="power-panel__main">
|
||||
<div v-if="loading" class="power-panel__state"><Spin /></div>
|
||||
|
||||
<div v-else-if="!selectedMenuId" class="power-panel__state">请选择左侧菜单</div>
|
||||
|
||||
<div v-else class="power-panel__list">
|
||||
<!-- 分区一:菜单入口(menu.{编码})—— 能否进入这个菜单页面;
|
||||
目录只是导航分组(menu.* 只由页面 / 外链派生),没有可授权的入口 -->
|
||||
<section class="power-section">
|
||||
<h3 class="power-section__title">菜单访问</h3>
|
||||
<div v-if="canAuthorizeEntry" class="power-row">
|
||||
<span class="power-row__name" :title="menuName">{{ menuName }}</span>
|
||||
<div class="power-row__access">
|
||||
<Switch
|
||||
:model-value="isChecked(menuPowerCode)"
|
||||
:aria-label="`${menuName} 菜单访问`"
|
||||
:title="
|
||||
groups.length
|
||||
? `${menuName} 菜单访问(关闭会收回该菜单下的全部授权)`
|
||||
: `${menuName} 菜单访问`
|
||||
"
|
||||
@update:model-value="toggleMenuAccess"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="power-panel__hint">
|
||||
目录只是分组节点,可见性与子菜单一致,不需要单独授权。
|
||||
</p>
|
||||
</section>
|
||||
<template v-else>
|
||||
<!-- 页头:当前菜单名 + 入口权限(menu.{编码})。入口是页面级属性,
|
||||
与下方"页面里能用哪些模块"不是同一层,所以做成页头而不是表里的一行。
|
||||
目录只是导航分组(menu.* 只由页面 / 外链派生),没有可授权的入口 -->
|
||||
<div class="power-head">
|
||||
<h3 class="power-head__name" :title="menuName">{{ menuName }}</h3>
|
||||
<Checkbox
|
||||
v-if="canAuthorizeEntry"
|
||||
class="power-head__entry"
|
||||
:model-value="isChecked(menuPowerCode)"
|
||||
:title="entryTitle"
|
||||
@change="toggleMenuAccess"
|
||||
>
|
||||
可访问
|
||||
</Checkbox>
|
||||
<!-- 目录没有 menu.* 权限点:只说明为什么这里没有可勾的东西 -->
|
||||
<span v-else class="power-head__note">目录不需要单独授权</span>
|
||||
<span v-if="pageSummary" class="power-head__meta">{{ pageSummary }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 分区二:该菜单绑定模块的权限点(没配权限点的模块不渲染,也不出空态文案) -->
|
||||
<section v-if="groups.length" class="power-section">
|
||||
<h3 class="power-section__title">模块权限</h3>
|
||||
<div v-for="group in groups" :key="group.moduleId" class="power-row">
|
||||
<span class="power-row__name" :title="group.moduleName">{{ group.moduleName }}</span>
|
||||
<!-- 模块使用(能不能用这个模块):开关,与「菜单访问」同一列 -->
|
||||
<div v-if="group.accessPower" class="power-row__access">
|
||||
<Switch
|
||||
:model-value="isChecked(group.accessPower.b_id)"
|
||||
:aria-label="`${group.moduleName} 模块使用`"
|
||||
:title="`${group.moduleName} 模块使用`"
|
||||
@update:model-value="toggleModuleAccess(group)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 操作权限(能用它做哪些动作):一组 chip;没有动作权限点时整列不渲染 -->
|
||||
<div v-if="group.actionPowers.length" class="power-row__caps">
|
||||
<button
|
||||
v-for="power in group.actionPowers"
|
||||
:key="power.b_id"
|
||||
type="button"
|
||||
class="power-chip"
|
||||
:class="{ 'is-checked': isChecked(power.b_id) }"
|
||||
role="checkbox"
|
||||
:aria-checked="isChecked(power.b_id)"
|
||||
:aria-label="String(power.b_name || power.b_id)"
|
||||
@click="toggleActionPower(group, power)"
|
||||
>
|
||||
<Check
|
||||
v-if="isChecked(power.b_id)"
|
||||
:size="12"
|
||||
:stroke-width="3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ translate(power.b_i18n, power.b_name || power.b_action) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="power-row__tail">
|
||||
<button
|
||||
v-if="group.powers.length > 1"
|
||||
type="button"
|
||||
class="power-row__toggle"
|
||||
:title="group.allChecked ? '清空该模块' : '全选该模块'"
|
||||
@click="toggleModule(group)"
|
||||
>
|
||||
{{ group.allChecked ? '清空' : '全选' }}
|
||||
</button>
|
||||
<span class="power-row__count">{{ group.checked }}/{{ group.powers.length }}</span>
|
||||
<!-- 一个模块一行。没有任何可授权模块的页面(外链、自定义页、模块都没配权限点)
|
||||
连表头也不渲染:一张空表只剩列名,不提供任何信息 -->
|
||||
<div v-if="groups.length" class="power-grid">
|
||||
<div class="power-grid__head">
|
||||
<span class="power-col--module">模块</span>
|
||||
<span class="power-col--actions">动作</span>
|
||||
<span class="power-col--count" title="该模块已授权的动作数">已选</span>
|
||||
</div>
|
||||
|
||||
<div class="power-grid__body">
|
||||
<!-- 一行一个模块:模块级勾选在「模块」列,动作勾选在「动作」列。
|
||||
没配权限点的模块不渲染,也不出空态文案 -->
|
||||
<div v-for="group in groups" :key="group.moduleId" class="power-row">
|
||||
<div class="power-col--module power-row__module">
|
||||
<Checkbox
|
||||
:model-value="group.allChecked"
|
||||
:indeterminate="group.someChecked && !group.allChecked"
|
||||
:title="moduleTitle(group)"
|
||||
@change="toggleModule(group)"
|
||||
>
|
||||
{{ group.moduleName }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 动作权限(能用它做哪些动作):流式铺排,一行放不下就换行 -->
|
||||
<div class="power-col--actions power-row__actions">
|
||||
<Checkbox
|
||||
v-for="power in group.actionPowers"
|
||||
:key="power.b_id"
|
||||
:model-value="isChecked(power.b_id)"
|
||||
:title="`${group.moduleName} ${power.label}`"
|
||||
@change="toggleActionPower(group, power)"
|
||||
>
|
||||
{{ power.label }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div class="power-col--count">
|
||||
<template v-if="group.actionTotal">
|
||||
{{ group.actionChecked }}/{{ group.actionTotal }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +420,7 @@ function disableAll() {
|
||||
}
|
||||
|
||||
/* 工具栏:账号级批量操作,横跨面板;与下方「选菜单 → 配模块」的内容区分开。
|
||||
左右缩进与两栏内容对齐(左 4px 对齐树、右 12px 对齐列表) */
|
||||
左右缩进与两栏内容对齐(左 4px 对齐树、右 12px 对齐表格) */
|
||||
.power-toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
@@ -428,172 +457,185 @@ function disableAll() {
|
||||
border-right: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
/* 右栏:页头固定,表格自己滚动 */
|
||||
.power-panel__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 分区之间留白,一个分区 = 一个授权维度(菜单访问 / 模块权限) */
|
||||
.power-panel__list {
|
||||
/* 页头:当前菜单名 + 入口权限。它是这一屏的前提(进不进得来),
|
||||
所以固定在顶部、不随表格滚走,并用一条分隔线与下方内容分开 */
|
||||
.power-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 4px 12px 16px 4px;
|
||||
}
|
||||
|
||||
.power-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 分区标题:结构锚点,不是说明文案 */
|
||||
.power-section__title {
|
||||
margin: 0 0 6px;
|
||||
padding: 0 8px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 说明文案:目录等「没有可授权项」的节点 */
|
||||
.power-panel__hint {
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: var(--fms-font-size);
|
||||
}
|
||||
|
||||
/* 行式四列:资源名(定宽)| 使用开关(定宽成列)| 操作 chip | 行尾「全选 / 计数」。
|
||||
开关列把「能不能用」抽成一条竖列,与右侧「能做什么」的 chip 天然分开;
|
||||
显式声明各列位置,缺开关 / 缺操作时也不会串位。 */
|
||||
.power-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 48px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--fms-control-radius);
|
||||
transition: background-color var(--fms-duration-fast) var(--fms-ease-standard);
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 2px 12px 10px 16px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.power-row:hover {
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
.power-row__name {
|
||||
grid-column: 1;
|
||||
/* 菜单名是这一栏的标题,不是表格里的一行:入口勾选框是它的附属属性 */
|
||||
.power-head__name {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--fms-text);
|
||||
font-weight: 500;
|
||||
font-size: var(--fms-font-size);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 使用开关列:与「菜单访问」的开关同一列,竖着扫一眼就知道哪些模块能用 */
|
||||
.power-row__access {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
align-items: center;
|
||||
/* 页头的「可访问」:页面级开关,与表格里的模块勾选不是同一层。
|
||||
留出内边距当作热区(表格里的勾选框为对齐列而不带内边距),
|
||||
用负外边距把这 6px 内边距抵消掉,视觉间距仍等于 .power-head 的 gap。
|
||||
这里不改 ui/Checkbox 的 gap:只叠它没定义的属性,避免与组件自带样式抢同一属性 */
|
||||
.power-head__entry {
|
||||
margin-left: -6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--fms-control-radius);
|
||||
transition: background-color var(--fms-duration-fast) var(--fms-ease-standard);
|
||||
}
|
||||
|
||||
/* 操作权限列:能用它做哪些动作 */
|
||||
.power-row__caps {
|
||||
.power-head__entry:hover {
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
/* 页头的进度说明:次要信息,不抢标题 */
|
||||
.power-head__meta {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.power-head__note {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 权限表:表头 + 行组成「模块 / 动作 / 已选」三列。
|
||||
三层权限的层级落在列上(模块列只有模块级勾选、动作列只有动作勾选),
|
||||
不再靠嵌套容器的底色深浅区分 */
|
||||
.power-grid {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.power-grid__head {
|
||||
display: flex;
|
||||
grid-column: 3;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
flex: none;
|
||||
padding: 0 12px 6px 16px;
|
||||
border-bottom: 1px solid var(--fms-border-soft);
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.power-grid__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.power-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 7px 12px 7px 16px;
|
||||
border-bottom: 1px solid var(--fms-border-soft);
|
||||
transition: background-color var(--fms-duration-fast) var(--fms-ease-standard);
|
||||
}
|
||||
|
||||
/* 悬停整行高亮:这是勾选框的主要热区反馈,比逐个勾选框亮底色更完整,
|
||||
也让「这一行属于哪个模块」在读动作时始终可见 */
|
||||
.power-row:hover {
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
/* 列宽:表头与行共用同一组类,三列才严格对齐 */
|
||||
.power-col--module {
|
||||
flex: none;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.power-col--actions {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 与左侧「使用」开关用浅分隔线分开 —— 只有该行确实有开关时才画 */
|
||||
.power-row__access + .power-row__caps {
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--fms-border-soft);
|
||||
}
|
||||
|
||||
/* 权限点 chip:勾选 = 主色淡底 + 描边 + 对勾;未勾选 = 灰描边。
|
||||
权限点是「有没有这个能力」的开关,用 chip 比方框轻,也不与模块并列时抢视线 */
|
||||
.power-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: var(--fms-control-height);
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 999px;
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--fms-duration-fast) var(--fms-ease-standard),
|
||||
border-color var(--fms-duration-fast) var(--fms-ease-standard),
|
||||
background-color var(--fms-duration-fast) var(--fms-ease-standard);
|
||||
}
|
||||
|
||||
.power-chip:hover {
|
||||
border-color: color-mix(in srgb, var(--fms-primary) 40%, var(--fms-border));
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.power-chip.is-checked {
|
||||
border-color: color-mix(in srgb, var(--fms-primary) 45%, transparent);
|
||||
background: var(--fms-primary-soft);
|
||||
color: var(--fms-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.power-chip:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--fms-primary) 55%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* 行尾:整模块全选 / 清空(hover 或键盘聚焦时出现)+ 已选计数 */
|
||||
.power-row__tail {
|
||||
display: flex;
|
||||
grid-column: 4;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.power-row__toggle {
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--fms-primary);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.power-row:hover .power-row__toggle,
|
||||
.power-row:focus-within .power-row__toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.power-row__toggle:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--fms-primary) 55%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.power-row__count {
|
||||
min-width: 26px;
|
||||
.power-col--count {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 26px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 模块列与动作列都是勾选框,行高与「已选」列的首行对齐 */
|
||||
.power-row__module,
|
||||
.power-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
/* 动作流式铺排(宽度跟文案走),一行放不下换行。
|
||||
不用等宽列 —— 等宽列会形成看不见的竖向对齐,动作名长短不一时间距忽宽忽窄;
|
||||
流式排布下每个勾选单元自身完整,读起来是一串选项而不是一张网格 */
|
||||
.power-row__actions {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
}
|
||||
|
||||
/* ui/Checkbox 落在表格里的形态调整:只调间距与配色上下文,
|
||||
方框、对勾、半选、聚焦环仍由 ui/Checkbox 自己定义(不在这里重画)。
|
||||
表格比表单密,勾选框与文案的间距收到 6px */
|
||||
.power-grid :deep(.checkbox) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.power-row__module :deep(.checkbox) {
|
||||
max-width: 100%;
|
||||
color: var(--fms-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 动作名未勾选时保持次要色(继承 .checkbox 的正文色会抢模块名),勾选后才提到正文色 */
|
||||
.power-row__actions :deep(.checkbox) {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.power-row__actions :deep(.checkbox-checked) {
|
||||
color: var(--fms-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 名称超长时不撑破列宽,省略后靠 title 查看全称 */
|
||||
.power-grid :deep(.checkbox-label) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 空态 / 加载态:占满右栏并居中。贴顶显示时下方留一大片空白,重心明显偏上。
|
||||
height:100% 依赖上方的确定高度链(.power-panel__main 已有 height:100%) */
|
||||
.power-panel__state {
|
||||
padding: 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: var(--fms-font-size);
|
||||
text-align: center;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 分配权限抽屉(UserPowerDrawer):头部结构。
|
||||
*
|
||||
* 数据加载与保存由本组件负责(面板只读展示),测试只锁头部两件事:
|
||||
* 1. 头部不渲染标题:只有页签一行 + ui/Drawer 的关闭按钮,没有其它元素;
|
||||
* 数据加载与保存由本组件负责(面板只读展示),测试只锁结构两件事:
|
||||
* 1. 头部常规显示:左侧账号标识标题 + 右侧关闭按钮,页签在内容区顶部;
|
||||
* 2. 三个页签对应三张权限表的内容面板(切换页签不销毁,用 v-show 承载)。
|
||||
*
|
||||
* 面板自身的规则由 user-power-panel.spec.js 覆盖(含「全授权 / 全禁用」),这里不重复。
|
||||
@@ -38,16 +38,28 @@ async function mountDrawer() {
|
||||
}
|
||||
|
||||
describe('分配权限抽屉:头部', () => {
|
||||
it('头部只有页签一行:无标题、无批量按钮,关闭按钮由 ui/Drawer 提供', async () => {
|
||||
it('头部常规显示:账号标识标题 + 关闭按钮,页签在内容区', 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-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()
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
* 数据加载与保存由父级 UserPowerDrawer 负责,面板自身只做这几件事,测试只锁这几件:
|
||||
* 1. 打开时的初始状态:只展开「全部分类」虚拟根,业务目录收起、不选中任何菜单;
|
||||
* 2. 菜单选中语义:重复点击已选中的菜单不取消选中,只有点「全部分类」才清空;
|
||||
* 3. 右侧两个分区:菜单访问(入口开关)+ 模块权限
|
||||
* (模块名 | 模块使用开关 | 操作权限 chip | 行尾全选与计数)。
|
||||
* 目录不渲染入口开关、没配权限点的模块不渲染,都不出空态文案;
|
||||
* 3. 右侧结构:页头(菜单名 + 入口勾选框 + 本页进度)+ 一张「模块 / 动作 / 已选」三列表。
|
||||
* 模块级勾选在「模块」列、动作勾选在「动作」列,层级靠列而不是靠嵌套容器;
|
||||
* 目录不渲染入口勾选框、没配权限点的模块不渲染,都不出空态文案;
|
||||
* 4. 面板顶部工具栏的「全授权 / 全禁用」作用于该账号的全部功能权限(s_power 全量),
|
||||
* 与左侧选中哪个菜单无关;
|
||||
* 5. 勾选结果经 v-model 回传,三层是「保护性单向联动」:
|
||||
* 勾动作补模块使用与入口,勾模块补入口,关模块清该模块动作,
|
||||
* 关入口收回该菜单下的全部授权。
|
||||
* 勾动作补模块使用与入口,模块级勾选框全给全撤,关入口收回该菜单下的全部授权。
|
||||
*
|
||||
* 勾选框是 ui/Checkbox(label 包原生 input),测试直接驱动 input 的 change ——
|
||||
* jsdom 不保证 label 点击会转发给内部控件,走 change 才是组件真实监听的入口。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
@@ -30,7 +32,11 @@ const MENU_TREE = [
|
||||
]
|
||||
|
||||
const MENU_MODULES = {
|
||||
partner: [{ b_menu_id: 'partner', b_module_id: 'v_b_othercompany', b_xh: 10 }],
|
||||
// 合作伙伴菜单绑两个模块,动作集不同 → 验证动作各自成组、互不影响
|
||||
partner: [
|
||||
{ b_menu_id: 'partner', b_module_id: 'v_b_othercompany', b_xh: 10 },
|
||||
{ b_menu_id: 'partner', b_module_id: 'v_b_contact', b_xh: 20 },
|
||||
],
|
||||
// 用户管理绑两个模块,其中币种(b_money)没配权限点 → 验证「未配置权限点的模块不渲染」
|
||||
user: [
|
||||
{ b_menu_id: 'user', b_module_id: 'v_b_user', b_xh: 10 },
|
||||
@@ -40,14 +46,24 @@ const MENU_MODULES = {
|
||||
|
||||
const MODULE_BY_ID = new Map([
|
||||
['v_b_othercompany', { b_id: 'v_b_othercompany', b_name: '合作伙伴' }],
|
||||
['v_b_contact', { b_id: 'v_b_contact', b_name: '联系人' }],
|
||||
['v_b_user', { b_id: 'v_b_user', b_name: '用户管理' }],
|
||||
['b_money', { b_id: 'b_money', b_name: '币种' }],
|
||||
])
|
||||
|
||||
/** 每个模块的权限点:access 是「模块使用」(并入模块级勾选框),其余是「动作」列 */
|
||||
const POWER_BY_MODULE = {
|
||||
v_b_othercompany: [
|
||||
{ b_id: 'module.v_b_othercompany', b_name: '模块使用', b_action: 'access' },
|
||||
{ b_id: 'action.v_b_othercompany.create', b_name: '新增', b_action: 'create' },
|
||||
{ b_id: 'action.v_b_othercompany.update', b_name: '修改', b_action: 'update' },
|
||||
{ b_id: 'action.v_b_othercompany.audit', b_name: '审核', b_action: 'audit' },
|
||||
],
|
||||
// 联系人只有新增 / 删除:动作列也只剩这两项
|
||||
v_b_contact: [
|
||||
{ b_id: 'module.v_b_contact', b_name: '模块使用', b_action: 'access' },
|
||||
{ b_id: 'action.v_b_contact.create', b_name: '新增', b_action: 'create' },
|
||||
{ b_id: 'action.v_b_contact.delete', b_name: '删除', b_action: 'delete' },
|
||||
],
|
||||
v_b_user: [{ b_id: 'module.v_b_user', b_name: '模块使用', b_action: 'access' }],
|
||||
}
|
||||
@@ -59,9 +75,22 @@ const ALL_POWER_IDS = [
|
||||
'menu.user',
|
||||
'module.v_b_othercompany',
|
||||
'action.v_b_othercompany.create',
|
||||
'action.v_b_othercompany.update',
|
||||
'action.v_b_othercompany.audit',
|
||||
'module.v_b_contact',
|
||||
'action.v_b_contact.create',
|
||||
'action.v_b_contact.delete',
|
||||
'module.v_b_user',
|
||||
]
|
||||
|
||||
/** 合作伙伴模块(v_b_othercompany)的权限点全量:模块级勾选框勾上时就该是这些 */
|
||||
const OTHERCOMPANY_ALL = [
|
||||
'module.v_b_othercompany',
|
||||
'action.v_b_othercompany.create',
|
||||
'action.v_b_othercompany.update',
|
||||
'action.v_b_othercompany.audit',
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
@@ -95,26 +124,59 @@ 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()) : []
|
||||
return wrapper.findAll('.power-row__module .checkbox-label').map((el) => el.text())
|
||||
}
|
||||
|
||||
/** 「菜单访问」分区的入口开关(目录没有入口权限点时返回 undefined) */
|
||||
function entrySwitch(wrapper) {
|
||||
return wrapper.findAll('.power-section')[0]?.find('[role="switch"]')
|
||||
/** 页头(菜单名 + 入口)的入口勾选框(目录没有入口权限点时返回 undefined) */
|
||||
function entryCheck(wrapper) {
|
||||
const found = wrapper.find('.power-head__entry')
|
||||
return found.exists() ? found : undefined
|
||||
}
|
||||
|
||||
/** 模块权限区的模块使用开关(第 index 行) */
|
||||
function moduleSwitch(wrapper, index = 0) {
|
||||
const section = wrapper.findAll('.power-section')[1]
|
||||
return section ? section.findAll('[role="switch"]')[index] : undefined
|
||||
/** 第 index 个模块行的模块级勾选框 */
|
||||
function moduleCheck(wrapper, index = 0) {
|
||||
return wrapper.findAll('.power-row__module .checkbox')[index]
|
||||
}
|
||||
|
||||
/** 勾选框形态的开关总数:页头入口 1 个 + 每行模块 1 个(动作勾选框在「动作」列,不计入) */
|
||||
function useChecks(wrapper) {
|
||||
return wrapper.findAll('.power-head__entry, .power-row__module .checkbox')
|
||||
}
|
||||
|
||||
/** 第 index 行行尾的「已选」计数 */
|
||||
function rowCount(wrapper, index = 0) {
|
||||
return wrapper.findAll('.power-row .power-col--count')[index]
|
||||
}
|
||||
|
||||
/** 某个模块行(按模块名定位,比按下标稳) */
|
||||
function moduleRow(wrapper, moduleName) {
|
||||
return wrapper
|
||||
.findAll('.power-row')
|
||||
.find((row) => row.find('.power-row__module .checkbox-label').text() === moduleName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作勾选框:按「模块名 + 动作名」定位。
|
||||
* 动作只有「动作」列有,所以按名字找比按下标找稳,动作数量变化也不影响。
|
||||
*/
|
||||
function actionCheck(wrapper, moduleName, actionName) {
|
||||
return moduleRow(wrapper, moduleName)
|
||||
.findAll('.power-row__actions .checkbox')
|
||||
.find((el) => el.find('.checkbox-label').text() === actionName)
|
||||
}
|
||||
|
||||
/** 某个模块的动作名清单(按铺排顺序) */
|
||||
function actionNames(wrapper, moduleName) {
|
||||
return moduleRow(wrapper, moduleName)
|
||||
.findAll('.power-row__actions .checkbox-label')
|
||||
.map((el) => el.text())
|
||||
}
|
||||
|
||||
/** 驱动 ui/Checkbox 内部的原生 input:勾上 / 取消 */
|
||||
function setCheck(checkbox, checked = true) {
|
||||
return checkbox.find('.checkbox-input').setValue(checked)
|
||||
}
|
||||
|
||||
/** 面板顶部工具栏按钮:0 = 全授权,1 = 全禁用 */
|
||||
@@ -199,54 +261,137 @@ describe('功能权限面板:菜单选中语义', () => {
|
||||
})
|
||||
|
||||
describe('功能权限面板:右侧结构', () => {
|
||||
it('两个分区:菜单访问 + 模块权限;入口与模块使用各一个开关', async () => {
|
||||
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(['新增'])
|
||||
expect(wrapper.find('.power-head__name').text()).toBe('合作伙伴')
|
||||
expect(entryCheck(wrapper).find('.checkbox-label').text()).toBe('可访问')
|
||||
expect(wrapper.find('.power-head__meta').text()).toBe('本页 2 个模块,已授权 0 个')
|
||||
// 页头里只有菜单名、勾选框与进度,没有多余的说明句
|
||||
expect(wrapper.find('.power-head').text()).toBe('合作伙伴 可访问 本页 2 个模块,已授权 0 个')
|
||||
})
|
||||
|
||||
it('本页进度:至少给了一项权限的模块才计入已授权', async () => {
|
||||
const wrapper = await mountWithMenu('partner', {
|
||||
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
|
||||
})
|
||||
|
||||
expect(wrapper.find('.power-head__meta').text()).toBe('本页 2 个模块,已授权 1 个')
|
||||
})
|
||||
|
||||
it('入口的连带影响只进 title,不常显在页头', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
expect(entryCheck(wrapper).attributes('title')).toBe(
|
||||
'可访问「合作伙伴」(关闭会收回该页面下的全部模块授权)',
|
||||
)
|
||||
})
|
||||
|
||||
it('表格:模块 / 动作 / 已选三列,一个模块一行', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
expect(wrapper.find('.power-grid__head').text()).toBe('模块动作已选')
|
||||
expect(moduleNames(wrapper)).toEqual(['合作伙伴', '联系人'])
|
||||
expect(wrapper.findAll('.power-row')).toHaveLength(2)
|
||||
// 勾选框形态的开关:页头入口 1 个 + 模块级 2 个(动作勾选框在「动作」列,不计入)
|
||||
expect(useChecks(wrapper)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('动作按模块分行:每行的动作只属于该行模块,互不挤占', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
expect(actionNames(wrapper, '合作伙伴')).toEqual(['新增', '修改', '审核'])
|
||||
expect(actionNames(wrapper, '联系人')).toEqual(['新增', '删除'])
|
||||
})
|
||||
|
||||
it('动作勾选框挂 title(模块名 + 动作名),名称超长被省略时可查看全称', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
expect(actionCheck(wrapper, '合作伙伴', '新增').attributes('title')).toBe('合作伙伴 新增')
|
||||
})
|
||||
|
||||
it('行尾「已选 x/y」= 该模块已授权的动作数 / 动作总数', async () => {
|
||||
const wrapper = await mountWithMenu('partner', {
|
||||
modelValue: ['module.v_b_othercompany', 'action.v_b_othercompany.create'],
|
||||
})
|
||||
|
||||
// 「模块使用」不计入已选:它并入模块级勾选框,与「动作」列不是一回事
|
||||
expect(rowCount(wrapper, 0).text()).toBe('1/3')
|
||||
expect(rowCount(wrapper, 1).text()).toBe('0/2')
|
||||
})
|
||||
|
||||
it('只有模块使用、没有动作的模块不显示计数', async () => {
|
||||
const wrapper = await mountWithMenu('user')
|
||||
|
||||
expect(moduleNames(wrapper)).toEqual(['用户管理'])
|
||||
expect(rowCount(wrapper, 0).text()).toBe('')
|
||||
})
|
||||
|
||||
it('模块级勾选框三态:全选 / 半选 / 未选', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
// 全选:模块使用 + 全部动作都在
|
||||
await wrapper.setProps({ modelValue: OTHERCOMPANY_ALL })
|
||||
expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-checked')
|
||||
expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-indeterminate')
|
||||
|
||||
// 半选:进了这个模块,但只给了部分动作
|
||||
await wrapper.setProps({
|
||||
modelValue: ['module.v_b_othercompany', 'action.v_b_othercompany.create'],
|
||||
})
|
||||
expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-checked')
|
||||
expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-indeterminate')
|
||||
|
||||
// 未选
|
||||
await wrapper.setProps({ modelValue: [] })
|
||||
expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-checked')
|
||||
expect(moduleCheck(wrapper, 0).classes()).not.toContain('checkbox-indeterminate')
|
||||
})
|
||||
|
||||
it('模块级勾选框的 title 说清点它会全给还是全撤', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
expect(moduleCheck(wrapper, 0).attributes('title')).toBe('全选「合作伙伴」的 4 项权限')
|
||||
|
||||
await wrapper.setProps({ modelValue: OTHERCOMPANY_ALL })
|
||||
expect(moduleCheck(wrapper, 0).attributes('title')).toBe('取消「合作伙伴」的全部授权')
|
||||
})
|
||||
|
||||
it('已授权的入口渲染为打开态', async () => {
|
||||
const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner'] })
|
||||
|
||||
expect(wrapper.vm.menuPowerCode).toBe('menu.partner')
|
||||
expect(entrySwitch(wrapper).attributes('aria-checked')).toBe('true')
|
||||
expect(entryCheck(wrapper).classes()).toContain('checkbox-checked')
|
||||
})
|
||||
|
||||
it('目录:不渲染入口开关(没有 menu.* 权限点),只给一行说明', async () => {
|
||||
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('目录只是分组节点')
|
||||
expect(useChecks(wrapper)).toHaveLength(0)
|
||||
// 目录没有可勾的入口:页头只显示名称,不出现勾选框
|
||||
expect(entryCheck(wrapper)).toBeUndefined()
|
||||
expect(wrapper.find('.power-head__name').text()).toBe('基础资料')
|
||||
expect(wrapper.find('.power-head__note').text()).toBe('目录不需要单独授权')
|
||||
})
|
||||
|
||||
it('外链菜单:没有可授权模块,入口开关独立可用', async () => {
|
||||
it('外链菜单:没有可授权模块,连表头都不渲染,入口勾选框独立可用', async () => {
|
||||
const wrapper = await mountWithMenu('portal')
|
||||
|
||||
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
|
||||
expect(moduleNames(wrapper)).toEqual([])
|
||||
expect(wrapper.findAll('.power-row')).toHaveLength(0)
|
||||
expect(wrapper.find('.power-grid').exists()).toBe(false)
|
||||
expect(wrapper.find('.power-head__meta').exists()).toBe(false)
|
||||
|
||||
await entrySwitch(wrapper).trigger('click')
|
||||
await nextTick()
|
||||
|
||||
await setCheck(entryCheck(wrapper))
|
||||
expect(lastEmitted(wrapper)).toEqual(['menu.portal'])
|
||||
})
|
||||
|
||||
it('页面没绑模块(自定义页):只剩入口开关,勾选即全部权限', async () => {
|
||||
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(wrapper.find('.power-grid').exists()).toBe(false)
|
||||
expect(useChecks(wrapper)).toHaveLength(1)
|
||||
|
||||
await setCheck(entryCheck(wrapper))
|
||||
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
|
||||
})
|
||||
|
||||
@@ -255,6 +400,8 @@ describe('功能权限面板:右侧结构', () => {
|
||||
|
||||
// 「币种」绑定了但没配权限点:不渲染成行,也不出任何提示
|
||||
expect(moduleNames(wrapper)).toEqual(['用户管理'])
|
||||
// 只有模块使用权限点的模块没有动作勾选框
|
||||
expect(wrapper.findAll('.power-row__actions .checkbox')).toHaveLength(0)
|
||||
expect(wrapper.find('.power-panel__main').text()).not.toContain('未配置权限点')
|
||||
})
|
||||
})
|
||||
@@ -299,19 +446,41 @@ describe('功能权限面板:全授权 / 全禁用', () => {
|
||||
})
|
||||
|
||||
describe('功能权限面板:三层联动', () => {
|
||||
it('勾模块使用:自动补上菜单入口', async () => {
|
||||
it('勾模块级勾选框:一次给全该模块的权限点,并补上菜单入口', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
await moduleSwitch(wrapper).trigger('click')
|
||||
await setCheck(moduleCheck(wrapper))
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmitted(wrapper)).toEqual(['module.v_b_othercompany', 'menu.partner'])
|
||||
expect(lastEmitted(wrapper)).toEqual([...OTHERCOMPANY_ALL, 'menu.partner'])
|
||||
})
|
||||
|
||||
it('勾操作权限:自动补上模块使用与菜单入口', async () => {
|
||||
it('半选时点模块级勾选框:补齐到全选,不产生重复项', async () => {
|
||||
const wrapper = await mountWithMenu('partner', {
|
||||
modelValue: ['module.v_b_othercompany', 'action.v_b_othercompany.create'],
|
||||
})
|
||||
|
||||
await setCheck(moduleCheck(wrapper))
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmitted(wrapper)).toEqual([...OTHERCOMPANY_ALL, 'menu.partner'])
|
||||
})
|
||||
|
||||
it('取消模块级勾选框:收回该模块的全部权限点,不动菜单入口', async () => {
|
||||
const wrapper = await mountWithMenu('partner', {
|
||||
modelValue: ['menu.partner', ...OTHERCOMPANY_ALL],
|
||||
})
|
||||
|
||||
await setCheck(moduleCheck(wrapper), false)
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
|
||||
})
|
||||
|
||||
it('勾单个动作:自动补上模块使用与菜单入口', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
await wrapper.findAll('.power-chip')[0].trigger('click')
|
||||
await setCheck(actionCheck(wrapper, '合作伙伴', '新增'))
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmitted(wrapper)).toEqual([
|
||||
@@ -321,21 +490,23 @@ describe('功能权限面板:三层联动', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('关模块使用:只清该模块的操作权限,不动菜单入口', async () => {
|
||||
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 setCheck(actionCheck(wrapper, '合作伙伴', '新增'), false)
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
|
||||
expect(lastEmitted(wrapper)).toEqual(['menu.partner', 'module.v_b_othercompany'])
|
||||
expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-indeterminate')
|
||||
expect(rowCount(wrapper, 0).text()).toBe('0/3')
|
||||
})
|
||||
|
||||
it('开菜单入口:只补入口本身,不给任何模块权限', async () => {
|
||||
const wrapper = await mountWithMenu('partner')
|
||||
|
||||
await entrySwitch(wrapper).trigger('click')
|
||||
await setCheck(entryCheck(wrapper))
|
||||
await nextTick()
|
||||
|
||||
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
|
||||
@@ -346,44 +517,22 @@ describe('功能权限面板:三层联动', () => {
|
||||
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
|
||||
})
|
||||
|
||||
await entrySwitch(wrapper).trigger('click')
|
||||
await setCheck(entryCheck(wrapper), false)
|
||||
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 () => {
|
||||
it('勾选态回显:各行动作各管各的,只勾动作时模块级是半选', 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',
|
||||
])
|
||||
expect(actionCheck(wrapper, '合作伙伴', '新增').classes()).toContain('checkbox-checked')
|
||||
expect(actionCheck(wrapper, '合作伙伴', '修改').classes()).not.toContain('checkbox-checked')
|
||||
// 同名动作在别的模块行里不受影响
|
||||
expect(actionCheck(wrapper, '联系人', '新增').classes()).not.toContain('checkbox-checked')
|
||||
// 只勾了动作、没勾模块使用 → 模块级勾选框半选
|
||||
expect(moduleCheck(wrapper, 0).classes()).toContain('checkbox-indeterminate')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user