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;