fix: 1. 重新对齐交叉评查的接口。

2. 确认评查结果的接口对接。 3. 新增评查点适配省级创建的响应数据和其他用户创建的单条响应数据。  4. 文档列表的文档类型通过通用的查询接口查询文档类型。优化加载状态的时机。
This commit is contained in:
2025-12-11 11:16:50 +08:00
parent ba517d7b9c
commit d8bba607fc
18 changed files with 3435 additions and 1086 deletions
@@ -0,0 +1,742 @@
# 通用权限前端对接文档
## 概述
系统中存在**通用权限**,即同一个权限被多个页面共享使用。例如「查看审计状态」权限同时被「文档评查结果详情」和「交叉评查-评查结果」两个页面使用。
本文档详细说明前端如何查询、展示和管理这些通用权限。
---
## ⚠️ 重要:必须修改的查询方式
### 问题
当前前端查询某个路由下的权限可能使用:
```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` = containsPostgreSQL 数组包含操作符
### 返回示例
```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 (
<div className={`permission-item ${isShared ? 'shared' : ''}`}>
<Checkbox
checked={checked}
onChange={onChange}
/>
<span className="permission-name">
{isShared && <Tag color="blue">通用</Tag>}
{permission.display_name}
</span>
{isShared && (
<Tooltip title={`此权限同时适用于: ${permission.related_routes.join(', ')}`}>
<Icon type="info-circle" />
</Tooltip>
)}
</div>
);
}
```
---
## 同步勾选逻辑
### 核心逻辑
当用户勾选/取消一个**通用权限**时,需要同步更新所有关联路由下该权限的勾选状态。
### 实现代码
```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 (
<div className="permission-tree">
{permissions.map(permission => (
<PermissionItem
key={permission.id}
permission={permission}
checked={checkedIds.has(permission.id)}
onChange={(checked) => handleCheckChange(permission.id, checked)}
/>
))}
</div>
);
}
```
---
## 保存角色权限
### 请求格式
```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 | 添加必须修改的查询方式说明、方案深入分析 |