ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot+Vue人事管理系统实战:从架构设计到部署避坑全解析

SpringBoot+Vue人事管理系统实战:从架构设计到部署避坑全解析 简介一份面向毕业设计与课程设计的SpringBootVue企业人事管理系统源码包主要解决企业员工、薪资、考勤、职位、招聘等模块的信息化管理问题。包内共244个文件压缩后32.65MB包括78个Java后端源文件、14个XML与2个YAML配置、1个SQL初始化脚本以及大量JPG/PNG/JPEG界面截图和30个运行日志文件截图便于预览页面效果日志与配置文件有助于部署和异常排查。目前已有28人学习。系统采用前后端分离架构后端SpringBoot提供RESTful API前端Vue通过请求完成数据交互员工模块支持个人信息、工作经历、教育背景与多条件查询薪资模块涵盖基本工资、奖金、扣款、个税等计算考勤模块可生成报表职位与招聘模块则覆盖岗位创建/变更/删除、职位发布、简历筛选、面试安排、录用通知等常见流程另含权限控制、数据备份恢复、系统日志与按部门/岗位定制功能界面等辅助能力。源码目录清晰、注释友好适合作为毕业设计、课程设计或期末大作业的完整参考也可在此基础上扩展为真实项目。1. 拿到这套SpringBootVue人事系统先搞清楚它能解决什么问题月底核对考勤、月初算工资是让每个行政都头疼的活。Excel 台账一旦超过几百人就变成黑匣子改一条请假记录要重算整行公式买一套商业 HR SaaS 又贵而且员工数据放在别人服务器上始终不踏实。这套基于 SpringBoot 与 Vue 技术栈的企业人事管理系统把组织、员工、考勤、薪资、审批这些常规模块做成了前后端分离的工程SpringBoot 负责提供接口、做权限校验Vue 负责页面展示和交互你拿到压缩包后既能直接改来用也能当做一个完整全栈项目去学习。它适合正在做毕设的学生、要给中小团队做内部工具的全栈工程师以及想把增删改查练成完整项目经验的人。2. 为什么是 SpringBoot Vue架构选型和模块边界先定下来先说结论人事管理系统不是一个高并发系统难点在业务杂、权限细、数据易错。选 SpringBoot Vue不是因为潮流而是因为这套组合把「后端出接口、前端做交互」的边界切得最清楚。后面所有代码都围绕这个边界展开。2.1 前后端分离到底解决了什么问题老式做法是 JSP/Thymeleaf页面里嵌 Java 代码改一个表格列名要动服务端模板前端样式和后端逻辑缠在一起人事提个需求两边人都要加班。SpringBoot 用内嵌 Tomcat打包成一个可执行 jar配合各种 starter 把数据源、MVC、JSON 序列化这些基础配置自动装配好你写接口时只需要关注 controller、service、mapper 三层。Vue 则把页面拆成组件每个业务板块是一个 view内部再拆成表格、弹窗、表单组件后端只要返回 JSON前端用 axios 拿数据渲染。这套组合还有一层人事系统特别需要的优势权限菜单可以动态生成。传统模板在服务端判断角色后渲染不同菜单页面一多模板到处都是 if elseVue Router 4 支持 addRoute 动态注册路由登录后根据后端返回的角色菜单生成路由表管理员和普通员工看到的是两套完全不同的侧边栏不用维护两份页面。2.2 人事系统的模块边界主数据、流程数据和统计报表开工前先把系统的数据分三类能避免绝大多数「表设计翻车」。第一类是主数据部门、员工、岗位它们变化慢是其他表的引用对象第二类是流程数据考勤记录、请假单、加班单、工资计算单它们有时序和状态第三类是统计报表月度工资汇总、部门人数统计通常只用临时查询生成不需要单独建宽表。数据分类对应模块核心字段举例操作特征主数据部门、员工、岗位部门ID、工号、入职日期低频修改逻辑删除流程数据考勤、请假、审批月份、状态、审批人高频插入状态流转统计报表工资月报、人数统计月份、部门、合计只读按时间维度汇总这个划分直接决定建表方式。主数据要保留唯一编码工号、部门编码因为后续请假、调薪、奖金都要引用它流程数据要有状态字段草稿/审批中/已通过/已驳回而不是直接删记录否则审批历史没法审计统计结果不落表需要时用 SQL 实时聚合减少脏数据来源。2.3 初始化前后端工程生成骨架时就把版本钉死压缩包里的工程一般会后端一个 Maven 工程、前端一个 Vite 工程。我自己的习惯是开工前就用固定版本生成骨架避免不同机器环境差太多。后端用 start.spring.io 生成# 生成 SpringBoot 2.7.x 工程javax 命名空间与大多数企业项目兼容 curl https://start.spring.io/starter.zip \ -d dependenciesweb,mysql,mybatis \ -d typemaven-project \ -d bootVersion2.7.18 \ -d groupIdcom.example -d artifactIdhr-server \ -o hr-server.zip说明bootVersion 我建议锁定 2.7.x。SpringBoot 3 把 javax.servlet 换成了 jakarta.servlet很多老版本的 starter、Druid 配置、代码生成器都没跟上光适配编译错误就能耗掉半天。如果只是做人事系统2.7.18 足够稳定也不缺官方维护。注意这里 dependencies 里选了 web、mysql、mybatisJWT 等第三方库后面在 pom.xml 手动加即可。前端初始化# Vue 3 工程Vite 构建 npm create vuelatest hr-web cd hr-web npm install npm install element-plus axios vue-router4 pinia说明create vue 脚手架会交互式询问是否启用 TypeScript、ESLint、Vitest做人事系统我不建议一开始就上 TypeScript数据结构和接口定义还没稳定时类型体操会拖慢节奏但项目稳定后可以逐步补。Element Plus 是 UI 库axios 负责 HTTPvue-router4 用于路由pinia 用来存登录员工信息、角色菜单这些全局状态功能上可以完全替代 Vuex而且语法更接近 setup 风格。2.4 目录结构按「接口、页面、状态」分开后期省心前后端工程目录不建议盲目分层常见做法是后端按技术分层前端按业务模块分目录。一个能直接跑的结构是这样hr-server/src/main/java/com/example/hr ├── controller # 接口层只做参数校验和返回统一 Result ├── service # 业务层处理事务、校验、审批流转 ├── mapper # MyBatis-Plus 的 Mapper 接口 └── entity # 实体类与表字段一一对应 hr-web/src ├── api # 按模块封装接口请求如 employee.js, salary.js ├── views # 页面组件一个业务模块一个文件夹 ├── router # 静态路由 动态路由注册逻辑 └── store # Pinia 状态登录用户、角色、菜单这套结构的好处是后端换数据库不影响前端前端换 UI 库不影响后端接口。新手最容易犯的错是把页面里的 fetch 请求直接写在组件里后期接口路径一改几十个文件都要动。你只需要把 api 文件统一收口改动就只在一处。另外接口返回结构要在一开始定死我统一用{ code: 0, data: ..., message: ok }code 为 0 表示成功非 0 表示业务失败HTTP 状态码只用来表达网络层和认证层错误。前后端都按这个约定写后面才能进第 3 章的编码环节。3. 后端落地从建表到接口的 SpringBoot 实现进入实操。人事系统后端核心就是三类事把表建明白、把登录权限守住、把增删改查和导入导出做好。逐个来。3.1 建表员工表、部门表、薪资表这样设计下面是我在人事系统里最常用的三张核心表结构直接按这个建能避开大部分数据上的坑。CREATE TABLE department ( id bigint PRIMARY KEY AUTO_INCREMENT, parent_id bigint NOT NULL DEFAULT 0 COMMENT 上级部门ID0表示根节点, dept_name varchar(50) NOT NULL COMMENT 部门名称, leader_id bigint DEFAULT NULL COMMENT 部门负责人员工ID, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, KEY idx_parent (parent_id) ) ENGINEInnoDB COMMENT部门表; CREATE TABLE employee ( id bigint PRIMARY KEY AUTO_INCREMENT, employee_no varchar(20) NOT NULL COMMENT 工号全局唯一, name varchar(30) NOT NULL COMMENT 姓名, dept_id bigint NOT NULL COMMENT 所属部门ID, position varchar(50) DEFAULT NULL COMMENT 岗位名称, phone varchar(20) DEFAULT NULL, entry_date date DEFAULT NULL COMMENT 入职日期, status tinyint NOT NULL DEFAULT 1 COMMENT 1在职 0离职, deleted tinyint NOT NULL DEFAULT 0 COMMENT 逻辑删除, UNIQUE KEY uk_emp_no (employee_no), KEY idx_dept (dept_id) ) ENGINEInnoDB COMMENT员工表; CREATE TABLE salary ( id bigint PRIMARY KEY AUTO_INCREMENT, employee_id bigint NOT NULL COMMENT 员工ID, month varchar(7) NOT NULL COMMENT 薪资月份如 2025-04, base_salary decimal(10,2) NOT NULL COMMENT 基本工资, bonus decimal(10,2) NOT NULL DEFAULT 0 COMMENT 奖金, deduction decimal(10,2) NOT NULL DEFAULT 0 COMMENT 扣款, final_salary decimal(10,2) NOT NULL COMMENT 实发工资, UNIQUE KEY uk_emp_month (employee_id, month) ) ENGINEInnoDB COMMENT薪资表;这几条 DDL 里藏着三个实战约定。第一员工表用deleted逻辑删除而不是物理删人事系统的薪资历史要和员工档案关联一旦物理删除员工几年后的审计就查不到人查询时所有接口必须带deleted 0条件。第二薪资表用(employee_id, month)唯一索引从数据库层面拦住同一个人同月二次录入这是防错最简单的手段。第三金额一律decimal(10,2)绝不要用 float 或 double工资计算差一分钱都会让财务部找上门。3.2 认证权限Spring Security JWT 的一次到位配置人事系统天然有角色管理员、HR、部门主管、普通员工。我不会用复杂的 OAuth2常见方案是 Spring Security 只做过滤器链 JWT 做无状态 token。先生成 token// JwtUtil.java public class JwtUtil { // 演示用密钥生产环境必须改为环境变量注入 private static final String SECRET hr-system-secret-change-me; private static final long EXPIRE 24 * 60 * 60 * 1000L; // 24 小时过期 public static String createToken(Long userId, String role) { return Jwts.builder() .claim(role, role) // 角色放进 token前端用它生成菜单 .setSubject(String.valueOf(userId)) // 主键放 subject .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parse(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }说明JWT 里只放 userId 和 role不放权限列表权限列表太重且修改后要等 token 过期才生效。人事系统的角色数量很少前端只需要角色就能渲染菜单后端接口可以直接按 role 字符串做拦截简单直观。接着是过滤器// JwtAuthFilter.java public class JwtAuthFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { String header req.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims JwtUtil.parse(token); req.setAttribute(userId, Long.parseLong(claims.getSubject())); req.setAttribute(role, claims.get(role)); } catch (Exception e) { res.setStatus(401); res.setContentType(application/json;charsetutf-8); res.getWriter().write({\code\:401,\message\:\token无效或已过期\}); return; } } chain.doFilter(req, res); } }说明过滤器只解析 token 并放到 request attribute真正判断角色是在 controller 接口上用RequiresRole(HR)这类自定义注解或 Spring Security 的PreAuthorize。这样接口的权限规则一眼可见不会出现藏在 service 里的 if else。注意登录接口要放行否则还没拿到 token 就被挡在外面。还有一点token 过期时间设 24 小时是人事系统的折中太长有安全风险太短会让员工一天登录好几次改用刷新 token 的成本对内部系统来说不值。3.3 分页查询MyBatis-Plus 分页插件与员工列表接口列表接口必须分页这是人事系统最容易被忽略的性能点。几千条员工记录一次性查出来前端和数据库一起卡。MyBatis-Plus 提供分页插件配置一个 Bean 就能让 selectPage 自动拼接 LIMIT// MybatisPlusConfig.java Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页查询接口// EmployeeServiceImpl.java public PageResultEmployee pageEmployees(int page, int size, String keyword, Long deptId) { LambdaQueryWrapperEmployee wrapper new LambdaQueryWrapper(); wrapper.eq(Employee::getDeleted, 0); if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(Employee::getName, keyword) .or().like(Employee::getEmployeeNo, keyword)); } wrapper.eq(deptId ! null, Employee::getDeptId, deptId); int safeSize Math.min(size, 100); // 防止前端传 10000 拖垮数据库 PageEmployee page employeeMapper.selectPage( new Page(page, safeSize), wrapper); return new PageResult(page.getTotal(), page.getRecords()); }有两个参数细节要说明。第一关键字搜索里的or必须用and(w - ...)包起来否则 SQL 会被拼成deleted0 AND name LIKE ? OR employee_no LIKE ?把已删除的数据查出来。第二page 从 1 开始size 做了上限截断这是前端的约定也是防止有人直接在控制台改请求参数把整表拉走的兜底。3.4 Excel 导入导出人事档案进出的最后一公里人事系统一定躲不过 Excel历史上员工的台账在 Excel 里新员工花名册也在 Excel 里。用 EasyExcel 做导入导出是社区最常见的方案内存友好且支持模板校验。// EmployeeController.java PostMapping(/import) public ResultString importExcel(RequestParam(file) MultipartFile file) throws IOException { ListEmployeeImportDto list EasyExcel.read(file.getInputStream()) .head(EmployeeImportDto.class) .sheet() .doReadSync(); ListString errors new ArrayList(); for (int i 0; i list.size(); i) { EmployeeImportDto dto list.get(i); if (StringUtils.isBlank(dto.getName())) { errors.add(第 (i 2) 行姓名不能为空); } if (StringUtils.isBlank(dto.getEmployeeNo())) { errors.add(第 (i 2) 行工号不能为空); } else if (employeeMapper.selectCount( new LambdaQueryWrapperEmployee() .eq(Employee::getEmployeeNo, dto.getEmployeeNo())) 0) { errors.add(第 (i 2) 行工号已存在); } } if (!errors.isEmpty()) { return Result.error(导入失败 String.join(;, errors)); } // 500 条一批批量插入避免参数过多真实实现里用 service.saveBatch employeeService.saveBatch(convertList(list)); return Result.ok(成功导入 list.size() 条记录); }说明doReadSync()一次性把文件读进内存对于人事系统几千行的 Excel 完全够用如果以后要支持几十万行再改套路监听器逐行读。逐行校验而不是整批失败这是业务方能不能接受的核心一次性告诉你第几行有问题比回去对着 Excel 数数高效得多。日期列记得在 DTO 字段上配ExcelProperty并指定DateTimeFormat否则默认数字串会变成时间戳导入时间就错了。上面几步做完后端主干已经能跑通建表、登录、分页、导入导出。然后是前端页面。4. 前端落地Vue3 Element Plus 把接口变成可用的页面后端接口写完前端的工作就是把接口数据渲染成可操作页面。这里用 Vue3 Vite Element Plus 做示范这也是当前 vue 项目实战里最常见的组合。4.1 环境配置与开发代理先解决跨域前后端分离开发时最常见的问题就是跨域。我在 vite.config.js 里用 devServer 代理把 /api 开头的请求转发到后端 8080// vite.config.js export default defineConfig({ server: { host: 0.0.0.0, port: 5173, proxy: { /api: { target: http://localhost:8080, // 后端服务地址 changeOrigin: true, // 后端接口统一带 /api 前缀所以这里不 rewrite } } } })参数说明changeOrigin: true会把请求头里的 Host 改成 target 域名后端不配置任何 CORS 也能正常接收rewrite默认不加前端请求GET /api/employee/list后端 controller 也是RequestMapping(/api/employee)路径完全一致开发和生产环境用同一套接口地址减少环境差异。这样本地开发时浏览器看到的是同源请求不会触发浏览器跨域拦截。4.2 动态路由登录后按角色生成菜单人事系统的菜单不能写死。管理员需要「员工管理-薪资调整」普通员工只该看到「我的考勤、我的薪资」。动态路由的标准做法是登录后调用后端/api/menu接口拿到当前角色能访问的路由列表然后用 router.addRoute 注册。// router/index.js router.beforeEach(async (to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) return } if (token !localStorage.getItem(menus)) { try { const menuList await fetchMenus() // 从后端拉当前角色的菜单 menuList.forEach(route router.addRoute({ path: route.path, component: route.view, // 组件已经提前映射好 children: route.children })) localStorage.setItem(menus, JSON.stringify(menuList)) next({ ...to, replace: true }) // 重新进入目标路由 } catch (e) { localStorage.clear() next(/login) } } else { next() } })这里有一个必经的坑后端返回的是组件路径字符串比如/hr/EmployeeList.vueVue 不能直接用字符串渲染组件。我的解决方法是用 Vite 的import.meta.glob把 views 下所有页面组件预注册成一个映射表// router/index.js const viewModules import.meta.glob(/views/**/*.vue) const mapComponent (str) viewModules[/src/views/${str}.vue]说明import.meta.glob默认是懒加载模式返回的每个组件都是函数路由切换时才加载对应 JS首屏体积会小很多。菜单存 localStorage 而不是 Pinia是因为刷新页面后 Pinia 会清空localStorage 能保证菜单不闪白但角色变更时要手动清一下菜单缓存否则老角色一直看到旧菜单。4.3 员工列表页el-table 加条件搜索员工列表页是人事系统的门面格式通常是上方搜索区 中间表格 下方分页器。用 Element Plus 实现!-- views/employee/EmployeeList.vue -- template div classpage el-form inline el-form-item label关键字 el-input v-modelquery.keyword placeholder姓名/工号 clearable keyup.enterhandleSearch / /el-form-item el-form-item label部门 el-select v-modelquery.deptId placeholder全部部门 clearable el-option v-ford in deptList :keyd.id :labeld.deptName :valued.id / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button clickresetQuery重置/el-button /el-form-item /el-form el-table :datarowList v-loadingloading border stripe el-table-column propemployeeNo label工号 width120 / el-table-column propname label姓名 width120 / el-table-column propdeptName label部门 / el-table-column propphone label手机号 / el-table-column propentryDate label入职日期 / el-table-column label状态 width90 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 sizesmall clickopenEdit(row)编辑/el-button el-button sizesmall typedanger clickoffboard(row)离职/el-button /template /el-table-column /el-table el-pagination layouttotal, sizes, prev, pager, next :totaltotal :current-pagequery.page :page-sizes[10, 20, 50, 100] :page-sizequery.size current-changehandlePageChange size-changehandleSizeChange / /div /template对应的 script 逻辑里搜索和分页都要走同一个 load 函数搜索按钮跳回第一页分页改变只改 page 再查一次。有一个容易忽略的细节clearable置空后并不会自动触发查询需要在值变化时重置查询参数否则选了部门再清空列表还带着旧部门过滤条件。部门下拉的数据来自/api/department/options这是一个按层级返回的简单列表前端只展示名字。4.4 axios 统一封装token 注入与 401 处理页面里不能到处写axios.get我把请求统一封装在api/request.js里token 注入和错误处理都收口在这里// api/request.js import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动带token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理业务码和HTTP错误 service.interceptors.response.use( res { const { code, data, message } res.data if (code ! 0) { ElMessage.error(message || 请求失败) return Promise.reject(new Error(message)) } return data // 页面里直接拿到业务data不用再包一层 }, error { if (error.response?.status 401) { localStorage.clear() router.push(/login) ElMessage.error(登录已过期请重新登录) } else if (error.response?.status 403) { ElMessage.error(没有权限操作此功能) } else if (error.code ECONNABORTED) { ElMessage.error(请求超时请稍后重试) } else { ElMessage.error(网络异常请检查服务是否启动) } return Promise.reject(error) } ) export default service说明响应拦截器里code ! 0是业务失败比如工号重复、导入有错行后端返回 HTTP 200 但 body 里 code 非 0而 HTTP 401/403 是认证和授权失败。把这两层分开前端只在真正需要的地方写额外逻辑。这里的 401 跳转做了router.push(/login)但要注意如果已经在 login 页面再跳一次会警告可以加一个当前路由判断。API 模块这样写// api/employee.js import request from ./request export const listEmployees (params) request.get(/employee/list, { params }) export const importEmployee (file) { const formData new FormData() formData.append(file, file) return request.post(/employee/import, formData, { headers: { Content-Type: multipart/form-data } }) }到这一步一个员工列表已经能完整跑通登录拿 token、动态菜单、条件搜索、分页、导入。5. 避坑记录人事系统从开发到部署最容易翻车的 5 个地方这一章写我在这类项目里反复踩过、也帮别人排查过的坑每条按「现象 → 原因 → 解决」来写。5.1 跨域与 token开发正常部署后 401现象本地npm run dev 本机 SpringBoot 联调一切正常部署到服务器后页面能打开但所有列表接口都返回 401 或浏览器控制台报 CORS 错误。原因开发时 Vite 代理让请求保持同源部署后如果直接把前端静态文件放到 Nginx、后端单独暴露 8080前端请求就变成了http://服务器地址:8080跨域拦截随之而来JWT 虽然存在 localStorage 里不怕 Cookie 跨域但后端一旦过滤了没有Authorization的请求就会全部 401。解决生产环境用 Nginx 同时托管前端文件和反向代理/api让浏览器始终请求同源地址。用一份最小的 Nginx 配置server { listen 80; # 前端打包后的 dist 目录 root /opt/hr-web/dist; index index.html; # 刷新页面回退到 index.html配合 Vue history 模式 location / { try_files $uri $uri/ /index.html; } # 接口反向代理到 SpringBoot location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意proxy_pass http://127.0.0.1:8080;末尾不带路径所以/api/employee/list会原样转发给后端后端 controller 写法保持/api/...前缀即可。如果配置里还加了proxy_set_header Authorization $http_authorization那是把前端的 Authorization 头发给后端SpringBoot 侧也能直接读到但默认 Nginx 会转发全部请求头一般不需要额外加。5.2 金额精度丢失工资显示成科学计数法现象薪资接口返回18888.50前端表格里显示成18888.5或者18888.5000000001导出 Excel 时合计又差几分钱。原因后端BigDecimal序列化成 JSON 数字后前端 axios 会转成 JS numberJS 对浮点数的二进制表示导致精度损失。还有人直接在后端用 double 存工资问题更早爆发。解决金额字段在后端序列化时强制转字符串在 DTO 或实体字段上加注解JsonFormat(shape JsonFormat.Shape.STRING) private BigDecimal finalSalary;这样 JSON 返回的是18888.50前端原样展示字符串。更彻底的方案是在全局 ObjectMapper 里注册一个BigDecimal的序列化器统一处理所有金额字段。只要记住一条规则金额计算在后端做前端只展示需要合计的数值也由后端SELECT SUM(...)返回字符串前端别自己拿一堆字符串做加法。5.3 刷新页面 404Vue Router history 模式的坑现象本地开发时点击菜单跳转子页面没问题部署到服务器后在employee/detail/3刷新浏览器直接 404。原因vue-router 4 默认用 history 模式路由是前端虚拟的服务器上并没有employee/detail/3这个文件。Nginx 按路径去找文件找不到就返回 404。解决在 Nginx location / 里加try_files $uri $uri/ /index.html;让所有不存在的路径都回退到index.html再由前端路由接管。上面 5.1 的配置里已经写进去了。还有一个细节如果项目里有图片或静态资源放在 CDNlocation 规则要写在带try_files的/之前否则静态资源也会被回退到 index.html导致图片 404。5.4 时间差 8 小时日期字段越传越乱现象员工入职日期保存后数据库里是2025-04-17前端却显示2025-04-16T16:00:00Z或2025-04-16考勤打卡时间整体少 8 小时。原因Jackson 在序列化LocalDateTime时用的是 UTC 时区而 MySQL 连接串里没指定serverTimezone系统默认取服务器 UTC数据库存的是北京时间读到内存再写回 JSON 时被减了 8 小时。解决在application.yml里同时设置 Jackson 时区和 JDBC 连接串时区spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai datasource: url: jdbc:mysql://localhost:3306/hr? useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/Shanghai zeroDateTimeBehaviorconvertToNull参数说明zeroDateTimeBehaviorconvertToNull是为了防止数据库里出现0000-00-00这类零值时JDBC 直接抛异常。时间字段建议数据库统一用datetime而非timestamp避免 MySQL 会话时区再次干扰插入时用数据库now()配置了自动填充就在 Java 里用LocalDateTime.now()写固定 UTC 时间戳但前端展示时统一转换。最省心的做法是前后端都显示字符串后端接收yyyy-MM-dd HH:mm:ss格式不依赖平台时区。5.5 分页查询条件丢失总数对数据不对现象列表加了关键字搜索后分页总数看起来是对的但当前页记录和条件对不上或者查询非常慢走了全表扫描。原因大多是 MyBatis-Plus 分页插件没注册、条件拼错或者把or条件写在了deleted过滤之外。另一个常见原因是 count 查询和 list 查询走了不同的 SQL 语句插件生成 count 子查询时不生效。解决先确认MybatisPlusInterceptorBean 已注册见第 3.3 节然后把所有查询条件放进LambdaQueryWrapper搜索条件的or用wrapper.and(w - ...)包裹。注意分页方法签名要写PageEmployee page employeeMapper.selectPage(new Page(page, size), wrapper)不要把 Page 对象写进实体参数。最后给员工表的dept_id、employee_no建立索引因为部门筛选和工号查询是人事系统最高频的过滤条件没索引到几百人就会开始卡。6. 进阶收尾拿这套系统做交付前我建议你再做三件事6.1 用 Docker Compose 把环境固定下来本地开发环境每个人都不一样MySQL 版本、Node 版本、JDK 版本都会导致「在我机器上能跑」。交付时我用 Docker Compose 把数据库、后端、前端的运行环境固定成一份声明文件# docker-compose.yml services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: hr volumes: - ./mysql-data:/var/lib/mysql backend: build: ./hr-server environment: DB_URL: jdbc:mysql://mysql:3306/hr depends_on: - mysql frontend: build: ./hr-web ports: - 80:80这几行配置隐含三个习惯数据库密码通过 environment 注入不放镜像后端连接串里的主机名写mysql而不是localhost因为 compose 内部用服务名做 DNS前端 Nginx 镜像构建时已经把 Vue 打包产物写进镜像所以对外只暴露 80 端口。真正交付前我会把mysql-data目录加入.gitignore防止把生产数据误提交进仓库。6.2 给接口留一个可调试的入口部署上线后前端报「接口 500」后端日志又没打印完整错误时排查很痛苦。我的习惯是集成 SpringDocSwagger 的 SpringBoot 实现登录系统后直接在/swagger-ui 页面调试接口token 填一次所有接口都能点。接口上写清楚Schema(description员工姓名)前端同事和后端同事用同一个文档对齐字段。上线时如果不能暴露 Swagger就在生产环境加一个开关只在内网 IP 范围开启。6.3 数据备份不能等出事才做人事数据丢了不是小事。数据库每天凌晨做一次逻辑备份保留最近三十天是我在多个内部系统项目里都坚持加的最后一环# crontab 每天凌晨 2 点执行 0 2 * * * mysqldump -uroot -p$DB_PASS hr | gzip /backup/hr_$(date \%F).sql.gz find /backup -mtime 30 -delete说明cron 里的%要转义成\%否则 crontab 会误判$DB_PASS放在环境变量里不写死在 crontab 文件里。备份恢复演练至少每季度做一次别等到要恢复时才发现 mysqldump 少了参数。我自己在交付这类项目时有个笨习惯把所有密码从代码里挪到环境变量然后把部署命令和备份策略写进 README。这些事不直接影响功能但决定这个系统半年后还健不健壮。做管理系统让数据不乱、权限不松、时间不错比堆新功能更重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表