ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue人事管理系统实战:从架构设计到JWT鉴权部署避坑指南

SpringBoot+Vue人事管理系统实战:从架构设计到JWT鉴权部署避坑指南 简介基于SpringBootVue实现的人事管理系统是一套前后端分离的毕业设计项目源码适合计算机相关专业学生用于课程设计、毕业设计参考也可供初学SpringBoot与Vue的开发者学习完整工程实践。系统覆盖用户认证授权、员工信息管理、组织架构、考勤休假、薪资福利、系统管理等核心模块包含Spring Security安全控制、RESTful API、Element UI界面构建等典型技术应用。各模块按业务分层组织Controller-Service-Mapper结构清晰便于二次开发与功能扩展。压缩包共185个文件以81个Java后端类、24个Vue组件、29个JavaScript脚本为主另含14个XML配置、10个MyBatis映射文件、SQL数据库脚本、Maven相关文件及说明文档包体约2MB目录结构清晰。已有71人学习下载整套资源提供可直接导入的工程框架、数据库初始化脚本、前端页面与后端接口实现便于快速搭建可运行的人事管理项目并通过实际代码理解前后端交互、权限管理和数据持久化流程。1. 一个被人事管理系统“带火”的毕业设计到底在做什么在计算机毕业设计选题库里基于SpringBootVue的人事管理系统几乎是常青树原因不复杂它把员工档案、部门组织、考勤请假、薪资核算这些企业里真实在用的流程搬到了线上业务流程足够通用又没复杂到让人半年都做不完。这类项目包通常由四部分组成一套SpringBoot后端工程、一套Vue前端工程、一份SQL初始化脚本和一篇文章。对正在做毕设的人或者想练手全栈的开发者来说真正值钱的不是复制粘贴代码而是能讲清楚三件事系统为什么这样拆模块、表结构为什么这样设计、前后端联调时那些坑是怎么踩过去的。这篇笔记就按这条主线展开从架构选型一直写到打包部署。2. 先把架构立住技术栈选型、模块拆分与数据模型动代码之前值得先把架构层的取舍说清楚。人事管理系统这类业务系统的难点不在某个接口有多难写而在模块边界、角色权限和数据表关联。如果代码写了一半开始乱绝大多数是这一步欠了账。2.1 SpringBootVue选型为什么这套组合成了“默认答案”人事管理系统的业务底色是“增删改查 权限 简单流程”前端需要一个能快速搭建表格、表单、弹窗的框架后端需要一个能快速出接口、做权限控制、打包部署的框架。SpringBoot负责把后端能力以REST API的方式暴露出去Vue负责把API的数据渲染成可操作的界面两者通过JSON通信。比起早期的JSPServlet单体应用这套组合让前后端职责分得很清楚调试时前端跑在Vite开发服务器上后端跑在Tomcat上各改各的互不阻塞。在技术版本上我一般会用Spring Boot 2.7这个分支配JDK 8或11而不是一上来就追最新版Spring Boot 3.x。原因后面会专门讲到很多毕业设计翻车就翻在依赖版本上。前端则用Vue 3 Vite Vue Router Pinia Element Plus这套组合对新手比较友好Vite的启动和热更新速度也明显比旧版Vue CLI快。如果拿到的项目包是Vue 2 Vue CLI的写法核心思路一样只是部分API写法不同不影响理解。2.2 人事系统的五类业务模块与角色边界人事系统即使做简版也会包含这几块登录与权限管理、组织架构、员工档案、考勤请假、薪资管理。登录模块管用户和角色用户登录后拿JWT令牌访问其余接口组织架构体现为部门表员工挂在某个部门下形成树形结构员工档案是核心主数据覆盖入职、转正、离职等状态考勤请假记录请假单和审批状态薪资管理维护每个月的工资条。角色一般分三类管理员、经理、普通员工。管理员能维护部门、用户和薪资经理能查看本部门员工信息和审批请假普通员工只能维护自己的部分档案和发起请假。这个边界先定下来后面写接口时的权限校验就有了依据。很多项目做到后面混乱不是因为代码复杂而是角色权限没有在一开始就约定好导致每个接口都要回头补判断逻辑。2.3 数据模型设计核心表结构与字段取舍数据表建议控制在六到七张以内既满足毕设要求又不至于冗长到写不完。下面这份SQL是常规设计可以直接作为初始化脚本的骨架CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), role VARCHAR(20) DEFAULT EMPLOYEE, create_time DATETIME ); CREATE TABLE dept ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, parent_id BIGINT DEFAULT 0, sort_order INT DEFAULT 0 ); CREATE TABLE employee ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender TINYINT, phone VARCHAR(20), email VARCHAR(100), dept_id BIGINT, position VARCHAR(50), hire_date DATE, status TINYINT DEFAULT 1, FOREIGN KEY (dept_id) REFERENCES dept(id) ); CREATE TABLE attendance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, employee_id BIGINT NOT NULL, work_date DATE NOT NULL, check_in TIME, check_out TIME, status TINYINT, FOREIGN KEY (employee_id) REFERENCES employee(id) ); CREATE TABLE salary ( id BIGINT PRIMARY KEY AUTO_INCREMENT, employee_id BIGINT NOT NULL, month VARCHAR(7), base_salary DECIMAL(10,2), bonus DECIMAL(10,2), deduction DECIMAL(10,2), actual_salary DECIMAL(10,2), FOREIGN KEY (employee_id) REFERENCES employee(id) );用户表单独拆出来不跟员工表合成一张是为了让登录账号和员工档案解耦避免“一个账号只能对应一个在职员工”这种限制连累整个系统。员工表里存dept_id外键而不是部门名字符串后续改部门名称只需改一处不会出现员工列表里部门名不一致的脏数据。这里有个特别值得注意的点员工工号emp_no一定要加唯一约束。很多项目在做导入历史数据时发现工号重复导致数据错乱这种问题属于典型的“图省事不加约束后期付出代价”。考勤和薪资这类流水表统一以employee_id做外键关联主档案后续做报表按员工聚合时会方便很多。字段类型上金额一律用DECIMAL不要用FLOAT否则工资计算会出现浮点误差。3. 后端落地SpringBoot核心接口与JWT鉴权的可复现步骤架构和表结构定好之后开始写后端。这里的顺序建议是先搭工程和依赖再配数据源再写实体和Mapper再写登录鉴权最后写业务接口。顺序反了容易出现“接口写完才发现权限过滤器没加”的窘境。3.1 工程初始化pom.xml与application.yml的关键配置Maven工程里关键的依赖是spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-j、java-jwt或者jjwt、lombok。下面是一份可直接参考的依赖骨架parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version3.19.4/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies依赖版本这里有两个坑。第一Spring Boot 3.x把javax包名切换成了jakarta如果把网上搜到的Spring Boot 3代码直接抄进2.7工程会出现import javax.servlet.*编译不过的现象反过来把2.7代码抄进3.x也一样。第二MyBatis-Plus的starter版本要和Spring Boot大版本匹配插件社区维护速度通常滞后于Spring Boot大版本这也是“springboot版本太高导致MyBatis-Plus起不来”这类问题的高发原因。配置文件的核心是数据源、MyBatis-Plus和Jackson时间格式server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hrms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case: true可以让数据库的create_time自动映射到Java对象的createTime不用手写一堆TableField。jackson.date-format统一了前后端日期传输格式不配置的话LocalDateTime序列化出来是一串数组或时间戳前端那边很难处理。逻辑删除的配置意味着删除操作默认变成update ... set deleted 1而不是物理删除给误删留了后悔药。3.2 实体、Mapper与Service的基本写法用MyBatis-Plus后单表的CRUD基本不用手写SQL实体类上加注解即可Data TableName(employee) public class Employee { TableId(type IdType.AUTO) private Long id; TableField(emp_no) private String empNo; private String name; private Integer gender; private String phone; private String email; private Long deptId; private String position; JsonFormat(pattern yyyy-MM-dd) private LocalDate hireDate; private Integer status; }TableName(employee)指定表名TableId(type IdType.AUTO)表示主键自增JsonFormat确保hireDate按yyyy-MM-dd格式传输。这里特意用LocalDate而不是Date因为日期类型只关心年月日用带时分秒的类型反而容易在比较时出偏差。Mapper只需要继承BaseMapper连基础SQL都不用写Mapper public interface EmployeeMapper extends BaseMapperEmployee { }再加一个带分页查询的Service方法就够覆盖列表页的核心逻辑Service RequiredArgsConstructor public class EmployeeService { private final EmployeeMapper employeeMapper; public PageResultEmployee pageQuery(int page, int size, String keyword) { PageEmployee p new Page(page, size); LambdaQueryWrapperEmployee wrapper new LambdaQueryWrapper(); if (keyword ! null !keyword.isBlank()) { wrapper.like(Employee::getName, keyword).or().like(Employee::getEmpNo, keyword); } wrapper.orderByDesc(Employee::getHireDate); employeeMapper.selectPage(p, wrapper); return new PageResult(p.getRecords(), p.getTotal()); } }LambdaQueryWrapper的好处是列名由编译器校验属性名写错会直接编译报错而不是运行之后才报SQL错误。这里有个细节容易翻车.like(Employee::getName, keyword).or().like(Employee::getEmpNo, keyword)拼出来的是(name LIKE ? OR emp_no LIKE ?)这个条件组。如果继续在后面链式追加.eq(...)要小心SQL优先级必要时用wrapper.and(w - w.like(...).or().like(...))把条件组包起来。3.3 登录接口与JWT鉴权拦截器登录接口返回一个JWT后续请求在Header里带Authorization: Bearer xxx拦截器统一校验。JWT工具类我一般这样写public class JwtUtil { private static final String SECRET hrms-secret-key-change-in-production; private static final long EXPIRE_MS 7 * 24 * 3600 * 1000L; public static String createToken(Long userId, String username, String role) { return JWT.create() .withClaim(userId, userId) .withClaim(username, username) .withClaim(role, role) .withExpiresAt(new Date(System.currentTimeMillis() EXPIRE_MS)) .sign(Algorithm.HMAC256(SECRET)); } public static DecodedJWT verify(String token) { return JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token); } }密钥在生产环境要挪到配置文件并用随机复杂串毕设里硬编码不算大问题但答辩时如果被问到“密钥怎么管理”要能回答出这个改进方向。登录接口的业务逻辑就是根据用户名查用户校验加密后的密码签发令牌RestController RequestMapping(/api/auth) public class AuthController { PostMapping(/login) public ResultLoginVO login(RequestBody Valid LoginDTO dto) { User user userMapper.selectOne( new LambdaQueryWrapperUser().eq(User::getUsername, dto.getUsername())); if (user null || !encoder.matches(dto.getPassword(), user.getPassword())) { throw new BizException(用户名或密码错误); } String token JwtUtil.createToken(user.getId(), user.getUsername(), user.getRole()); return Result.ok(new LoginVO(token, user.getRealName(), user.getRole())); } }密码不要用明文存储用BCryptPasswordEncoder加密。encoder.matches(dto.getPassword(), user.getPassword())校验的是“明文和已加密串是否匹配”不是把密码解密这是刚接触Spring Security的人最容易搞混的概念。拦截器层面更稳妥的做法是自定义HandlerInterceptor比引入整套Spring Security门槛低很多。拦截器里先放行/api/auth/**和静态资源再校验Header里的TokenComponent 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 auth request.getHeader(Authorization); if (auth ! null auth.startsWith(Bearer )) { try { DecodedJWT jwt JwtUtil.verify(auth.substring(7)); request.setAttribute(userId, jwt.getClaim(userId).asLong()); request.setAttribute(role, jwt.getClaim(role).asString()); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\token无效或过期\}); return false; } } response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } }OPTIONS请求直接放行非常关键。浏览器跨域时先发预检请求拦截器一旦拦下来前端会看到“CORS失败”而不是业务报错。这里如果把角色也写进Token并在拦截器里校验接口权限就能实现前面提到的“经理能审批、普通员工不能”的角色边界。注册拦截器时注意排除路径要写全Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /error); } }之前见过有人把/api/auth/login漏掉结果登录接口自己都要带Token直接死循环。配置完成后用Postman或Apifox先单独测后端确认每个接口的鉴权行为符合预期再切到前端联调。4. 前端落地Vue3页面、路由与Axios封装的全流程后端接口就绪后前端要能独立跑起来对接。这里采用Vue 3 Vite Element Plus如果拿到的项目包是Vue 2 Vue CLI结构会略有差异但封装思路一样路由做守卫、请求做拦截、页面拆列表和表单。4.1 用Vite初始化Vue3项目并接入Element Plus初始化一个Vue3项目的常用命令是npm create vitelatest hrms-front -- --template vue cd hrms-front npm install npm install element-plus element-plus/icons-vue axios vue-router4 piniaVite模板默认只给到最小骨架Router、Pinia、Axios都需要手动装。Element Plus的接入方式我一般用全量引入毕设场景不用太计较首屏加载体积// main.js import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router import { createPinia } from pinia const app createApp(App) app.use(ElementPlus) app.use(router) app.use(createPinia()) app.mount(#app)如果项目包里的Element Plus是按需引入的还会额外配unplugin-auto-import和unplugin-vue-componentsVite配置里多几行样板代码。这里建议先全量引入把业务跑通最后再来优化体积不要一上来就被按需引入的配置绊住。4.2 路由守卫与Axios拦截器的标准写法路由守卫解决“未登录不能进页面”的问题Axios拦截器解决“请求自动带Token、响应统一处理业务错误”的问题两者一前一后配合。// router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, component: () import(../views/Login.vue) }, { path: /, component: () import(../layout/Layout.vue), redirect: /employee, children: [ { path: employee, component: () import(../views/employee/EmployeeList.vue) }, { path: attendance, component: () import(../views/attendance/AttendanceList.vue) }, { path: salary, component: () import(../views/salary/SalaryList.vue) } ] } ] const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to) { const token localStorage.getItem(token) if (!token to.path ! /login) { return /login } if (token to.path /login) { return / } })路由里使用component: () import(...)做懒加载让页面按需拆包。beforeEach里的判断只做“有没有Token”的粗粒度控制Token是否真正过期由后端的401响应兜底前端在Axios响应拦截器里再处理跳转。请求封装是前后端联调的中间层几乎每个基于SpringBootVue的项目都会用到// src/api/request.js import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, timeout: 15000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( resp { const body resp.data if (body.code body.code ! 200) { ElMessage.error(body.msg || 请求失败) return Promise.reject(new Error(body.msg)) } return body.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } )这段代码要重点说明开发环境下baseURL: /api会走Vite的proxy把请求转发到localhost:8080从而绕开跨域生产环境下把前端打包产物放到SpringBoot的static目录同源访问也不会有跨域问题。这套方式比在浏览器里装CORS插件正规得多演示时也更稳定。Vite的proxy配置在vite.config.js里export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })4.3 员工管理页面列表、搜索、弹窗表单的最小实现员工管理页面把列表、分页、搜索、新增、编辑、删除串在一起是人事系统里最典型也最完整的一个页面。模板部分template div el-card el-form inline el-form-item label关键字 el-input v-modelquery.keyword placeholder姓名/工号 clearable keyup.enterloadData / /el-form-item el-button typeprimary clickloadData查询/el-button el-button typesuccess clickopenDialog()新增员工/el-button /el-form el-table :datalist border stripe el-table-column propempNo label工号 width120 / el-table-column propname label姓名 width120 / el-table-column propdeptName label部门 / el-table-column propposition label岗位 / el-table-column prophireDate label入职日期 / el-table-column label操作 width180 template #default{ row } el-button sizesmall clickopenDialog(row)编辑/el-button el-button sizesmall typedanger clickremove(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequery.page v-model:page-sizequery.size :totaltotal layouttotal, prev, pager, next current-changeloadData / /el-card /div /template列表中要显示部门名称而后端返回的Employee实体里只有deptId常规做法是让后端在返回列表时额外带一个deptName字段前端不做联表查询。不要用v-for在前端通过数组匹配去补部门名因为后端做了分页前端拿到的本来就不是全量数据匹配会漏数据。脚本部分逻辑script setup import { ref, onMounted } from vue import { ElMessage, ElMessageBox } from element-plus import { listEmployees, createEmployee, updateEmployee, deleteEmployee } from ../api/employee const list ref([]) const total ref(0) const query ref({ page: 1, size: 10, keyword: }) async function loadData() { const data await listEmployees(query.value) list.value data.records total.value data.total } function openDialog(row) { // 控制详情弹窗显示编辑时把 row 回填到表单 } async function remove(row) { await ElMessageBox.confirm(确定删除员工 ${row.name} 吗, 提示, { type: warning }) await deleteEmployee(row.id) ElMessage.success(删除成功) loadData() } onMounted(loadData) /script删除前加二次确认是必须的否则演示时误删数据会很难看。新增和编辑弹窗共用同一个表单组件通过row是否有值区分是编辑还是新增这是Element Plus里最常见的写法能省掉大量重复模板代码。提示loadData()里请求返回的数据结构要和后端的PageResult保持完全一致。常见问题是前端拿data.records但后端返回的是data.list这种字段名不一致导致的“前端空白”排查起来最费时间。5. 打包部署与联调避坑5个高频翻车点工程能跑通之后下一步就是联调和部署。这一章把最常见的几个坑按“现象 → 原因 → 解决”拆开写。这些基本是每次带毕设或带新人时反复出现的问题提前踩过能省大量时间。5.1 SpringBoot版本太高项目一启动就报依赖冲突现象新建的Spring Boot 3.x工程引入MyBatis-Plus或旧版starter启动时直接报NoClassDefFoundError或Failed to configure a DataSource。原因Spring Boot 3从javax迁移到了jakarta并对自动配置做了调整很多三方starter还停留在适配2.x的状态。加上用搜索引擎直接抄最新版博客博客之间互相抄出来的配置不一定兼容版本冲突雪上加霜。解决毕设场景优先选Spring Boot 2.7.x配JDK 8/11依赖版本也锁定在MyBatis-Plus 3.5.x、java-jwt 3.x这些已互相适配过的组合上。如果必须用Spring Boot 3就需要同步确认starter是否有兼容新坐标的版本并且代码里带javax的 import 要全部改成jakarta。这个坑在答辩前的“最后一天换版本”操作中尤其致命不要在答辩前夜动大版本。5.2 Vue打包后放进SpringBoot页面直接404现象npm run build后把dist内容拷到src/main/resources/static启动后端访问/是404或白屏刷新子路由如/employee会再次404。原因Spring Boot默认只处理static目录下的静态文件但Vue Router用的是history模式刷新/employee时后端认为这是接口路径找不到对应Controller于是404。hash模式不会有这个问题但地址栏会多个#号显得不专业。解决加一个转发规则把所有非/api的路径都转发到index.htmlConfiguration public class SpaForwardConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{spring:[^\\.]*}).setViewName(forward:/index.html); registry.addViewController(/**/{spring:[^\\.]*}).setViewName(forward:/index.html); } }补充一点拷完dist后发现没生效先清理浏览器缓存或者把static目录下的旧文件整个删掉再拷。前端路由路径如果带“.”比如/employee/2024.07会被上面这个转发规则误判为静态文件访问这类路径在后端路由设计里最好避免。5.3 跨域请求一直报错但CORS配置看起来没问题现象前端启动在5173端口后端在8080端口请求发出去后浏览器控制台报Access-Control-Allow-Origin缺失但后端已经在配置里加了CrossOrigin或CorsFilter。原因跨域配置的顺序不对或者拦截器把预检OPTIONS请求拦了。常见情况是Axios的baseURL直接写成了http://localhost:8080/api导致请求完全绕过了Vite的proxy所有跨域逻辑都压在后端CORS上而后端CORS又和JwtInterceptor顺序冲突。解决开发环境不折腾CORS直接用Vite proxy。把baseURL设为/api在vite.config.js里把/api代理到http://localhost:8080。如果后端确实要以CORS方式提供接口配置CorsFilter时注意拦截顺序必须在JwtInterceptor之前同时JwtInterceptor里对OPTIONS请求直接放行三条缺一条都会出问题。5.4 Token失效后页面不跳转却反复弹“未登录”现象Token过期后第一个接口返回401Axios拦截器里做了router.push(/login)但页面没有跳转后续接口仍在持续请求每个接口都弹一次“未登录”。原因拦截器里只跳转了但没有清空Token而路由守卫的逻辑是“有Token就放行”于是跳转到/login后又被守卫重定向回主页。另一种情况是多个并发请求同时401每个请求的拦截器里都触发跳转和弹窗体验极差。解决确认401处理中先localStorage.removeItem(token)再跳登录页并在跳转前判断当前路径不是/login再做跳转避免重复触发。多个并发请求同时401时用一个isRedirecting标志位只让第一个401触发跳转逻辑后续的直接reject掉即可。这个标志在路由切换后重置。5.5 日期字段前后端差8小时Excel导入变成一串数字现象前端el-date-picker选出的日期保存到数据库后多出8小时或者导出Excel时日期列变成一串数字。原因Jackson序列化LocalDateTime时没指定时区MySQL连接串里又没加serverTimezoneAsia/Shanghai两端对时间理解不一致。Excel导出时日期单元格没有生效格式化原生数字就暴露出来了。解决后端统一在application.yml配spring.jackson.time-zone和数据源URL里的serverTimezoneAsia/Shanghai。Excel导出时给日期单元格设置数据格式不要直接写入LocalDateTime的默认字符串。用EasyExcel的话在实体字段上加DateTimeFormat(yyyy-MM-dd)注解即可避免手写转换逻辑。这两个配置属于“平时不起眼出问题查半天”的类型。6. 进阶给人事管理系统补上“答辩加分”的两处细节系统能跑通、会打包之后还要做的一些细节往往是答辩和评审时被追问最多的地方。第一处是增加一个导出功能比如把员工列表导出成Excel。这个需求在真实HR场景里几乎是刚需实现难度不高但很能体现工程意识。用EasyExcel写一个通用导出接口核心代码就几行GetMapping(/export) public void export(HttpServletResponse response) throws IOException { ListEmployee list employeeService.listWithDeptName(); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filenameemployee.xlsx); EasyExcel.write(response.getOutputStream(), Employee.class) .sheet(员工档案) .doWrite(list); }列名用ExcelProperty注解在实体上声明前端按钮只需要window.open(/api/employee/export, _blank)就能触发下载。演示这个功能时评审最常问的是“大数据量导出会怎么样”能答出“分批查询 流式写入”就算过关。第二处是权限控制的完整落地。很多毕设只在后端做了登录拦截前端菜单谁都能点。更完整的做法是在Vue路由的meta里声明roles路由守卫中校验角色后再放行按钮级别则用自定义指令v-permission控制。后端每个请求除了校验Token还要校验角色与当前资源是否匹配。把这两端补上答辩时被问“权限怎么设计的”就稳了。最后一件事是部署验证。不要只在开发模式里跑过就结束一定要直接启动SpringBoot jar包访问http://localhost:8080/走一遍完整流程登录、新增员工、编辑、删除、导出、退出登录。我第一次带项目时就是在开发模式里一切正常然后直接把前端dist拷进去结果刷新路由404当场演示翻车。从那以后我把“打包后全链路跑一遍”当成固定验收动作提前把坑填平。希望这个习惯和你在这个系统上的投入最后都能得到对得起付出的结果希望帮到你。本文还有配套的精品资源点击获取
返回列表