
简介基于 Python、Django、Vue 3 与 Element-Plus 构建的前后端分离单体权限管理系统源码包面向需要快速搭建后台权限模块的开发者与计算机相关专业学生。项目以 Django 为后端核心整合用户、角色、菜单、部门、日志等系统管理应用配合 Vue 3 与 Element-Plus 的前端交互思路为理解 RBAC 权限模型、RESTful API 设计及 Django ORM 操作提供完整参考。压缩包内共 70 个文件以 65 个 Python 源文件为主辅以项目说明、依赖清单、许可文件等标准内容同时涵盖工程配置、路由、ASGI/WSGI 入口与各业务应用整体结构简洁目录划分清晰。项目后台应用中的数据模型、中间件等代码进一步展示了用户认证与权限校验在服务端的实现方式。整个项目包仅 121KB轻量易读便于快速定位关键代码。已有 137 人学习下载适合用于毕业设计、课程实训或企业后台管理系统的二次开发参考。 把权限管理系统做完的那天晚上我盯着终端里最后一行System check identified no issues愣了几秒。这个项目从零到一用了两个星期技术栈锁定在 Python Django 做后端、Vue 3 Element-Plus 做前端前后端完全分离最后打成一个单体权限管理系统。其实“单体”这个词在这里容易让人误会——它不是指一个 Django 模板渲染整站而是指整个业务系统本身是一个独立部署、统一运维、权限模型闭环的完整应用。无论是给公司内部做后台还是给客户交付一套带用户管理的系统这套结构都足够典型。这篇文章我想把这套系统的拆解思路、数据模型设计、核心代码实现以及我在实操过程中踩过的坑全部摊开来讲。适合谁看正在做毕业设计或课程项目的学生准备接手企业内部后台管理系统的开发或者想了解 RBAC 权限模型怎么落地成代码的人都能从里面找到可以参考的东西。我会把“为什么这么设计”也一并说清楚尽量不给只贴代码不解释的方案。1. 项目整体拆分为什么选这套技术栈1.1 前后端分离和“单体”并不冲突先说第一个容易困惑的点。很多人一听“前后端分离”脑海里浮现的是微服务、多个部署单元、API 网关这些复杂架构但权限管理系统这类中后台项目压根不需要那么重。这里的“前后端分离”指的是开发模式和交互模式上的分离后端 Django 只负责提供 RESTful API 和业务逻辑前端 Vue 3 负责页面渲染和用户交互两者通过 JSON 通信。而“单体”指的是部署形态前端打包后的 dist 目录可以交给 Nginx 托管API 请求通过反向代理转发到 Django 服务整个系统还是一个大单体没有拆成多个服务。这个选择的好处非常实在开发时前端和后端可以并行推进互不阻塞部署时只需要维护一个后端服务 一个前端静态目录成本低权限控制集中在后端逻辑不会散落到各个服务里。对于中小规模的权限管理系统这是收益最高、复杂度最低的组合。1.2 Django 做 RBAC 后端逻辑顺不顺选择 Django 不是因为它“流行”而是因为它在权限管理这个领域有天然优势。Django 自带的 User、Group、Permission 模型本身就是一个完整的 RBACRole-Based Access Control实现。虽然实际项目中往往需要自定义用户模型和权限表结构但 Django 这个框架对这类需求提供了非常成熟的基础设施ORM 让模型定义和迁移变得很省心Admin 后台在调试阶段可以直接用来管理数据Django REST FrameworkDRF更是把序列化、视图集、认证、权限等 API 开发必备能力全部内置了。如果换成 Flask 或 FastAPI权限这块就得自己拼不少轮子。不是说不行而是对于一个以权限管理为核心的系统用 Django 属于“顺势而为”开发效率会高很多。1.3 为什么前端锁死 Vue 3 Element-Plus前端选型时我对比过 React Ant Design 和 Vue 3 Element-Plus最终还是选了后者。原因有三一是 Vue 3 的组合式 API 在维护复杂的中后台页面时逻辑复用比 Vue 2 时代舒服太多二是 Element-Plus 本身就是为后台管理场景设计的组件库表格、表单、对话框、树形控件这些用到频率最高的组件全部开箱即用三是中文文档和社区案例极其丰富遇到问题基本一搜就有答案。菜单权限和按钮权限的实现需要前端根据当前用户的角色动态生成路由和渲染按钮这个需求在 Element-Plus 的 Menu 组件下配合 Vue Router 的动态路由写起来非常顺手。如果用别的组件库光是一个“递归渲染多级菜单”就能折腾掉半天时间。2. 核心功能模块与数据模型设计2.1 RBAC 模型的落地方式权限管理系统的核心不是界面而是数据模型。这套系统我采用了经典的五张表设计用户表、角色表、菜单/权限表以及用户-角色关联表、角色-菜单/权限关联表。用 Django 的 ORM 来描述大概是这样from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): # 扩展 Django 自带用户模型添加手机号、头像等字段 phone models.CharField(max_length11, blankTrue, nullTrue) avatar models.URLField(blankTrue, nullTrue) class Meta: db_table sys_user verbose_name 用户 class Role(models.Model): name models.CharField(max_length50, uniqueTrue, verbose_name角色名称) key models.CharField(max_length50, uniqueTrue, verbose_name角色标识) description models.TextField(blankTrue, verbose_name角色描述) users models.ManyToManyField(User, related_nameroles, blankTrue, verbose_name用户列表) class Meta: db_table sys_role verbose_name 角色 class Menu(models.Model): # 菜单即权限同时承担前端路由元信息和后端权限标识的功能 parent models.ForeignKey(self, nullTrue, blankTrue, on_deletemodels.CASCADE, related_namechildren) name models.CharField(max_length50, verbose_name菜单名称) path models.CharField(max_length200, verbose_name路由地址) component models.CharField(max_length200, blankTrue, verbose_name前端组件路径) permission models.CharField(max_length100, blankTrue, verbose_name权限标识) icon models.CharField(max_length50, blankTrue, verbose_name图标) sort models.IntegerField(default0, verbose_name排序) menu_type models.CharField(max_length1, choices((M, 目录), (C, 菜单), (F, 按钮)), defaultC) roles models.ManyToManyField(Role, related_namemenus, blankTrue) class Meta: db_table sys_menu verbose_name 菜单这里需要重点解释两个设计决策。第一个为什么用AbstractUser而不是直接用 Django 自带的 User因为在真实的权限系统中用户字段几乎一定会扩展比如手机号、部门、入职时间、头像等。如果用默认 User后期扩展字段需要额外的 Profile 表或者直接改源码都不够优雅。在项目一开始就设置AUTH_USER_MODEL app.User等于把自定义空间提前留好了。第二个为什么把“菜单”和“权限”放在同一张表里这是很多人的困惑点。在 RBAC 模型中权限往往被抽象为独立实体但实际操作时你会发现菜单项和按钮操作本质上都是权限的一种表现形态有目录菜单访问权限、有页面菜单路由权限、有按钮操作权限。合并到一张表里通过menu_type字段区分类型可以让角色-权限绑定关系变成统一的“角色拥有哪些菜单/按钮”前端根据这个列表生成动态路由后端根据权限标识做接口鉴权逻辑非常统一。2.2 登录认证方案为什么选 JWT 而不是 Session前后端分离架构下登录认证我选择了 JWTJSON Web Token方案而不是 Django 传统的 Session。原因在于 Session 依赖服务端存储状态——用户登录后Django 需要在数据库或者缓存中保存 Session 记录前端通过 Cookie 携带 sessionid 来维持登录态。在前后端不在同一域名下开发时Cookie 跨域处理相当麻烦而且在横向扩展服务时还需要额外配置 Session 共享方案。JWT 的思路是把用户身份信息签名后直接发给前端前端在请求头Authorization里带上这个 Token后端验证签名即可不需要存储任何状态。本质上是把“服务端存储登录状态”换成了“客户端持有经过签名的身份凭证”后端天然的变成无状态服务非常适合前后端分离的部署模式。我在实现时使用了djangorestframework-simplejwt这个库它和 DRF 结合得很好。配置也非常直接# settings.py from datetime import timedelta SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(minutes60), REFRESH_TOKEN_LIFETIME: timedelta(days7), AUTH_HEADER_TYPES: (Bearer,), UPDATE_LAST_LOGIN: True, } REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: [ rest_framework_simplejwt.authentication.JWTAuthentication, ], }Access Token 时效设为 60 分钟Refresh Token 时效设为 7 天这个配置对绝大多数后台管理系统来说都足够平衡安全性和体验。前端在 Axios 拦截器里统一把 Token 加到请求头遇到 401 状态码时自动用 Refresh Token 刷新刷新失败就跳回登录页一套闭环就完成了。2.3 前端动态路由与菜单渲染怎么配合后端数据前端部分的难点不是页面写得多华丽而是怎么让“当前登录用户的角色所拥有的菜单”和“Vue Router 中的路由表”以及“侧边栏展示的菜单”保持三方一致。我的做法是在前端先配置一份静态的基础路由包含登录页、404 页等公共页面其余的业务页面全部走动态路由用户登录后拿到菜单列表前端根据菜单数据动态router.addRoute()注册路由同时用这份数据递归渲染侧边栏菜单。这样保证了用户看不到没有权限的页面也无法通过直接输入 URL 访问没有权限的路由。动态路由的核心代码大概长这样// 根据后端菜单数据构建路由对象 function buildRoutes(menus) { const routes [] menus.forEach(menu { if (menu.menuType C) { routes.push({ path: menu.path, name: menu.name, component: () import(/views/${menu.component}), meta: { title: menu.name, icon: menu.icon, permission: menu.permission } }) } if (menu.children menu.children.length 0) { routes.push(...buildRoutes(menu.children)) } }) return routes }import()动态导入在 Vue Router 里很常用但这里有一个大坑Webpack 或 Vite 打包时动态导入的路径不能是完全动态的字符串否则打包工具无法静态分析出到底有哪些组件文件需要打包。直接写成() import(menu.component)这种形式十有八九会报错或打包出空 chunk。解决方案是用import.meta.glob(/src/views/**/*.vue)把视图文件全部映射成一个对象再根据组件路径去匹配对应的组件这个细节我会在后面的实操章节再展开。3. 实操过程与核心环节实现3.1 后端 Django REST Framework 如何编写权限校验接口当用户登录成功后前端需要请求“用户信息 权限菜单”这个接口来获取页面初始化数据。这个接口必须是动态的不同用户登录拿到的菜单和按钮权限完全不同。我在后端写了一个视图专门负责组装当前用户的信息和权限数据from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response class UserInfoView(APIView): permission_classes [IsAuthenticated] def get(self, request): user request.user roles user.roles.all() # 收集该用户所有角色关联的菜单含目录、菜单、按钮 menus Menu.objects.filter(roles__inroles).distinct().order_by(sort) menu_tree build_menu_tree(menus) # 收集权限标识列表用于前端按钮级权限判断 permissions set() for menu in menus: if menu.permission: permissions.add(menu.permission) return Response({ name: user.username, avatar: user.avatar, roles: [role.key for role in roles], permissions: list(permissions), menus: menu_tree })需要注意的坑点是Menu.objects.filter(roles__inroles).distinct()这段。如果用户被分配了多个角色而这些角色又绑定了重复的菜单ORM 查询结果会出现重复数据不加distinct()会导致前端菜单渲染出重复项而且权限标识列表也会变乱。这种问题在演示阶段不容易暴露一旦角色多了、菜单多了就必现排查起来会耗费不少时间。build_menu_tree这个函数负责把扁平的菜单列表转换成树形结构核心逻辑就是根据parent_id进行分组递归构建子节点。这个函数每次请求都会执行考虑到菜单表数据量通常也就是几十上百条完全不需要做缓存直接查库计算即可。3.2 前端 Axios 封装与 Token 刷新的工程化处理接口请求这块我建议所有前端项目都统一封装一个request.js不要把 Axios 配置散落在每个页面里。这套系统里我在request.js中做了三件非常重要的事请求拦截器自动附带 Token响应拦截器统一处理 HTTP 错误和业务错误401 时自动刷新 Token 并重放原请求。Token 刷新这块的并发控制是容易被忽略的点。如果没有做处理多个接口同时返回 401前端就会同时发出多个刷新 Token 的请求既浪费资源又可能因为 Refresh Token 被重复使用导致服务端校验失败。我的解决方案是使用一个isRefreshing标志变量和请求队列let isRefreshing false let pendingQueue [] async function refreshToken() { const refresh localStorage.getItem(refresh_token) if (!refresh) { redirectToLogin() return Promise.reject(new Error(No refresh token)) } const res await axios.post(/api/auth/refresh/, { refresh }) localStorage.setItem(access_token, res.data.access) return res.data.access } instance.interceptors.response.use( response response, async error { const { response, config } error if (response response.status 401 !config._retry) { if (isRefreshing) { // 把新请求加入队列等刷新完成后再重放 return new Promise((resolve) { pendingQueue.push((newToken) { config.headers.Authorization Bearer ${newToken} config._retry true resolve(instance(config)) }) }) } config._retry true isRefreshing true try { const newToken await refreshToken() pendingQueue.forEach(cb cb(newToken)) pendingQueue [] config.headers.Authorization Bearer ${newToken} return instance(config) } catch (refreshError) { redirectToLogin() return Promise.reject(refreshError) } finally { isRefreshing false } } return Promise.reject(error) } )这段代码的关键点在于同时并发三个接口都遇到 401只有第一个会触发刷新操作另外两个把请求缓存在队列里等待新 Token 生成后统一重放。如果不做这个处理刷新接口会被重复请求轻则浪费带宽重则因为并发刷新导致 Token 过期时序错乱。3.3 按钮级权限怎么实现自定义指令再配合后端口径菜单级权限靠动态路由解决后按钮级权限就简单直接了。页面上可能有一个“删除用户”的按钮如果当前用户没有user:delete这个权限标识按钮根本不应该渲染。我实现了一个 Vue 自定义指令v-permission全局注册后直接在按钮上使用// directive.js import { useUserStore } from /stores/user export const permission { mounted(el, binding) { const userStore useUserStore() const requiredPermission binding.value if (requiredPermission !userStore.permissions.includes(requiredPermission)) { el.parentNode el.parentNode.removeChild(el) } } }使用方式就是在按钮上写v-permissionuser:delete没有这个权限标识的按钮会被直接移除。这里有一个需要提醒的细节按钮级权限在前端做控制只是为了让界面更友好真正的安全边界必须同时在后端接口上做校验。如果删用户接口没有做权限判断攻击者只要拿到一个普通用户的 Token直接调用删除接口就能越权操作。后端我用了一个自定义权限类来实现from rest_framework.permissions import BasePermission class HasPermission(BasePermission): def __init__(self, permission_code): self.permission_code permission_code def has_permission(self, request, view): if not request.user or not request.user.is_authenticated: return False return request.user.roles.filter(menus__permissionself.permission_code).exists()然后在需要权限控制的接口上这样使用class UserViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action destroy: return [HasPermission(user:delete)] return [HasPermission(user:list)]前后端双重校验既保证了用户体验又守住了数据安全。3.4 Element-Plus 相关性能细节虚拟表格与图标管理Element-Plus 在组件用法上没什么门槛但有两个细节在开发这个系统时比较值得留意。第一个是虚拟表格。权限系统的用户管理列表数据量大了之后比如上万条Element-Plus 的表格渲染会明显卡顿页面滚动掉帧。Element-Plus 官方提供了一个el-table-v2组件它基于虚拟滚动实现只渲染可视区域内的行渲染性能比普通表格高很多。我在用户管理列表中就用了el-table-v2处理 5 万条数据时滚动依然流畅完全满足实际业务需求。第二个是 Element-Plus 图标自动注册。系统里有大量的菜单图标需要配置如果每个图标都要手动import再注册会非常繁琐。我使用了一个自动注册方案配合 Viteimport { defineComponent, h } from vue import * as ElementPlusIconsVue from element-plus/icons-vue export default defineComponent({ setup() { return () h(div, Object.keys(ElementPlusIconsVue)) } })更常用的做法是在main.js里遍历所有图标并全局注册import * as ElementPlusIconsVue from element-plus/icons-vue for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) }这样菜单配置里直接写图标名称字符串模板中el-iconFold //el-icon就能正常渲染。唯一要留意的是全局注册所有图库会稍微增加打包体积但对中后台项目来说完全在可接受范围内换来的开发便利性绝对值得。4. 常见问题与排查技巧实录4.1 登录成功后刷新页面404 或白屏这个问题的原因是Vue Router 的动态路由是在用户登录后才被注册的而浏览器刷新时页面重新加载动态路由还没注册用户访问的面包屑路径自然匹配不到路由于是出现 404 或白屏。我的解决方案是在路由守卫中做“已登录但路由未加载”的判断如果存在本地 Token、用户信息已存储在 Pinia 中但当前路由表是空的就先调用getUserInfo拉取当前用户的菜单权限动态注册路由后再放行。核心思路是“刷新时要能主动恢复动态路由”而不是依赖登录页面重新走一遍登录流程。这里也需要配合路由守卫处理另一个边界情况用户访问一个不在自己权限范围内的路径时应该跳转到 403 或 404 页面而不是默默放行或报错。4.2 Django CORS 导致前端接口请求失败前后端分离开发时Django 服务跑在 8000 端口前端 Vite 跑在 5173 端口跨域问题一定会碰到。解决方案是安装django-cors-headers然后在settings.py里配置INSTALLED_APPS [ # ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # ... ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, ]这里我要特别提醒一个坑CorsMiddleware的位置必须尽量靠前官方文档建议放在CommonMiddleware之前最好放在MIDDLEWARE列表的最上方。因为跨域检查是在请求进入视图逻辑之前就要完成的如果放在后面被前面的中间件拦截或改写后就可能生效不了。这个位置问题我之前调了很久才排查出来。4.3 动态导入组件打包后找不到模块前面提到的import()动态路径问题典型的报错是打包时提示 “Cannot find module”或者运行时组件加载 404。最好的解决办法就是用 Vite 的import.meta.globconst modules import.meta.glob(/src/views/**/*.vue) function loadComponent(component) { return modules[/src/views/${component}.vue] }这样打包工具在构建时能静态分析出所有视图文件动态匹配也就有了依据。4.4 权限列表查询时的 N1 问题在角色管理页面我需要为每个角色标识出它绑定了多少个菜单/用户。如果不做优化Django ORM 每次查询关联数据都会额外发 SQL角色多了以后接口会慢得让人怀疑人生。解决方法是使用annotate或Prefetch来减少查询次数from django.db.models import Count, Prefetch roles Role.objects.annotate( menu_countCount(menus, distinctTrue), user_countCount(users, distinctTrue) ).prefetch_related( Prefetch(menus, querysetMenu.objects.only(id, name)) )用Count配合distinctTrue可以避免多对多关联引起的数据重复计数Prefetch则把关联对象一次性查出来避免在循环中访问role.menus.all()时反复查库。这个优化对数据量不大的系统可能体感不明显但当角色和菜单数量增长到几百条时性能差异是数量级的。5. 一点经验总结权限管理系统这种项目技术栈的难度其实不在某一个单独的技术点而是在于把用户、角色、权限、路由、菜单、按钮几层关系串联起来让它们在整个系统中保持语义一致。单独看 Django、Vue 3、Element-Plus 都是成熟的框架难点在于“整合”时暴露出来的一堆细节比如动态路由和刷新状态的一致性、刷新 Token 的并发控制、菜单递归渲染和动态导入的路径处理。这些细节是官方文档不会完整告诉你的只有实际写一遍、跑一遍、踩过坑才能真正理解为什么社区里对这个组合有那么多讨论。如果在做类似项目我的建议是先画清楚 RBAC 的表结构关系再动手写代码前端先把路由守卫和状态管理想清楚再考虑页面样式后端先确定认证方案和权限校验接口再去设计其他业务接口。顺序对了开发效率会高很多。最后再分享一个小技巧调试权限问题时准备一个“超级管理员”账号让它直接拥有所有角色和所有权限很多排查不清的权限问题用这个账号一对比就能快速定位是前端判断的问题还是后端权限类的问题。这个小习惯给我省下了大量时间。本文还有配套的精品资源点击获取