简介:基于layui2.5.6与ThinkPHP6.0.2构建的权限管理后台项目,面向PHP开发者、后端初学者以及需要快速搭建企业级后台的工程师,重点演示RBAC权限控制模型与前后端协作开发方式。资源压缩后约6.1MB,共包含1098个文件,核心逻辑由481个php文件实现,界面交互由139个js文件和30个css文件承载,辅以png、gif等静态素材与html模板,另含composer.json、.env、.gitignore、README.md等工程化配置文档,目录结构清晰,便于按需检索。该后台内置用户角色分配与权限控制模块,可直接作为项目基础进行二次开发;同时涵盖ThinkPHP6服务容器、事件监听、依赖管理以及layui组件封装的实际用法,适合对照源码理解TP6与layui的整合方式,也可用于快速搭建内容管理或企业应用后台。目前已有2283人学习下载,对希望掌握主流PHP框架与前端UI库配合的开发者具有较高参考价值。
1. layui2.5.6 + tp6.0.2 这个组合,到底在解决什么问题
如果你是给公司内部搭一套运营后台、给客户做一套带角色划分的管理系统,前后端分离那一套往往不是最优解——你不需要单独维护一个 Vue 工程,不需要处理跨域,更不需要为了一两个表格页面去引入一整套 Node 构建链。layui 2.5.6 加 ThinkPHP 6.0.2 这个组合,是典型的服务端渲染思路:后端输出页面骨架,layui 负责表格、弹窗、树形菜单这些交互组件,权限逻辑全部收在 PHP 这一层。这套方案最适合 5~20 个页面左右的中后台系统,人少、周期短、上线快。
我最早折腾这个组合是因为一个真实需求:某公司内部的工单管理后台,要区分超管、运营、普通员工三种角色,菜单和按钮都要按角色隐藏。前后端分离做了两周发现进度太慢,换成 layui + tp6 的组合,一周就把框架搭完了。本文按我当时踩过的路径来写:先把 RBAC 表设计定下来,再实现登录认证和操作鉴权,最后把 layui 的前端组件接进去,最后给你几个容易翻车的坑。没有源码包可抄,但每一步都能照着敲。
2. 把权限模型拆成五张表:RBAC 在 tp6.0.2 里的建模方式
2.1 为什么不用三张表:用户、角色、权限之间的关系
权限管理最核心的不是页面,是数据模型。很多初学的人会做三张表:user 表、role 表、permission 表,然后在 user 表里加一个 role_id 字段。这样做的结果是:一个用户只能有一个角色,一旦要「一个人既是运营又是审核员」,就只能再建角色,用户数据越搞越乱。更麻烦的是,如果你想给「运营」角色里的某一个人多开放一个按钮权限,你会发现根本没有地方存这种差异。
我一般会直接上五张表:用户表、角色表、权限表、用户角色关联表、角色权限关联表。用户和角色是多对多,角色和权限是多对多,这就是 RBAC 的标准模型。tp6.0.2 里可以用 belongsToMany 关联非常方便地把这三层关系串起来,后面做菜单渲染和权限判断时,只需要 from user 表往里查两层就能拿到权限标识列表。
2.2 建表 SQL:字段设计的关键是权限标识而不是菜单路径
建表是这个项目里最需要一次做对的事情。下面是五张核心表的建表 SQL,我加了注释,字段名称是常见命名风格,你可以直接复用:
CREATE TABLE `admin_user` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(255) NOT NULL COMMENT '密码hash', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `last_login_time` int(11) DEFAULT NULL COMMENT '最后登录时间', `created_at` int(11) DEFAULT NULL, `updated_at` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台用户表'; CREATE TABLE `admin_role` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '角色名', `remark` varchar(255) DEFAULT '' COMMENT '备注', `created_at` int(11) DEFAULT NULL, `updated_at` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; CREATE TABLE `admin_permission` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '权限名称', `type` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1菜单 2按钮', `parent_id` int(11) NOT NULL DEFAULT '0' COMMENT '父级ID', `icon` varchar(50) DEFAULT '' COMMENT '菜单图标', `perms` varchar(100) NOT NULL COMMENT '权限标识,如 user:add', `path` varchar(100) DEFAULT '' COMMENT '路由地址', `sort` int(11) NOT NULL DEFAULT '0' COMMENT '排序', `created_at` int(11) DEFAULT NULL, `updated_at` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_parent_id` (`parent_id`), KEY `idx_perms` (`perms`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表'; CREATE TABLE `admin_user_role` ( `user_id` int(11) NOT NULL, `role_id` int(11) NOT NULL, PRIMARY KEY (`user_id`,`role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表'; CREATE TABLE `admin_role_permission` ( `role_id` int(11) NOT NULL, `permission_id` int(11) NOT NULL, PRIMARY KEY (`role_id`,`permission_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';关于权限表的字段,重点看perms这个字段。很多项目喜欢用 URL 路径当权限判断依据,比如判断当前请求地址是否在角色授权列表中。这是一个非常容易踩坑的做法:一旦路由地址调整、加了个参数,或者同一个操作有多个入口 URL,权限判断就会漏或误判。我用的方案是用一个稳定的权限标识,比如user:add、user:edit、order:export,在控制器里手动判断这个标识是否在用户权限集合中。这样做的好处是:页面 URL 随便改,权限不受影响;菜单和按钮都挂同一个标识,前端能控制显隐,后端也能拦截请求。
2.3 tp6 模型关联:在一行里把三层关系查出来
表建好了,接下来写模型关联。tp6 的belongsToMany写法比 5.1 干净很多,可以在 AdminUser 模型里直接定义如下代码:
<?php namespace app\common\model; use think\Model; class AdminUser extends Model { // 关联角色表,通过中间表 admin_user_role public function roles() { return $this->belongsToMany(AdminRole::class, 'admin_user_role', 'user_id', 'role_id'); } // 关联权限表,通过中间表 admin_role_permission public function permissions() { return $this->belongsToMany(AdminPermission::class, 'admin_role_permission', 'role_id', 'permission_id'); } }belongsToMany 的四个参数依次是:关联模型、中间表名、当前模型在中间表的外键、关联模型在中间表的外键。这四个参数是最容易写错的地方,尤其是第三个和第四个的顺序——第一个是当前表的字段,第二个是关联表的字段,反了查出来的数据就是错的。
有了这个关联,在服务里查用户权限时只需要这样写:
// 查询某个用户的全部权限标识 $perms = AdminUser::get($userId)->permissions->column('perms');这行代码会自动做两跳关联:先查 admin_user_role 拿到该用户的所有角色 ID,再查 admin_role_permission 拿到这些角色对应的所有权限 ID,最后从 admin_permission 表里取出 perms 字段。注意 tp6 的模型关联默认不会过滤 status 字段,如果用户已被禁用但角色还在关联,查询结果依然能拿到权限。所以登录时一定要先判断用户 status,再做权限查询。
3. 认证与鉴权:登录态用 session,操作权限用一个类搞定
3.1 登录流程:tp6 的 session 驱动与 cookie 参数
权限管理后台的第一步是登录认证。这个组合是服务端渲染,前后端同域部署,用 PHP session 是最省事的方案。不建议在这个场景里引入 JWT——layui 的页面跳转逻辑天然依赖 session/cookie,用 token 反而要把 token 存到 localStorage,还要在每个 ajax 请求里手动加 header,徒增工作量。
登录接口的代码大致如下:
<?php namespace app\admin\controller; use think\facade\Session; use think\facade\Validate; use app\common\model\AdminUser; class Login { public function index() { return view(); } public function doLogin() { $data = request()->post(); $validate = Validate::rule([ 'username' => 'require|max:50', 'password' => 'require', ]); if (!$validate->check($data)) { return json(['code' => 0, 'msg' => $validate->getError()]); } $user = AdminUser::where('username', $data['username'])->find(); if (!$user || !password_verify($data['password'], $user->password)) { return json(['code' => 0, 'msg' => '用户名或密码错误']); } if ($user->status != 1) { return json(['code' => 0, 'msg' => '账号已被禁用']); } // 登录成功,写入 session Session::set('admin_user', [ 'id' => $user->id, 'username' => $user->username, ]); return json(['code' => 1, 'msg' => '登录成功']); } }注意几个细节:密码字段我用的是password_hash()加密后的值,验证用password_verify(),这是 PHP 内置函数,不需要额外装库。tp6.0.2 里Session门面的set方法可以直接存数组,读取时用Session::get('admin_user')。
session 的配置在config/session.php里,有几个参数直接影响后台使用体验。expire默认是 1440 秒,即 24 分钟——很多管理者说「后台过一会儿就要重新登录」,原因就是这里没改。我一般会设成 28800(8 小时)或 86400(一天)。还有cookie_domain,如果你用localhost访问后台且后端有多个端口共用 session,这个参数留空即可,不用手动指定。path参数如果多个应用共用 session,建议设置成/,否则 session 文件可能被限制在某个目录下。
3.2 登录校验中间件:白名单与跳转逻辑
tp6.0.2 的中间件是常驻内存的,比 5.1 的行为(behavior)要好理解。给后台模块建一个统一中间件,做登录校验,是最常见的做法。
首先注册中间件,在app/middleware.php里加入:
return [ // 全局中间件 \app\middleware\AdminAuth::class, ];中间件代码:
<?php namespace app\middleware; use think\facade\Session; use think\Response; class AdminAuth { public function handle($request, \Closure $next) { // 白名单:不需要登录即可访问的控制器和方法 $whitelist = [ 'admin/login/index', 'admin/login/doLogin', 'admin/login/logout', ]; $current = strtolower($request->controller() . '/' . $request->action()); if (!in_array($current, $whitelist) && !Session::has('admin_user')) { if ($request->isAjax()) { return json(['code' => -1, 'msg' => '未登录或登录已过期']); } return redirect('/admin/login/index.html'); } return $next($request); } }这段代码里白名单是最值得管理好的。很多后台系统在开发阶段经常「自己把自己锁在外面」——中间件把登录页也拦截了,导致死循环跳转。解决方式就是把login/index和login/doLogin放进白名单。logout也加进来,是因为退出登录后用户可能直接在地址栏访问退出链接。
注意 tp6 里$request->controller()返回的控制器名是驼峰格式,比如Login,不是小写。如果你在中间件里比较字符串时用了小写,需要转一下。我在上面代码里统一strtolower,就是防止这种大小写问题导致的白名单失效。
3.3 操作权限的判断:把 perms 集合做成一个可复用的类
中间件做的是「是否登录」的粗粒度控制。细粒度到按钮级别的「是否有权执行某个操作」,我会写一个独立的权限检查类,放在app/common/library/Auth.php:
<?php namespace app\common\library; use think\facade\Session; use app\common\model\AdminUser; class Auth { // 当前用户所有权限标识 protected static array $perms = []; // 初始化权限集合 public static function init(): void { if (empty(self::$perms)) { $user = Session::get('admin_user'); if ($user) { self::$perms = AdminUser::get($user['id'])->permissions->column('perms'); } } } // 检查权限,$perm 为权限标识,如 'user:add' public static function check(string $perm): bool { self::init(); return in_array($perm, self::$perms); } }在控制器里使用时这样写:
// 删除用户操作,需要 user:delete 权限 public function delete() { if (!Auth::check('user:delete')) { return json(['code' => 0, 'msg' => '没有操作权限']); } // 执行删除逻辑... }这里用到了静态数组做缓存。同一个请求内多次检查user:edit、user:delete,不会重复查数据库,性能没有问题。有个细节:tp6 的模型查询在单次请求内会自动复用连接,所以即使不缓存,多次查询也就多几次 SQL 而已,不会连接爆炸。
另外,如果你的系统角色数量很大(比如几百个角色,每个角色权限标识几十个),把权限集合存 session 而不是每次请求都查库,是一种更快的做法。登录成功后直接把当前用户的所有 perms 字符串写入 session,检查时从 session 取即可。更新角色权限后需要主动清除受影响用户的 session,否则会有最多一个 session 生命周期的延迟。常见做法是在角色权限更新后,把该角色下所有用户重新登录一次,或者干脆不缓存,用上面的静态数组方案。
4. 用 layui 2.5.6 搭建后台骨架:菜单渲染与页面模板
4.1 菜单接口:先组装树,再交付给 layui.tree
layui 2.5.6 还自带 tree 组件,但它期望的数据结构是嵌套 children 格式,而我们的 admin_permission 表存的是 parent_id 平铺结构,所以后端接口必须把平铺结构转成树。
我的做法是在控制器里写一个公共方法:
// 把权限列表转成树形结构 public function buildTree(array $items, int $parentId = 0): array { $tree = []; foreach ($items as $item) { if ($item['parent_id'] == $parentId) { $children = $this->buildTree($items, $item['id']); if (!empty($children)) { $item['children'] = $children; } // 当前用户可访问的菜单才输出 title 和 path $tree[] = [ 'id' => $item['id'], 'title' => $item['name'], 'icon' => $item['icon'], 'path' => $item['path'], 'spread' => true, 'children' => $item['children'] ?? [], ]; } } return $tree; }注意一个细节:spread字段表示菜单默认展开。如果不设置,layui 的 tree 默认是全部折叠的,用户每次进后台都要手动展开左侧菜单,体验很差。一般把第一级(父级菜单)的 spread 设为 true,子级设为 false。递归函数里对所有层级都设 true,会导致整棵树全部展开,如果菜单层级深,页面一打开就展开一大片,也不好看。建议只对顶级菜单展开。
接口返回的数据格式:
{ "code": 0, "msg": "成功", "data": [ { "id": 1, "title": "用户管理", "path": "/admin/user/index.html", "spread": true, "children": [ { "id": 2, "title": "用户列表", "path": "/admin/user/index.html" }, { "id": 3, "title": "角色管理", "path": "/admin/role/index.html" } ] } ] }注意 layui 2.5.6 的 tree 组件对字段名称有要求:title是节点文本,children是子节点数组,id不能少。如果你习惯了别的树组件用name、label、nodes之类的字段名,在 layui 这里会直接渲染成空或者报错。这个我在第 5 章的避坑里会再强调一次。
4.2 左侧菜单与顶部标签页:iframe 布局是兼容性最好的方案
说到后台的整体布局,layui 2.5.6 官方示例里有「经典布局」模式,适合做单页应用式的后台。但以我的实际经验看,用 iframe 嵌套是最稳的方案,理由有三个:
第一,tp6 的控制器方法天然是按 URL 区分的,每个页面一个 HTML 模板,iframe 可以把每个页面独立加载,避免多个页面同时引入 layui 组件产生全局变量冲突。第二,iframe 隔离了样式和脚本,就算某个页面报错,左侧菜单和其他标签页还能用,不至于一崩全崩。第三,顶部标签页的关闭、刷新逻辑用 iframe 的contentWindow.location.reload()就能实现,非常直观。
左侧菜单渲染的核心代码:
// 请求菜单接口 $.get('/admin/index/getMenus', function (res) { if (res.code === 0) { // 渲染左侧菜单 tree.render({ elem: '#left-menu', data: res.data, click: function (node) { // 点击菜单时,在右侧 iframe 中打开对应页面 var path = node.path; if (!path) return; // 如果已存在相同标签页,就切换 if ($('.layui-tab-item[lay-id="' + node.id + '"]').length > 0) { element.tabChange('content-tab', node.id); } else { // 新建标签页 element.tabAdd('content-tab', { title: node.title, content: '<iframe src="' + path + '" class="page-iframe"></iframe>', id: node.id }); element.tabChange('content-tab', node.id); } } }); } });这段代码里有一个值得注意的点:getMenus接口返回的菜单必须是当前用户角色可见的。也就是说,菜单数据不是把 admin_permission 表全量返回,而是要先过滤掉当前用户没有权限的节点。过滤逻辑我习惯放在模型层:先拿到当前用户的权限集合,再循环树组装时判断perms是否在集合内。这样后端控制得死死的,前端怎么改都不会泄漏无权菜单。
4.3 table 列表页:layui 数据表格与 tp6 的接口格式对齐
后台的列表页是 table 组件的主场。layui 2.5.6 的 table 模块请求后端接口时,期望的返回结构是{code: 0, msg: "", count: 总数, data: [...]}。而 tp6 默认的json()输出没有任何包装,所以必须手动构造这个格式。
常用的做法是写一个统一的返回函数:
// 列表接口统一返回格式 public function index() { $page = request()->param('page', 1); $limit = request()->param('limit', 10); $list = AdminUser::order('id', 'desc') ->page($page, $limit) ->select() ->toArray(); $total = AdminUser::count(); return json([ 'code' => 0, 'msg' => 'ok', 'count' => $total, 'data' => $list, ]); }前端 layui table 的渲染配置:
table.render({ elem: '#user-table', url: '/admin/user/index.html', page: true, limit: 10, cols: [[ { field: 'id', title: 'ID', width: 80 }, { field: 'username', title: '用户名', minWidth: 120 }, { field: 'status', title: '状态', width: 100, templet: function (d) { return d.status == 1 ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge">禁用</span>'; }}, { title: '操作', width: 200, toolbar: '#user-toolbar' } ]] });这里最容易踩的坑有两个:第一个,tp6 分页查询的page字段名是page,而 layui 默认传的参数名恰好也是page,能对上是运气;但如果前端设置了limit参数,tp6 默认的列表接收参数是list_rows而不是limit。所以我在控制器里显式接收limit,再传给page()方法,避免接口拿到默认的 10 条却不知情。第二个,count字段的拼写不能错,layui table 组件只认count和data这两个字段名,顺序无所谓。
5. 权限后台联调中的 5 个典型坑:现象、原因与解决
5.1 tp6.0.2 没有单字母函数:下意识写I()直接报错
现象:按照 ThinkPHP 5.1 的习惯在控制器里写input('id')、I('id'),在 tp6.0.2 里直接报「方法不存在」或「函数未定义」。
原因:tp6.0 开始移除了大部分单字母助手函数,I()彻底没有了,input()也只在think\facade\Request门面下可用。tp6 推荐直接依赖注入think\Request对象来获取参数。
解决:统一使用依赖注入方式:
public function edit(\think\Request $request) { $id = $request->param('id'); $data = $request->post(); }如果整个项目里大量使用了request()助手函数,tp6 其实还是有request()这个函数的,它返回的是 Request 门面实例,用request()->param()是可以的。但I()这种老写法必须放弃。
5.2 layui table 接口返回格式不对:列表页一张白板
现象:前端 table 渲染后表格区域空白,浏览器 Network 面板能看到接口正常返回数据,控制台也没有明显 JS 报错。
原因:接口返回的 JSON 缺少count字段或code字段取值不是 0。仔细看的话,layui 2.5.6 的 table 组件对 code 的值做了严格判断,必须是 0 才认为是成功——很多后端习惯用 200 表示成功,这就直接触发了 layui 的表格错误处理。
解决:统一用我上面给出的返回格式:code=0成功,code=1业务失败,code=-1未登录。只要前后端约定好这三个值,table 组件可以避免大部分解析问题。
5.3 layui tree 组件不认你的字段名:菜单树显示为空
现象:菜单接口返回了完整数据,但 tree 渲染出来是一片空白,控制台也没有报错。
原因:layui 2.5.6 的 tree 组件期望每个节点字段是title和children。如果你的后端返回的是 Vue 项目常用的name、childList、label,layui 完全不认。
解决:在后端组装树时就把字段名统一成 layui 要求的格式,我上面第 4 章里的buildTree方法已经包含了字段映射。前端尽量不要再做二次转换——在 PHP 里处理好,模板更干净。
5.4 session 过期太快:后台用几分钟就掉线
现象:管理员登录后台后,编辑一个页面耗时较长,保存时提示「未登录」或者直接跳到登录页,刷新后要重新登录。
原因:tp6 默认 session 过期时间是 1440 秒(24 分钟),而且 PHP session 的垃圾回收机制是由gc_maxlifetime控制的,如果系统里没配好,可能更短。另外,如果你的服务器上存在多个 PHP 项目共用同一个 session 保存路径,某个项目执行session_destroy()时可能把别人的 session 文件也清了。
解决:把config/session.php里的expire改为 86400,同时检查config/session.php中的path是否配置了独立的 session 目录。如果服务器上有多个项目,建议每个项目设置不同的 session 目录,或者使用 Redis 做 session 存储。对后台系统而言,8 小时的过期时间再加「操作时自动续期」是体验最好的方案。自动续期的做法是:在中间件的$next($request)执行前,每次请求都重新设置一次 session 过期时间。
5.5 开启调试模式后看到一堆警告,关闭后白屏
现象:本地开发APP_DEBUG=true一切正常,上线后设置APP_DEBUG=false,后台页面白屏或报 500。
原因:tp6 关闭调试模式后,运行时缓存目录runtime会生成编译缓存。如果上线时没有更新目录权限,或者代码里有语法警告被缓存住了,就会出现白屏。另一个常见原因是 PHP 版本与 tp6.0.2 依赖不兼容,关闭调试后错误被吞。
解决:上线后如果白屏,先看runtime/log下的日志。还有一个老手常用的技巧:把runtime目录整个删掉再刷新一次,让 tp6 重新生成缓存。如果恢复了,说明是旧的编译缓存与当前代码不匹配。这个问题在 tp6.0.2 上尤其需要留意,因为 6.0 的系列版本缓存机制和 5.1 不完全一样,升级后一定要清一遍 runtime。
6. 进阶一步:把按钮级权限和操作日志串成一条线
到了这一步,你的后台已经能登录、能按角色显示菜单、列表页也跑通了。接下来最有价值的提升是把「操作权限校验」和「操作日志记录」合并到一个统一入口里。
现在的 Auth::check 只是返回 true/false,真正落地时每个需要权限保护的方法都要写一遍判断,代码重复度高。我会再包一层,建一个app\common\library\Operate.php类,它干两件事:第一,检查当前用户是否有权限;第二,如果权限通过,把操作记录写入日志表,记录操作人、操作时间、操作内容和请求参数。
<?php namespace app\common\library; class Operate { /** * 检查权限并记录日志 * @param string $perm 权限标识 * @param string $logContent 日志内容描述 * @return bool */ public static function checkAndLog(string $perm, string $logContent = ''): bool { if (!Auth::check($perm)) { return false; } // 记录操作日志 \app\common\model\OperateLog::create([ 'user_id' => Session::get('admin_user.id'), 'username' => Session::get('admin_user.username'), 'content' => $logContent, 'params' => json_encode(request()->except(['password'])), 'ip' => request()->ip(), 'created_at' => time(), ]); return true; } }然后控制器里的调用就变成:
public function delete() { if (!Operate::checkAndLog('user:delete', '删除用户ID:' . request()->param('id'))) { return json(['code' => 0, 'msg' => '没有操作权限']); } // 执行真正的删除操作 }这样做有一个立竿见影的好处:审计追踪更容易。哪天有人说「我的数据被删了」,直接查操作日志表,能精确到是谁、在什么时间、用什么权限标识操作的,不需要翻代码和服务器日志。
最后给你一个习惯层面的建议:千万不要把权限判断和业务逻辑写在一个大方法里,也不要图省事只在前端隐藏按钮。前端隐藏只对「不太懂技术的操作员」有效,懂点技术的用户按 F12 把按钮显示出来就能直接调接口。后端判断是底线,前端隐藏才是体验优化。我见过不止一个项目因为「只做了菜单隐藏、没做接口鉴权」被内部员工把数据导出去了。这样做的代价很小,收益是长期的。希望这整套从表设计到联调避坑的思路,能帮你把这个组合少踩几个坑,一次跑通。
本文还有配套的精品资源,点击获取