Files
leaudit-platform-frontend/auth_doc/通用权限前端对接文档(1).md
LiangShiyong d8bba607fc fix: 1. 重新对齐交叉评查的接口。
2. 确认评查结果的接口对接。 3. 新增评查点适配省级创建的响应数据和其他用户创建的单条响应数据。  4. 文档列表的文档类型通过通用的查询接口查询文档类型。优化加载状态的时机。
2025-12-11 11:16:50 +08:00

23 KiB
Raw Permalink Blame History

通用权限前端对接文档

概述

系统中存在通用权限,即同一个权限被多个页面共享使用。例如「查看审计状态」权限同时被「文档评查结果详情」和「交叉评查-评查结果」两个页面使用。

本文档详细说明前端如何查询、展示和管理这些通用权限。


⚠️ 重要:必须修改的查询方式

问题

当前前端查询某个路由下的权限可能使用:

// ❌ 错误方式:只能查到独立权限,查不到通用权限
const permissions = await fetch(`/api/postgrest/proxy/permissions?route_id=eq.${routeId}`);

这种查询方式无法获取通用权限,因为通用权限的 route_idNULL

解决方案

必须修改为以下查询方式

// ✅ 正确方式:同时查询独立权限 + 通用权限
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} 被多个页面共享

当前通用权限数据

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 查询语句

-- 查询 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 查询方式

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 数组包含操作符

返回示例

[
    // 独立权限
    {
        "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 (同上)

识别通用权限

// 判断是否为通用权限
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 展示建议

// 权限项组件
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>
    );
}

同步勾选逻辑

核心逻辑

当用户勾选/取消一个通用权限时,需要同步更新所有关联路由下该权限的勾选状态。

实现代码

// 权限状态管理
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 组件示例

// 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>
    );
}

保存角色权限

请求格式

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. 加载权限配置页面

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. 用户交互

// 勾选权限
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. 保存配置

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)
    });
}

样式建议

/* 通用权限样式 */
.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 保存角色权限配置

查询示例

# 查询所有权限
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. 后端权限判断 - 无影响

# 后端权限检查逻辑(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. 权限保存 - 无影响

// 保存时只提交 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 需要按页面区分权限/数据范围,则需要创建两个独立权限

-- 方案:为每个页面创建独立权限
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 添加必须修改的查询方式说明、方案深入分析