☰
layui+ThinkPHP6后台权限管理实战:RBAC五表与中间件鉴权
2026/10/6 3:44:16 网站建设 项目流程

简介:一套基于layui2.5.6与ThinkPHP6.0.2构建的权限管理后台源码包,适合PHP开发者、初学者以及需要快速搭建后台管理系统的技术人员。项目将layui前端组件库的轻量界面与TP6框架的RBAC权限控制模型结合,覆盖用户角色分配、权限控制与后台通用模块,可作为学习前后端协同开发或进行二次开发的起点。资源包共1098个文件,以PHP核心逻辑、JavaScript交互脚本、CSS样式与HTML页面为主,另含环境配置样例、SQL建库脚本、Composer依赖管理文件及项目说明文档,压缩包约6.1MB,目录结构清晰完整。已有2283人学习下载。通过源码可了解TP6的路由、中间件、服务容器等机制,以及layui在表格、弹层、表单等场景中的实践写法;配套的.env、.gitignore、composer.json等工程文件亦有助于规范日常项目开发。

1. 这个组合还值得抄吗:layui 2.5.6 与 TP6.0.2 的后台权限底座

先回答一个很多人会纠结的问题:都已经 2025 年了,用它俩搭权限管理后台,是不是在给简历埋雷?我的看法相反。你接手的大量存量项目,尤其是中小企业的内部运营后台、教学实训系统、外包交付的 CMS 扩展,前端依然是 layui 2.5.x、后端还是 ThinkPHP 6.0.x。这套组合的最大价值不是技术新,而是资料全、会的人多、权益关系干净、能快速让一个「需要登录才能用、不同角色只能看到自己该看的东西」的后台跑起来。layui 负责把表格、弹窗、表单、菜单这些后台常规交互包成现成组件,TP6 负责路由、ORM、中间件、鉴权这些服务端逻辑。权限管理是所有后台系统的地基,把这一层打扎实,后面接什么功能模块都不慌。

这篇文章会直接照着标题里的版本组合,把 RBAC 权限模型的表结构、TP6 中间件鉴权流程、layui 动态菜单和表格渲染串起来,给你一套能落地的代码骨架。我按自己习惯的「用户—角色—节点」三层来拆,不引入太多第三方权限扩展包,方便你在旧环境里原样跑通。前两章先立数据模型和权限校验逻辑,中间几章给前后端可复现代码,最后一章说点按钮级权限和翻车经验。老规矩,每一段代码都会带上参数说明和常见失败特征。

2. 先立数据模型:RBAC 的 5 张表与 TP6 模型关联写法

2.1 为什么用 RBAC 五表,而不是给用户表加一个 level 字段

权限管理最懒的做法是在 admin 表里加一个level整型字段,1 是超管、2 是编辑、3 是访客,判断的时候直接比对大小。这种做法在前几个功能模块下撑得住,一旦出现「运营能看到订单但看不到退款」「代理商能看到自己客户但看不到别人客户」这种细粒度需求,level 字段就彻底不够用了——层级关系只能表达大小,表达不了「拥有哪些权限点」。

我这边落地的方案是经典 RBAC 五表:管理员表、角色表、节点表(也叫权限规则表),外加两个关联表。管理员与角色是多对多,角色与节点也是多对多。这样新增一个角色不需要改代码,在表里配一下节点 ID 集合就行;调整某个角色能访问的菜单,也只需要操作关联表。

建表语句我直接给你可执行的版本,数据库字符集统一 utf8mb4。

