前端授权页开发
适用端: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-option | IMPORT / PRINT / MANAGE / VIEW_ALL | 「恰好是流程资源唯一在用的两个码」——从资源侧给流程配管理员根本选不到 |
权限诊断页 7 个硬编码 el-option | 同上再加 DESIGN | 「诊断流程权限」这件事在诊断页上做不了 |
后两份不是「显示不全」,是功能缺失,而且缺失的地方正好是最需要它的场景(流程权限最复杂、最需要诊断)。
// 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 }
]三个不能顺手改的地方:
*排在最前,虽然后端GrantActionEnum里WILDCARD("*")是最后一个常量。把通配符排在一堆具体动作末尾,它会被读成「最后一个不常用的选项」,而它恰恰是「一行封死该资源全部操作」那个最需要被看见的开关。- 不要按
effective重排。收窄与分组交给grantActionOptionsFor/splitGrantActionOptions;顺序一变,列表页的搜索下拉和这份词表就对不上号了。 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:
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 负责:
// 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 的语义是传 = 保留、不传 = 覆盖,而「不传」那半边是放权方向的:
// 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 上:
// ⚠️ 这里【不能】写 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 | 关键字段 | 前端口径 |
|---|---|---|
ResourceVO | path | 🔴 是权限串的物化路径(/root/system/,再往下一层是 menu:<menuId>),不是 id 路径也不是名称路径。拼不出人能读的层级,所以面包屑只能沿 parentId 走 resourceById 上溯 |
ResourceVO | depth | 物化深度。剪枝后的树里不能拿它当嵌套层数(见 collectKeysUpToDepth) |
ResourceVO | 无 datasetTable | 所以 isRowFilterableResource 只能判 type === 'DATASET',是一个会漂移的近似(后端真实判据是 type=DATASET && dataset_table非空 && status=ENABLE)。失效方向是「少写一个 ruleId」,不会越权 |
GrantVO | subjectName | 🔴 由后端 SubjectDisplayNameResolver 从权威业务表批量解析,不是 sys_subject.name。解析不到时后端给 #123(未找到),前端直接展示即可,不要再自己拼 simple-list |
GrantVO | resourceType | 用来判「这一行上的 ruleId / fieldMode 有没有意义」。没有它,列表页只能对所有行一律显示「不受限(全部行)」,那句话对死字段是假的 |
GrantVO | expireTime | 后端把 LocalDateTime 序列化成毫秒数。el-date-picker 带 value-format 时只认格式化字符串,直接塞数字会回显空白 ⟹ 回显前必须 dayjs(...).format('YYYY-MM-DD HH:mm:ss') |
EffectiveResourceNodeVO | structural | 自身没有任何生效权限、仅为撑起树而保留的路径节点。不区分的话前端会把一个空目录渲染成「有权限」 |
EffectiveResourceNodeVO | defaultEffect | 是资源的 default_effect 列,不是按 type 推出来的值(实测 PROCESS 有 18 行是 ALLOW) |
EffectiveTreeSummaryVO | defaultAllowCount | 🔴 与 includeDefaultAllow 开关无关,两种模式下都是同一个数——它存在的意义就是告诉你「收起了多少个」 |
EffectiveTreeSummaryVO | deniedCount | 被显式 DENY 否决的 (资源 × 动作) 数。不含「没人配过」的 default_effect=DENY |
SlotMappingVO | columnExists | 只在 list-by-resource 里返回,入参不带。专门用来把历史脏数据暴露出来:早期自动推断写进去的行不核实列存在,后果不是报错而是规则恒假,界面上看着和一条好配置一模一样 |
SlotMappingVO | tableName | 🔴 必填且只能从下拉选:列表查询带 JOIN,同一个列名(如 create_user_id)在主表和关联表上都可能有,不记表名就表达不出「这个槽位对应关联表上的那一列」。后端存的时候一律转小写 |
SlotMappingOptionsVO | tables | 🔴 只有表名没有列:这个库两百多张表、三千多列,一次摊开是几十万字节,而一次只配一行。选了表再调 getSlotMappingTableColumns |
GrantBatchSaveRespVO | convergedCount | 顺带收敛掉的历史同键重复行数(同键多行且内容一致时只留 id 最小的那条)。大于 0 说明库里本来就有重复行 |
接口清单
api/system/grant/index.ts 共 36 个函数,按后端 Controller 分组:
| 组 | 函数 | 端点 | 备注 |
|---|---|---|---|
| 资源 | getResourceList | GET /system/resource/list | includeFieldLevel 默认 false;不传的后果是「授权页里根本找不到字段资源,字段权限配不出来」 |
getResource | GET /system/resource/get | ||
createResource / updateResource | POST / PUT /system/resource/* | 菜单派生的资源仅可改 sort / status,后端校验 | |
deleteResource / deleteResourceList | DELETE /system/resource/delete[-list] | 批量是全有或全无 | |
getResourceChildren | GET /system/resource/list-children | 树表懒加载;根节点传 0 | |
updateSharePolicy | PUT /system/resource/update-share-policy | 传空串 = 清除配置,回落默认 | |
| 授权 | getGrantPage | GET /system/grant/page | 走后端分页,避免全量拉取卡死页面 |
getGrantListBySubject | GET /system/grant/list-by-subject | 批量抽屉算 conflicts 的输入,每次 open() 重拉 | |
getGrantListByResource | GET /system/grant/list-by-resource | 资源详情面板用 | |
createGrant | POST /system/grant/create | 🔴 upsert 语义:撞上同「主体 + 资源 + 动作 + 上下文」的既有行会覆盖它。表上没有唯一索引(软删模型下加了会被已删行占住键),判重全在后端应用层 | |
createGrantList | POST /system/grant/create-list | body 是 { items } 包装而不是裸数组:Spring 对 @RequestBody @Valid List<T> 不会级联校验元素,包一层 VO 才能让每一项都吃到 GrantSaveReqVO 上的全套注解 | |
updateGrant | PUT /system/grant/update | ||
deleteGrant / deleteGrantList | DELETE /system/grant/delete[-list] | 批量全有或全无:只要有一个 id 已不存在,整批都不删 | |
getEffectiveTree | GET /system/grant/effective-tree | 五个参数三个是布尔开关,见下面的口径警告 | |
getMyFieldModes | GET /system/grant/my-field-modes | 返回 { mobile: 'HIDDEN' } 形状;没配过的字段不出现在结果里,调用方按「不存在 = 不受限」处理 | |
| 规则 | getSimpleRuleList | GET /system/rule/simple-list | 🔴 只返启用规则(后端带 .eq(RuleDO::getStatus, 0)),所以绑了停用规则的授权行会回显成裸数字 id |
getRulePage / getRuleList / getRule | GET /system/rule/* | getRuleList 含停用 | |
createRule / updateRule / deleteRule / deleteRuleList | POST/PUT/DELETE | ||
getRuleTemplateMeta | GET /system/rule/template-meta | 后端驱动,杜绝前端硬编码漂移 | |
| 槽位 | getSlotMappingOptions | GET /system/slot-mapping/options | 槽位词表 + 物理表名 + mainTable |
getSlotMappingTableColumns | GET /system/slot-mapping/table-columns | 🔴 表不存在时返回空数组而不是报错 | |
getSlotMappingList / getSlotMappingListAll | GET /system/slot-mapping/list[-by-resource] | 全量那份是 @TenantIgnore 全局表,两百来行 | |
createSlotMapping / updateSlotMapping / deleteSlotMapping | POST/PUT/DELETE | ||
| 主体 | getSubjectListByType | GET /system/subject/list-by-type | |
| 诊断 | diagPermission | GET /system/permission-diag/check | 见诊断与排错 |
getEffectiveTree 的口径必须写在页面上,否则一定被当成漏权限报上来
源码注释列了三条,页面上那条 el-alert 逐条对应:
- 只算角色维度(该角色 + 它在主体图上的祖先 + 可选的全员主体),不含用户级 / 岗位 / 部门 / 用户组授权,因此少于「拥有该角色的用户实际能做的」;
- 只认启用的资源行,与菜单清单、权限清单同口径;
sys_grant分租户而sys_resource是全局表,所以同一棵树在不同租户下剪枝结果不同。
includeAllSubject 关掉会漏掉实测那 9 条全员授权;includeDefaultAllow 默认关,因为实测超管角色有 205 个纯默认放行节点,全渲染出来会淹掉真正配过的那些。
两处 axios 层的约定,写新接口前先知道
paramsSerializer用的是qs.stringify(params, { allowDots: true })(src/config/axios/service.ts:44-45)。后果:ids[]=绑不上后端的@RequestParam List<Long>,所以deleteGrantList/deleteRuleList/deleteResourceList一律ids.join(',')逗号拼接。- 业务错误码那一支 reject 的是
new Error(msg),与同文件 500 / 901 两支同形。原来是Promise.reject('error')——reject 出去的是字面量字符串,调用方catch (e)里e.message是undefined,后端那段msg除了进一个 4.5 秒自动消失的通知标题之外没有任何留得住的落点。service.spec.ts用读源码原文 + 正则断言把这三处钉住(抓不到锚点就抛,绝不回落默认值让断言假通过),同时把ElNotification.error那一行也钉住:后来人若以为「反正 catch 里有了」而删掉通知,用户就真的一条提示都看不见了。
怎么扩展
加一个动作码
- 后端
GrantActionEnum加常量; constants.ts#GRANT_ACTION_OPTIONS按枚举声明顺序插入(*始终在最前,不参与排序);- 决定
effective:判据是「后端有没有拿这个码去查询」,即GrantDecisionEngine#isAllowed的action入参全集。接了查询点就是true; - 决定是否
processOnly:只有流程/分类资源上才有意义的,加上它,PROCESS_RESOURCE_TYPES会自动放开; - 更新
constants.spec.ts里那份黄金副本(ENUM_ORDER)——它必须失败一次,逼人做上面第 3 步的显式决定; - 检查
components/GrantActionHelp.vue:它的正文是硬编码散文,见下面的坑。
加一个授权界面
- 词表一律从
resource/constants.ts取,不要再内联el-option(那正是四份副本的起点); - 要判「这个动作在这种资源上开放吗」用
grantActionGroupsFor(resourceType, currentAction),它会把当前值补回选项里; - 要判「用户主动换资源后要不要把动作重置成 VIEW」用
isGrantActionAllowed——不要拿它校验编辑既有行时的回显值,对它返回false的两种情况都可能是库里真实存着的一行,据此改值等于静默篡改一条已存在的授权; - 需要资源树就复用
useResourceCatalog+ResourceTreePicker,别自己拉一遍树; - 抽屉要穿透就照抄
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。
坑与注意
- 🔴
GrantActionHelp.vue的正文是动作词表的第二份副本,且没有任何测试钉住它。 它写着「生效动作……只有 6 个」并逐一列出,还列了预留组的全部七个码。四处共用这一个组件(角色授权抽屉、批量抽屉、资源详情面板、诊断页)确实避免了「说明文写四份」,但散文本身不参与GRANT_ACTION_OPTIONS的断言:后端给某个预留码接上查询点、effective翻成true之后,这个 popover 会继续告诉用户「配了不生效」。constants.spec.ts覆盖不到.vue文件。改effective时请手动搜一遍这个组件。 - 🔴
resourceTypeTagType与fieldModeTagType的返回类型是string,不是 el-tag 那个字面量联合。 因为兜底是?? ''(EP 默认色),而''不在 el-tag 的type取值里,整条联合被撑宽,于是每个调用点都得写as any。别「顺手改成返回undefined消掉as any」:那会把认不出的码从 EP 默认色变成显式 primary 蓝,与本文件其余format*的「认不出就回落默认、但看得见原值」口径不一致。 - 🔴
defaultEffectTagType把 DENY 一律染灰(info)不是偷懒。 MENU / ACTION / PROCESS / CATEGORY 这 4 类的出厂默认就是 DENY,它是常态不是异常;列表页一屏几十行几乎全是 DENY,全染红等于把红色用废(报警疲劳)。授权列表里 grant 行的effect=DENY继续用红——那是有人显式配的一条禁止。统一之后两种颜色反而携带信息:灰 = 出厂默认拒绝,红 = 有人显式拒绝。 - 🔴 认不出的 code 一律原样显示,不能改成
'-'。sys_grant.action里存着枚举外的历史值(本库实测有 4 行EXEC,已被sql/mysql/xpy-2026-10-12-grant-action-enum-cleanup.sql软删,但没跑过该脚本的环境里还在)。兜底掉了就渲染成空白 tag,界面上既看不出这一行是什么动作,也看不出它是脏数据。 - 🔴
SUBJECT_TYPE_OPTIONS现在只服务于展示。ResourceDetailPanel的新增/编辑已收窄成只支持角色(主体那一格直接是角色下拉,没有类型选择),角色侧的资源授权页也是把subjectType写死成ROLE。改它不会影响任何表单能选到什么。这个数组原先漏了POST,于是岗位主体的授权行在列表里显示成裸码——而formatSubjectType的兜底就是「原样返回」,正是为了让漏项不至于崩,代价是漏了没人发现。 - 🔴
matchResourceNode空串必须返回true,且applyFilter里那句filter('')后面必须紧跟setExpandedKeys([])。见前面「纯函数模块」一节。 - 🔴
buildResourceIndex用children?.length而不是children。handleTree的adaptToChildrenList会给叶子节点显式写上children: undefined(判据是childrenListMap[o.id] !== null,而叶子的 map 项是undefined),所以「有没有children这个键」区分不了叶子与非叶子。 - 🔴
resourceBreadcrumb的两个循环出口都不能删。byId.get(parentId)返回undefined时停:根节点的parentId指向一个不存在的 id(本库是 0),这是常态不是异常;被「显示字段级资源」开关移出视野的祖先同理。seen防环:parent_id是应用层维护的,库里一旦出现 a→b→a,没有这个集合就是一个死循环——而它发生在 computed 里,表现是页面直接冻住,连一条报错都拿不到。 - 🔴
isSameIdSet必须双向比。a.every(id => setB.has(id))配长度相等在有重复项时会误判([1,1]vs[1,2]长度都是 2、1 也在 B 里),所以两个Set的size要一并比。它唯一用途是ResourceTreePicker里那道受控回写守卫:没有它,用户每手点一次勾选框都会触发一次「emit → 父级改值 → watch →setCheckedKeys(内部先 clear 再nextTick重设)」,勾选框会闪,正在进行的连点也会被吃掉。 - 🔴
ResourceTreePicker的check-strictly与「父资源授权不隐含子资源」无关。 那句话曾经写在GrantBatchForm里,是错的:后端isAllowed把自己 + 全部祖先放进同一个chainIds再chainIds.contains过滤授权行,所以祖先的一条 ALLOW 本来就够,祖先的一条 DENY 也能否决后代自己的 ALLOW。本库 1086 个资源里 954 个有真实父级、grant-enabled也是true——这套继承对约 88% 的资源是活的,不是纸面机制。真正的原因是:联动模式下父节点的勾选态是从子节点推出来的(子节点没全勾时它被踢出checkedKeys、变成半选),于是「只勾这个菜单本身、不勾它下面的按钮」表达不出来——而菜单可见性也走sys_grant,这个能力有真实用途。附带一条:el-tree-v2不暴露父级状态的重算方法,模式切换后勾选态与外部值会不一致且无从修正。 - ⚠️ 「全选下级」产出的是冗余但更耐久的授权行。 不是多授了权(祖先链本来就覆盖后代),而是将来撤销父级那一条时,下级因为自己有行而不受影响,一步撤不干净。这是已知代价,不是 bug。
- 🔴
el-tree-v2的filter-method之外,权限串不能为了宽度截断。 本库「角色管理」这一个名字下有 8 个资源,permission是唯一可用的辨识键。类型标记放在权限串前面,且文案与配色一律取constants.ts——「资源类型不该再长出第五份」。 - 🔴
ResourceTreePicker测高只量 host 外壳。 观察一个你正在给它定尺寸的元素 = ResizeObserver 自激振荡。host 的高度来自flex:1 + min-h-0而不是内容,overflow-hidden兜住取整误差。 - 🔴
<el-form>必须包住两列,包括左列那个资源选择器。 EP 的规则注册发生在el-form-item挂载时向formItemContext上报;picker 一旦被挪到el-form外面,resourceId的required规则就根本没进表单,validate()会放行undefined,于是提交出resourceId为空的脏授权。 - 🔴
buildEmptyForm里resourceId必须是undefined而不是0。 EP 的required校验把0当「已填」,用0做初值时不选资源也能通过校验,提交出resourceId=0的脏授权。 - 🔴
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()想压掉的噪声放回来。 - 🔴 行过滤规则那一格的
:disabled必须带&& !formData.ruleId。 编辑一条ruleId挂在非 DATASET 资源上的历史行时,纯!isDatasetResource会让用户看得到却清不掉——而列表页一直显示那个ruleName,看着像「配了个限制」,其实是死字段。与fieldMode那处的差别就在于此:fieldMode是「无效值本就不该存在」,这里要留一条把既有的无效值摘掉的路。 - ⚠️ 编辑态回显规则名要补一个占位 option。
getSimpleRuleList只返启用规则,所以绑了停用/已删规则的行不在ruleList里,而el-select找不到匹配 option 时会把 value 原样渲染成裸数字 id,看着像控件坏了。GrantForm#ruleOptions补的是「规则名(已停用或已删除)」这个只读占位项——先例与理由同grantActionGroupsFor的「补选项而不是改成 VIEW」。 - 🔴
fieldMode的 value 必须与后端FieldModeEnum完全一致(EDITABLE/READONLY/HIDDEN)。 写成VISIBLE会被引擎整行过滤掉——「保存成功但不生效」。ResourceDetailPanel的字段权限页签里曾有一句提示写着「配置 VISIBLE/READONLY/HIDDEN」而下拉早就改对了,等于页面自己在教人踩坑。 - 🔴
/create是 upsert,所以提示文案不能写「新增成功」。GrantForm#submitForm写的是「保存成功」;批量那边必须回显计数(新增 N 条、覆盖 M 条,convergedCount > 0时再加一句),因为 upsert 语义下「重复提交同一批」与「首次提交」看起来一模一样,不报数的话操作者无从判断自己是不是点重了。 - 🔴
el-table/el-table-v2的height必须是数值,不能是calc()字符串。el-table内部走TableLayout.setHeight。同理,resource/index.vue的listcomputed 里不能写total.value = rows.length(Page crashed 且零 JS 错误)。 - ⚠️ 两个抽屉都不加
destroy-on-close,所以useResourceCatalog与它的includeFieldLevel、拖出来的宽度都跨开关存活。宽度是刻意记住偏好;includeFieldLevel则必须在open()里显式归位(「打开 → 勾上显示字段级 → 关掉 → 再打开」开关还是勾着的),批量那边同理。 - ⚠️ 抽屉不加
append-to-body。 EP 的el-drawer默认appendToBody=false,加了之后抽屉被 teleport 出本组件的 scoped 边界,那几条:deep(.el-drawer__*)会静默失效(不报错,只是不生效)。穿透模式也不需要append-to-body——ElTeleport的disabled只看appendToBody,与modal无关。 - ⚠️
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 个,而这件事不报错。 - ⚠️ 两份抽屉的
:deep()CSS 是重复的,但刻意不抽公用。 抽出去意味着要么放进全局样式(污染所有抽屉),要么用modal-class走非 scoped 的路子——两个调用点还不值当。这三条都不承重。 - ⚠️
GrantForm与GrantBatchForm的宽度下限注释各说一半。 单条那份写全了「为什么夹在resize-end而不是 CSSmin-width」与「拖到下限以下松手才弹回」这个副作用为什么是正确行为;批量那份只指向它。这是刻意的,源码里写明了「两份注释各说一半,是下一个人踩坑的标准起点」——改的时候两边一起看。 - ⚠️
batchActionOptions收窄之后必须主动剔除已选的、掉出选项集的动作(那个watch)。不剔就会提交出死行:先勾 PROCESS 选了START,再勾一个 MENU,START掉出选项集但它仍是GrantActionEnum里的合法值,后端@InEnum拦不住——校验只管「是不是枚举内的码」,管不了「这个码在这种资源上有没有意义」。 - ⚠️ 批量的可选项是已勾资源类型的交集而不是并集。 并集会在勾了菜单时把
START/DESIGN/MANAGE/VIEW_ALL也端出来,选中即产生「能保存但永不参与鉴权」的死行——单条表单里那是一行,批量一次就是 N 行。交集保留预留动作:「一次开 查看+新增+修改+删除」这个高频场景配的正是它们,裁掉等于砍掉批量授权的一半用途。 - ⚠️
buildInput抽成函数是因为同一份入参要被算两次:一次给conflicts(决定弹窗出不出现、报几行),一次给真正的提交。两处各写一遍的话,弹窗报的数与实际覆盖的行数就不是同一个数了。 - ⚠️
ruleHint那个状态机的顺序是承重的:「没勾资源」排在覆盖度之前(0 个资源时datasetIds也是空,不先拦住就会对着一个还没开始的操作报「这些资源里没有数据集」);loading/failed排在undecidable与uncovered之前(数据没到手时那几条分支本来也进不去,但文案必须由这里给,沉默会被读成「没问题」);uncovered排在success之前且是唯一的error态。 - ⚠️ 单条抽屉的缺槽位提示刻意不弹二次确认、也不禁用提交。 单条只影响一行,代价与批量差三个数量级,为它拦一道弹窗换来的确定性不值这个摩擦;红字本身就是告知。批量那边则单独再问一次但同样不禁用按钮——覆盖度是建议性的,操作者可能比前端更清楚那张表的实际情况。
- ⚠️
resource/index.vue的搜索必须保留祖先。 命中的节点不连同祖先一起保留,子节点就会挂在一个不存在的parentId上被handleTree整体丢弃——表现是「搜了个确实存在的权限串,一条都不显示」。这与历史上「菜单树返回空数组」是同一个病根。祖先上溯带guard++ < 32的循环上限。 - ⚠️ 诊断页的权限串下拉保留
allow-create。 权限串有 600+ 个且格式严格(system:user:query),让人纯手打的话打错一个字符就诊断出「无此资源」,而那和「确实没权限」在结果页上长得一模一样。保留手输是因为诊断本来就可能要查一个还没建资源行的权限串。 - ⚠️ 诊断页与列表页的动作下拉用全量
GRANT_ACTION_OPTIONS、不分组,这是刻意的:预留动作也要能选,才能实测出「配了确实不生效」;列表页的搜索下拉同理(要能搜到死行)。分组只发生在授权表单里。 - ⚠️
permissionDiag/index.vue里diagMaxHeight是死代码。 它算出来、跟着resize更新,但模板里没有任何一处引用它(全仓 grep 只有那两行)。真正生效的是<style>里那条:deep(.el-card):last-of-type { min-height: calc(100vh - 260px) }。删掉这两行与那个resize监听不影响任何观感。 - ⚠️
GrantEffectiveTree的筛选不做防抖,而ResourceTreePicker那个搜索框做了 150ms。不是不一致:后者防的是 EPfilter()对 1086 个节点的全树游走 + 展开态改写,前者只是一次 computed 剪枝。 - ⚠️ 本库
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:
| 文件 | it | describe | 钉的是什么 |
|---|---|---|---|
role/grant/batchGrant.spec.ts | 50 | 10 | 笛卡尔展开、ruleId 只落 DATASET、三个可选字段「有值才写 key」、mergeFrom 的传/不传、conflicts 检测、槽位覆盖度、GRANT_BATCH_MAX_ITEMS |
role/grant/effectiveTree.spec.ts | 66 | 14 | 展开态按嵌套深度、剪枝保留子树、聚合列的单值/多值、来源标签、structural 节点 |
role/grant/resourceTree.spec.ts | 22 | 4 | 空串返回 true、permission 参与匹配、叶子判定用 children?.length、面包屑的两个出口、isSameIdSet 的双向比 |
resource/constants.spec.ts | 21 | 6 | 🔴 一份刻意的黄金副本 + effective 判断 + 收窄只按 processOnly |
config/axios/service.spec.ts | 4 | — | 三处 new Error(msg)、零处裸字符串 reject、通知那一行也钉住 |
constants.spec.ts 的黄金副本值得单独说:
第一条用例是一份刻意的黄金副本:后端
GrantActionEnum加了码而这里没跟上时,它必须失败,逼人做一次显式决定(加进来、标好effective),而不是让界面上静默少一个选项。
它钉了两份顺序——ENUM_ORDER(后端 12 个具名动作的声明顺序)与 UI_ORDER = ['*', ...ENUM_ORDER](通配符打头 + 枚举原序),并把 PROCESS_ONLY 与 EFFECTIVE 分开写,「是为了让下面『收窄只按 processOnly、不按 effective』这条断言不是把实现重述一遍」。
覆盖不到的三块
- 组件层零测试。 六个
.vue要 mount 才测得到,而vitest.config.ts刻意不挂自动导入插件,useMessage在 node 环境里是未定义的。所以「穿透 + Esc」「drawerOpen门禁」「三态弹窗」「测高」这些全都没有测试,只有源码注释。 - 数据层三个 composable 零测试。 要 mock
@/api,而@/api/**一 import 就拽进@/config/axios→ store → i18n。 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得到的,本轮没有再打开那个文件。
