1. 需求来源与整体设计思路
1.1 为什么后台管理系统必须做按钮级权限控制
我最早接触layui的时候,其实也不太理解按钮权限这回事。菜单权限好理解,不同角色看到不同菜单,侧边栏渲染出来就不一样。但按钮权限是个更细的维度——同样是“编辑”按钮,管理员能点,普通运营只能看不能点,甚至直接不显示。这个需求在后台管理系统里几乎是标配,尤其是做中后台、管理平台、SaaS系统这类项目的时候,甲方经常一句话就是“这个按钮不要让所有人都能看到”。
说白了,按钮权限控制解决的核心问题就是:在前端UI层面,根据当前登录用户的权限集合,动态控制按钮、链接、标签页等操作入口的显示与隐藏。它想避免的场景很明确:一个普通员工打开客户管理页面,手里拿着一个“删除客户”的按钮,点了之后后端报401,那体验就很糟糕了。更糟糕的是,如果只做后端校验不做前端控制,用户看到的界面就是满屏的报错弹窗,这显然不是产品想要的。
那为什么拿layui来说这个事?因为layui这个框架本身是服务端渲染思维为主,它提供的table、form、layer等组件,都倾向于由后端把HTML渲染好再交给前端展示。这和Vue、React那种数据驱动DOM的方案不太一样,所以按钮权限控制的做法也要结合layui自己的生命周期和渲染机制来设计,不能照搬SPA里的指令权限方案。
1.2 我的方案选型:从后端权限码到前端控制的整条链路
做按钮权限控制,首先得想清楚一个核心问题:前端怎么能知道当前用户有没有某个按钮的操作权限?答案就是后端给。通常的做法是:
登录成功后,后端接口返回当前用户的权限码集合,比如
["system:user:add", "system:user:edit", "system:user:delete"]。前端拿到之后把这份权限码存起来,在渲染按钮的时候判断当前按钮需要的权限码是否在这个集合里,在就显示,不在就隐藏。
这个思路下,前端要解决的就两个问题:一个是权限码怎么存、怎么取,一个是按钮怎么根据权限码来决定显隐。第一个问题很简单,存到localStorage或者全局变量里都行。第二个问题才是文章的重点。
我实际做下来,layui环境下按钮权限控制大概有四种常用路线:
- 纯后端渲染方式:在服务端模板引擎(如Thymeleaf、FreeMarker)中判断权限,有权限就渲染按钮HTML,没有就不渲染。这种方式最安全,但前后端耦合度高,不适合前后端分离项目。
- 自定义属性+CSS隐藏:给按钮加一个自定义属性如
>// 后端实体定义(仅供参考) public class SysMenu { private Long menuId; private String menuName; private Long parentId; private Integer orderNum; private String path; private String component; private String perms; // 权限标识,如 system:user:list private String menuType; // M目录 C菜单 F按钮 }这里的关键就是把“菜单”和“按钮”都统一放在一张菜单表里,用
menuType字段区分。按钮的权限标识(perms)建议遵循资源:操作的命名规范:权限码示例 含义 system:user:list查看用户列表 system:user:add新增用户 system:user:edit编辑用户 system:user:remove删除用户 system:user:resetPwd重置密码 system:role:add新增角色 这样命名的好处是语义清晰、便于维护,而且权限码直接和后端接口的
@PreAuthorize("@ss.hasPermi('system:user:add')")对应起来,前后端用的是一套标识,不容易出偏差。然后登录成功之后,后端返回的JSON大概长这样:
{ "code": 200, "msg": "操作成功", "permissions": ["*:*:*"], "roles": ["admin"] }如果当前用户是超级管理员,通常直接返回
*:*:*,表示所有权限。如果你拿到的是*:*:*,前端所有按钮直接放行就行。2.2 前端存储与读取:localStorage还是全局变量?
权限码拿到之后,存哪里?我见过不少项目直接存在
sessionStorage里,理由是关了浏览器就没了,安全。也有存localStorage的,刷新页面不丢失。我的建议是:如果你的项目是纯前端控制按钮显隐,存sessionStorage就行,因为页面刷新时你需要重新从后端拉取权限码,这个动作通常在路由跳转或页面初始化的钩子里完成,权限码丢失无所谓,重新拉一遍就行。具体的存储读取代码我一般这样封装:
// 权限管理工具模块:src/utils/permission.js const PERMISSION_KEY = 'user_permissions'; export function setPermissions(perms) { sessionStorage.setItem(PERMISSION_KEY, JSON.stringify(perms || [])); } export function getPermissions() { const perms = sessionStorage.getItem(PERMISSION_KEY); return perms ? JSON.parse(perms) : []; } export function hasPermission(requiredPerm) { // 如果要求权限码为 *:*:* 或者当前用户拥有 *:*:*,直接放行 const perms = getPermissions(); if (perms.includes('*:*:*') || !requiredPerm) { return true; } return perms.includes(requiredPerm); } export function clearPermissions() { sessionStorage.removeItem(PERMISSION_KEY); }登录页登录成功、拿到权限码之后:
$.post('/login', { username, password }, function(res) { if (res.code === 200) { setPermissions(res.permissions); window.location.href = '/index'; } });这里有个小经验点要注意:退出登录的时候一定要
clearPermissions()清掉权限码,否则用户A退出后,用户B在同一台电脑上登录,前端显示的按钮可能是用户A的权限范围,这就是串权限了。我之前在测试环境踩过这个坑,排查了半天才发现是sessionStorage没清。3. 纯前端控制方案:自定义属性扫描法
3.1 基础写法:给按钮打上权限标记
这是我最推荐的一种方式,尤其适合服务端渲染页面中零星嵌入的按钮,或者页面中按钮数量不多、不想把所有模板都改写成laytpl语法的场景。
思路非常简单:在需要控制的按钮上,加一个自定义属性
><button class="layui-btn layui-btn-sm">// 在页面初始化时调用 function initButtonPermission() { // 先校验用户是否已登录 var hasLogin = sessionStorage.getItem('token'); if (!hasLogin) { return; } // 遍历所有带>$(function() { initButtonPermission(); });这里有一个细节选择:无权限的按钮应该
remove()还是hide()?我的习惯是remove()。原因在于,如果只是hide(),虽然用户看不到,但这个按钮仍然在DOM里,用户通过浏览器开发者工具删掉隐藏样式、或者直接修改HTML属性,就能看到按钮。虽然前端控制本身就防不了恶意用户,但remove()至少提升了门槛和整洁度。而且hide()的按钮如果绑定了事件,某些情况下事件委托依然会触发,容易出安全隐患。3.2 动态渲染的按钮怎么办:事件委托+重新扫描
上面的写法处理静态页面没问题,但后台管理系统里,表格数据大概率是通过
table.render()动态渲染出来的。这种情况下,操作列里的“编辑”“删除”按钮是渲染完成后才出现在DOM里的,单纯在页面加载时扫描一次肯定不够。怎么办?两种处理方式。方式一:表格渲染完成后的回调里重新扫描
table.render({ elem: '#userTable', url: '/system/user/list', cols: [[ { field: 'userId', title: 'ID' }, { field: 'userName', title: '用户名' }, { title: '操作', toolbar: '#userTableBar' } ]], done: function(res) { // 表格数据渲染完成后,重新扫描并控制按钮显隐 initButtonPermission(); } });注意,
done回调在每次分页、刷新、搜索之后都会触发,这时候重新扫描,就能保证每一页的操作按钮权限都是正确的。这个方法我用了很久,而且简单可靠。方式二:利用layui的事件委托机制
layui的
table.on('tool(filter)', callback)本身是事件委托,它会监听表格区域内所有lay-event的点击。所以我可以利用这一点,在模板中照样输出权限码,在事件回调里判断有没有权限,没有权限直接layer.msg('无权限'):<script type="text/html" id="userTableBar"> <a class="layui-btn layui-btn-xs" lay-event="edit">table.on('tool(userTable)', function(obj) { var requiredPerm = $(this).attr('data-permi'); if (!hasPermission(requiredPerm)) { layer.msg('抱歉,您没有操作权限'); return; } if (obj.event === 'edit') { // 编辑逻辑 } else if (obj.event === 'del') { // 删除逻辑 } });但这种方式的体验是“能看到按钮但点了报错提示”,不是“看不到按钮”。两种方式各有各的应用场景:如果你的产品经理觉得“无权限的入口应该直接不显示”,就选方式一;如果觉得“保留入口,点击时提示无权限”用户的感知更明确,就选方式二。我个人在交付项目时一般用方式一,UI上更干净。
3.3 工具类函数hasPermi的核心实现
很多后台管理框架里,前端都有一个内置函数叫
hasPermi,在模板中可以直接调用。layui项目里我们可以自己实现一个全局函数:// 全局暴露 window.hasPermi = hasPermission;然后在需要的场景直接调用:
if (hasPermi('system:user:add')) { // 显示新增按钮 $('#btnAdd').show(); } else { $('#btnAdd').hide(); }如果你的项目是使用的若依(RuoYi)这类基于layui的经典后台框架,你会发现它就是通过
th:hasPermi(后端的Thymeleaf方言)或者v-hasPermi(Vue版本)来实现的。在纯layui项目中,自己封装一个hasPermi函数是最通用、最少依赖的做法。这里我还想提醒一点:按钮权限控制是前端体验层的控制,安全边界永远在后端接口。也就是说,前端你做得再好,如果后端接口没有做权限校验,别人直接构造一个HTTP请求就能绕过前端调用接口。所以前端的按钮权限控制,请务必和后端接口的权限校验同步做,缺一不可。
4. 基于layui模板引擎的自动控制方案
4.1 laytpl模板语法下的条件渲染
如果你比较习惯layui原生的写法,而且页面里的按钮列表比较规整,可以尝试把权限判断直接写进layui的模板里,通过模板语法来条件渲染按钮。这种方式我觉得是最“layui原生”的,它利用的是layui自带的
laytpl模板引擎和自定义规则。先看一个简单的模板例子:
<script type="text/html" id="userTableBar"> {{# if (hasPermi('system:user:edit')) { }} <a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a> {{# } }} {{# if (hasPermi('system:user:remove')) { }} <a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</a> {{# } }} </script>这里面的关键点有两个:
laytpl的语法是{{# JS语句 }},可以在里面写任意的JavaScript逻辑,包括调用函数。- 函数
hasPermi必须在模板渲染的时候能从全局访问到。由于laytpl的render是在当前作用域执行模板中的JS,所以我需要在window上挂载hasPermi:
window.hasPermi = function(perm) { // 实际判断逻辑 return hasPermission(perm); };然后通过
layui.laytpl或者table的templet来渲染模板:table.render({ elem: '#userTable', url: '/system/user/list', cols: [[ { field: 'userId', title: 'ID' }, { field: 'userName', title: '用户名' }, { title: '操作', toolbar: '#userTableBar' } ]], done: function(res) { // ... } });使用这种方式,表格的每一行在渲染时都会调用
hasPermi来判断当前用户是否具备操作权限,没有权限的按钮直接不渲染出来。这个方案和表格的templet、toolbar结合得比较自然,而且模板里写起来也直观。4.2 自定义laytpl规则实现更优雅的按钮权限标签
laytpl本身支持自定义规则,虽然日常用得不多,但在这里特别合适。我可以注册一个标签,比如
<permi>标签,让模板里写起来更语义化:layui.laytpl.config({ open: '{{', close: '}}' }); // 注册一个自定义函数 layui.define(function(exports) { layui.laytpl.config({ // 如果不修改 open close,保持默认即可 }); // 通过 laytpl 的引擎能力,把 permi 标签翻译成 if 判断 layui.laytpl.fn = layui.laytpl.fn || {}; // 这里其实更推荐直接用自定义函数 layui.laytpl.fn.hasPermi = function(perm) { return hasPermission(perm); }; exports('permi', layui.laytpl.fn); });但说实话,laytpl自定义标签这块的写法在layui官方文档中着墨不多,容易踩坑。我在实际项目中更推荐上面那种直接
{{# if (hasPermi('xxx')) {} }}的写法,简单直接,不容易出幺蛾子。自定义标签适合有代码洁癖或有大量模板需要维护的团队,投入产出比你自己权衡。4.3 两种方案如何选择:扫描法与模板渲染法
我把这两种方式的优缺点列个表格,方便你做技术选型:
对比维度 自定义属性扫描法 模板语法控制法 实现难度 低,一个全局函数搞定 中,需要理解laytpl模板语法 适用范围 静态页面、动态渲染均可 主要适用于动态渲染的表格/列表 代码侵入性 低,只需在按钮上加属性 中,需要改写模板结构 权限码变更 刷新后自动生效 刷新后自动生效 可维护性 按钮和权限码分散在HTML中,维护稍麻烦 模板中集中管理,结构清晰 无权限时的表现 移除或隐藏元素 模板直接不输出该按钮 我的使用习惯是:单个页面零散按钮用扫描法,表格操作列统一用模板法。两者可以混用,互不冲突。
5. 常见问题与排查技巧实录
5.1 表单提交类按钮的权限控制:提交前校验
很多后台页面并不是表格里的操作列,而是新增、编辑表单页里的保存按钮。这类按钮一般不会被动态渲染,适合用自定义属性法控制。但要注意的是,如果这个按钮是表单提交的唯一入口,你还需要在提交事件里再次校验权限,防一手“按钮被手动重新启用”的情况:
$('#btnSubmit').on('click', function() { if (!hasPermi('system:user:add')) { layer.msg('无操作权限,请联系管理员'); return; } // 正常提交逻辑 form.on('submit(formSubmit)', function(data) { // ... }); });另外还有一种情况:一个表单页新增和编辑共用,新增需要
system:user:add权限,编辑需要system:user:edit权限。这种场景我一般动态修改按钮的>// 点击编辑时动态判断 $('#btnSave').attr('data-permi', isEdit ? 'system:user:edit' : 'system:user:add'); // 重新扫描按钮权限 initButtonPermission();5.2 异步加载权限码和页面渲染顺序不对,按钮全部消失
我在实际开发中踩过最深刻的一个坑:登录后跳转到首页,页面中的按钮一瞬间全部消失,刷新后才正常。排查下来发现原因是这样的——页面初始化函数先执行了,这时候权限码还没从后端接口返回,
sessionStorage里是空的,hasPermission返回false,于是所有带>$(function() { // 等待权限码就绪后再初始化按钮权限 setTimeout(function() { initButtonPermission(); }, 100); });这算是个土办法,但效果稳定。如果你愿意接受改造,更规范的做法是做一个全局的
readypromise,让初始化函数等待权限数据就绪后再执行:const permissionReady = new Promise((resolve) => { $.get('/getUserInfo', function(res) { setPermissions(res.permissions); resolve(); }); }); permissionReady.then(() => { initButtonPermission(); });这种方式比
setTimeout可靠得多,推荐有条件的话用这个。5.3 layui的lay-event与自定义属性冲突问题
layui的
table.on('tool')事件回调里,this指向的是当前被点击的元素。我见过不少人在判断权限时这样写:table.on('tool(userTable)', function(obj) { var requiredPerm = $(this).attr('data-permi'); // ... });这里有个陷阱:
layui对toolbar模板里的html片段,某些情况下会做属性过滤或重排,自定义属性在低版本layui中可能丢失。我在layui 2.6.x版本里就遇到过><script type="text/html" id="userTableBar"> <a class="layui-btn layui-btn-xs" lay-event="edit_system:user:edit">编辑</a> <a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del_system:user:remove">删除</a> </script>table.on('tool(userTable)', function(obj) { var eventName = obj.event; // edit_system:user:edit var perm = eventName.substring(eventName.indexOf('_') + 1); if (!hasPermi(perm)) { layer.msg('无操作权限'); return; } if (eventName.indexOf('edit_') === 0) { // 编辑逻辑 } });这个方法丑是丑了点,但兼容性极佳。如果你只需要控制表格内的按钮,而且不想额外引入扫描逻辑,可以试试。我个人觉得这个方案相对冷门,但是在老版本layui的项目里特别实用。
5.4 热词场景:layui的date最大日期、select动态赋值与权限控制的结合
看这次的热词,有“layui date 最大日期当前日期”和“layui select动态赋值”。虽然它们和按钮权限没有直接关系,但这类需求通常和权限控制出现在同一个业务场景里。比如,某个账单查询页面,“查询”按钮只有
finance:bill:query权限才能看到,而且日期选择器只允许选择到今天为止的日期,“导出”按钮也是权限控制的一部分。这里我简单说两个相关联的技巧。日期最大值为今天:
laydate.render({ elem: '#billDate', max: getNowDate(), // 返回当前日期 'YYYY-MM-DD' // 或者直接 max: 0 });max: 0表示最大日期为今天,这是laydate的快捷写法。如果你想动态赋值,可以在done回调里操作:laydate.render({ elem: '#dateRange', range: true, max: 0, done: function(value, date, endDate) { // 选择完日期后,动态给某个隐藏域赋值或者联动其他控件 $('#startDate').val(value); } });select动态赋值:
form.on('select(roleSelect)', function(data) { // 根据当前选中的角色,动态更新用户列表 var roleId = data.value; table.reload('userTable', { where: { roleId: roleId } }); }); // 如果是从接口动态加载选项 $.get('/system/role/list', function(res) { var html = '<option value="">请选择角色</option>'; res.rows.forEach(function(item) { html += '<option value="' + item.roleId + '">' + item.roleName + '</option>'; }); $('#roleSelect').html(html); form.render('select'); // 记得重新渲染select });这里有一个细节:如果你通过
html()方法改变了select的选项,必须要调用form.render('select'),否则layui的select外观不会更新。这个是layui新手经常踩的坑,我顺手在这里提醒一下。5.5 layui和Vue能否结合?按钮权限在混用场景下的注意事项
热词里还有一个问题:“layui可以用vue吗”。能,但需要一些磨合。最常见的玩法是:用Vue来控制数据渲染和按钮显隐,用layui的组件来做弹层、日期、表格等UI交互。
在按钮权限控制这个需求上,如果用Vue+Layui混用,我一般把权限判断放在Vue的指令或computed里:
// 注册一个全局自定义指令:v-permi Vue.directive('permi', { inserted: function(el, binding) { if (!hasPermission(binding.value)) { el.parentNode.removeChild(el); } } });模板中这样用:
<button v-permi="'system:user:add'" @click="handleAdd">新增用户</button>这里有个冲突要注意:layui的form渲染和Vue的DOM更新机制有时会打架。比如你用
v-if控制按钮显隐,按钮是Vue维护的,但你同时用layui.form.render()去渲染它,可能会造成样式错乱。我的经验是:用Vue控制v-if、v-show来动态显隐按钮时,按钮不要加layui-form相关样式,或者只在不需要动态控制的按钮上使用layui-btn类名。换句话说,让Vue管逻辑,让layui管UI,两者不要在同一块DOM上争夺控制权。这个方案我在一个老项目中实践过,效果还可以。但如果你问我最推荐哪种组合,我的答案是:能用纯layui解决的,不要轻易混入Vue,除非你的团队里前端实力比较强且愿意在工程化上多投入。毕竟layui的经典模式是服务端渲染,硬塞一个Vue进去,短期效率可能高,长期维护成本不好说。
6. 权限控制方案的扩展与最佳实践补充
6.1 后端接口校验是安全底线
前面已经提过了,这里再强调一遍,因为实在太重要。前端的按钮权限控制,本质上只是一种交互优化,它解决的是“没有权限的人看不到不该看的按钮”,而不是“没有权限的人无法调接口”。真正安全的系统,后端每一个写操作接口都必须校验当前用户是否有对应的权限码。
以Java Spring Security为例,后端接口上应该有这样的校验:
@PreAuthorize("@ss.hasPermi('system:user:remove')") @DeleteMapping("/{userIds}") public AjaxResult remove(@PathVariable Long[] userIds) { return userService.deleteUserByIds(userIds); }前端隐藏了按钮,后端校验了接口,双管齐下,这个系统才敢说按钮权限控制是完整的。
6.2 按钮权限的手动刷新与缓存策略
如果系统中有角色分配权限的功能,管理员给某个用户增加或删除了按钮权限,前端如何感知?常规做法是:让用户重新登录。因为绝大部分后台系统都是登录时一次性获取权限集合,后续整个会话内不再变动。
如果不想让用户重新登录,你可以提供一个“刷新权限”的功能,调用接口重新拉取权限码并更新sessionStorage,然后重新执行
initButtonPermission():function refreshPermissions() { $.get('/getUserInfo', function(res) { setPermissions(res.permissions); initButtonPermission(); }); }这里有个注意点:如果你的页面有通过
template渲染出来的表格操作列,重新扫描只能处理静态DOM中的按钮,动态渲染的那部分需要重新加载表格数据才能刷新。所以refreshPermissions()之后,最好先把表格table.reload()一下,保证操作列重新渲染。6.3 从“按钮权限”到“元素权限”的思路延伸
按钮是权限控制最常见的载体,但权限控制并不止于按钮。菜单项、Tab页、卡片、上传控件、导出入口……其实都可以统一用同一套权限码方案来控制。我经常把上面说的
hasPermi函数直接用在整个页面的元素控制上:<!-- 只有具备导出权限才显示导出功能卡片 --> <div class="layui-card">// src/constants/permission.js export const PERMISSION = { USER_ADD: 'system:user:add', USER_EDIT: 'system:user:edit', USER_REMOVE: 'system:user:remove', USER_RESET_PWD: 'system:user:resetPwd', ROLE_ADD: 'system:role:add', ROLE_EDIT: 'system:role:edit', ROLE_REMOVE: 'system:role:remove' };- 不要滥用
*:*:*。有些开发为了省事,在测试环境给所有用户都放行所有权限,一旦带上生产环境就变了调子。权限控件的粒度尽量细化到按钮,哪怕当前只做了菜单级控制,也要为将来按钮级预留好数据模型。
7. 最后再说几句实操感受
按钮权限控制这个需求,在后台管理系统里你会反复遇到。它本身不复杂,核心就是一个“判断-显隐”的过程,但因为涉及数据时序、模板渲染、事件绑定、安全校验这几个环节,实际写起来总会碰到各种意想不到的小问题。特别是layui这种偏服务端渲染的框架,在权限控制上不像现代前端框架有那么成熟的指令体系,所以很多团队都是在项目里自己封装一套,像我上面写的一样。
如果你是在一个全新项目里做这个功能,我的建议是:先看项目里有没有现成的权限字段和数据模型,如果没有,先去后端把权限码的数据结构定下来,再回前端写展示逻辑。前端写起来都很快,难的是前后端对权限码的定义是否统一、权限数据能否在合适的时机完整地到达前端。
如果在老项目里改造,那就要特别小心上面提到的几个时序坑。我自己的经验是,把
hasPermi、setPermissions、initButtonPermission这几个函数封装成独立工具模块,页面里统一调用,遇到权限需求就按标准流程走,基本不会再出什么问题。反过来,如果项目里每个页面都自己写一套判断逻辑,后面接手的同事一定会骂人。另外,热词里提到的日期最大日期、select动态赋值这些功能,虽然看起来和按钮权限没关系,但它们在同一张业务页面上经常同时出现。你可以把权限控制的函数和这些表单控件的初始化放在同一个初始化流程里,统一管理,页面逻辑会更清爽。例如:
$(function() { initButtonPermission(); // 权限控制 laydate.render({ ... }); // 日期初始化 initSelectOptions(); // 下拉框动态赋值 form.render(); // 渲染表单组件 });这样一句话就很清楚地表达了一个页面的初始化链路,后续维护的人看代码也一目了然。我做过的项目中,凡是能坚持这种“初始化函数平铺”写法的页面,后面改bug的效率都比把一堆逻辑堆在自定义函数里高很多。
按钮权限控制这件事,说白了就是一句话:让该看到的人看到,让不该看到的人看不到,但最终的安全防线永远在后端。前端方案可以花哨,这个原则不能丢。希望这篇文章对你有用。