# 通用权限前端对接文档 ## 概述 系统中存在**通用权限**,即同一个权限被多个页面共享使用。例如「查看审计状态」权限同时被「文档评查结果详情」和「交叉评查-评查结果」两个页面使用。 本文档详细说明前端如何查询、展示和管理这些通用权限。 --- ## ⚠️ 重要:必须修改的查询方式 ### 问题 当前前端查询某个路由下的权限可能使用: ```javascript // ❌ 错误方式:只能查到独立权限,查不到通用权限 const permissions = await fetch(`/api/postgrest/proxy/permissions?route_id=eq.${routeId}`); ``` 这种查询方式**无法获取通用权限**,因为通用权限的 `route_id` 为 `NULL`。 ### 解决方案 **必须修改为以下查询方式**: ```javascript // ✅ 正确方式:同时查询独立权限 + 通用权限 const permissions = await fetch( `/api/postgrest/proxy/permissions?or=(route_id.eq.${routeId},related_routes.cs.{${routeId}})` ); ``` ### 查询参数说明 | 参数 | 说明 | |------|------| | `route_id.eq.${routeId}` | 查询独立权限(route_id = 指定值) | | `related_routes.cs.{${routeId}}` | 查询通用权限(related_routes 数组包含指定值) | | `or=(...)` | 两个条件取并集 | | `cs` | PostgREST 的 contains 操作符,用于数组包含查询 | ### 修改前后对比 以交叉评查页面 (route_id=37) 为例: | 查询方式 | 返回权限数量 | 说明 | |---------|-------------|------| | `?route_id=eq.37` | 6个 | ❌ 只有独立权限 | | `?or=(route_id.eq.37,related_routes.cs.{37})` | 11个 | ✅ 独立权限 + 通用权限 | ### 交叉评查页面应显示的完整权限列表 | 类型 | 权限名称 | permission_key | |------|---------|----------------| | **通用** | 查看审计状态 | evaluation:audit_status:view | | **通用** | 更新审计状态 | evaluation:audit_status:update | | **通用** | 创建审核状态 | evaluation:audit_status:create | | **通用** | 更新评查结果 | evaluation:result:update | | **通用** | 确认文档审核完成 | evaluation:document:confirm | | 独立 | 查看评分提案 | cross_review:proposal:read | | 独立 | 创建评分提案 | cross_review:proposal:create | | 独立 | 对提案投票 | cross_review:proposal:vote | | 独立 | 删除评分提案 | cross_review:proposal:delete | | 独立 | 标记文档完成 | cross_review:document:complete | | 独立 | 查看任务进度 | cross_review:progress:view | --- ## 数据库结构 ### permissions 表关键字段 | 字段 | 类型 | 说明 | |------|------|------| | `id` | INTEGER | 权限ID(主键) | | `permission_key` | VARCHAR(100) | 权限唯一标识,如 `evaluation:audit_status:view` | | `display_name` | VARCHAR(200) | 权限显示名称 | | `route_id` | INTEGER | 关联的路由ID(独立权限使用) | | `related_routes` | INTEGER[] | **关联的多个路由ID(通用权限使用)** | ### 权限类型区分 | 类型 | route_id | related_routes | 说明 | |------|----------|----------------|------| | **独立权限** | 有值(如58) | NULL | 只属于单个页面 | | **通用权限** | NULL | 有值(如{58,37}) | 被多个页面共享 | --- ## 当前通用权限数据 ```sql SELECT id, permission_key, route_id, related_routes, display_name FROM permissions WHERE related_routes IS NOT NULL; ``` | id | permission_key | route_id | related_routes | display_name | |----|----------------|----------|----------------|--------------| | 88 | evaluation:audit_status:view | NULL | {58, 37} | 查看审计状态 | | 89 | evaluation:audit_status:update | NULL | {58, 37} | 更新审计状态 | | 136 | evaluation:result:update | NULL | {58, 37} | 更新评查结果 | | 137 | evaluation:audit_status:create | NULL | {58, 37} | 创建审核状态 | | 138 | evaluation:document:confirm | NULL | {58, 37} | 确认文档审核完成 | ### 关联的路由 | route_id | route_name | route_title | route_path | |----------|------------|-------------|------------| | 58 | Reviews | 文档评查结果详情 | /reviews | | 37 | CrossCheckingResult | 评查结果 | /cross-checking/result | --- ## 查询某个页面的所有权限 ### SQL 查询语句 ```sql -- 查询 route_id=58 (文档评查结果详情) 的所有权限 SELECT id, permission_key, display_name, route_id, related_routes, CASE WHEN related_routes IS NOT NULL THEN true ELSE false END AS is_shared FROM permissions WHERE route_id = 58 OR 58 = ANY(related_routes) ORDER BY is_shared, permission_key; ``` ### PostgREST 查询方式 ```http GET /api/postgrest/proxy/permissions?or=(route_id.eq.58,related_routes.cs.{58})&select=id,permission_key,display_name,route_id,related_routes ``` **参数说明**: - `route_id.eq.58` - 独立权限(route_id = 58) - `related_routes.cs.{58}` - 通用权限(related_routes 包含 58) - `cs` = contains,PostgreSQL 数组包含操作符 ### 返回示例 ```json [ // 独立权限 { "id": 82, "permission_key": "evaluation_point:result:view", "display_name": "查看评查结果", "route_id": 58, "related_routes": null }, { "id": 83, "permission_key": "evaluation_point:result:update", "display_name": "更新评查结果", "route_id": 58, "related_routes": null }, { "id": 48, "permission_key": "review_point:detail:read", "display_name": "查看评查详情", "route_id": 58, "related_routes": null }, // 通用权限 { "id": 88, "permission_key": "evaluation:audit_status:view", "display_name": "查看审计状态", "route_id": null, "related_routes": [58, 37] }, { "id": 89, "permission_key": "evaluation:audit_status:update", "display_name": "更新审计状态", "route_id": null, "related_routes": [58, 37] }, { "id": 136, "permission_key": "evaluation:result:update", "display_name": "更新评查结果", "route_id": null, "related_routes": [58, 37] }, { "id": 137, "permission_key": "evaluation:audit_status:create", "display_name": "创建审核状态", "route_id": null, "related_routes": [58, 37] }, { "id": 138, "permission_key": "evaluation:document:confirm", "display_name": "确认文档审核完成", "route_id": null, "related_routes": [58, 37] } ] ``` --- ## 前端展示逻辑 ### 页面权限树结构 ``` 文件管理 (/documents) ├── 文档列表 (/documents/list) ├── 文档评查结果详情 (/reviews) ← route_id=58 │ ├── [独立] GET 查看评查结果 │ ├── [独立] PATCH 更新评查结果 │ ├── [独立] GET 查看评查详情 │ ├── [通用] GET 查看审计状态 ← is_shared=true │ ├── [通用] PATCH 更新审计状态 ← is_shared=true │ ├── [通用] POST 创建审核状态 ← is_shared=true │ ├── [通用] PATCH 更新评查结果 ← is_shared=true │ └── [通用] PATCH 确认文档审核完成 ← is_shared=true │ 交叉评查 (/cross-checking) ├── 上传评查文档 (/cross-checking/upload) ├── 评查结果 (/cross-checking/result) ← route_id=37 │ ├── [独立] POST 查看评分提案 │ ├── [独立] POST 创建评分提案 │ ├── [独立] POST 对提案投票 │ ├── [独立] DELETE 删除评分提案 │ ├── [独立] POST 标记文档完成 │ ├── [独立] GET 查看任务进度 │ ├── [通用] GET 查看审计状态 ← is_shared=true (同上) │ ├── [通用] PATCH 更新审计状态 ← is_shared=true (同上) │ ├── [通用] POST 创建审核状态 ← is_shared=true (同上) │ ├── [通用] PATCH 更新评查结果 ← is_shared=true (同上) │ └── [通用] PATCH 确认文档审核完成 ← is_shared=true (同上) ``` ### 识别通用权限 ```javascript // 判断是否为通用权限 function isSharedPermission(permission) { return permission.related_routes !== null && Array.isArray(permission.related_routes) && permission.related_routes.length > 1; } // 获取通用权限关联的所有路由ID function getRelatedRouteIds(permission) { if (isSharedPermission(permission)) { return permission.related_routes; } return permission.route_id ? [permission.route_id] : []; } ``` ### UI 展示建议 ```jsx // 权限项组件 function PermissionItem({ permission, checked, onChange }) { const isShared = isSharedPermission(permission); return (
{isShared && 通用} {permission.display_name} {isShared && ( )}
); } ``` --- ## 同步勾选逻辑 ### 核心逻辑 当用户勾选/取消一个**通用权限**时,需要同步更新所有关联路由下该权限的勾选状态。 ### 实现代码 ```javascript // 权限状态管理 class PermissionManager { constructor() { // 存储所有权限数据 this.permissions = []; // 存储已勾选的权限ID集合 this.checkedPermissionIds = new Set(); // 通用权限ID列表(用于快速查找) this.sharedPermissionIds = new Set(); } // 加载权限数据 async loadPermissions() { // 查询所有权限 const response = await fetch('/api/postgrest/proxy/permissions?select=*'); this.permissions = await response.json(); // 识别通用权限 this.sharedPermissionIds = new Set( this.permissions .filter(p => p.related_routes !== null) .map(p => p.id) ); } // 获取某个路由下的所有权限 getPermissionsByRouteId(routeId) { return this.permissions.filter(p => p.route_id === routeId || (p.related_routes && p.related_routes.includes(routeId)) ); } // 勾选权限 checkPermission(permissionId) { this.checkedPermissionIds.add(permissionId); // 通用权限会自动在所有关联路由下显示为勾选状态 // 因为我们是用 permission_id 来管理勾选状态,而不是 route_id + permission_id } // 取消勾选权限 uncheckPermission(permissionId) { this.checkedPermissionIds.delete(permissionId); } // 判断权限是否被勾选 isPermissionChecked(permissionId) { return this.checkedPermissionIds.has(permissionId); } // 判断是否为通用权限 isSharedPermission(permissionId) { return this.sharedPermissionIds.has(permissionId); } // 获取通用权限关联的路由名称 getRelatedRouteNames(permission) { if (!permission.related_routes) return []; // 需要有路由名称映射 return permission.related_routes.map(routeId => { // 从 sys_routes 表获取路由名称 return this.routeMap[routeId]?.route_title || `路由${routeId}`; }); } } ``` ### React/Vue 组件示例 ```jsx // React 示例 function PermissionTree({ routeId, permissionManager }) { const [checkedIds, setCheckedIds] = useState(new Set()); // 获取当前路由下的所有权限 const permissions = permissionManager.getPermissionsByRouteId(routeId); // 处理勾选变化 const handleCheckChange = (permissionId, checked) => { const newCheckedIds = new Set(checkedIds); if (checked) { newCheckedIds.add(permissionId); } else { newCheckedIds.delete(permissionId); } setCheckedIds(newCheckedIds); // 如果是通用权限,通知其他关联路由的组件更新UI if (permissionManager.isSharedPermission(permissionId)) { // 触发全局状态更新或事件 eventBus.emit('sharedPermissionChanged', { permissionId, checked }); } }; // 监听通用权限变化事件 useEffect(() => { const handleSharedChange = ({ permissionId, checked }) => { // 检查这个通用权限是否属于当前路由 const permission = permissions.find(p => p.id === permissionId); if (permission) { // 更新本地勾选状态 const newCheckedIds = new Set(checkedIds); if (checked) { newCheckedIds.add(permissionId); } else { newCheckedIds.delete(permissionId); } setCheckedIds(newCheckedIds); } }; eventBus.on('sharedPermissionChanged', handleSharedChange); return () => eventBus.off('sharedPermissionChanged', handleSharedChange); }, [permissions, checkedIds]); return (
{permissions.map(permission => ( handleCheckChange(permission.id, checked)} /> ))}
); } ``` --- ## 保存角色权限 ### 请求格式 ```http POST /api/v3/rbac/role-permissions Content-Type: application/json { "role_id": 2, "permissions": [ {"permission_id": 82, "grant_type": "GRANT", "data_scope": "ALL"}, {"permission_id": 83, "grant_type": "GRANT", "data_scope": "DEPT"}, {"permission_id": 88, "grant_type": "GRANT", "data_scope": "ALL"}, // 通用权限 {"permission_id": 89, "grant_type": "GRANT", "data_scope": "ALL"}, // 通用权限 // ... ], "replace": true } ``` **注意**: - 通用权限只需要提交一次(使用 permission_id) - 不需要为每个关联路由分别提交 - 后端权限判断基于 `permission_key`,与 route_id 无关 --- ## 完整工作流程 ### 1. 加载权限配置页面 ```javascript async function loadPermissionConfigPage(roleId) { // 1. 获取所有路由树 const routes = await fetch('/api/postgrest/proxy/sys_routes?deleted_at=is.null&order=sort_order'); // 2. 获取所有权限 const permissions = await fetch('/api/postgrest/proxy/permissions?select=*'); // 3. 获取角色已有的权限 const rolePermissions = await fetch(`/api/v3/rbac/role-permissions?role_id=${roleId}`); // 4. 构建权限树(每个路由下挂载对应的权限) const permissionTree = buildPermissionTree(routes, permissions); // 5. 标记已勾选的权限 markCheckedPermissions(permissionTree, rolePermissions.data.permissions); return permissionTree; } function buildPermissionTree(routes, permissions) { return routes.map(route => ({ ...route, permissions: permissions.filter(p => p.route_id === route.id || (p.related_routes && p.related_routes.includes(route.id)) ).map(p => ({ ...p, isShared: p.related_routes !== null })) })); } ``` ### 2. 用户交互 ```javascript // 勾选权限 function onPermissionCheck(permissionId, checked) { // 更新本地状态 updateLocalState(permissionId, checked); // 如果是通用权限,同步更新所有关联路由的UI const permission = findPermission(permissionId); if (permission.isShared) { syncSharedPermissionUI(permission, checked); } } // 同步通用权限UI function syncSharedPermissionUI(permission, checked) { permission.related_routes.forEach(routeId => { // 更新对应路由节点下该权限的勾选状态 updateRoutePermissionUI(routeId, permission.id, checked); }); } ``` ### 3. 保存配置 ```javascript async function saveRolePermissions(roleId) { // 收集所有勾选的权限ID(通用权限只收集一次) const checkedPermissionIds = collectCheckedPermissionIds(); // 构建请求数据 const requestData = { role_id: roleId, permissions: checkedPermissionIds.map(id => ({ permission_id: id, grant_type: 'GRANT', data_scope: getDataScope(id) // 根据业务需求设置 })), replace: true }; // 提交到后端 await fetch('/api/v3/rbac/role-permissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestData) }); } ``` --- ## 样式建议 ```css /* 通用权限样式 */ .permission-item.shared { background-color: #f0f7ff; border-left: 3px solid #1890ff; } .permission-item.shared .permission-name { color: #1890ff; } /* 通用标签 */ .shared-tag { display: inline-block; padding: 0 6px; font-size: 12px; background-color: #e6f7ff; border: 1px solid #91d5ff; border-radius: 2px; color: #1890ff; margin-right: 8px; } /* 同步勾选提示 */ .sync-indicator { font-size: 12px; color: #999; margin-left: 8px; } ``` --- ## API 接口汇总 | 接口 | 方法 | 说明 | |------|------|------| | `/api/postgrest/proxy/permissions` | GET | 查询权限列表 | | `/api/postgrest/proxy/sys_routes` | GET | 查询路由列表 | | `/api/v3/rbac/role-permissions` | GET | 查询角色已有权限 | | `/api/v3/rbac/role-permissions` | POST | 保存角色权限配置 | ### 查询示例 ```bash # 查询所有权限 curl '/api/postgrest/proxy/permissions?select=id,permission_key,display_name,route_id,related_routes' # 查询 route_id=58 的权限(包含通用权限) curl '/api/postgrest/proxy/permissions?or=(route_id.eq.58,related_routes.cs.{58})' # 查询所有通用权限 curl '/api/postgrest/proxy/permissions?related_routes=not.is.null' ``` --- ## 注意事项 1. **通用权限只存储一份**:在 permissions 表中,通用权限只有一条记录,通过 `related_routes` 字段关联多个路由 2. **权限判断不依赖 route_id**:后端权限检查只看 `permission_key`,与 route_id 无关 3. **前端需要做 UI 同步**:当勾选/取消通用权限时,需要同步更新所有关联路由下该权限的显示状态 4. **保存时只提交一次**:通用权限在保存角色权限时只需要提交一次 permission_id 5. **建议添加视觉标识**:使用颜色、图标或标签区分通用权限和独立权限,提升用户体验 --- ## 方案深入分析 ### 数据结构说明 ``` role_permissions 表结构: ┌─────────────────┬───────────────┬──────────────┬────────────┐ │ role_id │ permission_id │ grant_type │ data_scope │ ├─────────────────┼───────────────┼──────────────┼────────────┤ │ 1 (市级管理员) │ 88 │ GRANT │ ALL │ │ 2 (普通员工) │ 88 │ GRANT │ DEPT │ └─────────────────┴───────────────┴──────────────┴────────────┘ 关键点:role_permissions 只关联 permission_id,不关联 route_id ``` ### 各场景分析 | 场景 | 状态 | 说明 | |------|------|------| | 后端权限判断 | ✅ 无影响 | 基于 permission_key,不涉及 route_id | | 权限保存 | ✅ 无影响 | 只保存 permission_id,通用权限只保存一次 | | 数据范围 (data_scope) | ⚠️ 注意 | 同一权限只有一个数据范围,不按页面区分 | | 按页面区分权限 | ❌ 不支持 | 同一权限无法在 A 页面有、B 页面无 | | 前端 UI 同步 | ⚠️ 需实现 | 前端需要实现同步勾选逻辑 | ### 场景详解 #### 1. 后端权限判断 - 无影响 ```python # 后端权限检查逻辑(permission_checker.py) SELECT p.permission_key FROM role_permissions rp JOIN permissions p ON rp.permission_id = p.id WHERE rp.role_id = {user_role_id} AND rp.grant_type = 'GRANT' ``` 只查 `permission_key`,完全不涉及 `route_id`,所以通用权限方案不影响权限判断。 #### 2. 权限保存 - 无影响 ```javascript // 保存时只提交 permission_id { "role_id": 2, "permissions": [ {"permission_id": 88, "grant_type": "GRANT"}, // 通用权限只保存一次 {"permission_id": 89, "grant_type": "GRANT"} ] } ``` 通用权限只有一条记录,保存一次即可,两个页面自动都有。 #### 3. 数据范围 (data_scope) - 需注意 **潜在问题**:如果同一个通用权限在不同页面需要不同的数据范围? | 页面 | 权限 | 期望的 data_scope | |------|------|-------------------| | /reviews | 查看审计状态 | DEPT(只看本部门) | | /cross-checking/result | 查看审计状态 | ALL(看所有) | **当前设计**:一个权限只有一个 `data_scope`,无法按页面区分。 **实际业务**:通用权限操作的是同一个数据表(audit_status),数据范围应该一致,所以这个问题在当前业务场景下不存在。 #### 4. 按页面区分权限 - 不支持 **场景**:用户想要在 `/reviews` 有权限,但在 `/cross-checking/result` 没有权限。 **当前方案不支持**,因为是同一个 `permission_id`。 **但是**:从业务逻辑来看,这种需求不合理: - 两个页面访问的是**同一个 API** - 如果用户能在 A 页面调用 API,直接调用 API 也能成功 - 按页面区分只是**前端显示**问题,不是**后端权限**问题 ### 适用场景 **方案适用于**: - 同一个 API 被多个页面共享 - 不需要按页面区分权限/数据范围 - 权限判断逻辑一致 **方案不适用于**: - 需要按页面单独控制权限 - 同一 API 在不同页面需要不同的数据范围 ### 如果需要按页面区分权限 如果将来有其他共享 API 需要按页面区分权限/数据范围,则需要**创建两个独立权限**: ```sql -- 方案:为每个页面创建独立权限 INSERT INTO permissions (permission_key, display_name, route_id) VALUES ('evaluation:audit_status:view:reviews', '查看审计状态(评查详情)', 58), ('evaluation:audit_status:view:crosschecking', '查看审计状态(交叉评查)', 37); ``` 后端权限检查也需要相应修改,根据请求来源判断使用哪个 permission_key。 --- ## 更新记录 | 日期 | 版本 | 说明 | |------|------|------| | 2025-12-10 | 1.0 | 初始版本,支持通用权限展示和同步勾选 | | 2025-12-10 | 1.1 | 添加必须修改的查询方式说明、方案深入分析 |