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

218 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 事件做实时跨标签同步;其他标签页刷新后读取最新持久化角色。
### 权限数据流
```text
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` 未模拟。
交付前运行:
```bash
pnpm test
pnpm typecheck
pnpm build
```
浏览器验收覆盖桌面、390px 和项目约定的 320px 最小宽度,并检查 200% 浏览器缩放:角色切换、菜单变化、未保存内容确认/取消、自动跳转、直接访问 403、代表性按钮显示与隐藏、键盘完整操作、可见焦点、动态播报、顶栏不重叠或裁切以及控制台错误。UI 修改完成后运行一次 Impeccable 检测。
## 完成标准
- 初始模拟数据中的五个启用角色可在顶栏切换,后续角色新增、停用或删除后列表与回退实时更新。
- 菜单、路由和已映射操作与当前角色的启用权限资源一致,停用权限不产生访问权。
- 权限预览明确不包含 `dataScope`,角色切换不改变模拟记录,桌面和窄屏均持续提供这一说明。
- 无权限直接访问有明确 403 反馈,不出现循环跳转。
- 角色授权、角色有效性或权限资源状态变化能即时影响当前预览;当前路由失去权限时立即修正到第一个可访问叶子或 `/403`。
- 目录停用语义固定为不级联后代;递归菜单过滤与渲染支持后续增加更深菜单层级。
- 角色切换不会静默丢失已修改表单;取消保持原角色、路由和内容,确认后使用 replace 完成必要跳转。
- 从 `/403` 切换到具有菜单权限的角色后能恢复到可访问页面。
- 刷新保留演示角色,退出登录清除预览状态,重新登录采用默认启用角色;`登录身份` 与 `权限预览` 的标签、数据来源和视觉层级清晰分离。
- 键盘、读屏播报、焦点管理、320px/390px、200% 缩放与长角色名称均通过验收。
- 不产生任何真实 API 请求或新的后端契约假设。
- admin 项目测试、类型检查、构建、桌面/移动端浏览器验收及 Impeccable 检测通过。