前后端分离人事系统,听起来像是教科书里的课程设计,但真正把这套东西从零跑到线上的人都知道,它背后涉及的不只是代码,而是一整套完整的前后端协作模式。SpringBoot负责接口,Vue渲染页面,MyBatis操作MySQL,这三样东西组合在一起,就是当前大多数中小型企业项目的标准配置。我断断续续折腾过好几个类似的人事系统,也帮别人部署上线过几次,这次干脆把一套能跑通的全源码和部署步骤整理出来,分享给正在做练习、搞毕业设计,或者刚入门前后端分离开发的朋友。
这篇文章不会只丢一个源码链接就完事,我会把里面最关键的设计思路、代码片段、联调过程、部署细节都拆开讲清楚。你看完以后不仅能跑起来项目,还能知道改哪里、为什么改、踩坑了怎么解决。哪怕你之前没碰过SpringBoot和Vue,只要按着步骤来,也能一步步搭出属于自己的管理系统。
1. 项目整体设计与技术选型解析
1.1 为什么做一套前后端分离的人事系统
先说一个很现实的问题:人事系统这种项目,功能无非就是员工增删改查、部门管理、考勤、薪资、权限,在传统的JSP时代早就做过无数遍了,为什么现在非要用前后端分离再写一遍?
因为实际开发环境变了。现在的企业项目,前端是一个团队,后端是一个团队,两边通过接口文档和数据结构协作。前端代码部署在Nginx或者CDN上,后端代码打包成jar包独立运行,互不占用部署资源,也互不拖累发布节奏。前端改页面样式不需要重新发后端服务,后端加接口也不影响前端静态页面。人事系统虽然业务规模不大,但它的模块划分足够全面,特别适合拿来模拟这种真实协作场景。
如果你只是想做一个内部小工具,前后端不分离确实更快,但如果你是奔着找工作、进项目组,甚至接私活去的,那前后端分离就是必须掌握的技能。这套人事系统麻雀虽小,五脏俱全,能帮你把整个开发链条串起来。
1.2 核心技术栈各自负责什么
先看一张简单的角色分工表,心里有个底:
| 技术栈 | 在这个项目里扮演的角色 | 为什么选它 |
|---|---|---|
| SpringBoot | 后端服务,提供RESTful接口 | 内置Tomcat,配置少,启动快,企业使用率高 |
| Vue | 前端页面渲染,用户交互 | 组件化开发,数据驱动视图,开发效率高 |
| MyBatis | 数据库访问层,执行SQL | SQL由开发者控制,灵活,复杂查询好优化 |
| MySQL | 数据存储 | 开源免费,稳定,配套生态成熟 |
整个请求链路是这样的:用户在浏览器打开Vue页面,Vue发起axios请求,请求到SpringBoot的Controller,Controller调Service,Service调Mapper接口,Mapper执行由MyBatis管理的SQL语句去操作MySQL数据库,拿到数据后一层层返回,最终由Vue把JSON数据渲染到页面上。
这套技术栈里,SpringBoot解决了“不用复杂配置就能跑起来一个Web服务”的问题,Vue解决了“操作DOM太麻烦”的问题,MyBatis解决了“Java对象和数据库字段之间转换”的问题,MySQL负责最终的数据落地。单独拆开每样都是经典,组合在一起就是目前最主流的中小型系统方案。
1.3 人事系统的功能模块规划
一个拿得出手的人事系统,至少需要下面这些模块:
- 用户登录与权限管理:区分管理员、HR、普通员工,登录成功后返回Token,前端通过路由和菜单控制页面访问。
- 部门管理:维护公司组织结构,增删改查,支持层级。
- 员工管理:员工信息的添加、编辑、删除、分页查询、关键字搜索,这是系统最核心的模块。
- 考勤管理:记录上下班打卡时间、请假申请、考勤统计。
- 薪资管理:设置基本工资、绩效,生成工资记录。
- 公告管理:发布公司通知,员工登录后能看到最新公告。
从工作量上看,员工管理和权限管理是最费时间的,尤其是员工信息的字段非常多:姓名、性别、手机号、邮箱、身份证、入职时间、职位、部门、学历、头像等等。前端要做复杂的表单校验,后端要做数据校验和异常处理。这套源码里我把这些核心模块都实现了一遍,考勤和薪资简化了一些,但主流程是完整的,方便你在此基础上继续扩展。
2. 环境准备与源码导入
2.1 开发环境版本千万不用乱选
很多新手卡的第一步不是写代码,而是环境版本匹配不上。这个项目我的建议是:
- JDK:1.8 或 11
- Maven:3.6 及以上
- Node.js:12.16 或 14(Vue CLI 4 项目用)
- MySQL:5.7 或 8.0
- 后端IDE:IntelliJ IDEA
- 前端IDE:VSCode 或 WebStorm 都可以
这里特别提醒一句:不要盲目追求SpringBoot最新版。当前这套项目是基于SpringBoot 2.5.x或者2.7.x开发的,你要是直接换成SpringBoot 3.x,很多依赖的写法都不一样了,JDK也得换到17,容易在一开始就劝退自己。选一个经过大量项目验证的稳定版本,远比尝鲜重要。
MySQL我建议直接装8.0,因为5.7虽然老当益壮,但8.0的安装教程和可视化工具支持已经非常成熟。Navicat连接的时候注意选择MySQL协议,不要选错。
2.2 数据库初始化与配置文件修改
拿到源码后先别急着跑后端,数据库必须先准备好。打开MySQL,新建一个数据库,名字随意,我用的是hr_db,字符集选utf8mb4,这个很关键,以后存emoji或者特殊字符不会乱码。
然后导入源码里提供的hr.sql脚本。命令行执行:
mysql -u root -p hr_db < hr.sql或者在Navicat里直接打开SQL文件运行。脚本会创建表结构,并且插入默认的管理员账号和演示数据。
接着修改后端配置文件application.yml,这是整个项目最重要的配置文件:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hr_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456这里每一段参数都有讲究。useSSL=false是关掉SSL连接,避免MySQL 8报ssl连接错误;serverTimezone=Asia/Shanghai是为了解决时区相差8小时的问题,不写经常会报The server time zone value异常;characterEncoding=utf8保证中文不会乱码。
2.3 前端依赖安装与启动命令
前端项目通常在frontend目录下。打开命令行,进入目录:
cd frontend npm install npm run servenpm install会安装package.json里声明的所有依赖,这个过程在国内可能会比较慢,甚至直接卡死。如果遇到这种情况,先设置一下淘宝镜像:
npm config set registry https://registry.npmmirror.com然后再执行npm install,速度会明显提升。
启动成功后,命令行会提示App running at Local: http://localhost:8080。这时候先别急着打开,因为后端服务还没启动。我们需要先在IDEA里启动SpringBoot应用,再访问前端页面,才能看到登录界面。
3. 后端核心实现与关键代码解析
3.1 SpringBoot项目结构与分层思路
后端项目我习惯按下面的结构组织目录:
hr-server ├── pom.xml └── src/main/java/com/hr ├── HrApplication.java ├── controller # 接口层,接收前端请求 ├── service # 业务层,处理业务逻辑 │ └── impl ├── mapper # MyBatis数据访问接口 ├── entity # 实体类,对应数据库表 ├── common # 统一返回结果、异常处理 └── config # 配置类,拦截器、跨域等分层的逻辑很简单:Controller不写业务代码,只负责参数接收和结果包装;Service专注业务逻辑,比如校验、计算、事务;Mapper只负责数据库操作。这样分层以后,每个类的职责都很单一,出了问题也容易定位。再加上MyBatis的接口和XML文件分离,SQL和Java代码互不干扰,后期优化SQL也不影响业务层。
如果项目再大一点,你还可以引入DTO、VO等对象,把前端传进来的参数和返回给前端的数据单独定义,减少实体类和前端字段的耦合。这套人事系统目前直接用了实体类返回,对学习来说已经足够。
3.2 MyBatis的XML映射与SQL写法
MyBatis最核心的是Mapper接口和XML映射文件。比如员工查询接口,Mapper接口定义方法:
public interface EmployeeMapper { List<Employee> selectEmployeeList(@Param("keyword") String keyword, @Param("offset") int offset, @Param("limit") int limit); }对应的EmployeeMapper.xml:
<select id="selectEmployeeList" resultType="com.hr.entity.Employee"> SELECT id, name, phone, department_id, position, hire_date, status FROM employee <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR phone LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY id DESC LIMIT #{offset}, #{limit} </select>注意这里用的是#{},不是${}。#{}会被MyBatis预编译成?占位符,自动加引号,有效防止SQL注入。${}是字符串拼接,千万别用在用户输入的地方。如果要动态拼接表名、排序列名这种不受用户控制的场景,才考虑${}。
很多人看MyBatis面试题的时候会看到XMLConfigBuilder、XMLMapperBuilder这些类,它们负责把配置文件解析成Configuration对象。平时写项目用不到,但理解这条初始化链路后,遇到“明明XML文件写对了,运行时却说找不到SQL”这种问题,你就知道应该去检查XML路径是否被扫描到了。
3.3 登录认证与权限拦截实现
人事系统里面权限控制是必须的。我采用的是JWT方案:用户登录成功后,后端生成一个Token字符串返回给前端,前端把Token存到localStorage里,后续每个请求在Header里带上Authorization: <token>,后端拦截器统一校验。
核心是自定义一个拦截器:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && JwtUtil.verify(token)) { return true; } response.setStatus(401); return false; } }然后注册拦截器,并放行登录接口:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login"); } }有了这个机制,未登录的用户访问员工接口会直接返回401,前端收到401后就跳转登录页。角色权限我用了简单的RBAC模型:用户表、角色表、用户角色关联表,登录后一次性查出用户对应的角色,然后在前端渲染对应菜单。如果你的需求角色很少,也可以在后端接口上用注解判断角色,但那样扩展性差一些。
3.4 员工管理接口的完整链路
拿新增员工来说,前端提交一个表单对象,后端Controller接收:
@RestController @RequestMapping("/api/employee") public class EmployeeController { @Autowired private EmployeeService employeeService; @PostMapping public Result add(@RequestBody Employee employee) { employeeService.add(employee); return Result.success(); } @GetMapping("/page") public Result page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { PageResult<Employee> data = employeeService.getPage(page, size, keyword); return Result.success(data); } }Service层要做的是参数校验、设置默认状态、然后调用Mapper插入数据库。这里我统一用了Result对象返回,包含code、message、data三个字段,前端拿到后先判断code是否为200,再处理数据。
有一个细节很容易被忽略:更新员工信息时,要把创建时间和更新时间分开处理。插入时设置create_time和update_time,更新时只修改update_time。很多新手会把创建时间丢了,或者在查询列表时字段映射错误,导致时间显示为null。这就是为什么我用MyBatis的resultType而不是resultMap做简单映射——字段名和数据库列名保持一致时,能省不少事。
4. 前端Vue实现解析
4.1 Vue项目结构、路由与页面规划
前端项目我使用的是Vue 2.6 + Element UI,稳定且资料多。目录结构大致如下:
frontend ├── public ├── src │ ├── main.js # 入口文件 │ ├── App.vue │ ├── router # 路由配置 │ ├── api # 接口封装 │ ├── views # 页面组件 │ ├── components # 公共组件 │ ├── utils # 工具类(request.js等) │ └── assets # 静态资源路由设计上,登录页单独一条路由;登录成功后进入Layout组件,里面套子路由,比如/employee、/department、/attendance、/salary。所有业务页面都放在Layout的内容区里,左侧是菜单,顶部是用户信息。
vue-router在使用的时候要特别注意两个点:一是路由模式,如果部署到Nginx,我建议使用history模式,但必须在Nginx里配置try_files,否则刷新页面会404;二是动态路由,如果不同角色看到的菜单不一样,前端要在登录拿到用户权限后,用router.addRoutes动态添加路由,这比一次性写死所有路由更安全。
4.2 Axios统一封装与登录拦截
前端最重要的一个公共文件是utils/request.js,它把axios实例统一封装起来。我一般这么写:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') Message.error('登录已过期,请重新登录') } else { Message.error(error.message || '请求失败') } return Promise.reject(error) } ) export default request把所有请求都走这个封装好的实例,好处是不用每个页面都重复写token校验和错误提示。尤其是401拦截,这是前后端分离项目里最常见的联动逻辑,前端只要在这里写一次,整个项目的登录状态控制就统一了。
API管理上,我会在src/api/employee.js里导出一个个函数:
import request from '@/utils/request' export function getEmployeePage(params) { return request({ url: '/employee/page', method: 'get', params }) }这样页面组件里只需要import { getEmployeePage } from '@/api/employee',调用起来非常清爽,以后后端接口地址变了,只需要改API文件,不用满项目去找。
4.3 动态菜单与员工管理页面落地
动态菜单的实现其实不复杂,登录接口返回当前用户的菜单列表,前端把它转成el-menu需要的结构,然后用v-for渲染。
<el-menu> <el-menu-item v-for="item in menuList" :index="item.path" :key="item.path"> {{ item.title }} </el-menu-item> </el-menu>如果菜单有层级,就需要递归组件。这套源码里我用的是两层级,父菜单作为el-submenu,子菜单作为el-menu-item,已经能覆盖多数管理系统的场景。
员工管理页面是典型的列表+弹窗结构。页面上方是搜索表单,中间是表格,下方是分页。我常用的做法是:
<el-table :data="tableData" border v-loading="loading"> <el-table-column prop="name" label="姓名" /> <el-table-column prop="phone" label="手机号" /> <el-table-column prop="departmentName" label="部门" /> <el-table-column label="操作"> <template slot-scope="scope"> <el-button type="text" @click="openEdit(scope.row)">编辑</el-button> <el-button type="text" @click="handleDelete(scope.row.id)">删除</el-button> </template> </el-table-column> </el-table>新增和编辑共用一个Dialog,判断form.id是否存在来区分。保存成功后重新加载列表。列表数据在created()钩子里调用:
created() { this.loadData() }这里有个小技巧:加载表格数据前设置loading = true,请求回来后置为false,不然快速切换页签时表格会闪一下,看起来很不专业。
5. 前后端联调与生产环境部署教程
5.1 本地联调:跨域代理的正确姿势
前端开发服务跑在8080端口,后端接口跑在8081端口,直接让浏览器去请求另一个端口会触发跨域问题。解决办法是让前端的开发服务器代理请求,在vue.config.js里配置:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } } }这样前端页面请求/api/employee/page时,Vue开发服务器会把这个请求转发到http://localhost:8081/api/employee/page,浏览器看到的请求是同源的,所以不会跨域。后端不用额外配置CORS,开发阶段用代理是既简单又安全的方式。
如果你用的不是Vue CLI,而是Vite,配置位置在vite.config.js的server.proxy,写法类似。千万别在浏览器里直接访问8081接口调试,那样一定跨域,调通了也是绕过的CORS,不是真实场景。
5.2 后端打包成jar并启动
开发环境调试没问题后,就该打包部署了。后端在项目根目录执行:
mvn clean package -DskipTests打包完成后,target目录下会生成一个hr-server-0.0.1-SNAPSHOT.jar。把这个jar上传到服务器,然后运行:
java -jar hr-server.jar如果想让服务在后台运行,用nohup:
nohup java -jar hr-server.jar > server.log 2>&1 &日志输出到server.log,排查问题非常方便。SpringBoot内置了Tomcat,所以不需要额外安装Tomcat容器。这也是我之前遇到很多人在问“tomcat部署前后端分离项目”的答案之一:后端jar本身就是Web服务,直接启动即可。
如果一定要用传统Tomcat部署SpringBoot项目,也不是不行,但需要把打包方式改成war,并实现SpringBootServletInitializer,配置过程相对麻烦,现在已经不是主流做法。
5.3 前端打包并部署到Nginx
前端打包:
npm run build打包完成后生成dist目录,这个目录里是静态文件,可以上传到服务器的任意一个目录,比如/usr/share/nginx/html。
Nginx配置参考如下:
server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } }这里两个关键点:
第一,location /api/的proxy_pass要把接口请求反向代理到后端的8081端口。注意后面的URL,如果proxy_pass没有带路径,则会把原始请求的完整路径转发过去;如果带了路径,可能会丢失一部分。通常建议不带路径,直接proxy_pass http://127.0.0.1:8081;,这样最不会出错。
第二,try_files $uri $uri/ /index.html;必须写。前端用的是history模式路由,当浏览器直接访问/employee地址时,服务器上并没有这个真实文件,Nginx需要自动返回index.html,然后由Vue Router接管路由。不写这句话,刷新页面就是404。
6. 常见问题与排查技巧实录
6.1 版本兼容性相关的坑
我见过最多的报错就是Jackson版本冲突、MyBatis绑定异常、JUnit版本问题。很大一部分原因是SpringBoot版本和MyBatis Starter版本不配套。建议直接使用源码pom.xml里的版本组合,不要轻易升级。
排查思路:先看mvn dependency:tree,确认MyBatis、SpringBoot的子依赖版本是否冲突;如果是Invalid bound statement,则优先检查XML文件的namespace是否和Mapper接口全限定名一致,id是否和接口方法名一致。还有一个冷门坑,resources目录没有把XML文件打包进去,导致运行环境中找不到Mapper XML。解决方案是在pom.xml的build中添加resources配置,把src/main/resources下的xml文件包含进来。
6.2 MySQL连接相关的报错集合
这里整理一份速查表:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
| The server time zone value | 时区未设置 | URL加serverTimezone=Asia/Shanghai |
| SSL connection error | 未关闭SSL | URL加useSSL=false |
| Public Key Retrieval is not allowed | MySQL8安全机制 | URL加allowPublicKeyRetrieval=true |
| Unknown database | 数据库未创建 | 先创建hr_db |
| Access denied for user | 用户名或密码错误 | 检查application.yml |
连接MySQL时还有一个常见问题,就是你安装MySQL教程走了很多步,结果服务没启动。Windows下可以执行net start mysql或者去服务管理器启动MySQL服务。我遇到过更蠢的情况,端口被占用了,localhost:3306连不上,用netstat -ano | findstr 3306一查才看到被别的程序占了。
6.3 前后端接口404与跨域排查
前端能打开页面,但列表数据出不来,大概率是接口路径不匹配。我建议先打开浏览器开发者工具,看Network里请求的URL是什么,再看看后端控制台有没有收到请求。
如果请求发到了/api/employee/page但后端报404,检查Controller类上是不是有@RequestMapping("/api/employee"),方法路径是不是/page。注意SpringBoot后端的context-path,如果你在application.yml里设置了server.servlet.context-path: /hr,那么前端/api代理也要加上/hr,不然就会404。
如果请求直接失败,显示ERR_CONNECTION_REFUSED,那多半是后端服务没起来,或者端口不是8081。日志是最好用的排查工具,后端别用Println乱打,至少用log.info,问题定位快很多。
6.4 几个容易忽略的小细节
第一个细节:前端跨域请求有时会有预检OPTIONS请求,后端拦截器如果对OPTIONS请求拦截并判为未登录,就会拦截失败。我在前面的拦截器代码里专门加了一个判断,直接放行OPTIONS,这一步容易漏。
第二个细节:员工头像上传或者导入导出功能,如果用到文件上传,SpringBoot的默认文件大小限制是1MB,很容易上传失败。需要在application.yml里调整:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB第三个细节:Element UI的表格列,如果宽度不够,会显示省略号。可以设置min-width,或者用show-overflow-tooltip让鼠标悬停显示完整内容。
第四个细节:打包前端后,如果部署到子目录下,比如http://example.com/hr/,你需要把vue.config.js里的publicPath改成/hr/,并且在路由里配置base: '/hr/'。不然静态资源会加载不到。
这些坑,我前前后后都踩过。每一次排查完,我都建议把原因和解决过程记下来,以后遇到同类问题直接查自己的笔记,比重新搜索答案快得多。
我个人在实际操作中的体会是,人事系统这类前后端分离项目,难点从来不是某个单独的技术,而是整条链路跑通前的耐心。数据库字符集、时区、代理配置、路径映射,随便一个小环节出问题,都能让人折腾半天。但只要你能按照先数据库、再后端、再前端、再联调的顺序一步步来,并且学会看日志,这套系统跑起来以后,你会对整个前后端分离开发有一个非常扎实的感知。
最后再分享一个小技巧:手动在浏览器里访问后端接口时,可以先把后端跑起来,地址输入http://localhost:8081/api/employee/page,看到返回JSON数据,说明后端没问题;再把前端跑起来,开着开发者工具看请求,如果前端请求到了但后端日志没收到,就是代理配置问题;如果后端有日志但数据渲染不出来,多半是字段名对不上。用这种“由后往前”的排查方式,很快就能定位问题所在。希望这篇内容能帮你顺利把这套人事系统跑起来,也能在里面学到动手能力。