Skip to content

前端授权页开发 ​

适用端:Web | 类型:机制

定制功能

本页讲的四个授权界面(角色资源授权页、批量授权抽屉、资源详情面板、权限诊断页)与它们共用的那份动作词表,全部是本项目自研。上游 yudao 的前端只有一张 system/role 的菜单树勾选弹窗,没有动作维度、没有 DENY、没有行过滤与字段模式下拉。

这一页讲授权行在界面上怎么配,以及这套前端为什么被切成「词表 / 纯函数 / 数据层 / 组件」四层。切法本身不是洁癖:本仓的 vitest 刻意不挂自动导入插件,凡是要测的逻辑都必须能从组件里拿出来单独 import——四层切法是测试可行性倒逼出来的,不是设计偏好。

前置阅读:权限体系总览、授权与决策引擎、行过滤规则。按钮级 v-hasPermi 指令的实现与它的 29 条 JS 错误实测在路由与权限机制,本页不重复。

它解决什么问题 ​

四层各自的职责边界:

层文件能不能 import vue谁在测它
词表views/system/resource/constants.ts能(只用类型)constants.spec.ts
纯函数role/grant/{resourceTree,batchGrant,effectiveTree}.ts不能三个同名 .spec.ts
数据层role/grant/{useResourceCatalog,useSlotCoverage}.ts、rule/useRuleTemplateMeta.ts能没有(要 mock @/api)
组件六个 .vue能没有(要 mount)

代码在哪 ​

src/views/system/role/grant/            角色资源授权页(本体)
  index.vue                    26.2 KB  列表模式 + 智能查看二选一,五个写按钮的 drawerOpen 门禁
  GrantForm.vue                34.7 KB  单条授权抽屉(穿透 + 可拖宽)
  GrantBatchForm.vue           32.0 KB  批量授权抽屉(资源多选 × 动作多选)
  GrantEffectiveTree.vue       25.6 KB  智能查看:只读的生效权限树(el-table-v2)
  ResourceTreePicker.vue       20.3 KB  单/多选共用的资源树(el-tree-v2)
  resourceTree.ts               5.0 KB  纯函数:搜索匹配、id 索引、面包屑、集合相等
  batchGrant.ts                20.9 KB  纯函数:笛卡尔展开、冲突检测、合并、槽位覆盖度
  effectiveTree.ts             17.7 KB  纯函数:展开态、剪枝、聚合列、来源标签
  useResourceCatalog.ts         3.8 KB  数据层:资源树 + 字段级开关 + id 索引
  useSlotCoverage.ts            3.0 KB  数据层:全量槽位映射 + 取数状态
  *.spec.ts                     3 个    50 + 66 + 22 = 138 个 it

src/views/system/resource/
  constants.ts                 17.8 KB  🔴 四份词表的唯一副本(资源类型/字段模式/主体类型/授权动作)
  constants.spec.ts            11.3 KB  21 个 it,含一份刻意的黄金副本
  index.vue                    24.5 KB  资源管理页(懒加载树表 + 搜索模式)
  ResourceDetailPanel.vue      57.0 KB  资源详情:基本信息 / 授权行 / 字段权限 / 槽位映射 / 分享策略
  components/GrantActionHelp.vue        「动作」字段的说明 popover,四处共用

src/views/system/rule/                  行过滤规则管理页
  useRuleTemplateMeta.ts                模板元数据的模块级单例缓存
src/views/system/permissionDiag/index.vue   12.3 KB  权限诊断页

src/api/system/grant/index.ts   25.8 KB  🔴 36 个接口函数,权限域前端唯一的 API 文件
src/directives/permission/hasPermi.ts    按钮级权限指令(详见 00-intro)

CRUD 配对约定在这三个目录不成立

本仓的约定是 src/api/<module>/<feature>/index.ts ↔ src/views/<module>/<feature>/index.vue + 同级 XxxForm.vue。权限域是双向破例的:

  • src/api/system/grant/ 存在,但 src/views/system/grant/ 不存在——界面散在 role/grant/、resource/、rule/、permissionDiag/ 四处;
  • src/views/system/resource/ 与 src/views/system/rule/ 存在,但 src/api/system/resource/ 与 src/api/system/rule/ 都不存在——资源、规则、槽位、诊断的接口全部挤在 api/system/grant/index.ts 里。

按路径找文件会找不到。认 API 就认 api/system/grant/index.ts 这一个入口。

核心机制 ​

一份词表:四份副本的代价 ​

resource/constants.ts 是四份枚举词表的唯一副本:资源类型(7)、字段显示模式(3)、主体类型(7)、授权动作(13)。前三份是展示映射,第四份参与判断——它的 effective 字段直接决定界面告诉用户「配了到底生不生效」。

收敛之前,GRANT_ACTION_OPTIONS 有四份各自维护的副本(源码注释原文):

副本缺什么后果
角色资源授权页私有 ACTIONS—与其余三份靠人盯着对齐
角色管理内嵌面板私有 ACTIONS—同上
资源详情面板 8 个硬编码 el-optionIMPORT / PRINT / MANAGE / VIEW_ALL「恰好是流程资源唯一在用的两个码」——从资源侧给流程配管理员根本选不到
权限诊断页 7 个硬编码 el-option同上再加 DESIGN「诊断流程权限」这件事在诊断页上做不了

后两份不是「显示不全」,是功能缺失,而且缺失的地方正好是最需要它的场景(流程权限最复杂、最需要诊断)。

ts
// resource/constants.ts:174-188(逐字)
export const GRANT_ACTION_OPTIONS: GrantActionOption[] = [
  { label: '* 全部动作', value: '*', effective: true },
  { label: '查看', value: 'VIEW', effective: true },
  { label: '新增', value: 'CREATE', effective: false },
  { label: '修改', value: 'UPDATE', effective: false },
  { label: '删除', value: 'DELETE', effective: false },
  { label: '导出', value: 'EXPORT', effective: false },
  { label: '导入', value: 'IMPORT', effective: false },
  { label: '打印', value: 'PRINT', effective: false },
  { label: '发起', value: 'START', effective: true, processOnly: true },
  { label: '设计', value: 'DESIGN', effective: true, processOnly: true },
  { label: '管理', value: 'MANAGE', effective: true, processOnly: true },
  { label: '查看全部', value: 'VIEW_ALL', effective: true, processOnly: true },
  { label: '被引用查询', value: 'REFERENCE', effective: false }
]

三个不能顺手改的地方:

  1. * 排在最前,虽然后端 GrantActionEnum 里 WILDCARD("*") 是最后一个常量。把通配符排在一堆具体动作末尾,它会被读成「最后一个不常用的选项」,而它恰恰是「一行封死该资源全部操作」那个最需要被看见的开关。
  2. 不要按 effective 重排。收窄与分组交给 grantActionOptionsFor / splitGrantActionOptions;顺序一变,列表页的搜索下拉和这份词表就对不上号了。
  3. effective 的判据是后端有没有拿这个码去查询,即 GrantDecisionEngine#isAllowed 的 action 入参全集:只有 VIEW、START、DESIGN、MANAGE、VIEW_ALL,外加 * 在 GrantDO#actionMatches 里通配。

