
简介这是一套面向Java全栈开发初学者与中级工程师的前后端分离后台管理系统实战源码聚焦权限管理类企业级应用开发助力快速掌握Spring Boot与Vue3协同开发全流程。资源包共214个文件涵盖142个Java后端业务与配置类、23个Vue3组件与页面、11个JS工具与路由逻辑、8个SVG图标及配套SQL、YML、MD等辅助文件整体压缩后仅494KB轻量易部署。已有137人下载学习适合用于课程设计、毕业项目或中小型企业内部管理平台原型开发。读者可直接运行获得完整菜单、角色、用户、字典、日志操作/异常等核心模块代码结构清晰集成Druid连接池、Spring Security权限控制、JWT鉴权、Knife4j接口文档及MyBatis Plus数据操作前端采用Element Plus组件库具备良好可读性与二次开发基础。1. 为什么选 Spring Boot Vue3 Element Plus 搭建后台管理系统不是为了“新”而是为了“稳中求快”你手头正要启动一个内部运营平台、数据看板、SaaS 多租户管理后台或者给客户交付一套可配置的运维系统——它不需要支撑千万级并发但要求两周内能跑通核心流程、三个月内上线可交付版本、后续能由 23 名全栈快速迭代、前端不卡顿、后端不改就崩。这时候“Spring Boot Vue3 Element Plus”不是技术炫技的选择而是一套被大量中小团队反复验证过的最小可行生产组合Spring Boot 提供开箱即用的 REST 接口、权限拦截、数据库连接池和 Actuator 监控Vue3 的 Composition API 让复杂表单、动态权限菜单、嵌套路由逻辑清晰可维护Element Plus 则是目前 Vue3 生态里最成熟、文档最全、主题定制最平滑的 UI 组件库尤其在表格分页、表单校验、弹窗嵌套、树形选择器等后台高频场景上比手写或拼凑其他组件库少踩至少 5 类玄学坑。这不是“最佳架构”但它是从需求评审到第一次客户演示之间失败概率最低、返工成本最小、新人上手最快的落地路径。本文不讲 Vue3 响应式原理也不展开 Spring Boot 自动装配源码只聚焦一件事如何用这个组合在本地 Windows/macOS/Linux 环境下5 分钟拉起可运行骨架30 分钟接入真实数据库2 小时完成登录用户管理菜单权限闭环并把所有关键参数、必调配置、血泪避坑点一次性说透。2. 从零初始化项目用官方脚手架生成可运行骨架拒绝手动拼包2.1 后端用 Spring Initializr 生成最小依赖的 Boot 工程非 Maven 手动建包不要自己新建空 Maven 项目再一个个加 starter。直接访问 https://start.spring.io国内可镜像为 https://spring.io/start按以下配置生成 ZIPProject:Maven ProjectLanguage:JavaSpring Boot:3.2.12当前 LTS 版本兼容 JDK 17避免 Spring Boot 3.x 与 MyBatis 旧版的MapperScan兼容问题Packaging:JarJava:17Spring Boot 3.x 强制要求Dependencies仅勾选这 4 项Spring Web提供 REST 控制器基础Spring Data JDBC轻量替代 JPA避免 Hibernate 二级缓存引发的权限数据脏读Lombok减少DataSlf4j等样板代码Validation后端表单校验必需提示不选 Spring Security 是因为本项目权限模型更适配 RBAC JWT 手动集成见 3.2 节而非 Spring Security 默认的 session 机制不选 MyBatis Starter 是因 JDBC 更可控且 Element Plus 表格分页需精确控制 SQL LIMIT/OFFSETJDBC 直接执行原生 SQL 更透明。生成后解压用 IntelliJ IDEA社区版完全够用打开确认pom.xml中无多余依赖。关键修改application.yml# src/main/resources/application.yml server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true path: /h2-console logging: level: com.example: debug启动Application.java访问http://localhost:8080/api/h2-console输入JDBC URL: jdbc:h2:mem:testdb即可进入内存数据库控制台——这是后续插入测试用户、菜单数据的入口。2.2 前端用 Vite Vue3 Element Plus 创建标准工程非 Vue CLIVue CLI 已停止维护Vite 是当前 Vue3 项目事实标准。终端执行npm create vitelatest my-admin -- --template vue cd my-admin npm install npm install element-plus element-plus/icons-vue axios pinia vue-router4注意element-plus/icons-vue是 Vue3 官方图标库必须单独安装pinia替代 VuexAPI 更简洁vue-router4是 Vue3 兼容版不可用 v3。修改main.js初始化 Element Plus 和 Pinia// src/main.js import { createApp } from vue import { createPinia } from pinia import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(createPinia()) app.use(ElementPlus) // 全局注册无需每个组件 import app.mount(#app)启动前端npm run dev访问http://localhost:5173确认空白页面加载成功控制台无报错。2.3 前后端联调第一步配置代理绕过跨域非 CORS 配置开发阶段前端请求http://localhost:5173/api/login应自动代理到后端http://localhost:8080/api/login。在vite.config.js中添加// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) // 去掉 /api 前缀后端已设 context-path } } } })此时前端发axios.post(/api/login, {username:admin,password:123})实际请求的是http://localhost:8080/login注意Vite 代理会 strip/api所以后端 Controller 映射路径写PostMapping(/login)即可无需/api/login。3. 实现登录与权限路由JWT 动态菜单 路由守卫三件套3.1 后端基于 JWT 的无状态登录接口不依赖 Spring Security创建LoginController.javaRestController RequestMapping public class LoginController { // 模拟用户数据实际应查 DB private static final MapString, String USER_CREDENTIALS Map.of( admin, $2a$10$8.6QZzX9Y7bKcR1sTfGvUeWnIoPqLrMjNkOoHtIuVwXyZaBcD, user, $2a$10$8.6QZzX9Y7bKcR1sTfGvUeWnIoPqLrMjNkOoHtIuVwXyZaBcD ); PostMapping(/login) public ResultMapString, Object login(RequestBody LoginRequest req) { if (!USER_CREDENTIALS.containsKey(req.getUsername())) { return Result.fail(用户名不存在); } // 此处应调用 BCrypt.checkpw(req.getPassword(), storedHash) // 为简化跳过密码校验仅演示流程 String token JwtUtil.generateToken(req.getUsername()); MapString, Object data new HashMap(); data.put(token, token); data.put(userInfo, Map.of(username, req.getUsername(), role, ADMIN)); return Result.success(data); } }关键工具类JwtUtil.java使用io.jsonwebtoken:jjwt-api:0.11.5jjwt-impl:0.11.5jjwt-jackson:0.11.5public class JwtUtil { private static final String SECRET mySecretKey1234567890; // 生产环境必须放配置中心 private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 24小时 public static String generateToken(String username) { Date now new Date(); Date expiryDate new Date(now.getTime() EXPIRE_TIME); return Jwts.builder() .setSubject(username) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS512, SECRET) .compact(); } public static String getUsernameFromToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody().getSubject(); } }参数说明SECRET必须是 32 字节以上随机字符串可用openssl rand -base64 32生成EXPIRE_TIME设为 24 小时是平衡安全与体验的常见值超时后前端需重新登录HS512算法比 HS256 更安全且 Spring Boot 3.x 默认支持。3.2 前端Pinia 管理 Token 路由守卫拦截未登录请求创建stores/user.js// src/stores/user.js import { defineStore } from pinia import { ElMessage } from element-plus import router from /router export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || {}) }), actions: { setToken(token, userInfo) { this.token token this.userInfo userInfo localStorage.setItem(token, token) localStorage.setItem(userInfo, JSON.stringify(userInfo)) }, clearToken() { this.token this.userInfo {} localStorage.removeItem(token) localStorage.removeItem(userInfo) router.push(/login) } } })在router/index.js中添加全局前置守卫// src/router/index.js import { createRouter, createWebHistory } from vue-router import { useUserStore } from /stores/user const router createRouter({ history: createWebHistory(), routes: [ { path: /login, name: Login, component: () import(/views/Login.vue) }, { path: /, redirect: /dashboard, meta: { requiresAuth: true }, // 标记需要登录 children: [ { path: dashboard, name: Dashboard, component: () import(/views/Dashboard.vue) }, { path: user, name: UserList, component: () import(/views/UserList.vue) } ] } ] }) router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { ElMessage.warning(请先登录) next(/login) } else { next() } }) export default router3.3 动态菜单后端返回菜单结构前端递归渲染 Element Plus Menu后端新增MenuController.javaGetMapping(/menus) public ResultListMenuVO getMenus() { // 模拟返回菜单数据实际从 DB 查询按用户角色过滤 ListMenuVO menus List.of( new MenuVO(1L, 仪表盘, /dashboard, el-icon-s-data, null), new MenuVO(2L, 用户管理, /user, el-icon-user, List.of( new MenuVO(21L, 用户列表, /user/list, , null), new MenuVO(22L, 角色管理, /user/role, , null) )) ); return Result.success(menus) }前端Layout.vue中使用el-menu递归组件!-- src/layout/Layout.vue -- template el-container el-aside width200px el-menu :default-active$route.path router MenuItem v-formenu in menus :keymenu.id :menumenu / /el-menu /el-aside el-container el-headerHeader/el-header el-main router-view / /el-main /el-container /el-container /template script setup import { ref, onMounted } from vue import { ElMessage } from element-plus import { useUserStore } from /stores/user import axios from axios const menus ref([]) const userStore useUserStore() onMounted(async () { try { const res await axios.get(/api/menus) menus.value res.data.data } catch (e) { ElMessage.error(菜单加载失败) } }) /scriptMenuItem.vue递归组件!-- src/components/MenuItem.vue -- template el-sub-menu v-ifmenu.children menu.children.length 0 :indexmenu.path template #title span{{ menu.title }}/span /template MenuItem v-forchild in menu.children :keychild.id :menuchild / /el-sub-menu el-menu-item v-else :indexmenu.path span{{ menu.title }}/span /el-menu-item /template script setup defineProps([menu]) /script关键点el-menu的router属性开启后index值会自动匹配router-view当前路径MenuVO中path必须与路由name或path严格一致递归组件必须显式声明defineProps否则 Vue3 会报props is readonly错误。4. 用户管理模块落地CRUD 表格 表单弹窗 后端分页查询4.1 后端JDBC 实现分页查询非 MyBatis PageHelper创建UserController.javaRestController RequestMapping(/user) public class UserController { Autowired private JdbcTemplate jdbcTemplate; GetMapping(/list) public ResultPageResultUserVO listUsers( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { int offset (pageNum - 1) * pageSize; String sql SELECT id, username, email, status, create_time FROM sys_user LIMIT ? OFFSET ?; ListUserVO users jdbcTemplate.query(sql, new BeanPropertyRowMapper(UserVO.class), pageSize, offset); // 查询总记录数实际项目应走 COUNT(*) 子查询优化 int total jdbcTemplate.queryForObject(SELECT COUNT(*) FROM sys_user, Integer.class); return Result.success(new PageResult(users, total, pageNum, pageSize)); } PostMapping(/save) public ResultString saveUser(RequestBody UserVO user) { String sql INSERT INTO sys_user (username, email, status, create_time) VALUES (?, ?, ?, ?); jdbcTemplate.update(sql, user.getUsername(), user.getEmail(), user.getStatus(), new Date()); return Result.success(保存成功); } }PageResult.java统一分页响应结构public class PageResultT { private ListT list; private long total; private int pageNum; private int pageSize; public PageResult(ListT list, long total, int pageNum, int pageSize) { this.list list; this.total total; this.pageNum pageNum; this.pageSize pageSize; } // getter/setter 省略 }注意LIMIT ? OFFSET ?是 PostgreSQL/MySQL 通用语法H2 内存库也支持若用 Oracle需改用ROWNUM此处不引入 MyBatis 是为避免其分页插件与 Spring Boot 3.x 的兼容性问题如PageHelper.startPage()在某些版本下失效。4.2 前端Element Plus Table Pagination Dialog 表单联动UserList.vuetemplate div classuser-list el-page-header click-backgoBack title用户管理 content用户列表/el-page-header !-- 搜索栏 -- el-card classmb-4 el-form :modelsearchForm inline el-form-item label用户名 el-input v-modelsearchForm.username placeholder请输入用户名 / /el-form-item el-form-item el-button typeprimary clickloadData搜索/el-button el-button clickresetSearch重置/el-button /el-form-item /el-form /el-card !-- 表格 -- el-card div classmb-4 el-button typeprimary clickopenAddDialog新增用户/el-button /div el-table :datatableData stripe stylewidth: 100% el-table-column propusername label用户名 width180 / el-table-column propemail label邮箱 width220 / el-table-column propstatus label状态 width120 template #default{ row } el-tag :typerow.status 1 ? success : info {{ row.status 1 ? 启用 : 禁用 }} /el-tag /template /el-table-column el-table-column propcreateTime label创建时间 width200 / el-table-column label操作 width180 template #default{ row } el-button sizesmall clickopenEditDialog(row)编辑/el-button el-button sizesmall typedanger clickdeleteUser(row.id)删除/el-button /template /el-table-column /el-table !-- 分页 -- div classmt-4 flex justify-end el-pagination v-model:current-pagepageParams.pageNum v-model:page-sizepageParams.pageSize :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next, jumper :totalpageParams.total size-changeloadData current-changeloadData / /div /el-card !-- 新增/编辑弹窗 -- el-dialog v-modeldialogVisible :titledialogTitle width500px el-form :modelform label-width80px :rulesrules refformRef el-form-item label用户名 propusername el-input v-modelform.username / /el-form-item el-form-item label邮箱 propemail el-input v-modelform.email typeemail / /el-form-item el-form-item label状态 propstatus el-radio-group v-modelform.status el-radio :label1启用/el-radio el-radio :label0禁用/el-radio /el-form-item /el-form-item /el-form template #footer span classdialog-footer el-button clickdialogVisible false取消/el-button el-button typeprimary clicksubmitForm确定/el-button /span /template /el-dialog /div /template script setup import { ref, reactive, onMounted } from vue import { ElMessage, ElMessageBox } from element-plus import axios from axios const tableData ref([]) const pageParams reactive({ pageNum: 1, pageSize: 10, total: 0 }) const searchForm reactive({ username: }) const dialogVisible ref(false) const dialogTitle ref(新增用户) const form reactive({ username: , email: , status: 1 }) const formRef ref(null) const rules { username: [{ required: true, message: 请输入用户名, trigger: blur }], email: [ { required: true, message: 请输入邮箱, trigger: blur }, { type: email, message: 请输入正确的邮箱地址, trigger: blur } ] } const loadData async () { try { const params { ...searchForm, ...pageParams } const res await axios.get(/api/user/list, { params }) tableData.value res.data.data.list pageParams.total res.data.data.total } catch (e) { ElMessage.error(加载失败) } } const openAddDialog () { dialogTitle.value 新增用户 Object.assign(form, { username: , email: , status: 1 }) dialogVisible.value true } const openEditDialog (row) { dialogTitle.value 编辑用户 Object.assign(form, row) dialogVisible.value true } const submitForm () { formRef.value.validate(async (valid) { if (valid) { try { await axios.post(/api/user/save, form) ElMessage.success(操作成功) dialogVisible.value false loadData() } catch (e) { ElMessage.error(操作失败) } } }) } const deleteUser (id) { ElMessageBox.confirm(此操作将永久删除该用户, 是否继续?, 提示, { confirmButtonText: 确定, cancelButtonText: 取消, type: warning }).then(async () { try { await axios.delete(/api/user/${id}) ElMessage.success(删除成功) loadData() } catch (e) { ElMessage.error(删除失败) } }) } const resetSearch () { searchForm.username loadData() } onMounted(loadData) /script关键细节v-model:current-page和v-model:page-size是 Vue3 的.sync语法糖必须用reactive包裹pageParamsel-form的ref必须赋给formRef变量才能调用validate()el-table-column的prop值必须与后端返回 JSON 字段名完全一致大小写敏感el-pagination的size-change和current-change事件必须触发loadData()否则分页不生效。5. 避坑指南这 4 类错误让 80% 的新手卡在第二天5.1 现象前端axios请求 404但后端 Controller 方法明明存在原因Vite 代理配置中rewrite规则错误或后端RequestMapping路径与代理目标不匹配。例如后端写了RequestMapping(/api/user)而 Vite 代理rewrite去掉了/api导致实际请求路径变成/user但 Controller 映射的是/api/user自然 404。解决统一约定——后端 Controller 不加/api前缀全部用RequestMapping(/user)Vite 代理rewrite保留/api前缀改为rewrite: (path) path并在axios请求时显式写/api/user/list。这样路径完全可控不会因代理规则变更而断裂。5.2 现象Element Plus 表单el-form-item校验不触发validate()返回true却不显示错误提示原因el-form的model绑定对象与el-form-item的prop字段名不一致或prop值包含点号如user.email但未用ref方式绑定。Vue3 的响应式系统对嵌套属性校验有严格要求。解决确保el-form的model是reactive对象且el-form-item的prop是顶层字段名如email不要用user.email若必须嵌套改用refcomputed构建扁平化校验字段或直接升级到 Element Plus 2.3 版本已修复深层属性校验。5.3 现象登录后跳转到/dashboard但菜单不显示控制台报Cannot read properties of undefined (reading children)原因MenuVO数据中某个节点的children字段为null但前端递归组件MenuItem.vue未做空值判断直接访问menu.children.length报错。解决在MenuItem.vue中增加空值保护v-ifmenu.children menu.children.length 0并确保后端返回的菜单数据中children字段要么是[]要么是null不能是undefined更稳妥的做法是在后端MenuVO类中为children字段加JsonProperty(children)注解并设默认值 new ArrayList()。5.4 现象H2 内存数据库重启后数据丢失无法持续测试用户登录流程原因jdbc:h2:mem:testdb是纯内存模式JVM 进程退出即清空而jdbc:h2:file:./data/testdb才是文件持久化模式但默认未启用。解决修改application.yml中的url为jdbc:h2:file:./data/testdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE并手动在项目根目录创建data文件夹首次启动后H2 会自动生成testdb.mv.db文件下次启动即可复用数据。生产环境务必切换为 MySQL/PostgreSQL。5.5 现象Vue3 页面刷新后 Token 丢失用户被强制跳回登录页原因localStorage.setItem(token, token)执行了但Pinia store的state未同步初始化导致userStore.token仍为空字符串路由守卫判定未登录。解决在stores/user.js的state函数中直接读取localStoragetoken: localStorage.getItem(token) || 同时在setTokenaction 中同步更新localStorage和state切勿在onMounted中异步读取 localStorage 并赋值因为路由守卫执行早于组件挂载。6. 进阶技巧用 Axios 拦截器统一处理 Token 刷新与错误降级6.1 Token 自动续期避免用户操作中突然登出JWT 过期后用户正在填表单突然弹窗“登录已过期”极伤体验。理想方案是在请求返回 401 时用 Refresh Token 换新 Access Token然后重放原请求。但本项目为简化采用更务实的策略——前端在每次请求头注入 Token并在 401 时清空本地凭证、跳转登录页同时后端在 JWT 解析失败时返回明确错误码。在src/utils/request.js中封装 Axios 实例// src/utils/request.js import axios from axios import { ElMessage } from element-plus import { useUserStore } from /stores/user const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器注入 Token request.interceptors.request.use( config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }, error Promise.reject(error) ) // 响应拦截器统一错误处理 request.interceptors.response.use( response response, error { const userStore useUserStore() if (error.response?.status 401) { ElMessage.error(登录已过期请重新登录) userStore.clearToken() return Promise.reject(error) } if (error.response?.status 500) { ElMessage.error(服务器繁忙请稍后再试) } return Promise.reject(error) } ) export default request关键点baseURL: /api与 Vite 代理的/api路径对齐避免重复写前缀Authorization头格式必须为Bearer token这是 JWT 规范401处理必须调用userStore.clearToken()否则下次请求仍带无效 Token陷入死循环。6.2 表单防重复提交按钮置灰 请求锁用户狂点“保存”按钮后端可能收到多条重复请求。Element Plus 本身不提供防抖需手动实现!-- 在 UserList.vue 的 submitForm 方法中 -- const isSubmitting ref(false) const submitForm () { formRef.value.validate(async (valid) { if (valid !isSubmitting.value) { isSubmitting.value true try { await axios.post(/api/user/save, form) ElMessage.success(操作成功) dialogVisible.value false loadData() } catch (e) { ElMessage.error(操作失败) } finally { isSubmitting.value false } } }) }并在按钮上绑定:loadingisSubmittingel-button typeprimary clicksubmitForm :loadingisSubmitting确定/el-button6.3 权限按钮级控制用指令v-has-perm替代v-if菜单是路由级权限但按钮如“删除”、“导出”需更细粒度控制。与其在每个v-if写userStore.userInfo.role ADMIN不如封装指令// src/directives/hasPerm.js import { useUserStore } from /stores/user export default { mounted(el, binding) { const userStore useUserStore() const perms binding.value // 如 [user:delete, user:export] const hasPerm perms.some(perm userStore.userInfo.perms?.includes(perm)) if (!hasPerm) { el.style.display none // 或 el.parentNode.removeChild(el) } } }在main.js中注册import hasPerm from /directives/hasPerm app.directive(has-perm, hasPerm)在模板中使用el-button v-has-perm[user:delete] sizesmall typedanger clickdeleteUser(row.id)删除/el-button注意v-has-perm的值必须是数组后端需在登录接口返回perms: [user:add,user:delete]字段指令中el.style.display none是最轻量的隐藏方式比v-if更高效因为不销毁 DOM 节点。我带过的 7 个团队里有 5 个在第三天卡在 Token 拦截器的401处理上——不是代码写错而是没意识到clearToken()必须同步执行否则router.beforeEach会再次触发形成无限跳转。后来我把这个逻辑抽成useAuth()组合式函数所有新项目直接const { token, logout } useAuth()再也没人翻车。希望帮到你。本文还有配套的精品资源点击获取