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

218 lines
17 KiB
Markdown
Raw Normal View History

2026-07-31 10:43:30 +08:00
# 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` 不作为无标签副标题展示,也不被预览状态读取或改写。
2026-09-04 16:44:08 +08:00
- `localStorage` 在同源标签页之间共享,但当前不监听 `storage` 事件做实时跨标签同步;其他标签页刷新后读取最新持久化角色。
2026-07-31 10:43:30 +08:00
### 权限数据流
```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 检测通过。