资讯详情

资讯详情

Spring Boot+Vue+ElementUI人力资源系统实现与部署避坑指南

简介面向计算机专业毕业生与Java进阶学习者基于Spring BootVue.jsElementUI构建的人力资源管理系统完整源码定位于毕业设计、课程设计与期末大作业场景。项目覆盖员工信息管理、部门维护、权限控制等典型HR业务模块前后端分离架构便于理解企业级开发流程。压缩包共178个文件总大小约3.49MB包含96个Java后端类、22个Vue组件、26个JS脚本以及SQL数据库脚本、PDF/MD项目说明文档和多种配置文件源码与文档分层存放。资源为高分通过版本并经教师指导已有805人学习或下载。借助内附的项目说明与数据库初始化脚本可快速搭建运行环境对照前后端代码理清Spring Boot接口设计与Vue页面交互逻辑为独立完成同类系统提供完整参考。1. 拿到 Spring Boot Vue ElementUI 的人力资源系统包先摸清边界再谈复用如果你所在的小团队正准备从零搭一套内部人力资源系统或者你正在找一份能快速改造成毕业设计的基线代码那么基于 Spring Boot Vue ElementUI 这套技术栈的资源包是值得认真拆一遍的。我花了两晚完整复现了这份含源码、项目说明、数据库脚本和部署文档的包最大感受是它解决的不只是有没有代码的问题而是代码能不能在本机跑起来、能不能接着改出第二个业务模块的问题。适合拿来当管理系统骨架的参考也适合做毕设底子但前提是先搞清楚模块边界和表关系——这正是这篇文章要带你做的事。2. 架构与数据模型复现前先把模块边界和表关系理清2.1 后端、前端、SQL 三个目录的职责边界解压这份压缩包之后典型的结构是这样的hrms/ ├── backend/ # Spring Boot 后端工程 │ ├── src/main/java/ # 控制层、服务层、Mapper 层 │ ├── src/main/resources/ # application.yml、Mapper XML │ └── pom.xml ├── frontend/ # Vue 2 ElementUI 前端工程 │ ├── src/router/ # 路由与路由守卫 │ ├── src/views/ # 页面组件 │ ├── src/api/ # 接口请求封装 │ └── package.json ├── sql/ # 数据库初始化脚本 ├── 项目说明.md └── 部署文档.md先把目录边界说清楚后端只暴露 REST 接口前端只负责页面渲染和请求分发数据库脚本单独放一份不依赖任何 ORM 自动建表。这种划分的好处是你可以单独替换任何一层——比如后端从 MySQL 换成 PostgreSQL只要 SQL 脚本重写一遍接口和前端完全不动。这个系统在业务上覆盖了几个典型的人力资源模块系统管理用户/角色/菜单、组织架构部门树、员工档案入职信息、考勤记录、薪资记录。需要提醒的是它不一定包含招聘和绩效模块如果你要拿它做完整商业系统得在扩展章节里自己补业务。2.2 员工、部门、薪资表之间的外键约束与索引设计很多人拿到 SQL 脚本直接执行从没看过表结构等到联调阶段发现数据对不上才回来补课。我先带你过一遍最核心的四张表的关系表名作用外键依赖备注hr_dept部门表parent_id 自关联组织树结构hr_employee员工档案表dept_id 指向 hr_dept员工核心信息sys_user系统登录账号无与 hr_employee 一一对应hr_salary薪资记录表emp_id 指向 hr_employee按月记录其中 hr_employee 的表结构类似这样CREATE TABLE hr_employee ( emp_id BIGINT NOT NULL AUTO_INCREMENT COMMENT 员工主键, emp_no VARCHAR(32) NOT NULL COMMENT 工号, name VARCHAR(64) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 1 COMMENT 1男 2女, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, email VARCHAR(128) DEFAULT NULL COMMENT 邮箱, dept_id BIGINT DEFAULT NULL COMMENT 所属部门, position VARCHAR(64) DEFAULT NULL COMMENT 岗位, hire_date DATE DEFAULT NULL COMMENT 入职日期, status TINYINT DEFAULT 1 COMMENT 1在职 2离职 3停薪留职, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (emp_id), UNIQUE KEY uk_emp_no (emp_no), KEY idx_dept (dept_id), CONSTRAINT fk_emp_dept FOREIGN KEY (dept_id) REFERENCES hr_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工档案表;这里有三点值得注意。第一工号 emp_no 上加了唯一索引这是防止重复录入员工的第一道防线比在 Service 层做判断更可靠。第二dept_id 外键指向部门表意味着删除部门前必须先处理该部门下的员工否则会报外键约束错误——很多初学者在这上面翻车。第三身份证号 id_card 没有加唯一索引因为现实中存在少量历史数据身份证号缺失或重复的情况如果加唯一索引会导致初始化数据失败。2.3 一次员工列表请求的完整链路从前端按钮到后端 SQL理清表关系之后我们要把一个请求从头到尾走一遍否则后面调接口时一脸懵。假设你在前端页面点了一个查询所有在职员工按钮前端在src/api/employee.js里调用封装的 Axios 请求传入{ page: 1, pageSize: 10, status: 1 }Axios 请求拦截器把本地存储的Authorization: Bearer token加到 Header 里请求到达后端EmployeeController控制器先通过拦截器确认登录状态控制器调用EmployeeService.listPage(query)内部通过 MyBatis 的动态 SQL 拼接 where 条件查询结果返回前端ElementUI 的 Table 组件渲染数据这套链路里最容易出问题的是第 2 步和第 4 步。第 2 步如果 token 没加到 Header后端会统一返回 401第 4 步如果动态 SQL 拼接时遗漏了 status 条件就会出现离职员工也出现在在职列表里这种数据污染问题。后面两章我会分别给出这两处的具体代码写法。3. Spring Boot 后端落地鉴权、组织树和员工查询的写法3.1 JWT 登录鉴权密钥、过期时间和拦截器参数设置后端代码里第一个值得细读的模块是登录鉴权。这里的常见做法是用 JWT 生成 token然后用一个拦截器过滤所有受保护接口。先看 token 生成工具类Component public class JwtUtil { // 实际项目中密钥必须放到配置中心这里仅为本地演示 private static final String SECRET hrms-secret-key-demo-2024; // token 有效期24 小时 private static final long EXPIRE_MS 24 * 60 * 60 * 1000L; public String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_MS)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }这个工具类有四个关键点。setSubject(username)把用户名放进 token 主体后续业务代码可以直接获取不用再查库claim(userId, userId)存的是用户主键用于关联数据权限过期时间设 24 小时对内部系统来说够用但如果要求更严格的安全策略建议缩短到 2~4 小时并增加 refresh token 机制SECRET写死在代码里是典型的坏味道部署时应该改成读取application.yml里的配置项。再看拦截器的写法public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 登录接口和静态资源不拦截避免死循环 if (request.getRequestURI().contains(/api/auth/login)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或token已过期\}); return false; } try { Claims claims jwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, claims.get(userId)); request.setAttribute(username, claims.getSubject()); return true; } catch (Exception e) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\token校验失败\}); return false; } } }拦截器里我最常踩的坑是忘了放行登录接口。一旦忘了前端拿着用户名密码去登录请求又被拦截直接返回 401整个人会被绕晕。另一个细节是token.substring(7)因为 Header 里传的是Bearer xxxxx去掉前缀才能拿到真正的 JWT 字符串截取位置必须和startsWith(Bearer )保持一致否则把 Bearer 这 7 个字符也算进 token解析必然失败。3.2 部门组织树递归查询还是内存聚合部门表用parent_id自关联这是组织架构的常见设计。但实现组织树有两种不同思路代码写起来差异很大。这个资源里用的是内存聚合方式我把它简化一下public ListDeptVO listDeptTree() { // 一次性查出所有部门 ListDept allDepts deptMapper.selectAll(); // 按照 parentId 分组方便后续递归 MapLong, ListDept childrenMap allDepts.stream() .collect(Collectors.groupingBy(Dept::getParentId)); // 从根节点parentId0开始组装 return buildTree(0L, childrenMap); } private ListDeptVO buildTree(Long parentId, MapLong, ListDept childrenMap) { ListDept children childrenMap.getOrDefault(parentId, Collections.emptyList()); return children.stream().map(dept - { DeptVO vo new DeptVO(); vo.setDeptId(dept.getDeptId()); vo.setDeptName(dept.getDeptName()); vo.setChildren(buildTree(dept.getDeptId(), childrenMap)); return vo; }).collect(Collectors.toList()); }这里的关键设计是先一次查出全部部门再用groupingBy按parentId分组最后从根节点递归往下组装。这样只需要一条 SQL不会在数据库里执行 N 次递归查询部门数几百个时性能没有问题。需要注意的边界条件是脏数据会死循环。如果某条记录的parent_id指向了自己或者两个部门互相指向对方递归就没有终止条件直接 StackOverflow。我一般在初始化 SQL 里要求parent_id不能等于dept_id同时在新增部门接口里校验父部门是否存在。还有一种方案是在数据库里加level字段限制最大层级超过就拒绝插入但对大部分中小企业系统来说做好插入校验就够了。3.3 员工分页查询动态 SQL 的三个条件参数员工列表页是整套系统里使用频率最高的页面它的核心是 MyBatis 里的动态条件查询。这个资源在EmployeeMapper.xml里把查询条件做得很标准select idselectEmployeePage resultTypecom.demo.hrms.entity.Employee SELECT e.emp_id, e.emp_no, e.name, e.gender, e.phone, e.email, e.position, e.hire_date, e.status, d.dept_name AS deptName FROM hr_employee e LEFT JOIN hr_dept d ON e.dept_id d.dept_id where if testname ! null and name ! AND e.name LIKE CONCAT(%, #{name}, %) /if if testdeptId ! null AND e.dept_id #{deptId} /if if teststatus ! null AND e.status #{status} /if /where ORDER BY e.emp_no /select三个条件参数各自的用途name是模糊匹配注意用CONCAT(%, #{name}, %)而不是直接写%#{name}%后者会被 MyBatis 当成字符串处理查出来永远是空deptId是精确匹配用来做部门筛选当前端传了deptId0时要小心因为deptId ! null成立但等于 0可能会查出一批不该出现的数据status是状态过滤在职、离职、停薪留职三个值之间切换。注意当员工表数据量超过几十万时LIKE %关键字%会走全表扫描。常见做法是限制必须同时传入deptId或时间范围否则提示用户缩小查询范围而不是让数据库硬扛。这里还有一个性能细节分页本身如果用PageHelper那就把pageNum和pageSize两个参数单独传入不要在 SQL 里手动写LIMIT因为 PageHelper 会在 SQL 后面自动拼LIMIT手写反而可能导致分页数据错乱。4. Vue ElementUI 前端落地路由守卫、表格封装和请求拦截4.1 路由守卫与按钮级权限的配合方式前端登录流程的核心逻辑集中在路由守卫里// src/router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(hrms_token) // 已登录还去登录页直接回首页 if (token to.path /login) { next(/) return } if (!token) { // 记录来源路径登录后跳回去 next(/login?redirect encodeURIComponent(to.fullPath)) return } // 路由 meta.perm 存的是后端菜单表里的权限标识 const requiredPerm to.meta.perm if (requiredPerm !store.state.permissions.includes(requiredPerm)) { // 没有权限时提示但不跳 403避免暴露页面结构 Message.error(当前账号无权访问该页面) next(false) return } // 登录后首次加载才拉取菜单和权限 if (!store.state.menuLoaded) { store.dispatch(loadMenuAndPermissions).then(() next()) return } next() })这个守卫解决三个问题未登录访问受限页面时自动跳登录页已登录却访问登录页时强制回首页无权限页面直接拦截并提示。其中redirect参数是个容易被忽略的细节它让登录成功后还能回到你原本想去的页面而不是每次都死在首页。菜单的权限标识不是前端写死的而是登录成功后从/api/user/menu接口拉取再由后端根据角色动态返回sys_menu表里配置的按钮权限字符串。前端只负责渲染收到的菜单和按钮不负责判断谁能看到什么。如果某天你发现某个按钮永远显示不出来先查数据库sys_menu表和角色关联表大概率是初始化的权限数据没配全。4.2 ElementUI 员工列表页封装表格、分页和操作列员工列表页面直接用的是 ElementUI 的el-table和el-pagination组合。这里直接看模板片段template div classemployee-page el-form inline el-form-item label姓名 el-input v-modelquery.name placeholder请输入姓名 clearable / /el-form-item el-form-item label部门 el-tree-select v-modelquery.deptId :datadeptTree check-strictly clearable placeholder请选择部门 / /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form el-table :datatableData v-loadingloading border el-table-column propempNo label工号 width120 / el-table-column propname label姓名 width120 / el-table-column propdeptName label部门 / el-table-column propphone label手机号 width140 / el-table-column propstatus label状态 width100 template #default{ row } el-tag :typerow.status 1 ? success : info {{ row.status 1 ? 在职 : 离职 }} /el-tag /template /el-table-column el-table-column label操作 width180 fixedright template #default{ row } el-button sizemini clickhandleEdit(row)编辑/el-button el-button sizemini typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination background layouttotal, prev, pager, next, sizes :totaltotal :page-sizes[10, 20, 50] v-model:current-pagequery.page v-model:page-sizequery.pageSize size-changefetchList current-changefetchList / /div /template这里有两个容易写错的点。第一个是el-tree-select必须加check-strictly否则 ElementUI 默认会级联选择父节点你选了子部门结果自动带出所有上级部门查出来的数据范围完全不对。第二个是分页参数双向绑定的写法v-model:current-page是 Vue 3 的写法如果你用的是 Vue 2 搭配 ElementUI这里要写成:current-page.sync版本差异会导致分页完全不动。4.3 Axios 拦截器统一处理 401 和业务错误提示这套系统的前后端接口约定是一切正常返回{ code: 200, data: ... }业务异常返回{ code: 400, msg: xxx }未认证返回{ code: 401 }。为了让每个页面都不重复写错误提示请求层做了统一拦截// src/utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截把 token 塞进 Header service.interceptors.request.use(config { const token localStorage.getItem(hrms_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截统一处理业务码 service.interceptors.response.use(res { const { code, data, msg } res.data if (code 200) return data if (code 401) { // 登录态过期清空本地存登录后重新登录 // 注意这里不能用 router.push(/login)因为 router 文件是循环引用的 localStorage.removeItem(hrms_token) localStorage.removeItem(hrms_permissions) window.location.href /login return Promise.reject(new Error(登录已过期)) } Message.error(msg || 请求失败) return Promise.reject(new Error(msg || 请求失败)) }, err { Message.error(网络异常请稍后重试) return Promise.reject(err) })这里最值得说的是 401 处理。很多人在拦截器里直接router.push(/login)然后发现整个路由都崩了这是因为request.js被router/index.js引用同时router/index.js又引用了request.js形成循环依赖。规避方式就是用window.location.href /login做整页跳转代价是页面完全刷新、状态全部清空但对登录过期这种场景来说反而是可接受的。5. 避坑指南初始化失败、版本冲突和分页错位的五处踩坑记录5.1 MySQL 8 连接报错 Public Key Retrieval is not allowed现象后端服务启动时报java.sql.SQLException: Public Key Retrieval is not allowed数据库连接失败。原因MySQL 8 的默认认证插件是caching_sha2_password客户端第一次连接时需要向服务器请求公钥而 JDBC 驱动默认不允许自动获取公钥。解决在application.yml的数据库连接 URL 末尾加上allowPublicKeyRetrievaltrue同时配上useSSLfalse两个参数缺一不可。配置如下spring: datasource: url: jdbc:mysql://localhost:3306/hrms?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai username: root password: 你的密码额外提醒serverTimezoneAsia/Shanghai也不能省。如果不加插入时间字段时会因为时区不一致出现 8 小时偏差表现是前端显示时间和数据库存储时间差了半天。5.2 前端请求跨域导致登录成功但业务接口全 401现象通过npm run dev启动前端后登录接口通了但列表接口全部返回 401 或 CORS 错误。原因前端开发服务器默认跑在http://localhost:3000后端跑在8080属于跨域请求。后端如果开启 CORS 配置但允许的来源写死为localhost:8080前端照样被拦截。解决最稳妥的跨域方案不是在后端写CrossOrigin而是在前端开发环境用代理转发。在vue.config.js里配置module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样前端请求/api/xxx时devServer 会把请求转发给8080端口浏览器看所有请求都是同源的不会触发 CORS。生产环境则由 Nginx 做同样的反向代理前端代码里不需要写完整的后端地址。5.3 Node 17 以上版本启动前端报 OpenSSL 错误现象执行npm run dev时报error:0308010C:digital envelope routines::unsupported前端服务起不来。原因Vue 2 搭配 Webpack 4 的项目依赖旧版 OpenSSL 的加密算法Node 17 及以上版本默认启用了更强的哈希算法导致 Webpack 4 构建失败。这是版本代差造成的不是代码问题。解决在package.json的启动脚本里加一行环境变量{ scripts: { dev: NODE_OPTIONS--openssl-legacy-provider vue-cli-service serve } }Windows 环境下语法略有不同用cross-env NODE_OPTIONS--openssl-legacy-provider vue-cli-service serve需要先安装cross-env依赖。如果项目里用的是 Webpack 5就不存在这个问题但 Vue 2 天然倾向于 Webpack 4所以这是要斟酌的内容。5.4 初始化 SQL 执行后中文全部变成问号现象执行完sql/hrms.sql脚本后打开数据库看到部门表、员工表里的中文全部是???。原因大概率是 MySQL 客户端连接时字符集设置不对SQL 脚本里的建表语句虽然写了DEFAULT CHARSETutf8mb4但创建数据库本身用的是utf8或者终端工具以latin1编码执行了脚本。解决创建数据库时显式指定字符集CREATE DATABASE hrms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;如果已经建过库直接执行ALTER DATABASE hrms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;并把现有表的字符集也改一遍ALTER TABLE hr_employee CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;今后再遇到乱码优先检查三个位置连接字符串的characterEncoding、数据库默认字符集、终端客户端的连接编码顺序排查命中率极高。5.5 分页接口第二页和第一页数据重复现象前端列表页翻到第二页时显示的数据跟第一页重复或者往下翻三五页后出现缺失。原因分页查询 SQL 里没有任何排序规则或者排序字段存在重复值。MySQL 在不显式排序时返回顺序是不保证稳定的同一条 SQL 执行两次可能返回不同的顺序。当ORDER BY emp_id这个唯一字段没写进分页 SQL 时LIMIT的偏移量切割出来的数据就不稳定。解决分页查询的 SQL 必须带稳定排序字段最好用主键ORDER BY e.emp_id如果业务上要求按入职日期排序则必须加上第二排序条件ORDER BY e.hire_date DESC, e.emp_id ASC原因很简单hire_date可能有多个人同日入职单靠这一个字段排序翻页时位置就会漂移。加上主键就能完全钉住顺序这也是后端分页场景里的通用法则。6. 进阶技巧十分钟验证整套环境以及新增离职管理模块的扩展顺序6.1 一键健康检查后端接口自检脚本拿到源码后不要急着看业务代码先跑一个最小验证脚本确认环境是通的再决定从哪个模块开始读#!/bin/bash # 快速验证后端服务是否正常 BASE_URLhttp://localhost:8080 # 第一步登录拿到 token TOKEN$(curl -s -X POST $BASE_URL/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:admin123} \ | python3 -c import sys,json; datajson.load(sys.stdin); print(data.get(data,{}).get(token,))) if [ -z $TOKEN ]; then echo 登录失败检查数据库初始化和后端日志 exit 1 fi echo 登录成功token 提取完成 # 第二步带 token 请求员工列表验证权限链路 curl -s $BASE_URL/api/employee/list?page1pageSize10 \ -H Authorization: Bearer $TOKEN | python3 -m json.tool | head -30这个脚本验证了两层连通性POST 登录接口能通说明数据库、MyBatis、Controller 三层的链路基本是完好的带 token 请求列表接口能通说明拦截器和权限机制生效。如果登录通了但列表 401问题基本集中在 token 解析或拦截器上。6.2 加一个离职管理模块的扩展顺序假设你要在这个系统上扩展一个离职管理模块不需要大动干戈按这套顺序改即可步骤操作内容涉及文件关键注意点1建表hr_resign并初始化菜单数据sql/hrms.sql菜单表里的按钮权限标识要预留出来2后端新增ResignController、ResignService、ResignMapperbackend/src/main/java复用现有的分页查询模式3前端新增views/resign/index.vuefrontend/src/views复制 employee 列表页再改字段名4在路由表里注册新页面frontend/src/router页面路径要和菜单权限标识保持一致5给指定角色分配离职管理菜单和按钮sys_role_menu表不分配就不显示这是权限设计带来的硬约束我做完了这套流程后最大的教训是新增业务模块时菜单权限数据比代码本身更容易出问题。代码写错了会直接报错好查但菜单没配好前端页面不显示后端接口却通着你只能一头雾水地去核对角色表和菜单表。从那以后我每次打开这类人力资源系统都先花三十分钟把 SQL 脚本跑完、接口自检一遍再动手读业务代码。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →