拓冰建站拓冰建站
首页 / 资讯中心 / 正文

ThinkPHP6+Vue2极简后台管理系统:从鉴权到CRUD闭环实践

简介这套基于ThinkPHP6与Vue2构建的极简后台管理系统是一份可直接运行的完整源码主要面向需要快速搭建管理后台、或希望学习前后端分离开发流程的PHP工程师。压缩包共409个文件、约5.91MB其中184个PHP文件承担后端业务逻辑127个JS文件配合27个CSS文件构建前端界面还附带SQL数据库脚本、模板配置、图标字体与图片素材并按后端接口、前端资源、页面模板等目录分区便于按模块查阅。系统内置用户登录、RBAC角色权限、日志管理等基础能力前端使用Vue2配合路由和状态管理渲染动态菜单后端通过ThinkPHP6提供数据接口与权限校验完整展现前后端联动机制。已有919人学习浏览源码附有可部署的基础工程读者可以对照研究数据库设计、接口封装、登录鉴权、菜单渲染等关键环节也可以在现有权限框架上快速添加新业务模块降低从零搭建成本。无论是作为课程设计、毕业设计还是企业小型后台系统这套代码都提供了清晰的分层结构与组件化实践具有较好的参考与复用价值。1. 基于ThinkPHP6和Vue2的极简后台管理系统为什么这套组合仍然值得用后台管理系统大概是整个 Web 开发里最容易被低估的需求表面上只是登录、菜单、增删改查但真正落到能交付、能维护、能换人接手这三个条件时新框架反而成了负担。ThinkPHP6 的部署门槛低到一台 1G 内存的云主机就能跑Vue2 加 Element UI 的组件生态覆盖了表格、表单、弹窗、树控件这些后台高频场景而源码包形式的交付意味着你不用从零搭工程骨架。这套组合适合三类人接外包需要用 PHP 快速交付的、公司内部系统要长期有人维护的、以及想搞清楚一个完整后台从登录到权限闭环怎么串起来的初学者。极简不是代码少而是把权限、接口规范、列表渲染这三条线理清楚剩下的业务表往上挂就行。2. ThinkPHP6 多应用模式下的接口骨架与鉴权设计2.1 多应用模式的目录约定与 URL 规则标题里的后台管理系统绝大多数会同时存在 admin 管理端和 api 接口端。ThinkPHP6 从 6.0 开始把多应用变成了可选组件先确认composer.json里有没有topthink/think-multi-app没有就装一下。装完之后app目录下每个子目录就是一个独立应用常见做法是拆成admin和api两个admin 走服务端渲染或独立前端打包api 统一返回 JSON。project/ ├── app/ │ ├── admin/ # 管理后台的控制器、模型、视图 │ │ ├── controller/ │ │ └── model/ │ ├── api/ # 对外接口 │ │ ├── controller/ │ │ ├── middleware/ │ │ └── validate/ │ └── common.php # 公共函数 ├── config/ │ └── app.php # 多应用开关 ├── public/ │ ├── index.php # 入口 │ └── .htaccess # 生产环境伪静态 └── route/ └── app.php # 全局路由config/app.php里有一段控制自动多应用的配置最常见的坑是忘了开自动解析导致访问/api/user直接 404auto_multi_app true, default_app index, app_express false,auto_multi_app打开后URL 规则形如https://你的域名/api/user/list第一个路径段就是应用名。如果入口文件在public下开发环境直接用php think run起内置服务器访问http://127.0.0.1:8000/api/user/list生产环境是 Nginx 时要注意伪静态规则必须把不存在的文件路径 rewrite 到index.php否则多应用 URL 会全部 404。URL 里不带index.php是 ThinkPHP6 的默认约定这和路由解析的pathinfo模式直接相关。2.2 用中间件统一处理登录态与角色校验极简后台不等于不鉴权。登录态校验放在中间件里做接口代码只需要关心业务逻辑这是前面提到的三者里优先级最高的一条线。在app/api/middleware/下建一个Auth.php登录接口放行其余接口全部走这里?php declare(strict_types1); namespace app\api\middleware; use think\facade\Cache; use think\Response; class Auth { public function handle($request, \Closure $next) { // 登录接口本身不校验 token if ($request-pathinfo() api/login) { return $next($request); } $token $request-header(token, ); if (empty($token)) { return json([code 401, msg 未登录或 token 缺失]); } $userId Cache::get(token_ . $token); if (!$userId) { return json([code 401, msg 登录已过期请重新登录]); } // 把用户 ID 挂在请求对象上下游控制器直接 $request-userId 取用 $request-userId $userId; return $next($request); } }中间件逻辑说明Cache::get(token_ . $token)拿到的userId是登录成功时写入的缓存驱动建议用 Redis 或think-cache的 file 驱动过期时间根据业务定我一般设 7200 秒。这个方案的优点是 token 无状态化服务端重启会话不丢缺点是每次请求多一次缓存读。极简系统里没必要上 OAuth2 那套单 token 就够了。接口层统一返回结构前后端对接时不吵架状态码表格如下code含义前端动作0成功正常渲染401未登录或过期跳登录页并清空本地 token403无权限提示无权限不跳转422参数校验失败用msg里的字段错误提示500服务端异常Toast 提示后保留当前页控制器基类里加一个success和error方法避免每个接口重复写json([code...])。2.3 验证器与 ORM 查询条件收口极简后台最常见的脏代码是控制器里一堆if ($_POST[name])之类的判断。ThinkPHP6 提供了验证器独立成类的好处是编辑和新增可以复用同一套规则。用户管理模块的验证器这样写?php declare(strict_types1); namespace app\api\validate; use think\Validate; class User extends Validate { protected $rule [ username require|max:32, email email, status in:0,1, sort number, role_id require|number, ]; protected $message [ username.require 用户名不能为空, username.max 用户名最长 32 个字符, email.email 邮箱格式不正确, status.in 状态值只能是 0 或 1, role_id.require 请选择角色, ]; // 场景编辑时 username 可以不传但传了必须校验 protected $scene [ edit [email, status, sort, role_id], ]; }控制器里调用validate(User::class)-scene(edit)-check($data)失败时getError()返回第一条错误信息。好处是参数规则集中在一个类里接口文档或前端同事问起来打开文件就能答。查询条件收口用模型的scope比如列表接口只展示未删除且状态正常的用户?php declare(strict_types1); namespace app\api\model; use think\Model; class User extends Model { protected $pk id; protected $name user; public function scopeActive($query) { return $query-where(status, 1)-where(is_delete, 0); } public function scopeKeyword($query, $keyword) { if (!empty($keyword)) { return $query-where(username|phone|email, like, % . $keyword . %); } return $query; } }控制器里User::scopeActive()-scopeKeyword($keyword)-paginate(15)一个接口就收工了。这个写法的可维护性在于任何列表接口的筛选条件都先看模型而不是在控制器里拼where。3. Vue2 生命周期驱动的登录态管理与动态路由3.1 axios 封装里 data 为什么必须返回 returnVue2 工程落地第一件事是封装请求层。src/utils/request.js里用 axios 实例统一处理 baseURL、超时和响应拦截器这一段抄作业价值最高import axios from axios const service axios.create({ baseURL: /api, timeout: 10000, }) // 请求拦截器从 localStorage 拿 token 塞进请求头 service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers.token token } return config }, error { return Promise.reject(error) } ) // 响应拦截器统一处理 code service.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(未登录)) } if (res.code ! 0) { window.$message.error(res.msg) return Promise.reject(new Error(res.msg)) } return res.data }, error { window.$message.error(网络异常请稍后重试) return Promise.reject(error) } ) export default service顺带解释一个 Vue2 面试题为什么在工程里是真规则组件里的data写成return { ... }而不是对象字面量是因为组件会被复用如果直接返回对象多个实例共享同一份引用改一个组件的字段会导致所有同组件实例一起变。对后台管理系统里每一行列表的展开、每一处弹窗的使用这个区别是可见的。页面级组件的data写成普通对象大多数时候没问题但统一写成函数返回能避免后续把页面改造成组件时的隐性问题。3.2 路由守卫在 created 里拉菜单的时序问题后台动态路由的标准做法是登录后拿到 token前端根据角色 ID 或权限码在后端接口返回的菜单列表里生成可访问的路由表再通过router.addRoutes加进去。src/permission.js文件一般长这样import router from ./router import store from ./store const whiteList [/login] router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (token) { if (to.path /login) { next(/) } else { // 有 token 但还没拉过菜单才需要拉取并动态注册 if (!store.state.user.menusLoaded) { store .dispatch(user/fetchMenus) .then(() { // 动态添加的路由必须在放行前加完 next({ ...to, replace: true }) }) .catch(() { localStorage.removeItem(token) next(/login) }) } else { next() } } } else { if (whiteList.includes(to.path)) { next() } else { next(/login) } } })这里的时序很容易踩坑next({ ...to, replace: true })看起来是重新导航到当前目标实际作用是在fetchMenus完成、addRoutes执行完之后让路由匹配器重新解析一次to。如果直接next()Vue Router 在本次导航里已经认为目标路由不存在动态添加的路由不会生效结果就是白屏。Vue2 生命周期在这个环节的体现是created钩子里只负责触发 action不直接调用next。菜单数据存在 Vuex 里store/modules/user.js的核心是import { getMenus } from /api/user const state { menusLoaded: false, menus: [], } const mutations { SET_MENUS: (state, menus) { state.menus menus state.menusLoaded true }, } const actions { async fetchMenus({ commit }) { const menus await getMenus() commit(SET_MENUS, menus) // 把后端返回的菜单结构转成路由配置常见字段映射 // path - path, name - name, component - (frame or view) }, }动态路由的component字段不能直接使用后端返回的字符串需要在前端维护一个组件映射表用import(/views/ componentPath)懒加载。极简做法是组件路径约定好比如system/user/index。3.3 用 Popconfirm 做出带输入框的二次确认Element UI 的Popconfirm组件本身只带确认/取消两个按钮加工成可输入表单的思路是把输入框放进具名插槽里。列表里的删除操作配合el-input可以顺手让用户填删除原因这是后台系统里很实际的一个小需求template el-popconfirm title确定删除该用户 confirm-button-text确定 cancel-button-text取消 confirmhandleDelete(row) template #reference el-button typetext sizesmall删除/el-button /template div classdelete-confirm p请输入删除原因/p el-input v-modeldeleteReason placeholder选填 sizemini maxlength50 / /div /el-popconfirm /template script export default { data() { return { deleteReason: , } }, methods: { async handleDelete(row) { await deleteUser({ id: row.id, reason: this.deleteReason }) this.$message.success(已删除) this.fetchList() }, }, } /script注意v-model绑定的deleteReason必须挂在data的 return 对象里且每次弹窗打开时手动清空否则上一次的输入会残留到下一次。这个场景比单独用this.$confirm弹窗轻量交互上点一下说明原因再确认误删率明显低。4. 把 CRUD 闭环打通用户管理的列表、分页与拖曳排序4.1 建表与模型绑定极简项目怎么设计第一张表标题里的极简落在数据表设计上就是限制自己不要滥用字段。用户表只保留刚需字段CREATE TABLE user ( id int(11) unsigned NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL DEFAULT COMMENT 用户名, password varchar(255) NOT NULL DEFAULT COMMENT 密码bcrypt, nickname varchar(32) NOT NULL DEFAULT COMMENT 昵称, email varchar(64) NOT NULL DEFAULT COMMENT 邮箱, role_id int(11) NOT NULL DEFAULT 0 COMMENT 角色ID, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, sort int(11) NOT NULL DEFAULT 0 COMMENT 排序值越小越靠前, is_delete tinyint(1) NOT NULL DEFAULT 0 COMMENT 软删除标记, create_time datetime DEFAULT NULL COMMENT 创建时间, update_time datetime DEFAULT NULL COMMENT 更新时间, PRIMARY KEY (id), KEY idx_status_sort (status, sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT后台用户表;密码存储用 PHP 自带的password_hash()校验用password_verify()不要再用 md5。sort字段一开始就加上后面做拖曳排序才有落点极简不等于不留扩展位。模型对应关系在 ThinkPHP6 里不需要额外配置文件类名和表名约定映射即可User模型对应user表。4.2 列表接口与前端字段映射的约定接口返回结构保持扁平前端不需要做二次加工。控制器里一个标准列表接口?php declare(strict_types1); namespace app\api\controller; use app\api\model\User; use think\response\Json; class UserController { public function list(): Json { $page (int) request()-param(page, 1); $pageSize (int) request()-param(page_size, 15); $keyword trim((string) request()-param(keyword, )); $list User::scopeActive() -scopeKeyword($keyword) -order(sort asc, id desc) -paginate([ list_rows $pageSize, page $page, ]); return json([ code 0, msg ok, data [ list $list-items(), total $list-total(), page $list-currentPage(), ], ]); } }paginate的数组参数里list_rows对应页码可返回条数page对应当前页这两个参数名是 ThinkPHP6 的分页约定很多人在接口联调时因为前端传了rows或pagesize拿不到数据本质是参数没有对齐。前端列表页对应关系后端返回字段前端 table 列处理方式username用户名直接渲染nickname昵称直接渲染email邮箱直接渲染status状态el-tag三元判断sort排序拖曳后回写create_time创建时间原样输出前端格式化前端el-table渲染时sort列用sortablecustom配合sort-change事件触发重新请求而不是用表格内置的排序——因为后端排序才能真正作用在分页后的数据上这是新手经常搞混的地方。4.3 Vue2 里最省心的列表拖曳排序实现拖曳排序在前台页面上不明显在后台管理系统里却是角色菜单排序、轮播图排序、套餐排序的刚需。Vue2 生态里最稳定的方案不是手写 HTML5 拖放而是sortablejs加一行包一层指令npm install sortablejs封装成 Vue 指令任何el-table或元素列表直接v-draggable就能拖import Sortable from sortablejs export const draggable { inserted(el, binding) { // 只在 el-table 的 body 行上启动拖曳 const tbody el.querySelector(.el-table__body-wrapper tbody) Sortable.create(tbody, { animation: 150, handle: .drag-handle, onEnd({ oldIndex, newIndex }) { if (oldIndex newIndex) return binding.value({ oldIndex, newIndex }) }, }) }, }列表页里注册指令拖完回调里把当前页数组重新排序并调用排序接口el-table v-draggablehandleSort :datatableData row-keyid el-table-column width50 template #default i classel-icon rank drag-handle stylecursor: move;/i /template /el-table-column /el-table // 拖拽结束回调 async handleSort({ oldIndex, newIndex }) { const movedItem this.tableData.splice(oldIndex, 1)[0] this.tableData.splice(newIndex, 0, movedItem) // 把全部可见行的 id 和 sort 发给后端一次更新 const sortMap this.tableData.map((item, index) ({ id: item.id, sort: index, })) await updateSort(sortMap) }后端对应接收排序结果的接口最省事的写法是逐条更新但数据量大时建议用一个事务批量更新。ThinkPHP6 里User::saveAll($sortMap)会根据主键批量更新sort字段前提是$sortMap里的每个元素必须带上id。需要注意拖曳排序只对当前页有效跨页排序需要在onEnd时判断是否移动到页边界极简后台可以不做跨页排序在界面上能一眼看出同页上下拖就够了。5. 部署与排错源码.zip 解压后先改这几个地方5.1 Nginx 伪静态与多应用 URL 重写拿到源码包本机开发用php think run没问题一上 Nginx 就 404九成是伪静态没写。public目录下的.htaccess只管 ApacheNginx 要在server块里加location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; } } location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; }rewrite ^(.*)$ /index.php?s$1 last;这个规则把多应用 URL 里的/api等路径作为参数传给入口文件。改完nginx -s reload再访问https://你的域名/api/user/list能出 JSON 就通了。5.2 三个绕不过去的部署前检查第一个是runtime目录权限。ThinkPHP6 的日志、缓存、session 文件默认写在runtime下容器或云主机用户不一致时直接 500 或白屏。执行chmod -R 775 runtime chown -R www-data:www-data runtime第二个是 PHP 版本与扩展。ThinkPHP6 要求 PHP 7.2.5实际用到password_hash和 JSON 解析建议 8.0 起步php -m确认pdo_mysql、curl、fileinfo存在缺少fileinfo会导致文件上传功能报错。第三个是.env文件里的数据库连接config/database.php读取.env把DATABASE_HOST、DATABASE_NAME、DATABASE_USERNAME、DATABASE_PASSWORD四行改好才能连库这个不叫部署难题但最多人栽在这。5.3 白屏时从哪里开始查前端白屏先按 F12 看 Network 面板接口是否 200接口返回了但页面空转打开 Vue 组件看console里有没有模板编译报错。接口 404 先看伪静态接口 500 再去runtime/log目录下找当天的日志文件tail -n 50 runtime/log/202404/09.log报错信息颜文字里夹着具体文件和行号。整个排查路上最省时间的选择是把app_debug在.env里设为true让异常直接渲染在浏览器上上线时改回false。这套流程走顺了再从登录态、角色权限、列表拖曳这一条主线往业务表上扩展源码包才真正变成你自己的东西。最后给一个长期维护的建议把sort字段回写接口做一次事务包裹顺便记录一下每一次拖拽前后的排序快照以后任何一次数据错乱都能回溯到人。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门