简介:这是一套基于 layui2.5.6 前端组件库与 ThinkPHP6.0.2 后端框架搭建的权限管理后台源码,面向需要快速构建企业级管理系统的 PHP 开发者,也适合初学者理解前后端协同与 RBAC 权限控制的实现思路。压缩包共 1098 个文件,约 6.1MB,以 481 个 php 业务与框架代码、139 个 js 脚本、36 个 html 模板、30 个 css 样式为主,另含图片、字体、配置与依赖锁文件,覆盖前端界面、后端逻辑与工程配置各层面。资源内置 RBAC 角色权限模型,配合 .env 环境配置、composer 依赖管理与清晰目录结构,可直接作为二次开发模板。已有 2283 人学习下载,读者能借此掌握权限分配、路由控制与前后端分离式后台的搭建方法,并借鉴其工程组织与持续集成配置,提升 PHP 项目实战能力。
1. 这套 layui2.5.6 + tp6.0.2 权限后台,为什么现在还有人拿它做交付
上周有个做外包的朋友找我,说他接了个工厂内部管理系统的活,客户要求两周出后台,预算压得很低。他翻了一圈市面上的后台模板,要么是前后端分离的 Vue3 + Element Plus,光环境搭建和联调就得三四天;要么是几年前的老 TP5 项目,PHP 版本一升就报一堆弃用警告。最后他选了这套 layui2.5.6 + ThinkPHP6.0.2 的权限管理后台,三天跑通登录、菜单、角色、用户四个模块,第五天就开始按客户需求加业务表了。
这套组合的核心价值就一个字:快。layui 是典型的服务端渲染友好型 UI 框架,不需要 npm、不需要 webpack、不需要 node_modules,一个 layui.js 加一个 layui.css 引进去就能用。ThinkPHP6.0.2 是 TP 系列里第一个真正拥抱 Composer 和 PSR 标准的版本,容器、中间件、门面这些机制都齐了,但目录结构比 Laravel 轻得多。两者拼在一起,做中小型后台管理系统——尤其是那种「客户就要个能跑的内网系统、不追求花哨交互」的场景——开发效率非常高。
这套资源适合谁?一是接私活或外包的独立开发者,需要快速交付一个带 RBAC 权限的后台底座;二是刚学完 PHP 基础、想找一个完整项目练手的同学,TP6 的 MVC 分层和 layui 的表单表格能让你把「请求怎么进来、数据怎么出去」这条链路走通;三是公司内部需要维护老系统的工程师,很多存量项目就是 TP + layui 的技术栈,读懂这套代码能直接上手改。不适合谁?如果你的项目要求前后端彻底分离、需要 SSR 或微前端,那这套方案会让你越写越别扭,趁早换 Vue 或 React 体系。
下面我按「环境怎么搭 → 权限怎么设计 → 代码怎么写 → 坑在哪 → 怎么验证」的顺序,把这套后台拆一遍。中间会给出可直接抄的代码和配置,参数含义和失败排查也会写清楚。
2. 环境搭建与项目初始化:从零把 TP6.0.2 跑起来
2.1 PHP 版本与 Composer 依赖的硬性要求
ThinkPHP6.0.2 对 PHP 版本有明确要求:必须 PHP 7.2.5 及以上,推荐 7.4 或 8.0。我实测过 PHP 8.1 也能跑,但部分扩展函数会有弃用提示,如果不想处理这些噪音,用 7.4 最稳。另外必须开启的扩展有:PDO、mbstring、openssl、json、curl。这几个缺一个,Composer 安装阶段就会报错。
先确认环境:
php -v # 输出应类似 PHP 7.4.x 或 8.0.x php -m | grep -E "pdo_mysql|mbstring|openssl|curl" # 确认这几个扩展都在列表里如果用的是集成环境(比如 phpstudy 或宝塔),直接在面板里切换 PHP 版本、勾选扩展即可。手动编译的话,--with-curl --with-openssl --enable-mbstring这几个编译参数别漏。
Composer 建议用国内镜像,否则拉依赖会非常慢:
composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/这一步不是可选项。TP6 的依赖树里有 symfony 系列组件,走默认源在国内网络环境下经常超时,换成镜像后基本秒下。
2.2 用 Composer 创建项目与目录结构说明
TP6.0.2 的标准创建方式是:
composer create-project topthink/think tpadmin 6.0.2注意版本号要写6.0.2,不写的话默认拉最新版,可能是 6.1 或 8.0,目录结构和中间件写法都有差异。创建完成后进入目录:
cd tpadmin php think run浏览器访问http://127.0.0.1:8000,看到 TP6 的默认欢迎页就说明骨架跑通了。此时目录结构是这样的:
tpadmin/ ├── app/ # 应用目录,控制器、模型、中间件都在这 │ ├── controller/ │ ├── model/ │ └── middleware.php ├── config/ # 配置文件 │ ├── database.php │ ├── app.php │ └── ... ├── public/ # Web 入口目录,layui 静态资源放这 │ ├── index.php │ └── static/ ├── route/ # 路由定义 │ └── app.php ├── vendor/ # Composer 依赖 └── .env # 环境变量(需自己创建)关键点:public是唯一的 Web 根目录,Nginx 或 Apache 的 document_root 必须指向它,不能指向项目根。我见过有人把根目录指到tpadmin/,结果.env和vendor全暴露在公网,这是血泪教训。
2.3 数据库配置与 .env 文件写法
TP6 推荐用.env管理敏感配置。在项目根目录创建.env:
APP_DEBUG = true [DATABASE] TYPE = mysql HOSTNAME = 127.0.0.1 DATABASE = tp_admin USERNAME = root PASSWORD = your_password HOSTPORT = 3306 CHARSET = utf8mb4 DEBUG = true [LANG] default_lang = zh-cn然后在config/database.php里用env()函数读取:
return [ 'type' => env('database.type', 'mysql'), 'hostname' => env('database.hostname', '127.0.0.1'), 'database' => env('database.database', ''), 'username' => env('database.username', 'root'), 'password' => env('database.password', ''), 'hostport' => env('database.hostport', '3306'), 'charset' => env('database.charset', 'utf8mb4'), ];参数说明:env('database.type', 'mysql')的第一个参数对应.env里[DATABASE]段下的键名,TP6 会自动把段名转小写加点拼接。第二个参数是默认值,.env不存在时用它兜底。APP_DEBUG = true只在开发环境开,上线必须改 false,否则异常页面会暴露数据库账号和文件路径。
数据库建好后,把权限相关的表导进去。这套后台通常包含这几张核心表:admin(管理员)、role(角色)、menu(菜单/权限节点)、admin_role(管理员角色关联)、role_menu(角色菜单关联)。建表 SQL 在资源包里一般有,导入后先确认字符集是utf8mb4,否则存中文菜单名会乱码。
3. RBAC 权限模型落地:五张表怎么设计、中间件怎么拦
3.1 权限表结构设计与字段含义
RBAC(基于角色的访问控制)的核心思路是:用户不直接绑权限,而是通过角色中转。这套后台的表设计是经典的五表结构,我逐个说字段。
admin表存管理员账号:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int unsigned | 主键自增 |
| username | varchar(50) | 登录名,唯一索引 |
| password | varchar(255) | 密码哈希,用 password_hash 生成 |
| salt | varchar(32) | 盐值,可选,TP6 自带哈希可不用 |
| status | tinyint | 1 正常 0 禁用 |
| create_time | int | 创建时间戳 |
role表存角色:id、name(角色名)、description、status。menu表存权限节点:id、pid(父级 ID)、title(菜单名)、url(路由地址)、icon、sort、type(1 菜单 2 按钮)。admin_role和role_menu是两张关联表,各存两个外键字段。
这里有个设计选择值得说:menu表用pid做无限级分类,type区分菜单和按钮。按钮级权限(比如「删除用户」这个操作)也作为一条记录存在 menu 表里,url填对应的控制器方法,前端用{:auth('admin/delete')}这类辅助函数判断是否渲染。这样菜单展示和操作鉴权用的是同一套数据,不用维护两份权限清单。
3.2 登录鉴权与 Session 保持
TP6 默认用 Session 保持登录态。登录控制器的核心逻辑:
public function doLogin() { $username = $this->request->post('username'); $password = $this->request->post('password'); $admin = AdminModel::where('username', $username)->find(); if (!$admin) { return json(['code' => 0, 'msg' => '用户不存在']); } if ($admin['status'] != 1) { return json(['code' => 0, 'msg' => '账号已禁用']); } if (!password_verify($password, $admin['password'])) { return json(['code' => 0, 'msg' => '密码错误']); } // 写入 session session('admin_id', $admin['id']); session('admin_name', $admin['username']); // 查出该管理员的角色和权限节点 $roleIds = AdminRoleModel::where('admin_id', $admin['id'])->column('role_id'); $menuIds = RoleMenuModel::whereIn('role_id', $roleIds)->column('menu_id'); session('menu_ids', $menuIds); return json(['code' => 1, 'msg' => '登录成功']); }逻辑说明:先查用户,再验状态,再用password_verify比对哈希密码。注意不要用 md5 或 sha1 存密码,TP6 环境下直接用 PHP 内置的password_hash和password_verify就行,不需要额外库。登录成功后把admin_id和权限节点 ID 列表写进 Session,后续中间件和视图都从这里取。
参数上有个细节:session('menu_ids', $menuIds)存的是数组,TP6 的 Session 默认支持数组序列化,不用手动 json_encode。但如果你换了 Session 驱动(比如 Redis),要确认驱动支持数组。
3.3 中间件拦截未授权请求
TP6 的中间件是鉴权的关键。在app/middleware/下建Auth.php:
namespace app\middleware; use think\facade\Session; use think\Response; class Auth { public function handle($request, \Closure $next) { // 白名单:登录页和登录接口不拦 $whiteList = ['admin/login', 'admin/doLogin', 'admin/captcha']; $path = strtolower($request->controller() . '/' . $request->action()); if (in_array($path, $whiteList)) { return $next($request); } if (!Session::get('admin_id')) { return redirect('/admin/login'); } // 校验当前访问的节点是否在权限列表里 $menuIds = Session::get('menu_ids', []); $currentMenu = \app\model\MenuModel::where('url', $path)->find(); if ($currentMenu && !in_array($currentMenu['id'], $menuIds)) { return Response::create('无权限访问', 'html', 403); } return $next($request); } }然后在app/middleware.php里全局注册:
return [ \app\middleware\Auth::class, ];逻辑说明:$request->controller()和$request->action()拿到当前请求的控制器和方法名,拼成admin/login这种格式,和 menu 表的url字段对应。白名单里的路径直接放行,没登录的跳登录页,登录了但节点不在权限列表里的返回 403。
这里有个容易翻车的点:$request->controller()返回的是驼峰还是小写,取决于路由配置。如果路由用了Route::rule('admin/:controller/:action', ...)这种动态规则,拿到的可能是原始大小写。稳妥做法是统一转小写再比对,menu 表里的 url 也全存小写。
4. layui 前端集成:表格渲染、表单提交与权限按钮控制
4.1 layui 静态资源引入与布局骨架
layui2.5.6 的引入非常简单,把layui整个文件夹放到public/static/下,然后在视图里引:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>权限管理后台</title> <link rel="stylesheet" href="/static/layui/css/layui.css"> </head> <body> <div class="layui-layout layui-layout-admin"> <div class="layui-header"> <div class="layui-logo">后台管理</div> <ul class="layui-nav layui-layout-right"> <li class="layui-nav-item"> <a href="javascript:;">{$admin_name}</a> <dl class="layui-nav-child"> <dd><a href="/admin/logout">退出登录</a></dd> </dl> </li> </ul> </div> <div class="layui-side layui-bg-black"> <div class="layui-side-scroll"> <ul class="layui-nav layui-nav-tree"> {volist name="menus" id="menu"} <li class="layui-nav-item"> <a href="javascript:;">{$menu.title}</a> {if condition="isset($menu.children)"} <dl class="layui-nav-child"> {volist name="menu.children" id="child"} <dd><a href="{$child.url}">{$child.title}</a></dd> {/volist} </dl> {/if} </li> {/volist} </ul> </div> </div> <div class="layui-body"> {__CONTENT__} </div> </div> <script src="/static/layui/layui.js"></script> </body> </html>参数说明:{volist}是 TP6 内置的模板标签,name="menus"对应控制器 assign 的变量名。{__CONTENT__}是布局模板的占位符,子模板用{extend name="layout"}继承后填充。layui 的layui-nav-tree类会自动把嵌套的dl渲染成可折叠的树形菜单。
4.2 数据表格的异步加载与分页
layui 的 table 模块是异步加载数据的,后端返回固定格式的 JSON。控制器:
public function list() { $page = $this->request->get('page', 1); $limit = $this->request->get('limit', 10); $count = AdminModel::count(); $list = AdminModel::page($page, $limit)->select(); return json([ 'code' => 0, 'msg' => '', 'count' => $count, 'data' => $list, ]); }前端表格渲染:
layui.use(['table', 'form'], function(){ var table = layui.table; table.render({ elem: '#adminTable', url: '/admin/list', page: true, cols: [[ {field: 'id', title: 'ID', width: 80}, {field: 'username', title: '用户名'}, {field: 'status', title: '状态', templet: function(d){ return d.status == 1 ? '正常' : '禁用'; }}, {title: '操作', toolbar: '#barDemo', width: 200} ]] }); });逻辑说明:layui table 要求后端返回的 JSON 必须包含code、msg、count、data四个字段,code为 0 表示成功。count是总记录数,用于分页组件计算页数。page: true开启分页,layui 会自动往请求里带page和limit参数。templet是列的自定义渲染函数,这里把 status 的 0/1 转成中文。
参数上注意:limit默认是 10,可以在table.render里用limits: [10, 20, 50]自定义每页条数选项。如果数据量大,后端page($page, $limit)的第二个参数要跟 limit 一致,否则分页会错位。
4.3 按钮级权限的前端控制
按钮级权限靠一个模板辅助函数实现。在app/common.php里定义:
function auth($url) { $menuIds = session('menu_ids', []); $menu = \app\model\MenuModel::where('url', $url)->find(); if (!$menu) { return false; } return in_array($menu['id'], $menuIds); }视图里这样用:
<script type="text/html" id="barDemo"> {{# if(auth('admin/edit')){ }} <a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a> {{# } }} {{# if(auth('admin/delete')){ }} <a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a> {{# } }} </script>逻辑说明:auth()函数查 menu 表拿到 url 对应的节点 ID,再判断是否在当前用户的menu_ids里。layui 的模板语法用{{# }}包裹 JS 逻辑,{{# if(...){ }}和{{# } }}成对出现。这样没有权限的按钮直接不渲染,比前端隐藏更彻底。
注意:前端不渲染只是体验优化,后端接口必须再校验一次。我见过有人只做了前端隐藏,结果用户直接构造请求就把数据删了。中间件那层的节点校验才是真正的防线。
5. 避坑与排查:这套组合最容易翻车的五个地方
5.1 登录后跳转 404,Session 却显示已登录
现象:输入正确账号密码,接口返回登录成功,但跳转后台首页时 404。查 Session 发现admin_id已经写进去了。
原因:TP6 的默认路由是「控制器/方法」自动解析,但如果你在route/app.php里定义了强制路由规则,或者 Nginx 没配 pathinfo,/admin/index/index这种路径就解析不到。
解决:先确认 Nginx 的try_files配置:
location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } }然后在config/app.php里把url_route_must设为 false,允许自动解析。如果用了强制路由,检查route/app.php里有没有漏掉首页的路由定义。
5.2 layui 表格数据不显示,控制台报 parseerror
现象:表格一直转圈或显示「数据接口请求异常」,浏览器控制台报 JSON 解析错误。
原因:后端返回的 JSON 格式不对。常见的是code返回了 1 而不是 0,或者data字段是 null 而不是空数组。
解决:用浏览器 Network 面板看接口的原始响应。layui 要求code为 0 才认为成功,data必须是数组(空数据也要返回[]而不是null)。TP6 的select()查不到数据时返回空数组,但如果你用了find()再包一层,可能返回 null。统一在返回前做$list = $list ?: []兜底。
5.3 权限节点改了但用户不生效
现象:在后台给角色加了新菜单,但该角色下的用户刷新页面还是看不到。
原因:权限节点 ID 列表存在 Session 里,登录时写一次,之后不再更新。改了角色权限,Session 里的旧数据没变。
解决:两个方案。一是在角色权限保存后,清掉相关用户的 Session;二是把权限校验改成实时查库,不走 Session 缓存。实时查库性能差一点,但权限变更即时生效。我一般用折中方案:Session 里只存admin_id,权限列表每次请求时查一次,配合 Redis 缓存查询结果,角色权限变更时清缓存。
5.4 密码验证一直失败,明明输入是对的
现象:用password_hash生成的密码存进库,登录时password_verify返回 false。
原因:数据库字段长度不够。password_hash用 PASSWORD_DEFAULT 算法生成的哈希是 60 个字符,如果password字段设的是varchar(32)或char(32),存进去就被截断了。
解决:把password字段改成varchar(255)。另外确认数据库连接字符集是utf8mb4,哈希里可能包含特殊字符,字符集不对也会导致比对失败。
5.5 上线后样式全丢,layui 图标变方块
现象:本地开发正常,部署到服务器后 layui 的图标字体不显示,控制台报字体文件 404。
原因:layui 的图标用的是iconfont字体文件,路径是相对 CSS 的。如果你用了 CDN 或者改了静态资源目录结构,字体文件的相对路径就断了。
解决:检查layui.css里@font-face的src路径,确保font/iconfont.woff等文件在对应位置。如果用了 Nginx 的alias或root指向了不同目录,字体文件的 MIME 类型也要配:
location ~* \.(woff|woff2|ttf|eot|svg)$ { add_header Access-Control-Allow-Origin *; }跨域加载字体时,缺Access-Control-Allow-Origin头也会导致字体加载失败。
6. 进阶验证:用一条命令确认权限链路是否真的通了
代码写完、页面能跑,不代表权限逻辑是对的。我习惯用一个笨办法验证:建两个角色,一个只有查看权限,一个有增删改权限,然后用两个浏览器(或一个正常窗口一个无痕窗口)分别登录,手动把「查看角色」的删除按钮对应的接口请求抓出来,直接构造请求打过去。如果返回 403,说明中间件那层生效了;如果返回成功,说明你的鉴权只做了前端隐藏,后端是裸奔的。
更系统的验证方式是写一个命令行脚本,模拟不同角色的 Session 去请求所有节点:
// 在项目根目录执行 php think check:auth namespace app\command; use think\console\Command; use think\console\Input; use think\console\Output; use app\model\MenuModel; use app\model\RoleMenuModel; class CheckAuth extends Command { protected function configure() { $this->setName('check:auth')->setDescription('校验角色权限节点覆盖情况'); } protected function execute(Input $input, Output $output) { $roles = \app\model\RoleModel::select(); foreach ($roles as $role) { $menuIds = RoleMenuModel::where('role_id', $role['id'])->column('menu_id'); $allMenus = MenuModel::column('id'); $missing = array_diff($allMenus, $menuIds); $output->writeln("角色 [{$role['name']}] 未授权节点数:" . count($missing)); if (count($missing) > 0) { $titles = MenuModel::whereIn('id', $missing)->column('title'); $output->writeln(" 缺失:" . implode('、', $titles)); } } return 0; } }注册到config/console.php后,执行php think check:auth,就能看到每个角色漏了哪些节点。这个脚本在交付前跑一遍,能避免「某个角色少配了一个菜单导致用户点进去 403」这种低级问题。
参数说明:array_diff拿全量节点减去已授权节点,差集就是缺失的。column('title')把 ID 转成可读的菜单名,方便定位。如果角色多、节点多,输出会很长,可以加个--role=1参数只查指定角色。
从那以后我每次交付权限后台,都会在测试环境跑一遍这个脚本,再手动用无痕窗口验证一遍越权请求。这两步走完,心里才踏实。希望帮到你。
本文还有配套的精品资源,点击获取