effective 标错是权限系统里最难查的一类谎

标成 false 而它其实生效 → 界面告诉用户「配了不生效」,用户不敢配,权限缺口一直存在;标成 true 而它其实不生效 → 用户配完以为妥了,保存成功、列表正常、没有任何报错,只有事后「他怎么有这个权限」或「权限怎么没生效」两种追问。

DESIGN 就是这样从 false 翻过来的(判定入口 BpmModelServiceImpl#validateModelDesigner)。后端给某个预留码接上查询点之后,这里必须同步改成 true——源码注释管这叫「反向的谎更难查」。

预留动作不从下拉里删掉,而是分组展示:splitGrantActionOptions 把它们收进「预留动作(配了不生效)」组。理由是删掉之后库里已有的这类行会渲染成裸英文码,而「选了不生效」这件事必须在选的那一刻就看得见——分组标题只在下拉展开时可见,所以还配了一条常驻的 reservedActionWarning(el-alert),补的正是「选完一收起来提示就没了,而点保存时眼前什么提示都没有」那个空档。

纯函数模块:为什么必须从组件里拿出来 ​

resourceTree.ts / batchGrant.ts / effectiveTree.ts 三个文件的文件头注释写着同一个理由:

与 batchGrant.ts 同一个理由独立成模块:ResourceTreePicker.vue 要 mount 才测得到,而本仓的 vitest.config.ts 刻意不挂自动导入插件,useMessage 这类全局在 node 环境里是未定义的,组件测不起来。这几个函数决定「搜出来的命中项是哪些」「面包屑显示哪几级」「勾选态要不要回写给树」,错了不报错,只会静默配出错误权限。

约束是硬的:**这三个文件不得 import vue,也不得 import @/api/**``**。@/api/system/grant会拽进@/config/axios,后者又拽进 store 与 i18n,整个依赖图在 node 环境下会崩,测试文件连加载都过不去。所以树节点一律用 any`。

三个文件里最容易改错的一处,是 resourceTree.ts#matchResourceNode:

ts
export const matchResourceNode = (keyword: string, data: any): boolean => {
  if (!keyword) return true   // 🔴 空串必须返回 true
  const kw = keyword.toLowerCase()
  return (
    (data.name ?? '').toLowerCase().includes(kw) ||
    (data.permission ?? '').toLowerCase().includes(kw)
  )
}

空串返回 true 看着像「懒得写判断」,实际是承重的:EP 的 doFilter 靠这个方法决定哪些节点进 hiddenNodeKeySet,而清掉搜索的唯一手段就是 filter('')。空串返回 false 的话,「清空搜索框」会把整棵树藏光。副作用(filter('') 顺手把所有节点都展开)由 ResourceTreePicker#reset 紧随其后的一句 setExpandedKeys([]) 收回来——两句话的顺序是承重的,因为 hiddenNodeKeySet 只在 doFilter 内部被清。

permission 参与匹配也不是锦上添花:本库「角色管理」这一个名字下有 8 个资源,名称根本不是可用的检索键,system:role:query 才是。

数据层:三种生命周期口径,刻意不同 ​

composable缓存范围每次 open() 重拉?拉不到时
useResourceCatalog一次页面停留否抛(ensureLoaded 不 catch)
useSlotCoverage一次页面停留否state='failed',不抛
useRuleTemplateMeta模块级单例否抛(load 只 finally)
GrantBatchForm#loadExisting不缓存是existingLoadFailed=true,不抛

前两个是「一次页面停留只拉一次」:资源目录与槽位映射由管理员维护,同一次授权操作期间不会变;换成模块级缓存会把过期窗口从「一次页面停留」放大到「一次 SPA 会话」,还得按 includeFieldLevel 分键。useRuleTemplateMeta 反过来选了模块级——后端 /system/rule/template-meta 是纯静态枚举组装,一次会话内不会变,所以跨组件复用同一份是对的。

loadExisting 是唯一每次 open 都重拉的:它的价值全在于「此刻库里的状态」,缓存一份上次的快照,算出来的 conflicts 就是过期的——而 conflicts 决定那个「保留还是覆盖」的弹窗出不出现。

「与选了哪个规则无关」= 竞态从「要处理」变成「结构上不存在」

useSlotCoverage 在 open() 里 eagerly 拉一次全量槽位映射,而不是 watch(ruleId) 懒加载。理由不是性能,是正确性:全量结果与「当前选了哪个规则」无关,于是快速切换规则时的请求竞态在结构上就不存在——不用请求序号,也不用 AbortController。懒加载能省的只有一次两百来行的 GET,换来的是一个必须正确处理、否则会显示错误覆盖度的竞态。

同一个道理还顺手避免了「选完规则那一刻才开始拉、提示先闪一下绿色」。

两个「取不到数据」的处理刻意相反,这是本页最该记住的一条:

  • 槽位覆盖度取不到 → 降级不拦提交。它是建议性的:操作者可能没有 system:slot-mapping:query 权限点,那时抛出去会让整个抽屉的 open() 失败。computeRuleSlotCoverage 见 state='failed' 会把 uncoveredIds 强制成空数组,退化成「无法核对」,而不是「178 个数据集全缺槽位」那句假话。
  • 既有授权行取不到 → 拦住提交(canSubmit 里带 !existingLoadFailed)。拿不到既有行 = 算不出 conflicts = 三态弹窗不出现 = 提交静默走成「覆盖」,把已配好的行过滤规则与字段模式抹成 NULL。那是一次放权,必须拦住。

useSlotCoverage 的 state 初值是 'loading' 而不是 'ready':UI 在 state !== 'ready' 时显示「核对中 / 无法核对」,绝不默认绿。默认绿意味着「取数还没回来」这段时间里,一个缺槽位的规则被报成安全——而那正好是用户最可能立刻点提交的时间窗。

单条授权抽屉:穿透模式的三个陷阱 ​

GrantForm.vue 与 GrantBatchForm.vue 都是非模态穿透 + 可拖宽的抽屉::modal="false" + modal-penetrable,后面的授权列表页不变暗、可以点。一边配授权、一边回头查这个权限标识已经授过什么,是遮罩模式下做不到的。

代价必须在别处付,于是有了三个陷阱:

① :modal="false" 与 modal-penetrable 必须成对。 EP 那边是 penetrable = modalPenetrable && !modal,成立才给 overlay 加 is-penetrable,而 theme-chalk 里正是 .el-modal-drawer.is-penetrable { pointer-events: none } 配 .el-modal-drawer.is-penetrable .el-drawer { pointer-events: auto } 这两条。少写一个,穿透静默不生效。

② :close-on-press-escape="false" 一点都不惰性,必须显式关掉。 与遮罩点击不同,Esc 不是从 overlay 上收的:EP 的 focus-trap 走 useEscapeKeydown,那个 hook 把监听器挂在 document 上。链路是 document 上的 Esc → focus-trap emit release-requested → 抽屉的 onCloseRequested → if (props.closeOnPressEscape) handleClose(),而 closeOnPressEscape 出厂默认是 true。

于是「穿透」把 Esc 变成一个陷阱:操作者正在后面那张列表页上核对权限标识(这正是穿透要交付的能力),随手按一下 Esc——想关掉的是别的东西——抽屉连同填了一半的表单一起没了,没有草稿、没有撤销。批量抽屉更贵:一次误按毁掉的是几百个已勾资源加上一整套「保留 / 覆盖」选择。

对比同文件里那条「已知边界」:Tab 键仍被 focus-trap 锁在抽屉内(鼠标能点后面,键盘不能)。Tab 锁焦点只是不方便,Esc 会毁掉工作,所以那条记下来忍了,这条必须关掉。关掉之后关抽屉仍有两个入口:header 的关闭图标与底部「取消」。

反过来,:close-on-click-modal="false" 在穿透模式下是惰性的(overlay 已 pointer-events: none,永远收不到点击)。留着是为了哪天把 modal 打开时不退回「误点遮罩毁掉一整份表单」。

③ 抽屉开着期间,列表页那五个会毁数据的按钮必须禁用。 由 index.vue 的 drawerOpen 负责:

ts
// role/grant/index.vue
const drawerOpen = ref(false)
const openForm = (type: 'create' | 'update', row?: GrantVO) => {
  drawerOpen.value = true
  formRef.value.open(type, roleId.value, row)
}

禁的是:新增授权、批量授权、批量删除、行内编辑、行内删除——前三个都会调 open() 重填同一个抽屉实例,静默替换掉正在填的内容;后两个会在授权进行中删行。

反过来,搜索、重置、五个查询条件、翻页、行首勾选框、切换按钮、树里的展开箭头与两个显示开关一个都不许禁

「一边配授权一边回头看这一页」就是穿透存在的全部理由,把它们一起禁掉等于把功能退回遮罩模式。勾选框留着也无害:批量删除已禁用,而抽屉提交成功后 handleGrantSuccess() 里的 getList() 会自然清掉勾选。

drawerOpen 置回 false 走抽屉 emit 的 closed(收起动画结束)而不是 close(动画开始)——后者会让按钮在抽屉还盖在页面上时就活过来,点到的东西是看不见的。

宽度下限夹在 @resize-end 里而不是写一条 CSS min-width:EP 的 useResizable 只把尺寸夹在 [4, 窗口宽],没有下限,一路往右拖能把抽屉收成 4px 的一条线(header 的关闭按钮与 footer 的「取消」都点不到)。而 min-width 得打在 .el-drawer 上,scoped 样式的 scopeId 只保证落到子组件的根节点——这里是 ElTeleport → Transition → ElOverlay 三层之后的 div.el-overlay,还能不能再穿进去取决于 EP 的内部结构。宽度下限是拖拽的安全网,不该建在那个不确定性上。

批量授权抽屉:「不传」是放权方向 ​

batchGrant.ts#mergeFrom 的语义是传 = 保留、不传 = 覆盖,而「不传」那半边是放权方向的:

ts
// GrantBatchForm.vue#chooseMergeStrategy(逐字,注释保留)
// 🔴 用 ElMessageBox 而不是 useMessage().confirm:后者把两个按钮的文案写死成「确定 / 取消」,
// 也不支持 distinguishCancelAndClose。而这里三个出口必须是三个意思,
// 且确认按钮得是安全的那一个 —— 沿用「确定 = 继续提交」的直觉,
// 操作者会一路点过去把自己已配好的行过滤规则清空,而那是放权方向的数据丢失。
// 所以「放弃」被放到关闭(X / ESC)上:不选就不提交,什么都不发生

三态的实现是 distinguishCancelAndClose: true + catch (action) { return action === 'cancel' ? 'overwrite' : 'abort' }:确认键 = 保留既有配置(安全项),取消键 = 按本次填写覆盖,X / ESC = 放弃提交。

为什么「不传」真的会落库成 NULL:GrantDO#ruleId / #fieldMode / #expireTime 都是 @TableField(updateStrategy = FieldStrategy.ALWAYS),而 GrantAdminServiceImpl#doUpsert 走 updateById——null 会真的写进去。后果是行过滤放开成「全部行可见」、HIDDEN 的字段重新露出来。

expandGrantBatch 的三个细节:

  • 资源外层 × 动作内层,与弹窗里那句「N 资源 × M 动作 = X 行」的预览同口径;
  • ruleId 只写在 DATASET 行上(isRowFilterableResource 判定)。绑在非数据集资源上的 ruleId 是死字段:isAllowed 与 resolveFieldMode 从不读它,行过滤也不沿资源树上溯。源码注释的原话是「那不是中性噪声,是假信息」——列表页会把它渲染成一个其实不存在的限制;
  • 三个可选字段只在有值时才写 key。否则 'ruleId' in item 恒为真,那条钉住「死字段不写」的断言就形同虚设。

fieldMode 与 contextResourceId 到现在也不随批量下发,但理由不同:fieldMode 是「能不能判有效性」的问题(字段级资源默认不在树里,批量只能产出无效行),contextResourceId 是「连单条表单都没有这个 UI」。

GRANT_BATCH_MAX_ITEMS = 500 镜像后端的 @Size(max = 500),改一处必须改另一处。前端超限时把提示转 error 并禁用提交按钮,而不是等后端 400 换回一句参数错误。

智能查看:前端一行判定逻辑都没有 ​

effectiveTree.ts 的文件头有一段保证,逐字抄在这里,因为它是整页最硬的约束:

🔴 本文件里没有任何判定逻辑:不重算优先级、不重算过期、不重算 default_effect 回落。「这个动作生不生效」「它从哪来」全部是后端 GrantDecisionEngine#explain 的结论,经 EffectiveActionVO 原样送过来。这里只做三件事:翻译成人话、聚合多个动作、决定展开态。

再抄一份判定的话,本仓库注释里那句「另写一份判定必然与鉴权路径漂移」就要应验第三次了(前两次:GrantDecisionService#buildResourceChain 与 #buildChainFromMap 在停用祖先上分叉;GrantAdminController#listByResource 与 #toRespVOs 两处 expired 口径不一致)。

api/system/grant/index.ts#EffectiveActionVO 的注释写着同一件事:isAllowed 已退化成 explain(...).allowed(),所以图上说「拥有」与鉴权放行在构造上不可能分叉。

前端只做三件事,每件都有具体的坑:

① 默认展开到哪一层。 collectKeysUpToDepth(tree, 2)——默认 2 层,因为实测超管角色有 702 个生效节点,全展开是一堵墙,而第一屏真正要看到的是「哪几个大类下有权限」。

层数按嵌套深度数,不能读 node.depth

node.depth 是资源在 sys_resource 里的物化深度,而这棵树是剪过枝的:一个 depth=3 的节点在祖先全被剪掉之后会成为根。按 node.depth 判就会把根当成第三层直接折叠掉,用户看到一棵空树还以为没权限。

另外只把有子节点的 id 收进来:叶子塞进去不改变任何观感,只会让「展开全部」在 702 个节点上多造几百个无用 key。

② 客户端筛树,不加后端筛选参数。 这棵树是后端一次性求值、剪枝、合并之后整棵送过来的,既没有分页也没有服务端筛选口径。再加一个服务端关键词参数,就得把「命中一个节点要连带保留哪些祖先」这条规则在后端重实现一遍——而祖先链的计算已经在 buildResourceChain 里了,两处口径一漂移就是「列表页搜得到、智能查看搜不到」这种最难解释的分歧。前端剪的输入就是屏幕上这棵树,所见即所筛。

配套的一条:命中一个节点就保留它的整棵子树,不继续往下筛。这是权限地图,搜到一个父资源之后要看的恰恰是「它下面都授了什么」。筛选中一律全展开——命中项可能落在很深的位置,而它的祖先是被剪枝后新造的副本,沿用「默认两层」会让用户搜到东西却只看见几行折叠的父级,「搜了没反应」和「搜不到」在体感上是同一件事。

③ 把「藏了什么」说出来。 summaryText 存在的理由:这棵树剪过枝,节点数天然少于列表页的行数(多行合并成一个节点、被拒绝的动作不出现、过期行不计入、纯默认放行的节点默认收起)。不报数的话,管理员拿两页一对,第一反应是「智能查看漏了权限」,而不是「它合并了」。

同一类误读还有超管那条 el-alert:真实鉴权对超管角色是旁路授权台账的(GrantDecisionService#isSuperAdmin 命中即放行,压根不查 sys_grant),而这张图画的是台账本身。不说清楚,管理员会拿着这张图去论证「超管怎么少了这个权限」——而那个权限他其实一直有。

el-table-v2 的 height 必须是像素数字,由 measure() 量出来,不能用 h-[calc(100vh-…)]:本组件上面还压着 index.vue 的「角色条 + 工具条」那张卡片,它的高度取决于 EP 按钮行高、表单行距、卡片内边距,把这些猜进 calc 就是一串会漂的魔数。measure() 里的两个 🔴 都是防振荡的:

  • 三个量都在视口坐标系里,页面滚动时一起移动、差值不变 ⟹ 与滚动位置无关;
  • 夹 min(内容区底部, 视口底部):section 用的是 min-h,表格一旦把它撑破,「内容区底部」就跟着往下跑,算出来的余量反而更大 ⟹ 越量越高。夹住视口就断掉了这个正反馈。

useResizeObserver 观察的是 infoRef(内容驱动的包裹层)而不是 hostRef:观察一个你正在给它定尺寸的元素 = ResizeObserver 自激振荡(高度在两个值之间来回跳)。

列表页与树形页是两个组件实例 ​

🔴 两个视图是两个互斥的 v-if 块、两个组件实例,不是在同一个 <el-table> 上来回切树形 props。
src/views/system/resource/index.vue 的脚本注释记着后者的代价:切 lazy / 树形 props 会让
渲染器直接崩(「Page crashed」),而且没有任何 JS 错误,:key 强制重建也救不回来。

resource/index.vue 里那条崩溃的根因写在 list 这个 computed 上:

ts
// ⚠️ 这里【不能】写 total.value = rows.length。
// 在 computed 里改 ref 是副作用:total 变化会触发依赖它的渲染,
// 渲染又可能重新求值本 computed,配合 tableKey 的组件重建形成循环,
// 最终把渲染进程撑爆(表现是 Page crashed,控制台里一条 JS 错误都没有)。
// 复现路径:搜索空结果 → 重置 → 点展开。

修法是 total 独立成一个 computed。「Page crashed 且零 JS 错误」是这一类循环的指纹——没有堆栈可看,只能靠复现路径反推。

另外三条与勾选/删除有关的口径:

  • 刻意不加 row-key / reserve-selection:翻页或搜索重载即重置勾选。跨页攒住用户已经看不见的 id,正是批量删除最危险的输入。getList() 开头也显式 checkedIds.value = []。
  • 【批量删除】只属于列表模式:树形用的是 el-table-v2,压根不支持 type="selection"。放进树形模式就是一个永远灰着的按钮——比不放更坏,因为它会让人以为「批量删除坏了」,而不是「这里本来就没有可勾的行」。
  • 抽屉提交成功后两个视图都要刷(handleGrantSuccess 同时 getList() 与 treeRef.value?.reload())。只刷列表:用户在树形页存的,眼前那棵树一动不动,第一反应必然是「没存上」,于是再提交一遍——而 /create 是 upsert,第二遍会静默覆盖第一遍,用户永远不知道自己刚白担心了一场。只刷树也不行:list / queryParams 都是父组件的 ref,不会因为 v-if 重新挂载而重拉。

treeRef 在列表模式下是 undefined(组件被 v-if 卸载),所以 ?. 天然就是「只刷挂载着的那棵树」,不需要再判 viewMode。

「靠重新挂载保证新鲜」是隐式的

树在 v-else 分支里,每次切进来都重新挂载,onMounted 自己就把数据拉新了。将来谁给这两个分支套上 <KeepAlive>、或把 v-if 换成 v-show,树就会开始返回陈旧权限,而且不报任何错。所以 handleGrantSuccess 写成「两个视图都刷」,不依赖这个前提。

数据模型 ​

前端侧的「数据模型」就是 api/system/grant/index.ts 里那批 VO——它们是后端 RespVO 的镜像,字段注释里记着几处只有前端会踩的口径:

VO关键字段前端口径
ResourceVOpath🔴 是权限串的物化路径(/root/system/,再往下一层是 menu:<menuId>),不是 id 路径也不是名称路径。拼不出人能读的层级,所以面包屑只能沿 parentId 走 resourceById 上溯
ResourceVOdepth物化深度。剪枝后的树里不能拿它当嵌套层数(见 collectKeysUpToDepth)
ResourceVO无 datasetTable所以 isRowFilterableResource 只能判 type === 'DATASET',是一个会漂移的近似(后端真实判据是 type=DATASET && dataset_table非空 && status=ENABLE)。失效方向是「少写一个 ruleId」,不会越权
GrantVOsubjectName🔴 由后端 SubjectDisplayNameResolver 从权威业务表批量解析,不是 sys_subject.name。解析不到时后端给 #123(未找到),前端直接展示即可,不要再自己拼 simple-list
GrantVOresourceType用来判「这一行上的 ruleId / fieldMode 有没有意义」。没有它,列表页只能对所有行一律显示「不受限(全部行)」,那句话对死字段是假的
GrantVOexpireTime后端把 LocalDateTime 序列化成毫秒数。el-date-picker 带 value-format 时只认格式化字符串,直接塞数字会回显空白 ⟹ 回显前必须 dayjs(...).format('YYYY-MM-DD HH:mm:ss')
EffectiveResourceNodeVOstructural自身没有任何生效权限、仅为撑起树而保留的路径节点。不区分的话前端会把一个空目录渲染成「有权限」
EffectiveResourceNodeVOdefaultEffect是资源的 default_effect 列,不是按 type 推出来的值(实测 PROCESS 有 18 行是 ALLOW)
EffectiveTreeSummaryVOdefaultAllowCount🔴 与 includeDefaultAllow 开关无关,两种模式下都是同一个数——它存在的意义就是告诉你「收起了多少个」
EffectiveTreeSummaryVOdeniedCount被显式 DENY 否决的 (资源 × 动作) 数。不含「没人配过」的 default_effect=DENY
SlotMappingVOcolumnExists只在 list-by-resource 里返回,入参不带。专门用来把历史脏数据暴露出来:早期自动推断写进去的行不核实列存在,后果不是报错而是规则恒假,界面上看着和一条好配置一模一样
SlotMappingVOtableName🔴 必填且只能从下拉选:列表查询带 JOIN,同一个列名(如 create_user_id)在主表和关联表上都可能有,不记表名就表达不出「这个槽位对应关联表上的那一列」。后端存的时候一律转小写
SlotMappingOptionsVOtables🔴 只有表名没有列:这个库两百多张表、三千多列,一次摊开是几十万字节,而一次只配一行。选了表再调 getSlotMappingTableColumns
GrantBatchSaveRespVOconvergedCount顺带收敛掉的历史同键重复行数(同键多行且内容一致时只留 id 最小的那条)。大于 0 说明库里本来就有重复行

接口清单 ​

api/system/grant/index.ts 共 36 个函数,按后端 Controller 分组:

组函数端点备注
资源getResourceListGET /system/resource/listincludeFieldLevel 默认 false;不传的后果是「授权页里根本找不到字段资源,字段权限配不出来」
getResourceGET /system/resource/get
createResource / updateResourcePOST / PUT /system/resource/*菜单派生的资源仅可改 sort / status,后端校验
deleteResource / deleteResourceListDELETE /system/resource/delete[-list]批量是全有或全无
getResourceChildrenGET /system/resource/list-children树表懒加载;根节点传 0
updateSharePolicyPUT /system/resource/update-share-policy传空串 = 清除配置,回落默认
授权getGrantPageGET /system/grant/page走后端分页,避免全量拉取卡死页面
getGrantListBySubjectGET /system/grant/list-by-subject批量抽屉算 conflicts 的输入,每次 open() 重拉
getGrantListByResourceGET /system/grant/list-by-resource资源详情面板用
createGrantPOST /system/grant/create🔴 upsert 语义:撞上同「主体 + 资源 + 动作 + 上下文」的既有行会覆盖它。表上没有唯一索引(软删模型下加了会被已删行占住键),判重全在后端应用层
createGrantListPOST /system/grant/create-listbody 是 { items } 包装而不是裸数组:Spring 对 @RequestBody @Valid List<T> 不会级联校验元素,包一层 VO 才能让每一项都吃到 GrantSaveReqVO 上的全套注解
updateGrantPUT /system/grant/update
deleteGrant / deleteGrantListDELETE /system/grant/delete[-list]批量全有或全无:只要有一个 id 已不存在,整批都不删
getEffectiveTreeGET /system/grant/effective-tree五个参数三个是布尔开关,见下面的口径警告
getMyFieldModesGET /system/grant/my-field-modes返回 { mobile: 'HIDDEN' } 形状;没配过的字段不出现在结果里,调用方按「不存在 = 不受限」处理
规则getSimpleRuleListGET /system/rule/simple-list🔴 只返启用规则(后端带 .eq(RuleDO::getStatus, 0)),所以绑了停用规则的授权行会回显成裸数字 id
getRulePage / getRuleList / getRuleGET /system/rule/*getRuleList 含停用
createRule / updateRule / deleteRule / deleteRuleListPOST/PUT/DELETE
getRuleTemplateMetaGET /system/rule/template-meta后端驱动,杜绝前端硬编码漂移
槽位getSlotMappingOptionsGET /system/slot-mapping/options槽位词表 + 物理表名 + mainTable
getSlotMappingTableColumnsGET /system/slot-mapping/table-columns🔴 表不存在时返回空数组而不是报错
getSlotMappingList / getSlotMappingListAllGET /system/slot-mapping/list[-by-resource]全量那份是 @TenantIgnore 全局表,两百来行
createSlotMapping / updateSlotMapping / deleteSlotMappingPOST/PUT/DELETE
主体getSubjectListByTypeGET /system/subject/list-by-type
诊断diagPermissionGET /system/permission-diag/check见诊断与排错

getEffectiveTree 的口径必须写在页面上,否则一定被当成漏权限报上来

源码注释列了三条,页面上那条 el-alert 逐条对应:

  • 只算角色维度(该角色 + 它在主体图上的祖先 + 可选的全员主体),不含用户级 / 岗位 / 部门 / 用户组授权,因此少于「拥有该角色的用户实际能做的」;
  • 只认启用的资源行,与菜单清单、权限清单同口径;
  • sys_grant 分租户而 sys_resource 是全局表,所以同一棵树在不同租户下剪枝结果不同。

includeAllSubject 关掉会漏掉实测那 9 条全员授权;includeDefaultAllow 默认关,因为实测超管角色有 205 个纯默认放行节点,全渲染出来会淹掉真正配过的那些。

两处 axios 层的约定,写新接口前先知道

  1. paramsSerializer 用的是 qs.stringify(params, { allowDots: true })(src/config/axios/service.ts:44-45)。后果:ids[]= 绑不上后端的 @RequestParam List<Long>,所以 deleteGrantList / deleteRuleList / deleteResourceList 一律 ids.join(',') 逗号拼接。
  2. 业务错误码那一支 reject 的是 new Error(msg),与同文件 500 / 901 两支同形。原来是 Promise.reject('error')——reject 出去的是字面量字符串,调用方 catch (e) 里 e.message 是 undefined,后端那段 msg 除了进一个 4.5 秒自动消失的通知标题之外没有任何留得住的落点。service.spec.ts 用读源码原文 + 正则断言把这三处钉住(抓不到锚点就抛,绝不回落默认值让断言假通过),同时把 ElNotification.error 那一行也钉住:后来人若以为「反正 catch 里有了」而删掉通知,用户就真的一条提示都看不见了。

怎么扩展 ​

加一个动作码 ​

  1. 后端 GrantActionEnum 加常量;
  2. constants.ts#GRANT_ACTION_OPTIONS 按枚举声明顺序插入(* 始终在最前,不参与排序);
  3. 决定 effective:判据是「后端有没有拿这个码去查询」,即 GrantDecisionEngine#isAllowed 的 action 入参全集。接了查询点就是 true;
  4. 决定是否 processOnly:只有流程/分类资源上才有意义的,加上它,PROCESS_RESOURCE_TYPES 会自动放开;
  5. 更新 constants.spec.ts 里那份黄金副本(ENUM_ORDER)——它必须失败一次,逼人做上面第 3 步的显式决定;
  6. 检查 components/GrantActionHelp.vue:它的正文是硬编码散文,见下面的坑。

加一个授权界面 ​

  1. 词表一律从 resource/constants.ts 取,不要再内联 el-option(那正是四份副本的起点);
  2. 要判「这个动作在这种资源上开放吗」用 grantActionGroupsFor(resourceType, currentAction),它会把当前值补回选项里;
  3. 要判「用户主动换资源后要不要把动作重置成 VIEW」用 isGrantActionAllowed——不要拿它校验编辑既有行时的回显值,对它返回 false 的两种情况都可能是库里真实存着的一行,据此改值等于静默篡改一条已存在的授权;
  4. 需要资源树就复用 useResourceCatalog + ResourceTreePicker,别自己拉一遍树;
  5. 抽屉要穿透就照抄 GrantForm.vue 模板顶部那三段注释里的四件事(:modal="false" + modal-penetrable 成对、显式关 Esc、drawerOpen 门禁、closed 而非 close)。

加一个纯函数并测它 ​

放进 role/grant/*.ts,守住两条:不 import vue、不 import @/api/**。树节点用 any。测试文件与它同名同目录,pnpm test:unit 会自动捡到(vitest.config.ts 匹配 src/**/*.spec.ts)。

顺带一提:@vueuse/core 不在自动导入名单里

build/vite/index.ts 的 AutoImport({ imports: [...] }) 只有 vue、vue-router 和五个自定义 hook。useResizeObserver / useWindowSize 必须显式 import { ... } from '@vueuse/core'(GrantEffectiveTree.vue 就是这么写的)。EP 的 API(ElMessageBox、ElMessage、ElNotification)则由 ElementPlusResolver() 负责,可以不写 import。

坑与注意 ​

  1. 🔴 GrantActionHelp.vue 的正文是动作词表的第二份副本,且没有任何测试钉住它。 它写着「生效动作……只有 6 个」并逐一列出,还列了预留组的全部七个码。四处共用这一个组件(角色授权抽屉、批量抽屉、资源详情面板、诊断页)确实避免了「说明文写四份」,但散文本身不参与 GRANT_ACTION_OPTIONS 的断言:后端给某个预留码接上查询点、effective 翻成 true 之后,这个 popover 会继续告诉用户「配了不生效」。constants.spec.ts 覆盖不到 .vue 文件。改 effective 时请手动搜一遍这个组件。
  2. 🔴 resourceTypeTagType 与 fieldModeTagType 的返回类型是 string,不是 el-tag 那个字面量联合。 因为兜底是 ?? ''(EP 默认色),而 '' 不在 el-tag 的 type 取值里,整条联合被撑宽,于是每个调用点都得写 as any。别「顺手改成返回 undefined 消掉 as any」:那会把认不出的码从 EP 默认色变成显式 primary 蓝,与本文件其余 format* 的「认不出就回落默认、但看得见原值」口径不一致。
  3. 🔴 defaultEffectTagType 把 DENY 一律染灰(info)不是偷懒。 MENU / ACTION / PROCESS / CATEGORY 这 4 类的出厂默认就是 DENY,它是常态不是异常;列表页一屏几十行几乎全是 DENY,全染红等于把红色用废(报警疲劳)。授权列表里 grant 行的 effect=DENY 继续用红——那是有人显式配的一条禁止。统一之后两种颜色反而携带信息:灰 = 出厂默认拒绝,红 = 有人显式拒绝。
  4. 🔴 认不出的 code 一律原样显示,不能改成 '-'。 sys_grant.action 里存着枚举外的历史值(本库实测有 4 行 EXEC,已被 sql/mysql/xpy-2026-10-12-grant-action-enum-cleanup.sql 软删,但没跑过该脚本的环境里还在)。兜底掉了就渲染成空白 tag,界面上既看不出这一行是什么动作,也看不出它是脏数据。
  5. 🔴 SUBJECT_TYPE_OPTIONS 现在只服务于展示。 ResourceDetailPanel 的新增/编辑已收窄成只支持角色(主体那一格直接是角色下拉,没有类型选择),角色侧的资源授权页也是把 subjectType 写死成 ROLE。改它不会影响任何表单能选到什么。这个数组原先漏了 POST,于是岗位主体的授权行在列表里显示成裸码——而 formatSubjectType 的兜底就是「原样返回」,正是为了让漏项不至于崩,代价是漏了没人发现。
  6. 🔴 matchResourceNode 空串必须返回 true,且 applyFilter 里那句 filter('') 后面必须紧跟 setExpandedKeys([])。见前面「纯函数模块」一节。
  7. 🔴 buildResourceIndex 用 children?.length 而不是 children。 handleTree 的 adaptToChildrenList 会给叶子节点显式写上 children: undefined(判据是 childrenListMap[o.id] !== null,而叶子的 map 项是 undefined),所以「有没有 children 这个键」区分不了叶子与非叶子。
  8. 🔴 resourceBreadcrumb 的两个循环出口都不能删。 byId.get(parentId) 返回 undefined 时停:根节点的 parentId 指向一个不存在的 id(本库是 0),这是常态不是异常;被「显示字段级资源」开关移出视野的祖先同理。seen 防环:parent_id 是应用层维护的,库里一旦出现 a→b→a,没有这个集合就是一个死循环——而它发生在 computed 里,表现是页面直接冻住,连一条报错都拿不到。
  9. 🔴 isSameIdSet 必须双向比。 a.every(id => setB.has(id)) 配长度相等在有重复项时会误判([1,1] vs [1,2] 长度都是 2、1 也在 B 里),所以两个 Set 的 size 要一并比。它唯一用途是 ResourceTreePicker 里那道受控回写守卫:没有它,用户每手点一次勾选框都会触发一次「emit → 父级改值 → watch → setCheckedKeys(内部先 clear 再 nextTick 重设)」,勾选框会闪,正在进行的连点也会被吃掉。
  10. 🔴 ResourceTreePicker 的 check-strictly 与「父资源授权不隐含子资源」无关。 那句话曾经写在 GrantBatchForm 里,是错的:后端 isAllowed 把自己 + 全部祖先放进同一个 chainIds 再 chainIds.contains 过滤授权行,所以祖先的一条 ALLOW 本来就够,祖先的一条 DENY 也能否决后代自己的 ALLOW。本库 1086 个资源里 954 个有真实父级、grant-enabled 也是 true——这套继承对约 88% 的资源是活的,不是纸面机制。真正的原因是:联动模式下父节点的勾选态是从子节点推出来的(子节点没全勾时它被踢出 checkedKeys、变成半选),于是「只勾这个菜单本身、不勾它下面的按钮」表达不出来——而菜单可见性也走 sys_grant,这个能力有真实用途。附带一条:el-tree-v2 不暴露父级状态的重算方法,模式切换后勾选态与外部值会不一致且无从修正。
  11. ⚠️ 「全选下级」产出的是冗余但更耐久的授权行。 不是多授了权(祖先链本来就覆盖后代),而是将来撤销父级那一条时,下级因为自己有行而不受影响,一步撤不干净。这是已知代价,不是 bug。
  12. 🔴 el-tree-v2 的 filter-method 之外,权限串不能为了宽度截断。 本库「角色管理」这一个名字下有 8 个资源,permission 是唯一可用的辨识键。类型标记放在权限串前面,且文案与配色一律取 constants.ts——「资源类型不该再长出第五份」。
  13. 🔴 ResourceTreePicker 测高只量 host 外壳。 观察一个你正在给它定尺寸的元素 = ResizeObserver 自激振荡。host 的高度来自 flex:1 + min-h-0 而不是内容,overflow-hidden 兜住取整误差。
  14. 🔴 <el-form> 必须包住两列,包括左列那个资源选择器。 EP 的规则注册发生在 el-form-item 挂载时向 formItemContext 上报;picker 一旦被挪到 el-form 外面,resourceId 的 required 规则就根本没进表单,validate() 会放行 undefined,于是提交出 resourceId 为空的脏授权。
  15. 🔴 buildEmptyForm 里 resourceId 必须是 undefined 而不是 0。 EP 的 required 校验把 0 当「已填」,用 0 做初值时不选资源也能通过校验,提交出 resourceId=0 的脏授权。
  16. 🔴 onResourceSelected 里那句 formRef.value?.validateField('resourceId') 不能删,也不能改成 watch。 el-select 一走,change 校验就没人触发了:EP 的 trigger:'change' 是各输入组件自己调 formItem.validate('change')(见 useSelect 里那条 watch(modelValue)),el-form-item 自己不监听值。不补这一句,「不选资源点确定」出来的那行红字在选中之后不会消失,看着像校验坏了。改成 watch(() => formData.value.resourceId) 也不行:它在 open('update') 回显时也会触发,正好把上面那句 clearValidate() 想压掉的噪声放回来。
  17. 🔴 行过滤规则那一格的 :disabled 必须带 && !formData.ruleId。 编辑一条 ruleId 挂在非 DATASET 资源上的历史行时,纯 !isDatasetResource 会让用户看得到却清不掉——而列表页一直显示那个 ruleName,看着像「配了个限制」,其实是死字段。与 fieldMode 那处的差别就在于此:fieldMode 是「无效值本就不该存在」,这里要留一条把既有的无效值摘掉的路。
  18. ⚠️ 编辑态回显规则名要补一个占位 option。 getSimpleRuleList 只返启用规则,所以绑了停用/已删规则的行不在 ruleList 里,而 el-select 找不到匹配 option 时会把 value 原样渲染成裸数字 id,看着像控件坏了。GrantForm#ruleOptions 补的是「规则名(已停用或已删除)」这个只读占位项——先例与理由同 grantActionGroupsFor 的「补选项而不是改成 VIEW」。
  19. 🔴 fieldMode 的 value 必须与后端 FieldModeEnum 完全一致(EDITABLE / READONLY / HIDDEN)。 写成 VISIBLE 会被引擎整行过滤掉——「保存成功但不生效」。ResourceDetailPanel 的字段权限页签里曾有一句提示写着「配置 VISIBLE/READONLY/HIDDEN」而下拉早就改对了,等于页面自己在教人踩坑。
  20. 🔴 /create 是 upsert,所以提示文案不能写「新增成功」。 GrantForm#submitForm 写的是「保存成功」;批量那边必须回显计数(新增 N 条、覆盖 M 条,convergedCount > 0 时再加一句),因为 upsert 语义下「重复提交同一批」与「首次提交」看起来一模一样,不报数的话操作者无从判断自己是不是点重了。
  21. 🔴 el-table / el-table-v2 的 height 必须是数值,不能是 calc() 字符串。 el-table 内部走 TableLayout.setHeight。同理,resource/index.vue 的 list computed 里不能写 total.value = rows.length(Page crashed 且零 JS 错误)。
  22. ⚠️ 两个抽屉都不加 destroy-on-close,所以 useResourceCatalog 与它的 includeFieldLevel、拖出来的宽度都跨开关存活。宽度是刻意记住偏好;includeFieldLevel 则必须在 open() 里显式归位(「打开 → 勾上显示字段级 → 关掉 → 再打开」开关还是勾着的),批量那边同理。
  23. ⚠️ 抽屉不加 append-to-body。 EP 的 el-drawer 默认 appendToBody=false,加了之后抽屉被 teleport 出本组件的 scoped 边界,那几条 :deep(.el-drawer__*) 会静默失效(不报错,只是不生效)。穿透模式也不需要 append-to-body——ElTeleport 的 disabled 只看 appendToBody,与 modal 无关。
  24. ⚠️ el-form 上那个 h-full 是承重的,别当成与 flex-1 重复而删掉。 这个 <form> 是 .el-drawer__body 的直接子元素,height: 100% 一跳就解析成功,不依赖那条 :deep(.el-drawer__body)。那条覆盖要生效得让 scopeId 穿过 ElTeleport → Transition → ElOverlay 三层;一旦没落上,body 保持 display: block,下面所有 flex-1 全变成空操作,树最终停在 treeHeight 的 160px 下限——132 个根节点里只露约 6 个,而这件事不报错。
  25. ⚠️ 两份抽屉的 :deep() CSS 是重复的,但刻意不抽公用。 抽出去意味着要么放进全局样式(污染所有抽屉),要么用 modal-class 走非 scoped 的路子——两个调用点还不值当。这三条都不承重。
  26. ⚠️ GrantForm 与 GrantBatchForm 的宽度下限注释各说一半。 单条那份写全了「为什么夹在 resize-end 而不是 CSS min-width」与「拖到下限以下松手才弹回」这个副作用为什么是正确行为;批量那份只指向它。这是刻意的,源码里写明了「两份注释各说一半,是下一个人踩坑的标准起点」——改的时候两边一起看。
  27. ⚠️ batchActionOptions 收窄之后必须主动剔除已选的、掉出选项集的动作(那个 watch)。不剔就会提交出死行:先勾 PROCESS 选了 START,再勾一个 MENU,START 掉出选项集但它仍是 GrantActionEnum 里的合法值,后端 @InEnum 拦不住——校验只管「是不是枚举内的码」,管不了「这个码在这种资源上有没有意义」。
  28. ⚠️ 批量的可选项是已勾资源类型的交集而不是并集。 并集会在勾了菜单时把 START / DESIGN / MANAGE / VIEW_ALL 也端出来,选中即产生「能保存但永不参与鉴权」的死行——单条表单里那是一行,批量一次就是 N 行。交集保留预留动作:「一次开 查看+新增+修改+删除」这个高频场景配的正是它们,裁掉等于砍掉批量授权的一半用途。
  29. ⚠️ buildInput 抽成函数是因为同一份入参要被算两次:一次给 conflicts(决定弹窗出不出现、报几行),一次给真正的提交。两处各写一遍的话,弹窗报的数与实际覆盖的行数就不是同一个数了。
  30. ⚠️ ruleHint 那个状态机的顺序是承重的:「没勾资源」排在覆盖度之前(0 个资源时 datasetIds 也是空,不先拦住就会对着一个还没开始的操作报「这些资源里没有数据集」);loading / failed 排在 undecidable 与 uncovered 之前(数据没到手时那几条分支本来也进不去,但文案必须由这里给,沉默会被读成「没问题」);uncovered 排在 success 之前且是唯一的 error 态。
  31. ⚠️ 单条抽屉的缺槽位提示刻意不弹二次确认、也不禁用提交。 单条只影响一行,代价与批量差三个数量级,为它拦一道弹窗换来的确定性不值这个摩擦;红字本身就是告知。批量那边则单独再问一次但同样不禁用按钮——覆盖度是建议性的,操作者可能比前端更清楚那张表的实际情况。
  32. ⚠️ resource/index.vue 的搜索必须保留祖先。 命中的节点不连同祖先一起保留,子节点就会挂在一个不存在的 parentId 上被 handleTree 整体丢弃——表现是「搜了个确实存在的权限串,一条都不显示」。这与历史上「菜单树返回空数组」是同一个病根。祖先上溯带 guard++ < 32 的循环上限。
  33. ⚠️ 诊断页的权限串下拉保留 allow-create。 权限串有 600+ 个且格式严格(system:user:query),让人纯手打的话打错一个字符就诊断出「无此资源」,而那和「确实没权限」在结果页上长得一模一样。保留手输是因为诊断本来就可能要查一个还没建资源行的权限串。
  34. ⚠️ 诊断页与列表页的动作下拉用全量 GRANT_ACTION_OPTIONS、不分组,这是刻意的:预留动作也要能选,才能实测出「配了确实不生效」;列表页的搜索下拉同理(要能搜到死行)。分组只发生在授权表单里。
  35. ⚠️ permissionDiag/index.vue 里 diagMaxHeight 是死代码。 它算出来、跟着 resize 更新,但模板里没有任何一处引用它(全仓 grep 只有那两行)。真正生效的是 <style> 里那条 :deep(.el-card):last-of-type { min-height: calc(100vh - 260px) }。删掉这两行与那个 resize 监听不影响任何观感。
  36. ⚠️ GrantEffectiveTree 的筛选不做防抖,而 ResourceTreePicker 那个搜索框做了 150ms。不是不一致:后者防的是 EP filter() 对 1086 个节点的全树游走 + 展开态改写,前者只是一次 computed 剪枝。
  37. ⚠️ 本库 sys_resource(deleted=0)1086 行,depth 1/2/3/4 = 132/113/726/115。 filter('') 之后收完只剩 132 个根节点,虚拟化下约 23 行可见。这些数字是历史快照,见下面的「未证实」。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

pnpm test:unit(vitest,匹配 src/**/*.spec.ts)。权限域前端共 5 个 spec:

文件itdescribe钉的是什么
role/grant/batchGrant.spec.ts5010笛卡尔展开、ruleId 只落 DATASET、三个可选字段「有值才写 key」、mergeFrom 的传/不传、conflicts 检测、槽位覆盖度、GRANT_BATCH_MAX_ITEMS
role/grant/effectiveTree.spec.ts6614展开态按嵌套深度、剪枝保留子树、聚合列的单值/多值、来源标签、structural 节点
role/grant/resourceTree.spec.ts224空串返回 true、permission 参与匹配、叶子判定用 children?.length、面包屑的两个出口、isSameIdSet 的双向比
resource/constants.spec.ts216🔴 一份刻意的黄金副本 + effective 判断 + 收窄只按 processOnly
config/axios/service.spec.ts4—三处 new Error(msg)、零处裸字符串 reject、通知那一行也钉住

constants.spec.ts 的黄金副本值得单独说:

第一条用例是一份刻意的黄金副本:后端 GrantActionEnum 加了码而这里没跟上时,它必须失败,逼人做一次显式决定(加进来、标好 effective),而不是让界面上静默少一个选项。

它钉了两份顺序——ENUM_ORDER(后端 12 个具名动作的声明顺序)与 UI_ORDER = ['*', ...ENUM_ORDER](通配符打头 + 枚举原序),并把 PROCESS_ONLY 与 EFFECTIVE 分开写,「是为了让下面『收窄只按 processOnly、不按 effective』这条断言不是把实现重述一遍」。

