Files
docs/superpowers/specs/2026-07-28-admin-static-rbac-preview-design.md
T

17 KiB
Raw Blame History

Admin 静态 RBAC 权限预览设计

背景

管理后台已经具备内部用户、角色、权限资源和分组菜单的静态交互,但角色授权结果尚未作用到登录后的菜单、路由和操作按钮。路由中的 meta.permission 目前只是元数据,无法演示角色权限是否真正生效。

本阶段建立一个可操作的静态 RBAC 演示闭环,用于前端原型验收。它不代表真实服务端鉴权,也不定义 Java API、Token 内容、权限响应结构或错误码。

目标

  • 在管理后台顶部切换初始模拟数据中的五个启用角色,并继续支持后续新增或停用角色后的动态列表。
  • 根据角色授权实时过滤侧边栏菜单。
  • 阻止静态原型访问无权限路由,并提供清晰的无权限页面。
  • 对已经存在稳定权限编码的操作按钮进行显示控制。
  • 角色授权数据发生变化时,权限预览同步更新。
  • 保留现有登录、页面交互、响应式布局和独立前端工程结构。

不在本次范围

  • 不调用真实登录、用户、角色、菜单或权限接口。
  • 不修改 src/api/http.ts 的请求或响应约定。
  • 不新增或猜测 Java API 路径、响应包络、分页结构和错误码。
  • 不把前端菜单隐藏或路由跳转描述为安全边界;服务端仍需在后续接口阶段执行最终鉴权。
  • 不为当前静态权限模型中没有定义的操作擅自增加权限编码。
  • 不模拟角色的 dataScope 数据范围,不因切换“全部数据”“本部门数据”或“本人数据”角色而过滤客户、订单等模拟记录。
  • 不处理企业客户端、官网、小程序、APP 或 PDA 的权限体系。

方案选择

采用方案:顶部切换演示角色

在后台顶栏提供“演示角色”切换入口,角色变化后菜单、路由和明确映射的操作按钮立即更新。该方式不要求反复退出登录,适合静态原型展示和权限配置验收。

未采用方案

  • 使用不同账号重新登录:更接近真实登录流程,但重复退出登录会降低原型验收效率,且当前没有服务端身份契约。
  • 只控制菜单和路由:改动较少,但无法验证现有按钮权限是否产生可见效果。

架构边界

权限预览状态

新增 admin 专属 Pinia 状态层,职责仅限静态权限预览:

  • 保存当前演示角色 ID。
  • 从 useAdminDataStore 中读取当前启用角色和权限资源。
  • 根据角色的 permissionIds 计算权限编码集合,但只纳入 status === '启用' 的权限资源;角色仍保留已停用权限 ID 时,不授予对应访问权。
  • 提供 can(permissionCode) 判断。
  • 提供当前角色、可选角色及第一个可访问菜单。
  • 提供登录成功后的初始化、退出时重置和两阶段角色切换动作;页面和布局不直接写入持久化角色 ID。

认证状态仍由 src/stores/auth.ts 管理。权限预览状态不写入 Token,不修改 CurrentUser 的稳定共享模型,也不在页面中复制认证逻辑。

演示角色 ID 使用 admin 项目专属的 localStorage 键保存,以便刷新后保留预览上下文。保存值失效、角色被删除或角色被停用时,回退到启用的超级管理员;如果超级管理员不可用,则回退到第一个启用角色。此回退只服务静态原型,不代表后端策略。

预览状态与认证状态采用明确的生命周期边界:

  • 页面刷新保留当前演示角色。
  • 退出操作先调用权限预览 Store 的重置动作,清除内存状态与持久化演示角色 ID,再调用 auth.logout();认证 Store 不承担预览状态职责。
  • 登录成功后由登录流程调用权限预览 Store 的初始化动作,默认选择启用的超级管理员;超级管理员不可用时选择第一个启用角色。未登录阶段不自动初始化或重新持久化默认角色。
  • 顶栏固定区分两个来源:登录身份:{auth.user.name}({auth.user.account}) 与 权限预览:{previewRole.name}。认证角色 auth.user.roles 不作为无标签副标题展示,也不被预览状态读取或改写。
  • localStorage 在同源标签页之间共享,但当前不监听 storage 事件做实时跨标签同步;其他标签页刷新后读取最新持久化角色。

