layui 2.5.6 + ThinkPHP 6.0.2 权限管理后台实战:三层权限模型与避坑指南

📅 发布时间:2026/10/20 8:03:26
layui 2.5.6 + ThinkPHP 6.0.2 权限管理后台实战:三层权限模型与避坑指南
简介这是一套基于 layui2.5.6 前端组件库与 ThinkPHP6.0.2 后端框架搭建的权限管理后台源码面向需要快速构建企业级管理系统的 PHP 开发者也适合初学者理解前后端协同开发流程。资源包共 1098 个文件约 6.1MB以 481 个 php 业务与框架代码、139 个 js 脚本、36 个 html 页面、30 个 css 样式为主另含图片、字体、配置与依赖锁文件覆盖从视图到控制器的完整链路。项目内置 RBAC 权限控制模型可方便地实现用户角色分配与权限校验并借助 .env 环境配置、Composer 依赖管理和持续集成配置保证部署一致性与可维护性。目前已有 2283 人学习下载。读者可据此掌握 ThinkPHP6 的服务容器、事件监听等特性理解 layui 模块化布局与后台交互实现并在此基础上进行二次开发或搭建自己的权限管理模板。1. 权限管理后台为什么老框架组合反而更稳做过七八个后台系统之后我反而越来越常回到 layui 2.5.6 ThinkPHP 6.0.2 这套组合。不是因为新东西不好而是权限管理这个场景太特殊了——它要的是稳定、可预测、改起来不心惊胆战。layui 2.5.6 的 table 组件和 form 组件在这个版本已经足够成熟TP6.0.2 的中间件和注解路由也让权限拦截写起来干净利落。两者搭配前端不用构建工具后端不用复杂依赖注入一个后台权限系统从零到能用两三天就能跑起来。这篇文章面向的是需要快速交付一套可用权限管理后台的开发者——不管是接私活、做内部工具还是给中小型项目搭管理端。我会把菜单权限、按钮权限、数据范围这三层怎么落地讲清楚把 TP6 中间件和 layui 表格的配合方式拆开最后给出几个我踩过的坑和对应的排查路径。整套方案不依赖任何前端脚手架后端也不需要额外扩展包纯手写就能复现。2. 权限模型设计三层控制怎么拆才不打架权限管理后台最容易翻车的地方不是代码写不出来而是模型没设计好写到一半发现菜单权限和按钮权限混在一起数据范围又和角色绑死改一处动全身。我一般会把权限拆成三层菜单可见性、操作按钮可用性、数据行可见范围。这三层分别对应不同的存储结构和校验时机混在一起就是给自己挖坑。2.1 菜单权限用树形结构存路由和显示状态菜单权限的本质是「这个角色能看到哪些导航项和页面」。常见做法是建一张auth_rule表用pid字段做树形结构每个节点代表一个菜单或一个页面。关键字段包括id、pid、title、name路由标识、icon、sort、status、type。其中type区分「目录」「菜单」「按钮」——虽然按钮权限也放这张表但校验逻辑完全不同后面会讲。建表 SQL 如下CREATE TABLE auth_rule ( id int unsigned NOT NULL AUTO_INCREMENT, pid int unsigned NOT NULL DEFAULT 0 COMMENT 父级ID, title varchar(50) NOT NULL COMMENT 菜单名称, name varchar(50) NOT NULL DEFAULT COMMENT 路由标识, icon varchar(50) NOT NULL DEFAULT COMMENT 图标class, sort int NOT NULL DEFAULT 0 COMMENT 排序, type tinyint NOT NULL DEFAULT 1 COMMENT 1目录 2菜单 3按钮, status tinyint NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, PRIMARY KEY (id), KEY pid (pid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT权限规则表;pid为 0 的是顶级菜单type1表示目录只用于分组不指向具体页面type2表示实际菜单项type3表示按钮。name字段在菜单类型下存路由地址在按钮类型下存权限标识符比如user:add、user:delete。这样设计的好处是菜单树和按钮权限共用一张表但通过type区分查询时各取所需。角色和权限的关联用一张中间表auth_group_ruleCREATE TABLE auth_group_rule ( group_id int unsigned NOT NULL, rule_id int unsigned NOT NULL, PRIMARY KEY (group_id,rule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色权限关联表;角色表auth_group只需要id、title、status三个核心字段。用户和角色的关联用auth_user_group一个用户可以属于多个角色权限取并集。2.2 按钮权限前端渲染时就要过滤掉无权限的操作按钮权限的校验分两步前端渲染时根据权限列表决定是否输出按钮后端接口再校验一次防止绕过。前端这一步很多人偷懒不做结果就是用户能看到按钮但点了报 403体验很差。在 TP6 中用户登录后把该用户所有角色的权限标识符合并成一个数组存到 session 或返回给前端。layui 的表格工具栏和行操作按钮通过layui.table的toolbar和tool配置动态生成。我一般会在页面初始化时请求一个/api/auth/rules接口返回当前用户的权限标识符列表然后在前端用v-if或 JS 判断控制按钮显示。后端校验用 TP6 的中间件实现。先定义一个AuthMiddleware?php namespace app\middleware; use think\facade\Session; use think\Response; class AuthMiddleware { public function handle($request, \Closure $next) { $controller $request-controller(); $action $request-action(); $rule strtolower($controller . : . $action); $rules Session::get(auth_rules, []); if (!in_array($rule, $rules) !in_array(*, $rules)) { return json([code 403, msg 无权限操作]); } return $next($request); } }这里$rule的格式是控制器名:方法名和auth_rule表中按钮类型的name字段对应。比如user:add对应User控制器的add方法。中间件注册在app/middleware.php中对需要鉴权的路由分组生效。注意中间件里不要用$request-url()去匹配因为 URL 可能带参数或前缀用控制器和方法名更稳定。2.3 数据范围角色绑定部门或用户ID实现行级过滤数据范围是三层里最容易被忽略的。菜单和按钮控制的是「能不能进这个页面、能不能点这个按钮」数据范围控制的是「进了页面能看到哪些数据」。常见做法是在角色表加一个data_scope字段取值如1 全部数据、2 本部门及以下、3 本部门、4 仅本人。然后在查询时根据当前用户的角色数据范围拼接 where 条件。比如用户列表查询$scope $this-getDataScope($userId); $query Db::name(user); if ($scope 2) { $deptIds $this-getDeptAndChildren($deptId); $query-whereIn(dept_id, $deptIds); } elseif ($scope 3) { $query-where(dept_id, $deptId); } elseif ($scope 4) { $query-where(id, $userId); } $list $query-paginate(20);getDataScope方法从当前用户的所有角色中取最宽松的范围数值最小的优先级最高。如果用户有多个角色取并集而不是交集——这点和菜单权限不同菜单权限取并集数据范围也取并集但按钮权限取并集后还要看具体操作。三层拆开之后每层的存储、校验时机、失败表现都清晰了。菜单权限在登录后一次性计算并缓存按钮权限在每次请求时中间件校验数据范围在具体查询时拼接。互不干扰改一层不影响另外两层。3. TP6 中间件 layui 表格把权限校验嵌进请求链路模型设计好之后落地阶段的核心是把校验逻辑嵌进 TP6 的请求生命周期同时让 layui 表格的渲染和权限数据配合起来。这一章讲具体实现包括中间件注册、权限数据接口、layui 表格的动态列和工具栏控制。3.1 注册中间件并区分登录态和权限态TP6 的中间件分全局中间件、应用中间件、路由中间件和控制器中间件。权限校验我一般放在应用中间件里但登录校验单独一个中间件因为登录接口本身不需要权限校验。先定义LoginMiddleware?php namespace app\middleware; use think\facade\Session; use think\Response; class LoginMiddleware { public function handle($request, \Closure $next) { if (!Session::has(user_id)) { if ($request-isAjax()) { return json([code 401, msg 请先登录]); } return redirect(/login); } return $next($request); } }然后在app/middleware.php中注册return [ \app\middleware\LoginMiddleware::class, \app\middleware\AuthMiddleware::class, ];但这样所有请求都会走权限校验包括登录页和登录接口。所以需要在AuthMiddleware里放行白名单$whiteList [login/index, login/check, index/index]; if (in_array($rule, $whiteList)) { return $next($request); }index/index是后台首页登录后默认展示不需要权限标识符。白名单里的规则用控制器:方法格式和权限标识符保持一致。3.2 权限数据接口返回菜单树和按钮标识符登录成功后前端需要拿到两个东西菜单树用于渲染左侧导航和按钮标识符列表用于控制操作按钮。我一般合并成一个接口/api/auth/info返回{ code: 0, data: { menus: [ {id:1,pid:0,title:系统管理,icon:layui-icon-set,children:[ {id:2,pid:1,title:用户管理,name:user/index,icon:} ]} ], rules: [user:add,user:edit,user:delete] } }后端实现public function info() { $userId Session::get(user_id); $groupIds Db::name(auth_user_group)-where(user_id, $userId)-column(group_id); // 获取所有权限规则ID $ruleIds Db::name(auth_group_rule)-whereIn(group_id, $groupIds)-column(rule_id); $rules Db::name(auth_rule)-whereIn(id, $ruleIds)-where(status, 1)-select()-toArray(); // 分离菜单和按钮 $menus []; $btnRules []; foreach ($rules as $r) { if ($r[type] 3) { $btnRules[] $r[name]; } else { $menus[] $r; } } // 构建菜单树 $menuTree $this-buildTree($menus, 0); return json([code 0, data [menus $menuTree, rules $btnRules]]); } private function buildTree($list, $pid) { $tree []; foreach ($list as $item) { if ($item[pid] $pid) { $children $this-buildTree($list, $item[id]); if ($children) { $item[children] $children; } $tree[] $item; } } return $tree; }buildTree递归构建菜单树layui 的element组件可以直接渲染这种嵌套结构。按钮标识符列表返回给前端后存到全局变量或 layui 的layui.data里表格渲染时读取。3.3 layui 表格工具栏按权限动态渲染layui 2.5.6 的表格工具栏支持toolbar属性传入模板字符串或 DOM 选择器。我一般用 JS 动态拼接var rules layui.data(auth).rules || []; var toolbarHtml ; if (rules.indexOf(user:add) -1) { toolbarHtml button classlayui-btn layui-btn-sm lay-eventadd添加/button; } if (rules.indexOf(user:delete) -1) { toolbarHtml button classlayui-btn layui-btn-sm layui-btn-danger lay-eventbatchDel批量删除/button; } table.render({ elem: #userTable, url: /api/user/list, toolbar: toolbarHtml, cols: [[ {type: checkbox}, {field: id, title: ID, width: 80}, {field: username, title: 用户名}, {field: dept_name, title: 部门}, {field: status, title: 状态, templet: function(d){ return d.status 1 ? 启用 : 禁用; }}, {title: 操作, toolbar: #rowTool, width: 150} ]], page: true });行操作按钮的模板#rowTool也需要动态生成因为每个按钮的权限不同var rowToolHtml script typetext/html idrowTool; if (rules.indexOf(user:edit) -1) { rowToolHtml a classlayui-btn layui-btn-xs lay-eventedit编辑/a; } if (rules.indexOf(user:delete) -1) { rowToolHtml a classlayui-btn layui-btn-xs layui-btn-danger lay-eventdel删除/a; } rowToolHtml /script; document.body.insertAdjacentHTML(beforeend, rowToolHtml);这里有个细节layui 的表格工具栏模板必须在table.render之前插入到 DOM 中否则找不到模板。用insertAdjacentHTML在body末尾插入是最稳妥的方式。提示如果按钮权限经常变动建议把rules存到sessionStorage而不是layui.data因为layui.data是持久化的用户权限变更后需要手动清除。3.4 后端接口的二次校验不能省前端隐藏按钮只是体验优化后端接口必须再校验一次。在AuthMiddleware中已经做了统一校验但有些接口的权限标识符和控制器方法名不是简单对应关系比如批量删除接口user/batchDel对应的权限标识符可能是user:delete。这种情况我一般用注解或者在控制器里单独校验public function batchDel() { $this-checkAuth(user:delete); $ids input(ids/a); // ... } protected function checkAuth($rule) { $rules Session::get(auth_rules, []); if (!in_array($rule, $rules)) { throw new \think\exception\HttpException(403, 无权限操作); } }checkAuth方法可以在基类控制器中定义所有需要特殊校验的接口调用它。这样中间件负责通用校验控制器负责特殊校验两层配合。4. 避坑指南权限后台开发中最容易翻车的五个点这套组合我用了很多次每次都会遇到一些相似的问题。有些是 layui 2.5.6 的版本特性有些是 TP6 的中间件机制导致的还有些是权限模型设计时没想清楚留下的隐患。下面五条是出现频率最高的每条按「现象 → 原因 → 解决」写清楚。4.1 菜单树渲染后点击无反应现象左侧菜单能正常显示但点击菜单项时右侧 iframe 不加载页面控制台无报错。原因layui 的element组件渲染菜单时如果菜单项没有href属性或lay-href属性点击不会触发页面跳转。TP6 返回的菜单树中name字段存的是路由地址但 layui 需要的是完整的 URL 或lay-href属性。解决在构建菜单树时把name字段映射为lay-href属性。前端渲染时用templet或手动拼接function renderMenu(menus) { var html ; menus.forEach(function(m) { if (m.children m.children.length 0) { html li classlayui-nav-item; html a hrefjavascript:; m.title /a; html dl classlayui-nav-child; m.children.forEach(function(c) { html dda lay-href c.name c.title /a/dd; }); html /dl/li; } else { html li classlayui-nav-itema lay-href m.name m.title /a/li; } }); return html; }lay-href是 layui 的element模块识别的属性点击后会自动加载对应 URL 到layui-body容器中。4.2 中间件里 Session 取不到值现象登录接口明明写了Session::set(user_id, $id)但下一个请求在中间件里Session::get(user_id)返回 null。原因TP6 的 Session 默认是惰性启动的如果中间件执行时 Session 还没有初始化取到的就是空。另外如果前后端分离部署Session 的跨域配置也可能导致丢失。解决在app/middleware.php中把 Session 初始化中间件放在最前面return [ \think\middleware\SessionInit::class, \app\middleware\LoginMiddleware::class, \app\middleware\AuthMiddleware::class, ];SessionInit是 TP6 内置的 Session 初始化中间件必须放在自定义中间件之前。如果是前后端分离建议改用 Token 方案把用户信息存到 Redis 或 JWT 中不依赖 Session。4.3 按钮权限标识符大小写不一致导致校验失败现象前端明明有user:add权限但点击添加按钮后接口返回 403。原因TP6 的$request-controller()返回的控制器名首字母大写比如User而auth_rule表中存的name字段可能是小写的user:add。中间件里拼接$rule时没有统一大小写。解决在中间件里统一转小写$rule strtolower($controller . : . $action);同时建表时约定name字段全部小写。如果已经有数据用 SQL 批量更新UPDATE auth_rule SET name LOWER(name) WHERE type 3;4.4 数据范围查询在分页时统计总数不对现象用户列表分页显示正常但总条数显示的是全部数据的总数而不是当前数据范围内的总数。原因TP6 的paginate方法在拼接 where 条件之前就执行了 count 查询或者 where 条件没有应用到 count 查询上。解决确保 where 条件在paginate之前拼接并且不要用Db::name(user)-where(...)-paginate()这种链式调用中混用order和where的顺序问题。正确写法$query Db::name(user); // 先拼接所有 where 条件 if ($scope 4) { $query-where(id, $userId); } // 最后再 paginate $list $query-order(id desc)-paginate(20);如果还是不对用$query-buildSql()打印 SQL 检查 where 条件是否包含在 count 查询中。4.5 layui 表格重载时工具栏权限丢失现象第一次加载表格时按钮正常但点击搜索或翻页后工具栏按钮消失或权限判断失效。原因layui 表格的toolbar属性只在table.render时生效table.reload时不会重新渲染工具栏。如果工具栏 HTML 是动态生成的重载后不会重新执行生成逻辑。解决把工具栏 HTML 生成逻辑封装成函数在table.reload之前重新生成并更新 DOMfunction buildToolbar() { var rules layui.data(auth).rules || []; var html ; if (rules.indexOf(user:add) -1) { html button classlayui-btn layui-btn-sm lay-eventadd添加/button; } return html; } // 初始渲染 table.render({ elem: #userTable, toolbar: buildToolbar(), ... }); // 重载前更新工具栏 $(.layui-table-tool).html(buildToolbar()); table.reload(userTable, { where: { keyword: keyword } });或者更简单的方式把工具栏模板放在页面中用layui.table的toolbar属性指向模板 ID模板内容用 JS 在页面加载时生成一次重载时不会丢失。5. 从能用 to 好用权限缓存和操作日志的两个进阶技巧权限校验每次请求都查数据库用户量上来之后数据库压力很明显。我一般会在登录时把用户的权限标识符列表和菜单树缓存到 Redis 或文件缓存中设置和 Session 相同的过期时间。TP6 的缓存用法use think\facade\Cache; $cacheKey auth_rules_ . $userId; $rules Cache::get($cacheKey); if (!$rules) { $rules $this-loadRulesFromDb($userId); Cache::set($cacheKey, $rules, 7200); }用户权限变更时需要清除对应缓存。在角色权限保存接口中public function saveGroupRules() { $groupId input(group_id); // ... 保存逻辑 // 清除该角色下所有用户的权限缓存 $userIds Db::name(auth_user_group)-where(group_id, $groupId)-column(user_id); foreach ($userIds as $uid) { Cache::delete(auth_rules_ . $uid); } }操作日志是另一个容易被忽略但实际很有用的功能。权限管理后台本身的操作谁改了谁的权限、谁删了哪个用户需要留痕。我一般建一张admin_log表记录user_id、url、method、params、ip、create_time。在AuthMiddleware中校验通过后写入Db::name(admin_log)-insert([ user_id Session::get(user_id), url $request-url(), method $request-method(), params json_encode($request-param(), JSON_UNESCAPED_UNICODE), ip $request-ip(), create_time date(Y-m-d H:i:s), ]);日志表数据量大了之后查询会慢我一般按月分表或者只保留最近三个月的日志历史日志归档到单独的表。注意写日志不要放在中间件的最前面否则登录失败的请求也会被记录日志表会迅速膨胀。放在权限校验通过之后、$next($request)之前最合适。这两个技巧看起来简单但实际用起来能省很多事。权限缓存让接口响应从 200ms 降到 20ms 以内操作日志在排查「谁把权限改错了」这种问题时几乎是唯一的后悔药。我现在的习惯是任何后台系统权限缓存和操作日志从第一天就加上后面再补的成本远高于一开始就做。希望帮到你。本文还有配套的精品资源点击获取