简介一个基于layui2.5.6与ThinkPHP6.0.2整合的权限管理后台项目面向需要借助RBAC模型快速搭建管理系统的PHP开发者也适合学习前后端协同开发与框架工程实践的人群。压缩包含1098个文件、约6.1MB以php文件481个为主涵盖控制器、模型与中间件逻辑同时搭配js、css、html实现layui交互界面与页面模板另有png、gif、md、json等静态与文档类辅助资源以及.env环境配置与composer依赖管理文件。目录结构清晰便于按模块检索、二次开发或部署迁移。项目内置ThinkPHP6的RBAC权限控制模型可完成角色分配、权限节点与后台菜单管理layui2.5.6提供表格、弹层、表单等后台常用组件页面无需额外前端构建即可直接使用。包内还附带后台样式与编辑器相关配置方便二次开发时直接替换或扩展界面。目前已有2283人学习下载对想理解服务容器、事件监听、依赖注入等TP6机制以及希望看到完整后台权限模块实际落地的开发者而言是一份高参考价值的工程基础模板。1. 这套 TP6.0.2 layui 2.5.6 权限后台先回答三个问题再动手如果你维护过两三个中小型管理系统大概率对这套组合不陌生。TP6.0.2 负责后端逻辑和权限控制layui 2.5.6 负责页面骨架和表格表单两者拼在一起就是一个能快速出活的权限管理后台。它解决的核心痛点是后台系统 70% 的重复劳动集中在登录认证、角色权限、菜单渲染和列表分页这几块而这几块恰恰最容易写乱。适合需要几天内搭好内部管理系统、又不想被权限边界坑住的开发者。先说清楚它不是完整业务系统而是把权限地基打好的基础套件业务代码在控制器和模板里直接往上加就行。2. 登录认证闭环验证码、登录态与中间件拦截的一次性落地登录是后台安全的第一道门做扎实了后面都顺。TP6 里登录态有三个常见选项Session、JWT、Cache 驱动 token。后台管理系统同源同域Session 和验证码组件天然配合我一般直接选 SessionJWT 更适合给 App 或第三方开放接口用在纯后台场景里属于前期爽后期麻烦的方案。下面这套就是基于 Session 的完整闭环从验证码到中间件拦截一节走完。2.1 Session 方案的技术前提中间件注册和扩展安装TP6.0.2 默认不会自动启用 Session很多人在登录接口里写Session::set()结果下一次请求死活取不到值问题就出在中间件没注册。需要先确认app/middleware.php文件里注册了 SessionInit// app/middleware.php return [ \think\middleware\SessionInit::class, ];这段代码的作用是让框架在每次请求进入时初始化 Session并把 Session 数据与请求生命周期绑定。不注册的话验证码组件把答案写进 Session 后第二次请求根本读不到登录逻辑会直接卡死在验证码校验上。另两个前置扩展也要装好。TP6.0.2 默认没有内置模板引擎和验证码需要 Composer 安装topthink/think-view和topthink/think-captcha。装完后在config/captcha.php里调整参数常见的配置是// config/captcha.php return [ length 4, useCurve false, useNoise true, fontSize 30, reset true, ];reset这个参数值得单独说它默认是true表示验证码校验成功后立刻清除 Session 里的旧答案防止同一个验证码被重复使用。如果你在对接时发现验证码只能成功一次、第二次就报错先别怀疑逻辑看看reset是不是被改成了false。2.2 登录控制器核心逻辑与密码兼容处理登录控制器我习惯拆成三个方法index()渲染登录页、captcha()输出验证码图片、doLogin()处理登录请求。核心代码集中在doLogin()// app/controller/Login.php ?php namespace app\controller; use think\facade\Session; use think\captcha\facade\Captcha; class Login { public function index() { return view(login/index); } public function captcha() { return Captcha::create(); } public function doLogin() { $data request()-post(); $captcha $data[captcha] ?? ; // 验证码校验失败直接返回避免继续查库 if (!Captcha::check($captcha)) { return json([code 1, msg 验证码错误]); } $username trim($data[username] ?? ); $password trim($data[password] ?? ); if (empty($username) || empty($password)) { return json([code 1, msg 账号密码不能为空]); } $user \app\model\AdminUser::where(username, $username)-find(); if (!$user) { return json([code 1, msg 账号或密码错误]); } // 兼容旧系统 md5 密码登录成功后自动升级为 password_hash if (isset($user-salt) $user-password md5($password . $user-salt)) { $user-password password_hash($password, PASSWORD_DEFAULT); $user-save(); } elseif (!password_verify($password, $user-password)) { return json([code 1, msg 账号或密码错误]); } if ((int)$user-status ! 1) { return json([code 1, msg 账号已禁用]); } // 登录态只放必要字段避免把整行数据塞进 Session Session::set(admin_user, [ id $user-id, username $user-username, role_id $user-role_id, ]); // 冗余字段直接打在用户表上方便后台列表展示 $user-last_login_time time(); $user-last_login_ip request()-ip(); $user-save(); return json([code 0, msg 登录成功]); } public function logout() { Session::delete(admin_user); return redirect(/login/index); } }这段逻辑里有几个参数处理要留意。Captcha::check()默认从 Session 读取验证码答案所以前一步 SessionInit 中间件没注册时这里会一直返回失败。密码校验的顺序是先判断旧系统 md5 格式、再走password_verify这个兼容分支只对老项目迁移有用新项目直接password_hash入库就行。登录态 Session 只存id、username、role_id三个字段不推荐把整个用户对象塞进去Session 里数据越少后面做权限判断越不容易出幺蛾子。2.3 登录态中间件与路由分组的拦截规则登录态写好了还要保证未登录的人进不了后台其他控制器。TP6 的中间件机制很适合干这件事我一般单独写一个CheckLogin中间件// app/middleware/CheckLogin.php ?php namespace app\middleware; use think\facade\Session; class CheckLogin { public function handle($request, \Closure $next) { // 登录页、验证码、登录提交接口直接放行 $path strtolower($request-pathinfo()); $passList [login/index, login/captcha, login/dologin]; if (in_array($path, $passList)) { return $next($request); } $adminUser Session::get(admin_user); if (empty($adminUser)) { // Ajax 请求返回 JSON普通请求跳登录页这个区分很关键 if ($request-isAjax()) { return json([code 401, msg 登录已过期请重新登录]); } return redirect(/login/index); } return $next($request); } }中间件的$passList是白名单凡是登录链路相关的请求都必须放行。$request-pathinfo()返回的是小写路径格式所以白名单里也用全小写避免大小写不一致导致中间件误伤。isAjax()分支的意义在于列表页加载到一半 Session 过期时前端收到 JSON 提示可以直接跳转登录页而不是整个 iframe 被重定向成登录表单。路由分组里挂载中间件是这个套件的标准做法// route/app.php ?php use think\facade\Route; Route::group(admin, function () { Route::get(user/index, admin.User/index); Route::get(user/edit, admin.User/edit); Route::post(user/save, admin.User/save); // 其他后台路由 })-middleware([CheckLogin]);注意这里中间件只挂在了admin路由分组上没有写成全局中间件。如果图省事注册成全局中间件会出现登录页自己被拦截的死循环这个坑我在第 5 章详细展开。2.4 登录日志的落点用户表打点还是独立日志表上面代码把last_login_time和last_login_ip直接打在admin_user表上这是最常见的轻量做法适合后台用户量不大的场景。但如果你需要审计登录历史比如运营要查某个管理员最近一周登录了几次、从哪些 IP 登录的就应该单独建一张admin_login_log表字段建议是字段名类型说明idint主键admin_idint管理员 IDlogin_ipvarchar(50)登录 IPlogin_timeint登录时间戳user_agentvarchar(255)浏览器 UA独立日志表的数据写入位置放在doLogin()成功分支里最合适因为此时已经确认了账号密码无误。注意不要放在中间件里记录中间件拦截的是每一次请求会把正常页面刷新也记成一次登录。3. RBAC 权限模型五张表、节点分配与两级校验的完整拆解登录闭环拿到的是「你是谁」权限后台紧接着要回答「你能做什么」。这里最经典的做法是 RBAC也就是基于角色的访问控制。RBAC 的核心思路用户不直接绑定权限而是绑定角色角色再绑定权限节点。这套模型的好处是后期加人、换岗、调权限时只改角色和节点的关联不用挨个改用户。3.1 五张核心表的结构设计与关联关系权限后台的数据表我通常固定为五张分别是管理员表、角色表、节点表、角色-节点关联表、管理员-角色关联表。字段设计如下admin_user 管理员表字段名类型说明idint主键usernamevarchar(50)登录账号passwordvarchar(255)密码哈希role_idint角色 ID直接冗余在用户表statustinyint账号状态1 启用 0 禁用last_login_timeint最后登录时间last_login_ipvarchar(50)最后登录 IPadmin_role 角色表字段名类型说明idint主键role_namevarchar(50)角色名称statustinyint状态remarkvarchar(255)备注admin_node 节点表字段名类型说明idint主键pidint父节点 ID0 为顶级titlevarchar(50)菜单或操作名称namevarchar(100)节点标识如 user/indexiconvarchar(50)图标类名is_menutinyint1 菜单 0 操作sortint排序statustinyint状态admin_role_node 角色-节点关联表role_idnode_id。admin_user_role 管理员-角色关联表user_idrole_id。如果你觉得一张管理员表只对应一个角色就够用可以去掉最后一张关联表用admin_user.role_id单字段角色方案这套权限后台默认就是这种单角色模式逻辑更简单。3.2 节点管理中菜单节点与操作节点的区分节点表是整个权限模型的核心也是最容易被新手搞混的地方。is_menu字段把节点分成两类is_menu 1的节点会出现在左侧菜单里比如用户管理、角色管理is_menu 0的节点只参与权限判断不渲染菜单比如编辑按钮、删除按钮、导出按钮。节点name字段的命名规则我建议统一成「控制器/操作」小写格式比如user/index、user/add、role/edit。这样权限判断时可以直接用控制器名/操作名去比对不需要额外的映射表。菜单节点的pid用来构建层级顶级菜单pid 0二级菜单指向父级菜单的id。这里有一个边界要提前说清楚菜单节点和操作节点之间不是严格父子关系。一个菜单节点下面可以有多个操作节点操作节点的pid指向菜单节点的id但is_menu为 0 的节点不会出现在菜单树里只在权限判断时生效。换句话说菜单是节点的展示形态权限判断是节点的实际用途两者通过is_menu做了区分。3.3 Auth 公共类与权限判断中间件节点表和角色关系确定后权限判断的核心就收敛成一个公共类我习惯叫Auth// app/common.php 或单独类文件 ?php namespace app\common; use think\facade\Cache; use think\facade\Session; class Auth { // 获取指定角色拥有的全部节点标识 public static function roleNodes(int $roleId): array { $cacheKey role_nodes_ . $roleId; $nodes Cache::get($cacheKey); if (empty($nodes)) { $nodeIds \app\model\AdminRoleNode::where(role_id, $roleId)-column(node_id); $nodes $nodeIds ? \app\model\AdminNode::whereIn(id, $nodeIds) -where(status, 1) -column(name) : []; Cache::set($cacheKey, $nodes, 3600); } return $nodes; } // 当前登录用户是否拥有某节点权限 public static function check(string $node): bool { $roleId (int)Session::get(admin_user.role_id); // 角色 ID 为 1 视为超级管理员直接放行 if ($roleId 1) { return true; } $nodes self::roleNodes($roleId); return in_array($node, $nodes, true); } }这段代码的关键点有三个。第一角色节点集合用Cache::set($cacheKey, $nodes, 3600)缓存一小时避免每次请求都查一次关联表。第二超级管理员的判断写死为role_id 1这是一个约定俗成的做法新项目初始化时第一个角色固定是超管。第三roleNodes缓存的是节点name数组不是节点 ID 数组因为权限判断最终比对的是路由标识提前转好可以省掉一次关联查询。配合权限判断中间件可以在路由层完成第二级校验// app/middleware/CheckPermission.php ?php namespace app\middleware; use think\facade\Session; use app\common\Auth; class CheckPermission { public function handle($request, \Closure $next) { // 超管直接放行 if ((int)Session::get(admin_user.role_id) 1) { return $next($request); } // 当前请求对应的节点标识 $node strtolower($request-controller() . / . $request-action()); // 公共操作白名单不需要权限控制 $publicNodes [login/logout, index/index, index/console]; if (in_array($node, $publicNodes)) { return $next($request); } if (!Auth::check($node)) { return $request-isAjax() ? json([code 403, msg 无权限访问]) : abort(403, 无权限访问); } return $next($request); } }注意这里$request-controller()返回的可能是原始大小写格式所以统一用strtolower转小写再比对否则你节点表里存的user/index和中间件里拼出来的User/index会对不上导致有权限也被拦截。3.4 角色分配节点后缓存如何即时生效权限缓存一旦引入就必须考虑一个场景管理员刚给某个角色分配了新节点这个角色下的用户立刻去刷新页面发现还是没权限。原因很简单roleNodes()里缓存了 3600 秒旧节点集合还在缓存中。所以角色分配节点的接口里必须主动清掉对应角色的缓存// app/controller/Role.php public function assignNode() { $roleId (int)request()-post(role_id); $nodeIds request()-post(node_ids/a, []); \app\model\AdminRoleNode::where(role_id, $roleId)-delete(); $rows []; foreach ($nodeIds as $nodeId) { $rows[] [role_id $roleId, node_id (int)$nodeId]; } if (!empty($rows)) { (new \app\model\AdminRoleNode)-saveAll($rows); } // 关键删除角色节点缓存否则权限变更不生效 Cache::delete(role_nodes_ . $roleId); return json([code 0, msg 权限已更新]); }这里node_ids/a是 TP6 的请求参数强制数组类型转换前端提交的多选数组会自动转成 PHP 数组。saveAll批量插入关联数据注意先delete旧关联、再批量插入保证角色节点集合与最后一次勾选完全一致。我一般会再加一步更新权限后同时删除该角色下所有在线用户的 Session 权限缓存避免用户在不重新登录的情况下带着旧权限操作。4. layui 后台布局与动态菜单左侧导航跟着权限走权限模型是后端的事但用户能感知到的是左侧菜单——他看到的菜单项必须和角色权限一一对应。这一章把 layui 2.5.6 的布局骨架和动态菜单渲染完整走一遍包括按钮级权限的显示控制。4.1 整体布局左侧菜单加右侧 iframe 的结构选型管理后台的布局方案很多纯 SPA、路由单页、iframe 嵌套都有。这套组合里我推荐最省心的经典结构左侧固定宽度菜单栏右侧大区域放 iframe顶部放用户信息和退出按钮。layui 2.5.6 自带layui-layout-admin这套布局样式骨架如下!-- app/view/index/main.html 主布局模板 -- div classlayui-layout layui-layout-admin div classlayui-header div classlayui-logo后台管理系统/div ul classlayui-nav layui-layout-right li classlayui-nav-item a hrefjavascript:;{$adminUser.username}/a dl classlayui-nav-child dda href/login/logout退出/a/dd /dl /li /ul /div div classlayui-side layui-bg-black div classlayui-side-scroll idsideMenu/div /div div classlayui-body iframe idmainFrame src/admin/index/console frameborder0/iframe /div /div不要做多标签页原因很实在iframe 套多标签页时切标签要同步 iframe 的src还要维护哪些页面已经打开过、关闭标签后焦点给谁工作量不小。单 iframe 直接替换src每次点击左侧菜单就刷新 iframe 内容代码少、易维护对内部管理系统完全够用。4.2 菜单树的后端递归与前端渲染菜单数据来自节点表is_menu 1的数据后端要做两件事过滤权限 递归构建树。控制器里这样处理// app/controller/Index.php public function menu() { $roleId (int)Session::get(admin_user.role_id); // 先查全部菜单节点 $nodes \app\model\AdminNode::where(is_menu, 1) -where(status, 1) -order(sort, asc) -select() -toArray(); // 普通角色按权限过滤菜单超管直接全部显示 if ($roleId ! 1) { $authNodes Auth::roleNodes($roleId); $nodes array_values(array_filter($nodes, function ($node) use ($authNodes) { return in_array($node[name], $authNodes, true); })); } // 递归构建树形结构叶子节点补上完整链接 $tree buildMenuTree($nodes, 0); return json([code 0, msg ok, data $tree]); } function buildMenuTree(array $nodes, int $pid): array { $tree []; foreach ($nodes as $node) { if ((int)$node[pid] ! $pid) { continue; } $children buildMenuTree($nodes, (int)$node[id]); // 父节点只做展开叶子节点才生成页面链接 $node[href] empty($children) ? (string)url(/admin/ . $node[name]) : javascript:;; $node[children] $children; $tree[] $node; } return $tree; }代码里buildMenuTree是递归函数每次只处理pid匹配当前层级的节点子节点继续递归。叶子节点的href通过url()函数生成路径由节点name拼接成/admin/用户控制器/方法格式。注意前端拿到javascript:;的父节点时不要触发跳转只展开子菜单。前端渲染用原生 JS 拼接 DOM比引入 layui tree 组件更可控// 静态资源/js/menu.js function renderMenu(data, parent) { var ul $(ul classlayui-nav layui-nav-tree layui-bg-gray/ul); data.forEach(function (item) { var li $(li classlayui-nav-item/li); if (item.children item.children.length 0) { li.append(a hrefjavascript:; item.title /a); li.append(renderMenu(item.children, li)); } else { li.append(a href item.href >// app/controller/Base.php ?php namespace app\controller; use think\facade\View; use think\facade\Session; use app\common\Auth; class Base { protected function initialize() { $roleId (int)Session::get(admin_user.role_id); $authNodes $roleId 1 ? [*] : Auth::roleNodes($roleId); View::assign(authNodes, $authNodes); View::assign(adminUser, Session::get(admin_user)); } }模板里用in_array判断按钮是否渲染{if in_array(user/delete, $authNodes)} button classlayui-btn layui-btn-danger layui-btn-xs onclickdelUser({$user.id})删除/button {/if}这个写法的优势是权限判断逻辑集中在Auth类模板只负责展示。超管角色因为拿到了[*]in_array无论如何都匹配不到所以我在Auth::check()里已经做了超管放行但模板层的判断还需要额外兜底一次。我会在Auth类里加一个静态方法can()专门处理超管和普通角色public static function can(string $node): bool { $authNodes Session::get(auth_nodes); return in_array(*, $authNodes, true) || in_array($node, $authNodes, true); }控制器基类里把$authNodes同时写入 Session 和 View模板判断就统一走Auth::can()不需要每个模板自己处理超管分支。4.4 面包屑与当前菜单高亮后台系统的导航体验里面包屑不是刚需但当前菜单高亮是必须的。菜单渲染时给每个菜单项绑定>$page max(1, (int)request()-param(page, 1)); $limit max(1, (int)request()-param(limit, 10)); $offset ($page - 1) * $limit; $list $model-limit($offset, $limit)-select();6. 列表接口统一封装把 layui table 的分页参数一次对齐后台最多的页面就是列表页每次写分页查询都重复解析page、limit时间久了必然出问题。我把这套参数映射关系整理成一张对照表然后封装到一个方法里所有列表接口复用。layui table 请求后台时默认会带这几个参数layui 参数类型含义TP6 获取方式pageint当前页码从 1 开始request()-param(page, 1)limitint每页条数request()-param(limit, 10)fieldstring排序字段点击表头排序时才有request()-param(field)orderstring排序方向 asc/descrequest()-param(order)封装方法我放在一个公共 trait 里列表控制器直接调用// app/common/traits/LayuiTable.php ?php namespace app\common\traits; trait LayuiTable { protected function layuiPage($model, array $map []) { $page max(1, (int)request()-param(page, 1)); $limit max(1, (int)request()-param(limit, 10)); $limit min($limit, 500); $orderField request()-param(field, id); $orderType strtolower(request()-param(order, desc)); $allowField [id, username, role_id, status, create_time]; // 排序字段白名单校验避免注入风险 if (!in_array($orderField, $allowField, true)) { $orderField id; } $orderType in_array($orderType, [asc, desc]) ? $orderType : desc; $total $model-where($map)-count(); $list $model-where($map) -order($orderField, $orderType) -limit(($page - 1) * $limit, $limit) -select() -toArray(); // layui table 规定 code 必须为 0 才算成功 return json([ code 0, msg , count $total, data $list, ]); } }控制器里调用时只需要声明好模型和查询条件// app/controller/User.php public function index() { $model new \app\model\AdminUser(); $map []; $keyword trim(request()-param(keyword, )); if ($keyword ! ) { $map[] [username, like, %$keyword%]; } return $this-layuiPage($model, $map); }这个封装的边界要提一下$map是通用查询条件数组适合大多数列表的等值和模糊查询如果业务里有动态条件、联表查询或分组统计直接在控制器里先写好$model的查询构造再传给layuiPage()不要在封装里处理复杂条件避免方法膨胀。从那以后我每接一个后台列表页都强制先走一遍这套封装再谈业务逻辑。排序字段白名单和limit上限限制是我吃过亏之后补上的之前因为排序字段没校验被拼了奇怪的 SQL。这套权限后台跑上生产环境后我会把 Session 驱动换成 Redis缓存驱动也指到 Redis权限判断的响应时间能控制在几毫秒内。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?