权限数据流

adminData.roles + adminData.permissions
                  ↓
        静态权限预览 Store
        ↓         ↓          ↓
     侧边栏     路由决策     页面操作
                    ↓
             未保存内容保护

角色授权编辑保存到 adminData.roles 后,权限编码集合通过计算属性立即更新,不复制第二份角色或权限数据。

菜单与路由行为

菜单过滤

在现有 adminNavigation 基础上增加纯函数过滤:

  • 叶子菜单只在其 permission 存在于当前权限编码集合时保留。
  • 目录仅作为导航组织节点,其自身启停状态不级联授予或撤销后代权限;叶子菜单只判断自身对应的启用权限资源。
  • 任意层级目录在递归过滤后没有任何子菜单时隐藏。
  • 菜单原始配置保持不可变,过滤函数返回新节点。
  • 工作台继续使用 dashboard:view,与其他菜单采用相同规则。
  • “第一个可访问菜单”固定为过滤后 adminNavigation 配置顺序中的第一个叶子,采用深度优先遍历,不使用权限资源的 sort 决定回退目标。

工作台路由必须显式声明 meta.permission: 'dashboard:view'。所有受导航权限控制的叶子页面都应同时具有对应路由权限元数据,不能只过滤菜单而遗漏直接地址访问。

AdminSidebarMenu 接收过滤后的导航数据,不直接读取 Pinia,并通过递归菜单节点支持后续新增更深层级;权限来源与具体渲染层级不耦合。

路由守卫

登录校验继续先于权限校验执行:

  1. 未登录访问受保护页面时跳转登录页。
  2. 已登录且目标路由声明 meta.permission 时,使用静态权限预览判断访问权。
  3. 无权限的直接访问跳转到静态 /403 页面,使用 query.from 保留经过校验的站内来源路径用于纯文本提示;只接受以单个 / 开头的内部路径,不把该值作为外部跳转地址。
  4. /403 本身需要登录,但不要求业务权限,避免重定向循环。
  5. 404 重定向保持现状。

为便于单元测试,路由权限决策下沉为不依赖浏览器导航的纯函数;Vue Router 守卫只负责读取状态并执行决策结果。

除导航守卫外,登录态布局还需要响应式监听当前有效权限集合、当前演示角色有效性和当前路由。当角色授权被修改、当前角色被停用或删除、权限资源被停用等变化导致当前页面失去访问权时,立即重新校验当前路由:优先跳转到回退后角色的第一个可访问叶子菜单;没有可访问菜单时进入 /403。

导航守卫负责首次进入和显式导航,登录态布局只负责已渲染页面上的响应式权限变化;两者调用同一个纯路由决策函数。布局执行修正前比较目标与当前 fullPath,使用单次导航锁避免并发修正,并统一调用 router.replace,使浏览器后退不会再次回到刚被拒绝的地址。目标已经相同或当前已处于无菜单可恢复的 /403 时不重复导航。

主动切换角色

用户从顶栏切换角色采用“请求—确认—提交”两阶段流程,不在下拉选择事件中直接覆盖当前角色:

  • 先根据候选角色计算候选权限、当前页面可访问性和回退目标。
  • 当前页面的新增/编辑抽屉或对话框存在已修改但未保存的内容,且切换会离开当前路由或撤销该表单保存所需的操作权限时,显示确认框,明确候选角色和将离开的页面;取消后角色、路由和表单内容均保持不变。
  • 各业务页面通过 admin 专属的轻量离开保护协调器登记 { dirty, requiredPermission, label };只在字段实际变化后标记 dirty,关闭或成功保存时注销,不把业务表单数据复制到全局 Store。
  • 用户确认或没有受影响的未保存内容后,才提交演示角色 ID;需要修正路由时统一使用 router.replace。
  • 顶栏随后显示新的演示角色名称,并显示 已切换至“{角色名}”,当前仅为静态权限预览。
  • 当前页面仍可访问时保持原路由。
  • 当前页面不再可访问时,跳转到该角色第一个可访问菜单。
  • 如果切换角色时当前页面是 /403,只要新角色存在可访问菜单,就始终跳转到第一个可访问叶子菜单,不把 /403 视为应继续停留的业务页面。
  • 如果角色没有任何菜单权限,进入 /403,显示“当前演示角色暂无可访问菜单”。
  • 自动跳转完成后将焦点移到新页面主标题,并通过 aria-live="polite" 播报当前预览角色与目标页面;仅停留原页面时也播报角色变化。