覆盖不到的三块

  1. 组件层零测试。 六个 .vue 要 mount 才测得到,而 vitest.config.ts 刻意不挂自动导入插件,useMessage 在 node 环境里是未定义的。所以「穿透 + Esc」「drawerOpen 门禁」「三态弹窗」「测高」这些全都没有测试,只有源码注释。
  2. 数据层三个 composable 零测试。 要 mock @/api,而 @/api/** 一 import 就拽进 @/config/axios → store → i18n。
  3. GrantActionHelp.vue 的散文零测试(坑 1)。

src/utils/commentLineRefBan.spec.ts(19 个 it)是另一类测试:它读源码原文、用正则禁止某种注释写法,抓不到锚点就抛。service.spec.ts 沿用的正是这套先例(另一处先例是 views/agent/workflow/layout.spec.ts)。

未证实 ​

  • 本页所有「实测 N」的数字——29 条 JS 错误、1086 行资源、depth 132/113/726/115、954 个有真实父级、702 个生效节点、205 个默认放行、9 条全员授权、8 个同名「角色管理」、4 行 EXEC、600+ 个权限串——全部来自源码注释或历史快照,本轮没有重新查库。GrantDecisionService:482-484 对这一类数字的态度可以直接搬过来:别把任何一个数字当常量用。
  • ResourceDetailPanel.vue(57 KB)只读了 grep 出的 🔴/⚠️ 锚点与动作下拉、字段权限两段注释,没有通读。它的槽位映射编辑器(两级下拉、columnExists 标红、非主表提示)只从注释与 API 侧反推。
  • ResourceTreePicker.vue 只读了 :28-100、:164-215、:289-315 三段,reveal() 的实现与 reset() 的完整顺序没有逐行核。
  • GrantEffectiveTree.vue 只读了模板头部、summaryText、measure()、defineExpose 四处,load() 的完整流程、三个布尔开关的组合语义、动作 tooltip 的聚合逻辑没有逐行核。
  • batchGrant.ts 只读了 :15-27 与 :100-200;computeRuleSlotCoverage 与 findUnspecifiedConflicts 的实现是从调用点与 .spec.ts 反推的。
  • EP 内部行为(penetrable = modalPenetrable && !modal、useEscapeKeydown 挂 document、useResizable 只夹 [4, 窗口宽]、doFilter 内部清 hiddenNodeKeySet、useSelect 里那条 watch(modelValue))全部来自源码注释的转述,没有去 node_modules/element-plus 里核对版本对应的实现。
  • src/styles/index.scss 末尾那两条 [data-permission-hidden] 规则是读 00-intro/router-permission.md 得到的,本轮没有再打开那个文件。

相关页面 ​