简介:这是一套基于 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限规则表';pid为 0 的是顶级菜单,type=1表示目录(只用于分组,不指向具体页面),type=2表示实际菜单项,type=3表示按钮。name字段在菜单类型下存路由地址,在按钮类型下存权限标识符,比如user:add、user:delete。这样设计的好处是菜单树和按钮权限共用一张表,但通过type区分,查询时各取所需。
角色和权限的关联用一张中间表auth_group_rule:
CREATE TABLE `auth_group_rule` ( `group_id` int unsigned NOT NULL, `rule_id` int unsigned NOT NULL, PRIMARY KEY (`group_id`,`rule_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 class="layui-btn layui-btn-sm" lay-event="add">添加</button>'; } if (rules.indexOf('user:delete') > -1) { toolbarHtml += '<button class="layui-btn layui-btn-sm layui-btn-danger" lay-event="batchDel">批量删除</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 type="text/html" id="rowTool">'; if (rules.indexOf('user:edit') > -1) { rowToolHtml += '<a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a>'; } if (rules.indexOf('user:delete') > -1) { rowToolHtml += '<a class="layui-btn layui-btn-xs layui-btn-danger" lay-event="del">删除</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 class="layui-nav-item">'; html += '<a href="javascript:;">' + m.title + '</a>'; html += '<dl class="layui-nav-child">'; m.children.forEach(function(c) { html += '<dd><a lay-href="' + c.name + '">' + c.title + '</a></dd>'; }); html += '</dl></li>'; } else { html += '<li class="layui-nav-item"><a 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之前重新生成并更新 DOM:
function buildToolbar() { var rules = layui.data('auth').rules || []; var html = ''; if (rules.indexOf('user:add') > -1) { html += '<button class="layui-btn layui-btn-sm" lay-event="add">添加</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 以内,操作日志在排查「谁把权限改错了」这种问题时几乎是唯一的后悔药。我现在的习惯是:任何后台系统,权限缓存和操作日志从第一天就加上,后面再补的成本远高于一开始就做。
希望帮到你。
本文还有配套的精品资源,点击获取