操作权限映射

本次只控制现有静态模型中已有明确编码的操作:

页面 操作 权限编码
客户管理 新增客户 customer:create
客户管理 审核客户、详情中的通过审核 customer:audit
头程订单 创建订单、创建并提交、保存新草稿 first-leg:create
头程订单 单笔处理、批量更新、更新状态 first-leg:status
内部用户 新增、编辑、重置密码、启停、保存 system:user:write
角色管理 新增、配置、启停、删除、保存 system:role:grant
菜单权限 新增、编辑、添加下级、启停、保存 system:permission:write

详情、查询、筛选和已有页面级导出继续由对应菜单权限覆盖。客户编辑/启停、订单编辑等没有独立稳定操作编码的功能暂不额外限制;待 Java 权限契约确定后再细分,避免静态原型创造错误的长期编码。

页面通过同一个权限预览 Store 的 can 方法控制明确映射的操作,不创建页面私有权限判断逻辑。无权限操作不渲染,避免用户误以为按钮可用后才收到拒绝。

顶栏交互

  • 在现有用户信息和退出按钮之间增加“演示角色”下拉入口。
  • 使用 Lucide 图标和 Element Plus 现有组件,不引入新依赖。
  • 下拉只展示启用角色,并标记当前角色。
  • 桌面端持续显示 权限预览:{角色名},并提供“仅预览菜单、页面和操作;数据范围未模拟”的可聚焦说明;登录用户区域显示 登录身份:{姓名}({账号}),两者不共用“角色”副标题。
  • 窄屏至少保留可识别图标、权限预览:{角色名},数据范围未模拟 的可访问名称和 44px 触控区域;长角色名称使用省略与完整 tooltip,不挤压退出按钮。
  • 角色下拉支持 Tab 聚焦、Enter/Space 打开、方向键移动、Enter 选择和 Escape 关闭,并保持可见焦点。
  • 入口和反馈文案始终包含“演示”或“预览”,避免被理解为真实用户换号或数据范围切换。

无权限页面

新增轻量静态 403 页面:

  • 说明当前演示角色无权访问目标页面。
  • 显示当前演示角色名称。
  • 来源路径只作为经过校验、可截断换行的纯文本显示,不生成“返回来源地址”链接。
  • 当前角色存在可访问菜单时,提供“返回可访问首页”操作。
  • 当前角色没有任何可访问菜单时,提示用户使用顶栏入口切换演示角色。
  • 不使用夸张插画或与现有后台不一致的视觉语言。
  • 页面作为登录态布局的子路由呈现,使顶栏角色切换始终可用。

状态与边界处理

  • 角色切换只修改本地预览状态,不修改内部用户的真实 roleIds。
  • 停用或删除当前演示角色时自动执行回退,不保留悬空角色 ID。
  • 停用权限资源会立即从当前角色的有效权限编码集合中移除,即使该权限 ID 仍存在于角色授权数据中。
  • 停用目录资源只改变该目录资源自身的有效编码,不级联改变启用后代的菜单、路由或操作权限;权限管理页的停用确认明确说明这一点。
  • 超级管理员通过角色现有的全部 permissionIds 获得权限,不在代码中使用绕过所有检查的特殊布尔值。
  • 角色拥有目录权限但没有任何叶子菜单权限时,不显示空目录。
  • 如果异常模拟数据中不存在任何启用角色,当前角色为空、有效权限集合为空并进入 /403;正常页面继续保护内置超级管理员,避免产生该状态。
  • 多角色合并不在本次预览范围;预览入口一次选择一个角色,真实用户多角色合并规则等待后端契约。
  • 静态权限预览不静默回退到远程数据,也不发起网络请求。