CREATE TABLE `admin_user` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL DEFAULT '' COMMENT '登录账号', `password` varchar(64) NOT NULL DEFAULT '' COMMENT '密码,sha256 加盐', `real_name` varchar(50) NOT NULL DEFAULT '' COMMENT '姓名', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `last_login_time` int NOT NULL DEFAULT '0' COMMENT '最后登录时间', `last_login_ip` varchar(45) NOT NULL DEFAULT '' COMMENT '最后登录IP', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台管理员表'; CREATE TABLE `role` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `name` varchar(30) NOT NULL DEFAULT '' COMMENT '角色名称', `remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注', `status` tinyint NOT NULL DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; CREATE TABLE `node` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `parent_id` int NOT NULL DEFAULT '0' COMMENT '父节点ID,0为顶级', `name` varchar(50) NOT NULL DEFAULT '' COMMENT '节点名称/菜单名', `path` varchar(100) NOT NULL DEFAULT '' COMMENT '路由地址,如 admin/user/index', `icon` varchar(50) NOT NULL DEFAULT '' COMMENT 'layui 图标 class', `type` tinyint NOT NULL DEFAULT '1' COMMENT '1菜单 2按钮/操作', `sort` int NOT NULL DEFAULT '0' COMMENT '排序值', `status` tinyint NOT NULL DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节点表'; CREATE TABLE `admin_user_role` ( `admin_id` int NOT NULL DEFAULT '0', `role_id` int NOT NULL DEFAULT '0', PRIMARY KEY (`admin_id`, `role_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员-角色关联表'; CREATE TABLE `role_node` ( `role_id` int NOT NULL DEFAULT '0', `node_id` int NOT NULL DEFAULT '0', PRIMARY KEY (`role_id`, `node_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色-节点关联表';

这里栽过跟头的地方有两处。第一处,node 表的parent_id默认值写成 NULL,导致 layui 的 tree 组件读取时出现parent_id为 null 判断失误,渲染出无限层级的空节点,建表时务必把默认值0写死。第二处,password字段长度如果按 ThinkPHP 5 时代习惯留 32 位,存 sha256 加盐后的 64 位字符串会被截断,登录永远比对不上。

2.2 TP6 模型关联:belongsToMany 的用法与坑

TP6 里的模型关联写法比 5.x 更规整。在app/common/model/AdminUser.php里定义与角色的关联:

<?php declare(strict_types=1); namespace app\common\model; use think\Model; class AdminUser extends Model { // 表名 protected $name = 'admin_user'; // 自动时间戳 protected $autoWriteTimestamp = true; // 密码自动加密写入 public function setPasswordAttr(string $value): string { return password_hash($value, PASSWORD_BCRYPT); } // 管理员与角色 多对多 public function roles() { return $this->belongsToMany(Role::class, 'admin_user_role', 'role_id', 'admin_id'); } }

重点说belongsToMany四个参数。第一个参数是关联模型;第二个是中间表名;第三个是当前模型在中间表里的外键,对 AdminUser 来说就是role_id;第四个是中间表里关联本表的键,即admin_id。顺序反了会导致查出来的角色列表一直是空的,而且 TP6 不报错,这是最难受的。你在排查时可以直接看生成的 SQL:

$admin = AdminUser::with(['roles'])->find(1); echo $admin->toJson();

如果 roles 为[],先确认中间表有没有数据,再确认上面四个参数是不是写反。另外,TP6 默认主键是id,如果你的表主键叫admin_id,必须在模型里加protected $pk = 'admin_id';,否则关联条件会拼错。

接下来,角色模型的关联写法对称:

<?php declare(strict_types=1); namespace app\common\model; use think\Model; class Role extends Model { protected $name = 'role'; public function nodes() { return $this->belongsToMany(Node::class, 'role_node', 'node_id', 'role_id'); } public function admins() { return $this->belongsToMany(AdminUser::class, 'admin_user_role', 'admin_id', 'role_id'); } }

2.3 登录时把节点缓存起来,避免每次都查关联表

权限判断如果每次请求都走多次联表查询,后台一旦菜单多、用户多,慢查询会很明显。我一般会在登录成功时,一次性查出当前用户的全部节点 path,按类型分别缓存到 Session 里。

use app\common\model\AdminUser; use think\facade\Session; public function doLogin() { $username = input('post.username'); $password = input('post.password'); $admin = AdminUser::where('username', $username)->where('status', 1)->find(); if (!$admin || !password_verify($password, $admin->password)) { return json(['code' => 0, 'msg' => '账号或密码错误']); } // 取出角色关联的节点列表 $roleIds = $admin->roles()->column('id'); if (empty($roleIds)) { return json(['code' => 0, 'msg' => '该账号未分配角色']); } $nodes = Node::whereIn('id', function ($query) use ($roleIds) { $query->table('role_node')->whereIn('role_id', $roleIds)->field('node_id'); })->order('sort asc')->select()->toArray(); // 拆分菜单节点与按钮节点 $menuNodes = array_values(array_filter($nodes, function ($v) { return $v['type'] == 1; })); $buttonNodes = array_values(array_filter($nodes, function ($v) { return $v['type'] == 2; })); Session::set('admin_id', $admin->id); Session::set('admin_real_name', $admin->real_name); Session::set('menu_nodes', $menuNodes); Session::set('button_nodes', $buttonNodes); return json(['code' => 1, 'msg' => '登录成功']); }

这段代码有一个小知识点:whereIn('id', function($query){...})传子查询,TP6 会拼成id IN (SELECT node_id FROM role_node WHERE role_id IN (...))。子查询里我用field('node_id')指定字段,如果不指定,默认*在某些 MySQL 版本下会导致子查询返回多列,TP6 拼 SQL 报错。这个报错非常典型,我初学 TP6 时在这个语法上翻了好几次车。

3. 后端权限校验:TP6.0.2 中间件与路由拦截

3.1 中间件写成类,注册到全局或分组

TP6 的中间件机制和 Laravel 的中间件很像,但初学者最容易摔的地方是先写了中间件却忘了注册。TP6.0.2 里中间件注册文件是app/middleware.php,如果你把类放到app/middleware/目录,但没在middleware.php里挂上,它不会被自动加载。这一点跟 TP5 时代的自动加载行为完全不同。

权限校验中间件的职责很简单:从 Session 拿当前管理员 ID,没有就跳登录;有就把请求的 path 与当前用户可访问节点比对,不在允许列表里就返回 403。先看代码:

<?php declare(strict_types=1); namespace app\middleware; use think\facade\Session; use think\Request; use think\Response; class AuthCheck { public function handle(Request $request, \Closure $next) { // 无需登录的接口列表 $whitelist = [ 'admin/login/index', 'admin/login/doLogin', 'admin/login/captcha', ]; // 当前请求路径,格式如 admin/user/index $path = strtolower($request->pathinfo()); if (in_array($path, $whitelist)) { return $next($request); } // 校验登录态 if (!Session::has('admin_id')) { // 如果是 AJAX 请求,返回 JSON,否则跳转登录页 if ($request->isAjax() || $request->isJson()) { return json(['code' => 401, 'msg' => '请先登录'], 401); } return redirect((string)url('admin/login/index')); } // 校验节点权限 if (!$this->checkAuth($path)) { if ($request->isAjax()) { return json(['code' => 403, 'msg' => '无权限访问'], 403); } return Response::create('无权限访问', 'html', 403); } return $next($request); } private function checkAuth(string $path): bool { // 超级管理员直接放行,用 admin_id 判断,例如 1 号 if (Session::get('admin_id') == 1) { return true; } // 从缓存中取出按钮节点和菜单节点,合并成可访问路径集合 $menuNodes = Session::get('menu_nodes', []); $buttonNodes = Session::get('button_nodes', []); $allNodes = array_merge($menuNodes, $buttonNodes); foreach ($allNodes as $node) { if (strtolower($node['path']) === $path) { return true; } } return false; } }

3.2 注册中间件并处理 TP6 的返回 null 问题

在app/middleware.php中注册:

return [ \app\middleware\AuthCheck::class ];

注意 TP6.0.2 的中间件回调里,如果你在handle中忘记return $next($request);,控制器的输出会变成空白页面。这个现象神似控制器写错了命名空间,但实际上是中间件吞掉了响应。遇到白屏时先检查中间件是不是丢 return,再看控制器命名空间。

我把这个中间件注册成全局中间件,原因很简单:所有后台模块都挂在admin路由前缀下,统一在这里做权限校验可以省去在每个控制器手动调checkAuth。如果项目里有api模块,建议启动app\Request的模块判断,区分后台路由。

AST 步骤里有一件事必须做:路由定义时,控制器方法要严格和 node 表里的path对上。例如用户列表页是admin/user/index,对应的 Controller 是app\admin\controller\User的index方法。如果路由用了别名、大小写混用,权限比对就会漏掉你明明分配过的节点。我的建议是 node 表path字段统一小写,路由定义也统一小写。

下面给一份路由文件示例:

use think\facade\Route; Route::group('admin', function () { Route::get('/login', 'admin/login/index'); Route::post('/login/doLogin', 'admin/login/doLogin'); Route::get('/logout', 'admin/login/logout'); Route::group('user', function () { Route::get('index', 'admin/user/index'); Route::get('add', 'admin/user/add'); Route::post('save', 'admin/user/save'); Route::get('edit/:id', 'admin/user/edit'); Route::post('update/:id', 'admin/user/update'); Route::post('delete/:id', 'admin/user/delete'); })->prefix('admin.'); // 更多模块... })->middleware(\app\middleware\AuthCheck::class);

这串路由代码有几个细节。prefix('admin.')让组内控制器都指向app\admin\controller\User,不再额外写控制器命名空间,避免拼错。edit/:id这种路径参数,在控制器方法里接收时用Request->param('id'),不再支持$id方式直接注入,这是 TP6 和 TP5 的一个明显差异。

注册方式上,我建议不要只挂在分组中间件上,还是挂全局app/middleware.php,然后在中间件里做白名单,因为后台前端偶尔会调用一些公共接口,全局拦截不会漏掉 module 下其他控制器。

3.3 节点分配页面:把 node 表变成 layui 表格能选的数据

权限的后端不只是校验,还得给运营同学一个分配入口。这里我通常做一个简单的节点列表管理页面,左侧显示树形菜单,右侧是角色管理。比较常规的做法是直接建一个admin/role控制器的auth方法,接收角色 ID,查出该角色的 node_ids,然后 layui 前端用 table 渲染全部节点,并回显勾选状态。

因为 2.5.6 版本的 layui 对 tree 组件的支持没有后续版本好,我直接在 node 列表页用一个纯表格,每行带parent_id和id,前端 layui 的table.render渲染时通过treeView模板把层级缩进显示出来。后端返回的 JSON 保持扁平列表,不嵌套:

public function auth() { $roleId = input('get.role_id/d', 0); $nodes = Node::order('sort asc')->select()->toArray(); $roleNodeIds = Db::name('role_node')->where('role_id', $roleId)->column('node_id'); return json([ 'code' => 0, 'msg' => 'ok', 'data' => $nodes, 'role_node_ids' => $roleNodeIds, ]); }

这样前端可以拿到两个数据源,一个是全节点列表,一个是角色已拥有的节点 ID 数组,用它来操控 checkbox 勾选状态。

4. 前端落地:layui 2.5.6 动态菜单、主框架与列表页

4.1 登录页 layout 与主框架 iframe 方案选择

layui 1.x 时代很多人直接用 iframe 多页面方式写后台,到了 2.5.6,官方 admin 模板和 index 页依然是 iframe 风格。为什么不用 SPA 单页?因为你要快速把权限后台搭完,iframe 的好处是按路由地址加载页面,后端返回 HTML 片段,省去前端路由配置,而且和 TP6 的 pathinfo 路由天然匹配。

主页面结构一般长这样:左侧菜单栏、顶部顶部栏、右侧 content iframe。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>后台管理</title> <link rel="stylesheet" href="/static/layui/css/layui.css"> </head> <body class="layui-layout-body"> <div class="layui-layout layui-layout-admin"> <div class="layui-header"> <div class="layui-logo" style="font-size:16px">权限管理后台</div> <ul class="layui-nav layui-layout-right"> <li class="layui-nav-item"> <a href="javascript:;"><?php echo session('admin_real_name'); ?></a> <dl class="layui-nav-child"> <dd><a href="/admin/login/logout">退出</a></dd> </dl> </li> </ul> </div> <div class="layui-side layui-bg-black"> <div class="layui-side-scroll" id="menuBox"> <!-- 菜单动态渲染到这个容器 --> </div> </div> <div class="layui-body" style="margin-left: 200px;"> <iframe id="mainFrame" src="/admin/index/welcome" style="width:100%;height:100%;border:0;"></iframe> </div> </div> <script src="/static/layui/layui.js"></script> <script> layui.use(['element', 'jquery'], function () { var element = layui.element; var $ = layui.$; // 从后端注入的菜单数据渲染左侧 var menuNodes = <?php echo json_encode(session('menu_nodes'), JSON_UNESCAPED_UNICODE); ?>; function renderMenu(nodes) { var html = '<ul class="layui-nav layui-nav-tree">'; // 先渲染顶级节点 var topNodes = nodes.filter(function (v) { return v.parent_id == 0; }); topNodes.forEach(function (top) { // 找子节点 var children = nodes.filter(function (v) { return v.parent_id == top.id; }); if (children.length > 0) { html += '<li class="layui-nav-item">'; html += '<a href="javascript:;">' + top.name + '</a>'; html += '<dl class="layui-nav-child">'; children.forEach(function (child) { html += '<dd><a href="javascript:;">table.render({ elem: '#userTable', url: '/admin/user/index', page: true, cols: [[ { field: 'id', title: 'ID', width: 80, align: 'center' }, { field: 'username', title: '账号', minWidth: 120 }, { field: 'real_name', title: '姓名', width: 120 }, { field: 'last_login_time', title: '最后登录', width: 160, templet: function (d) { if (!d.last_login_time) return '-'; var date = new Date(d.last_login_time * 1000); return date.getFullYear() + '-' + (date.getMonth()+1) + '-' + date.getDate(); }}, { title: '操作', width: 160, toolbar: '#barTemplate' } ]], parseData: function (res) { return { "code": res.code, "msg": res.msg, "count": res.data.total, "data": res.data.data }; }, response: { statusCode: 0 } });

后端控制器对应写法:

public function index() { // 列表分页查询 $page = input('get.page/d', 1); $limit = input('get.limit/d', 20); $list = AdminUser::order('id desc') ->paginate([ 'list_rows' => $limit, 'page' => $page, 'query' => request()->get(), ]); return json([ 'code' => 0, 'msg' => 'ok', 'data' => [ 'total' => $list->total(), 'per_page' => $list->listRows(), 'current_page' => $list->currentPage(), 'data' => $list->items(), ] ]); }

这里参数对应关系是 layui 表格的page参数会默认传page和limit到后台,我手动用input('get.page/d')接收。/d是强制转整型,防止非法参数把 SQL 带偏。分页查询里的'query' => request()->get()在生成上一页、下一页链接时保留搜索参数,这一步如果漏了,翻页时会丢失搜索条件。

碰过一次印象深刻的问题:AdminUser::paginate(20)这种固定 20 条每页的写法,会让前端切换每页条数失效。因为 layui 传了limit,但 TP6 的paginate(20)把它截断了。必须用上面的数组传参方式。

4.3 表单新增与编辑:layer 弹窗 + iframe 表单提交

后台列表的新增编辑我习惯用 layer 的 iframe 弹窗。layui 表单的lay-submit拦截提交,然后发送到后台,成功之后父页面刷新表格。这里最常踩的坑是layer.close(index)拿不到弹窗索引,导致关不掉弹窗。

// 新增按钮 $('#addBtn').on('click', function () { var index = layer.open({ type: 2, title: '新增用户', area: ['600px', '400px'], content: '/admin/user/add' }); }); // 编辑按钮(表格工具条事件) table.on('tool(userTable)', function (obj) { var data = obj.data; if (obj.event === 'edit') { var index = layer.open({ type: 2, title: '编辑用户', area: ['600px', '400px'], content: '/admin/user/edit?id=' + data.id }); } if (obj.event === 'delete') { layer.confirm('确定删除该用户?', function (index) { $.post('/admin/user/delete', {id: data.id}, function (res) { if (res.code === 0) { layer.msg('删除成功'); table.reload('userTable'); } else { layer.msg(res.msg, {icon: 2}); } layer.close(index); }); }); } });

添加用户弹窗的 iframe 页面里,表单提交脚本是这一套:

layui.use(['form', 'jquery'], function () { var form = layui.form; var $ = layui.$; form.on('submit(saveBtn)', function (data) { $.post('/admin/user/save', data.field, function (res) { if (res.code === 0) { // 父页面刷新列表并关闭当前弹窗 parent.table.reload('userTable'); var index = parent.layer.getFrameIndex(window.name); parent.layer.close(index); } else { layer.msg(res.msg, {icon: 2}); } }, 'json'); return false; // 阻止表单默认提交 }); });

parent.layer.getFrameIndex(window.name)是获取 iframe 所在弹窗索引的标准做法,不要直接在子页面里layer.close(index),因为那个 index 是父级的变量。

5. 避坑指南:TP6.0.2 与 layui 2.5.6 实战中的 6 个常见问题

5.1 中间件没注册导致权限校验形同虚设

现象:访问后台任意 URL,都能看到页面内容,登录不登录一个样。

原因:TP6.0.x 的中间件不像 TP5 那样自带模块配置,你必须在app/middleware.php里显式注册。我见过不少同事在控制器构造函数里写了$this->middleware(AuthCheck::class),但 TP6 的构造函数不自动绑定中间件。

解决:在app/middleware.php里直接注册中间件,并且确认控制器里没有二次unset会删掉中间件别名。注册后用php think route:list查看路由是否带middleware属性。

5.2 登录过期以后,AJAX 拿到的是一段 HTML 而不是 JSON

现象:后台挂着页面长时间不操作,再点保存或删除,前端报 parsererror,Network 面板显示返回的是登录页 HTML。

原因:中间件里对未登录的 AJAX 请求直接 redirect,被 fetch/jQuery 解析 JSON 失败。

解决:在中间件对isAjax()或isJson()请求返回 401 JSON,而不是 redirect。同时前端$.post失败回调里判断xhr.status == 401,自动跳转/admin/login/index,接上登录态。

5.3 layui table 显示「数据接口请求异常」,后台却明明有返回

现象:列表数据加载不出来,控制台 F12 里看到接口 200,返回 JSON 结构也接近 layui 要求,但表格就是报异常。

原因:parseData 返回结构里没有code字段,或者code字段值和response.statusCode不一致。TP6 的json(['code'=>0,...])默认 HTTP 状态码是 200,layui 判断数据是否正常以code == 0为准。如果后端把code写成1表示成功,layui 默认不认。

解决:统一code=0表示成功,并在 parseData 里显式映射code: res.code。要么修改response.statusCode: 1,二者选其一,最忌前端一套后端一套。

5.4 TP6 分页每页条数失效,limit 参数永远是默认值

现象:layui 表格切换每页 20、50、100 条,URL 里 limit 变了,返回数量不变。

原因:控制器用了paginate(20)这种固定条数写法,TP6 不再理会请求里传入的 limit。TP5 时代paginate有自动读取 limit 的逻辑,6.0 改掉了。

解决:必须用数组写法paginate(['list_rows'=>$limit, 'page'=>$page, 'query'=>request()->get()]),并且$limit = input('get.limit/d', 20)。这是我在升级时翻车最严重的地方,旧的固定条数代码全得改。

5.5 layui 菜单树明明渲染出来了,点击 path 却 404

现象:左侧菜单能显示层级,点了以后 iframe 内 404 或者报控制器不存在。

原因:菜单数据里的path字段没带admin前缀,比如存的是user/index,登录后 iframe 却访问/user/index,路由对应不上admin模块。TP6 多应用模式下默认访问的模块按 URL 第一段来解析,少admin前缀等于直接走 index 模块。

解决:node 表里path统一存完整路径,如admin/user/index,前端拼 src 时直接/+ path。校验登录状态时也是拿这个完整路径比对,别只比对控制器方法。

5.6 sha256 密码长度不足导致登录比对失败

现象:更新密码后无法登录,数据库里的密码字符串看起来被截断了。

原因:早期建表用varchar(32),sha256 输出 64 位,migration 改过长度后旧数据依然是截断的 32 位。

解决:ALTER TABLE admin_user MODIFY COLUMN password VARCHAR(100) NOT NULL DEFAULT '';然后重置密码。顺便建议在密码入库前做password_hash,兼容未来更换散列算法,这也是上一章模型里setPasswordAttr存在的意义。

6. 最后一公里:按钮级权限与动态隐藏的收尾技巧

权限系统做到菜单级只能算及格。运营提的需求往往是「张三能看到订单列表,但不能点导出」「李四能编辑资料,但不能删除」。菜单级拦不住这种诉求,按钮级才算完整。

按钮级权限落地方案:node 表里type=2的节点作为按钮项,path 可以存对应请求接口,前端在模板渲染时比对button_nodes是否有对应 path,没有就不渲染按钮。思路不复杂,难的是让权限点与前端操作项一一对应。

我在模板里写了一个公共函数,放在app/common.php:

function hasAuth(string $path): bool { $adminId = session('admin_id'); if ($adminId == 1) { return true; } $buttonNodes = session('button_nodes', []); foreach ($buttonNodes as $node) { if (strtolower($node['path']) === strtolower($path)) { return true; } } return false; }

然后前端 layui 表格里加按钮时判断:

<script type="text/html" id="barTemplate"> {{# if (hasAuth('admin/user/edit')) { }} <a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a> {{# } }} {{# if (hasAuth('admin/user/delete')) { }} <a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="delete">删除</a> {{# } }} </script>

注意 layui 自带简易模板引擎里调用函数必须在渲染前把函数暴露到全局,否则模板编译报错。我一般在layui.use前加一行window.hasAuth = hasAuth;。另外,后端中间件同样要校验按钮对应的接口,因为同事从控制台直接 POST 一样能调接口,前端隐藏只防误点不防恶意请求。

这套组合还有一个值得做好但很多人忽略的点:操作日志。每次删除、编辑、导出,都应该记录管理员 ID、操作时间、请求 path、入参摘要。权限系统不配日志,出了越权事故没法追溯。日志表不要做的太重,五到六个字段足够,写一个中间件统一记录即可,别每个控制器手写。

做了这么多后台权限的项目,我自己的习惯是先把 node 表的数据结构设计到「菜单 + 按钮」两级再动手写页面。菜单做导航,按钮做校验,权限点天然能对应到接口,后续加新功能模块时只需要在 node 表里插两条记录和一个菜单项,不用改动任何 PHP 逻辑。这个习惯帮我避开过很多次「为了加个按钮改动整张表结构」的血泪教训。希望这套方案能让你少踩几个坑,改造成自己顺手的样子比直接照搬更重要。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询