0%

大型企业级权限设计方案:从菜单到页面交互的前端设计

权限设计的结果不应该是一堆用户看不懂的开关,而应该是一条清晰的用户路径:我能看到什么、能打开什么、能查看什么、能执行什么,以及没有权限时下一步怎么办。

一、先从用户路径设计权限

用户打开一个企业应用,前端通常要经历四个阶段:读取当前身份、准备菜单、进入页面、根据操作权限渲染内容。每一步都需要明确状态,不能在权限还没准备好时先渲染一个空壳页面。

1
2
3
4
5
初始化权限
├── loading:展示应用准备状态
├── ready:构建菜单和路由
├── unauthenticated:进入登录页
└── failed:展示重试和帮助入口

进入页面后,用户还会遇到查询、编辑、删除、导出等不同操作。权限设计的重点,是让这些状态组合起来仍然符合直觉:能查看就能看到内容,没有编辑权限不应该影响阅读,没有查询权限也不应该伪装成“暂无数据”。

二、菜单权限与查询权限必须强绑定

每个菜单至少设计一个独立的查询权限,并且菜单权限与查询权限强绑定:拥有菜单权限,就一定拥有对应查询权限。查询权限不能和普通按钮权限一样随意删除或拆散。

这是企业前端非常容易遗漏的细节。假设一个页面有新增、修改、删除、导出四个组件权限,管理员把四个权限全部取消后,如果查询权限也被错误地当成普通操作删除,用户虽然还能看到菜单,却只能看到空白页或错误页。

推荐为每个菜单生成稳定的权限树:

1
2
3
4
5
6
菜单:enterprise:billing:invoice
└── 查询:enterprise:billing:invoice:query // 页面基础能力,强绑定
├── 新增:enterprise:billing:invoice:create
├── 修改:enterprise:billing:invoice:update
├── 删除:enterprise:billing:invoice:delete
└── 导出:enterprise:billing:invoice:export

前端配置可以直接表达这种关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type PagePermission = {
menuCode: string;
queryCode: string;
actionCodes: string[];
};

const invoicePermission: PagePermission = {
menuCode: "enterprise:billing:invoice",
queryCode: "enterprise:billing:invoice:query",
actionCodes: [
"enterprise:billing:invoice:create",
"enterprise:billing:invoice:update",
"enterprise:billing:invoice:delete",
"enterprise:billing:invoice:export",
],
};

页面注册时要做配置保护:如果菜单有了、查询权限没有,开发环境直接报错并给出修复提示;如果只取消了所有 action,查询权限仍然保留;如果整个菜单被移除,菜单、查询和 action 才一起移除。这样新增或删除某个组件权限时,不会让旧用户突然失去页面基础能力。

三、菜单、路由、标签页要保持一致

菜单权限决定导航是否展示,路由权限决定地址是否允许进入,标签页权限决定已经打开的页面如何处理。三者必须共享同一份权限判断,不要出现“菜单隐藏了但手动输入 URL 还能打开”或“页面失权了但标签页一直保留”的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type MenuNode = {
code: string;
path: string;
title: string;
queryCode: string;
children?: MenuNode[];
};

function filterMenu(node: MenuNode, can: (code: string) => boolean): MenuNode | null {
if (!can(node.code) || !can(node.queryCode)) return null;
return {
...node,
children: node.children?.map((item) => filterMenu(item, can)).filter(Boolean) as MenuNode[],
};
}

用户切换身份、组织或权限版本后,前端需要重新计算菜单;已失权的当前页面进入 403 状态,已失权的标签页关闭或标记,而不是继续显示旧内容。导航、面包屑和多标签页都要随当前页面同步更新。

四、组件权限不是简单的隐藏按钮

组件权限至少有三种表现:隐藏、禁用和只读。不同业务语义要使用不同交互:

状态适用场景用户看到的效果
隐藏用户不需要知道该操作存在不渲染入口
禁用操作存在,但当前条件不满足保留按钮并解释原因
只读用户需要查看,但不能编辑展示值,不展示编辑控件
二次确认删除、导出、批量修改等高风险操作弹窗确认后继续
1
2
3
4
5
6
7
8
9
function InvoiceActions({ can }: { can: (code: string) => boolean }) {
return (
<div className="actions">
{can("enterprise:billing:invoice:create") && <button>新增</button>}
{can("enterprise:billing:invoice:export") && <button>导出</button>}
{can("enterprise:billing:invoice:delete") && <button>删除</button>}
</div>
);
}

按钮权限全部取消时,页面仍然应该保留查询、筛选、分页和详情阅读能力。不要因为操作列为空就把整张表删除。

五、权限状态要进入页面状态机

每个受保护页面至少要设计以下状态:

1
2
3
4
5
6
7
type PageState =
| "permission-loading"
| "forbidden"
| "query-loading"
| "empty"
| "ready"
| "error";
1
2
3
4
5
function InvoicePage({ auth }: { auth: { can: (code: string) => boolean } }) {
if (!auth.can("enterprise:billing:invoice")) return <ForbiddenPage />;
if (!auth.can("enterprise:billing:invoice:query")) return <PermissionConfigError />;
return <InvoiceTable />;
}

“无权限”和“暂无数据”必须使用不同组件;“正在加载权限”和“查询失败”也要提供不同的重试入口。对用户来说,这些细节决定了系统是可靠还是像坏掉了一样。

六、权限变化时如何不打断用户

权限变化可能来自登录过期、切换工作空间、用户资料刷新或其他页面修改权限。前端可以用版本号检测变化,并执行统一流程:

1
2
3
4
5
6
权限版本变化
├── 保留仍有权限的页面和表单草稿
├── 重算菜单、面包屑和收藏
├── 关闭已失权的标签页
├── 当前页面失权 → 403 + 返回入口
└── 查询权限失效 → 提示重新获取权限

不要在权限更新时强制刷新整个浏览器,也不要让用户直接掉到空白页。能保留的页面状态要保留,必须关闭的页面要告诉用户原因。

七、权限配置页应该怎么设计

给管理员使用的权限配置页,也应该以用户体验为中心:左侧展示菜单树,中间展示页面基础权限,右侧展示组件操作权限。选中菜单时,查询权限默认勾选且不可单独取消;取消菜单时,相关查询和操作权限联动取消。

1
2
3
4
5
6
□ 发票管理
└─ ☑ 查询(随菜单绑定)
├─ □ 新增
├─ □ 修改
├─ □ 删除
└─ □ 导出

页面还应展示“当前配置影响哪些用户/角色”“修改前后差异”和“保存后何时生效”,避免管理员只看到一堆 Code 却无法理解结果。保存前可以给出权限预览:以某个测试用户的身份查看菜单和页面最终效果。

八、错误页面与可恢复操作

401 表示需要登录,403 表示当前身份不能访问,404 表示路径不存在,错误页应提供返回上级、返回首页和重新获取权限等明确按钮。权限接口暂时不可用时,前端应展示可重试的状态,不要把错误吞掉后渲染空菜单。

九、前端验收清单

1
2
3
4
5
6
7
8
□ 菜单权限与查询权限强绑定
□ 组件权限取消不会影响页面查询
□ 菜单、路由、面包屑、标签页使用同一判断
□ 隐藏、禁用、只读三种状态有明确语义
□ 无权限和无数据使用不同页面
□ 权限变化后导航和标签页自动收敛
□ 权限配置页能预览最终用户体验
□ 401、403、404、加载失败都有恢复入口

小结

前端权限设计的核心不是把按钮藏起来,而是让用户在每个状态下都得到准确反馈:能看什么、能做什么、为什么不能做、如何继续操作。菜单与查询权限强绑定,是避免权限拆分后页面失效的基础;组件分层、路由同步和状态机,才是大型企业前端真正需要落地的细节。

bulb