20260921173344

This commit is contained in:
oneao committed 2026-09-21 17:33:45 +08:00
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')
})
})
@@ -0,0 +1,389 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>分配权限 · 布局草图</title>
<style>
/* 值取自 fms-vue/src/theme/tokens.css(浅色),保证草图与系统观感一致 */
:root{
--fms-background:#eef1f6; --fms-card:#fff; --fms-text:#1f2329;
--fms-secondary:#e9edf2; --fms-secondary-hover:#f1f5f9;
--fms-text-secondary:#6f7784; --fms-border:#e3e8ee; --fms-border-soft:#ebedf2;
--fms-primary:#0f172b; --fms-primary-text:#fff; --fms-primary-soft:rgba(15,23,43,.12);
--fms-table-header-bg:#f2f4f8;
--fms-font-size:13px; --fms-line-height:1.4;
--fms-control-height:26px; --fms-control-padding-x:8px;
--fms-control-radius:6px; --fms-control-size:16px;
--fms-popover-shadow:0 3px 8px rgba(0,0,0,.12); --fms-mask-bg:rgba(0,0,0,.45);
--fms-duration-fast:100ms; --fms-duration-base:150ms;
--fms-ease-standard:cubic-bezier(.4,0,.2,1);
--fms-tree-node-height:32px; --fms-tree-indent:18px;
}
*{box-sizing:border-box}
body{
margin:0;padding:28px 24px 40px;background:var(--fms-background);color:var(--fms-text);
font-family:"Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
font-size:var(--fms-font-size);line-height:var(--fms-line-height);
}
/* ---------- 仅演示用的控件条(不属于设计本身) ---------- */
.demo-bar{
display:flex;align-items:center;gap:8px;margin:0 auto 14px;max-width:920px;
padding:8px 12px;border:1px dashed #b9c2cf;border-radius:6px;background:#fff;
color:var(--fms-text-secondary);font-size:12px;flex-wrap:wrap;
}
.demo-bar strong{color:var(--fms-text);font-weight:600}
.demo-bar em{font-style:normal;color:#8b93a1;margin-right:2px}
.demo-btn{
height:24px;padding:0 10px;border:1px solid var(--fms-border);border-radius:4px;
background:#fff;color:var(--fms-text);font:inherit;font-size:12px;cursor:pointer;
}
.demo-btn:hover{background:var(--fms-secondary-hover)}
.demo-btn.is-on{border-color:var(--fms-primary);background:var(--fms-primary);color:#fff}
/* ---------- 抽屉外壳:按真实 ui/Drawer 复刻 ---------- */
.drawer{
display:flex;flex-direction:column;max-width:920px;height:660px;margin:0 auto;
background:var(--fms-card);box-shadow:var(--fms-popover-shadow);border-radius:2px;overflow:hidden;
}
.drawer-header{
display:flex;align-items:center;gap:12px;flex:none;flex:0 0 auto;
padding:8px 20px;border-bottom:1px solid var(--fms-border);
}
.drawer-title{
margin:0;flex:1;min-width:0;font-size:14px;font-weight:600;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.close{
display:inline-flex;align-items:center;justify-content:center;flex:none;
width:24px;height:24px;border:0;border-radius:4px;
background:transparent;color:var(--fms-text-secondary);font-size:14px;cursor:pointer;
}
.close:hover{background:var(--fms-secondary-hover);color:var(--fms-text)}
.drawer-body{display:flex;flex-direction:column;flex:1;min-height:0}
.drawer-footer{
display:flex;justify-content:flex-end;gap:8px;flex:none;padding:8px 20px;border-top:1px solid var(--fms-border);
}
.btn{
height:var(--fms-control-height);padding:0 var(--fms-control-padding-x);border-radius:var(--fms-control-radius);
border:1px solid var(--fms-border);background:#fff;color:var(--fms-text);font:inherit;cursor:pointer;
}
.btn:hover{background:var(--fms-secondary-hover)}
.btn-primary{border-color:var(--fms-primary);background:var(--fms-primary);color:var(--fms-primary-text)}
.btn-primary:hover{opacity:.9}
/* ---------- tabs 落在内容区,不在 header ---------- */
.tabs-band{display:flex;gap:20px;flex:none;padding:0 20px;border-bottom:1px solid var(--fms-border)}
.ptab{padding:9px 0 8px;color:var(--fms-text-secondary);cursor:pointer;white-space:nowrap}
.ptab.is-active{color:var(--fms-text);font-weight:600;box-shadow:inset 0 -2px 0 var(--fms-primary)}
/* ---------- 面板:左菜单树 + 右权限表 ---------- */
.panel{display:flex;flex:1;min-height:0;padding:12px 20px}
/* 左栏:菜单树 */
.tree{
display:flex;flex-direction:column;flex:none;width:220px;height:100%;
padding-right:12px;border-right:1px solid var(--fms-border);
}
.tree__search{padding:0 0 8px}
.tree__search input{
width:100%;height:var(--fms-control-height);padding:0 8px;border:1px solid var(--fms-border);
border-radius:var(--fms-control-radius);font:inherit;color:var(--fms-text);
}
.tree__scroll{flex:1;min-height:0;overflow-y:auto}
.tnode{
display:flex;align-items:center;gap:6px;height:var(--fms-tree-node-height);padding:0 8px;
border-radius:var(--fms-control-radius);color:var(--fms-text-secondary);cursor:pointer;
}
.tnode:hover{background:var(--fms-secondary-hover);color:var(--fms-text)}
.tnode.is-selected{background:var(--fms-primary-soft);color:var(--fms-text);font-weight:600}
.tnode__caret{width:12px;flex:none;text-align:center;font-size:10px;color:var(--fms-text-secondary)}
.tnode--l1{padding-left:26px}
.tnode--l2{padding-left:44px}
/* 右栏:页头(菜单权限)+ 模块/动作表 */
.detail{display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;padding-left:16px}
/* 页头:菜单入口,唯一的一个页面级开关 */
.page-band{
display:flex;align-items:center;gap:6px;flex:none;min-height:34px;
padding:0 8px 8px 0;border-bottom:1px solid var(--fms-border);
}
.page-band__name{margin:0;font-size:14px;font-weight:600;white-space:nowrap}
.page-band__meta{color:var(--fms-text-secondary);font-size:12px}
.page-band__actions{margin-left:auto;display:flex;gap:2px}
.link-btn{
height:24px;padding:0 8px;border:0;border-radius:4px;background:none;
color:var(--fms-text-secondary);font:inherit;font-size:12px;cursor:pointer;
}
.link-btn:hover{background:var(--fms-secondary-hover);color:var(--fms-text)}
/* 表头:只有「模块 / 动作」两列,层级靠列而非靠嵌套框 */
.grid{display:flex;flex-direction:column;flex:1;min-height:0;padding-top:8px}
.grid__head{
display:flex;align-items:center;gap:12px;flex:none;padding:0 8px 6px;
color:var(--fms-text-secondary);font-size:12px;border-bottom:1px solid var(--fms-border-soft);
}
.col-module{width:160px;flex:none}
.col-actions{flex:1;min-width:0}
.col-count{width:52px;flex:none;text-align:right}
.grid__body{flex:1;min-height:0;overflow-y:auto}
.grid__body.is-locked{opacity:.45;pointer-events:none}
.row{
display:flex;align-items:flex-start;gap:12px;padding:7px 8px;
border-bottom:1px solid var(--fms-border-soft);
transition:background-color var(--fms-duration-fast) var(--fms-ease-standard);
}
.row:hover{background:var(--fms-secondary-hover)}
.row__module{display:flex;align-items:center;width:160px;flex:none}
.row__actions{display:flex;flex-wrap:wrap;flex:1;min-width:0;margin:-2px 0}
.row__count{
width:52px;flex:none;text-align:right;line-height:26px;
color:var(--fms-text-secondary);font-size:12px;font-variant-numeric:tabular-nums;
}
.grid__empty{padding:40px 8px;text-align:center;color:var(--fms-text-secondary)}
/* ---------- 勾选单元:[框] + 文字,整块可点 ---------- */
.check{
display:inline-flex;align-items:center;gap:6px;min-height:26px;padding:2px 6px;
border:0;border-radius:var(--fms-control-radius);background:none;
color:var(--fms-text-secondary);font:inherit;text-align:left;cursor:pointer;
transition:color var(--fms-duration-fast) var(--fms-ease-standard),
background-color var(--fms-duration-fast) var(--fms-ease-standard);
}
.check:hover{background:var(--fms-secondary);color:var(--fms-text)}
.check.is-checked{color:var(--fms-text);font-weight:500}
.box{
display:inline-flex;align-items:center;justify-content:center;flex:none;
width:var(--fms-control-size);height:var(--fms-control-size);
border:1px solid var(--fms-border);border-radius:4px;
background:var(--fms-card);color:var(--fms-primary-text);
}
.check:hover .box{border-color:var(--fms-primary)}
.check.is-checked .box{border-color:var(--fms-primary);background:var(--fms-primary)}
.box svg{display:none}
.check.is-checked .box svg{display:block}
.box i{display:none;width:8px;height:2px;background:var(--fms-primary)}
.check.is-partial .box{border-color:var(--fms-primary)}
.check.is-partial .box i{display:block}
.check--use{color:var(--fms-text);font-weight:500}
.check--use .label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* ---------- 动作两种呈现,仅供对比 ---------- */
/* A. 标签式:选中=浅底 chip,未选=正文灰字,动作多时靠换行消化 */
.tag{
display:inline-flex;align-items:center;height:24px;margin:2px 4px 2px 0;padding:0 10px;
border:1px solid transparent;border-radius:12px;background:transparent;
color:var(--fms-text-secondary);font:inherit;cursor:pointer;white-space:nowrap;
transition:background-color var(--fms-duration-fast) var(--fms-ease-standard),
color var(--fms-duration-fast) var(--fms-ease-standard);
}
.tag:hover{background:var(--fms-secondary);color:var(--fms-text)}
.tag.is-on{
background:var(--fms-primary-soft);color:var(--fms-text);font-weight:500;
box-shadow:inset 0 0 0 1px rgba(15,23,43,.16);
}
/* B. 复选框式 */
.action-check{margin:2px 4px 2px 0}
</style>
</head>
<body>
<div class="demo-bar">
<strong>演示控件</strong>
<em>动作呈现</em>
<button class="demo-btn" data-style="tag">标签</button>
<button class="demo-btn" data-style="check">复选框</button>
<em style="margin-left:8px">动作数量</em>
<button class="demo-btn" data-scale="normal">常规</button>
<button class="demo-btn" data-scale="huge">超多</button>
<span style="flex:1 1 100%;color:#8b93a1">
点模块左侧方框 = 该模块动作全给/全撤 | 点动作 = 单项开关 | 关掉页头「可访问」= 收回本页全部模块授权(灰色锁定)
</span>
</div>
<div class="drawer">
<header class="drawer-header">
<h1 class="drawer-title">分配权限 · 张三(001)</h1>
<button class="close" title="关闭">✕</button>
</header>
<div class="drawer-body">
<nav class="tabs-band">
<span class="ptab is-active">功能权限</span>
<span class="ptab">字段权限</span>
<span class="ptab">数据范围</span>
</nav>
<div class="panel">
<aside class="tree">
<div class="tree__search"><input placeholder="搜索菜单" value=""></div>
<div class="tree__scroll">
<div class="tnode is-selected"><span class="tnode__caret">▾</span><span>海运工作台</span></div>
<div class="tnode tnode--l1"><span class="tnode__caret"></span><span>海运操作</span></div>
<div class="tnode tnode--l1"><span class="tnode__caret"></span><span>基础资料</span></div>
<div class="tnode"><span class="tnode__caret">▸</span><span>空运工作台</span></div>
<div class="tnode"><span class="tnode__caret">▸</span><span>财务管理</span></div>
<div class="tnode"><span class="tnode__caret">▸</span><span>系统管理</span></div>
</div>
</aside>
<main class="detail">
<div class="page-band">
<h3 class="page-band__name">海运工作台</h3>
<button class="check check--use" id="entry-check" type="button"
title="菜单入口。关闭会收回该页面下的全部模块授权">
<span class="box"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg><i></i></span>
<span class="label">可访问</span>
</button>
<span class="page-band__meta" id="entry-meta"></span>
<div class="page-band__actions">
<button class="link-btn" id="grant-all">全授权</button>
<button class="link-btn" id="clear-all">全禁用</button>
</div>
</div>
<div class="grid">
<div class="grid__head">
<span class="col-module">模块</span>
<span class="col-actions">动作</span>
<span class="col-count">已选</span>
</div>
<div class="grid__body" id="rows"></div>
</div>
</main>
</div>
</div>
<footer class="drawer-footer">
<button class="btn">取消</button>
<button class="btn btn-primary">保存</button>
</footer>
</div>
<script>
const CHECK = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
/* 模块与动作:动作数量可切到「超多」验证换行表现 */
const BASE_MODULES = [
{ name:'海运主单', actions:['新增','修改','删除','审核','反审核','作废','结算','放单','改单','分摊','批量导入','导出','打印','复制'],
checked:['新增','修改','审核','导出'] },
{ name:'装箱信息', actions:['新增','修改','删除','导出'], checked:['新增','导出'] },
{ name:'费用', actions:['新增','修改','删除','审核','导出'], checked:[] },
{ name:'船名船次', actions:['新增','修改','删除'], checked:[] },
{ name:'港口', actions:['新增','修改'], checked:[] },
]
const EXTRA_ACTIONS = ['订舱','改配','舱单发送','AMS 发送','ISF 发送','电放','换单','滞期费','押箱','核销','对账','统计']
let modules = clone(BASE_MODULES)
let entry = true
let actionStyle = 'tag'
let scale = 'normal'
function clone(list) {
return list.map((m) => ({ name: m.name, actions: [...m.actions], checked: [...m.checked] }))
}
function actionHtml(m, i, a) {
const on = m.checked.includes(a)
if (actionStyle === 'check') {
return `<button class="check action-check${on ? ' is-checked' : ''}" type="button" data-act="${a}" data-mod="${i}">
<span class="box">${CHECK}<i></i></span><span class="label">${a}</span>
</button>`
}
return `<button class="tag${on ? ' is-on' : ''}" type="button" data-act="${a}" data-mod="${i}">${a}</button>`
}
function render() {
const rows = document.getElementById('rows')
const body = modules.map((m, i) => {
const total = m.actions.length
const count = m.checked.length
const all = count === total && total > 0
const partial = count > 0 && !all
const state = all ? ' is-checked' : partial ? ' is-partial' : ''
const actions = m.actions.map((a) => actionHtml(m, i, a)).join('')
return `<div class="row" data-mod="${i}">
<div class="row__module">
<button class="check check--use${state}" type="button" data-toggle="${i}">
<span class="box">${CHECK}<i></i></span><span class="label">${m.name}</span>
</button>
</div>
<div class="row__actions">${actions}</div>
<div class="row__count">${count}/${total}</div>
</div>`
}).join('')
rows.innerHTML = body || '<div class="grid__empty">该页面下没有可授权的模块</div>'
rows.classList.toggle('is-locked', !entry)
const entryCheck = document.getElementById('entry-check')
entryCheck.classList.toggle('is-checked', entry)
const grantedModules = modules.filter((m) => m.checked.length > 0).length
document.getElementById('entry-meta').textContent =
`本页 ${modules.length} 个模块,已授权 ${grantedModules} 个`
}
function syncEntry() {
if (modules.some((m) => m.checked.length > 0)) entry = true
render()
}
document.getElementById('rows').addEventListener('click', (e) => {
const toggle = e.target.closest('[data-toggle]')
if (toggle) {
const m = modules[+toggle.dataset.toggle]
m.checked = m.checked.length === m.actions.length ? [] : [...m.actions]
return syncEntry()
}
const act = e.target.closest('[data-act]')
if (act) {
const m = modules[+act.dataset.mod], a = act.dataset.act
const at = m.checked.indexOf(a)
at < 0 ? m.checked.push(a) : m.checked.splice(at, 1)
return syncEntry()
}
})
document.getElementById('entry-check').addEventListener('click', () => {
entry = !entry
if (!entry) modules.forEach((m) => { m.checked = [] })
render()
})
document.getElementById('grant-all').addEventListener('click', () => {
entry = true
modules.forEach((m) => { m.checked = [...m.actions] })
render()
})
document.getElementById('clear-all').addEventListener('click', () => {
modules.forEach((m) => { m.checked = [] })
render()
})
document.querySelectorAll('.demo-btn').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.style) {
actionStyle = btn.dataset.style
document.querySelectorAll('[data-style]').forEach((b) => b.classList.toggle('is-on', b === btn))
}
if (btn.dataset.scale) {
scale = btn.dataset.scale
const target = scale === 'huge'
modules[0].actions = target
? [...BASE_MODULES[0].actions, ...EXTRA_ACTIONS]
: [...BASE_MODULES[0].actions]
modules[0].checked = modules[0].checked.filter((a) => modules[0].actions.includes(a))
document.querySelectorAll('[data-scale]').forEach((b) => b.classList.toggle('is-on', b === btn))
}
render()
})
})
document.querySelector('.demo-btn[data-style="tag"]').classList.add('is-on')
document.querySelector('.demo-btn[data-scale="normal"]').classList.add('is-on')
render()
</script>
</body>
</html>