测试策略

使用 admin 现有 Vitest,按测试驱动方式实现:

  • 权限预览 Store:两阶段角色选择、权限编码派生、停用权限资源不授予访问权、失效角色回退、零启用角色、角色授权变更实时生效、退出登录重置且刷新保留、未登录时不重新初始化默认角色。
  • 导航过滤:隐藏无权限叶子、目录停用不级联后代、递归移除空目录、保持原配置不可变、按 adminNavigation 深度优先顺序确定第一个可访问菜单。
  • 路由配置与决策:工作台显式声明 dashboard:view;覆盖未登录、已登录有权限、已登录无权限、403 免业务权限四种分支。
  • 当前路由再校验:角色授权减少、角色停用或删除、权限资源停用后,当前无权限页面通过 router.replace 跳转到第一个可访问叶子或 /403;覆盖目标相同、导航锁和浏览器后退不返回被拒页面。
  • 侧边栏组件:只渲染传入的过滤结果,并覆盖三层导航数据以证明递归渲染不受当前两层结构限制。
  • 顶栏角色切换:当前角色回显、准确的登录身份/权限预览标签、窄屏可访问名称、键盘操作、长角色名,以及从 /403 切换到有权限角色后恢复到第一个可访问菜单。
  • 未保存内容保护:覆盖无修改直接切换、有修改但权限不受影响、有修改且将离开或失去保存权限、取消保持全部状态、确认后提交角色并 replace 跳转。
  • 无障碍动态状态:覆盖成功切换和自动跳转的 polite 播报、跳转后主标题焦点与 Escape 关闭角色菜单。
  • 状态来源与生命周期:用户姓名/账号来自认证 Store,演示角色来自权限预览 Store;退出清除预览角色,重新登录使用默认启用角色。
  • 页面操作:至少覆盖每个操作权限域的一项代表按钮,确认无权限时不渲染。
  • 数据范围边界:角色切换不改变模拟记录集合,界面持续说明 dataScope 未模拟。

交付前运行:

pnpm test
pnpm typecheck
pnpm build

浏览器验收覆盖桌面、390px 和项目约定的 320px 最小宽度,并检查 200% 浏览器缩放:角色切换、菜单变化、未保存内容确认/取消、自动跳转、直接访问 403、代表性按钮显示与隐藏、键盘完整操作、可见焦点、动态播报、顶栏不重叠或裁切以及控制台错误。UI 修改完成后运行一次 Impeccable 检测。

完成标准

  • 初始模拟数据中的五个启用角色可在顶栏切换,后续角色新增、停用或删除后列表与回退实时更新。
  • 菜单、路由和已映射操作与当前角色的启用权限资源一致,停用权限不产生访问权。
  • 权限预览明确不包含 dataScope,角色切换不改变模拟记录,桌面和窄屏均持续提供这一说明。
  • 无权限直接访问有明确 403 反馈,不出现循环跳转。
  • 角色授权、角色有效性或权限资源状态变化能即时影响当前预览;当前路由失去权限时立即修正到第一个可访问叶子或 /403。
  • 目录停用语义固定为不级联后代;递归菜单过滤与渲染支持后续增加更深菜单层级。
  • 角色切换不会静默丢失已修改表单;取消保持原角色、路由和内容,确认后使用 replace 完成必要跳转。
  • 从 /403 切换到具有菜单权限的角色后能恢复到可访问页面。
  • 刷新保留演示角色,退出登录清除预览状态,重新登录采用默认启用角色;登录身份 与 权限预览 的标签、数据来源和视觉层级清晰分离。
  • 键盘、读屏播报、焦点管理、320px/390px、200% 缩放与长角色名称均通过验收。
  • 不产生任何真实 API 请求或新的后端契约假设。
  • admin 项目测试、类型检查、构建、桌面/移动端浏览器验收及 Impeccable 检测通过。