ARTICLE DETAIL

资讯详情

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

Spring Boot + Vue 人事档案管理系统:数据库表设计与前后端联调实战

Spring Boot + Vue 人事档案管理系统:数据库表设计与前后端联调实战 简介这是一套面向高校计算机相关专业学生的毕业设计/课程设计完整项目采用Spring Boot后端与Vue前端技术栈实现人事档案管理系统及配套数据库设计适合作为期末大作业或毕业项目参考。压缩包共242个文件约2.64MB其中92个Java文件承载后端业务逻辑36个Vue组件与20个JS文件构成前端页面与交互另有29个XML配置、2个SQL脚本用于数据库建表并附带properties、yml等配置文件及字体、图标等静态资源结构完整、开箱即用。系统涵盖用户登录认证、人事信息增删改查、部门职位薪酬考勤等多表管理及报表统计模块数据库设计包含员工、部门、职位、薪酬、考勤等核心表主外键与索引规划清晰。目前已有44人学习下载读者可借此掌握RESTful接口设计、Spring Data JPA数据访问、Element UI组件化开发与Vue Router、Vuex状态管理等实践要点是理解现代Web全栈开发流程的实用范本。1. 人事档案管理系统从 Spring Boot Vue 到数据库表设计一个毕业项目怎么落地很多同学做毕业设计时第一反应是“找个模板改改”结果改到一半发现表结构对不上、前后端字段名不一致、分页查询写成了全表扫描。人事档案管理系统这个题目看起来是 CRUD 堆叠实际上它涉及员工信息、部门组织、合同记录、异动历史、附件存储这几条业务线表设计一旦没想清楚后面写接口就是拆东墙补西墙。我见过太多项目在“员工表里塞部门名称”这一步翻车等到要按部门统计人数时只能靠 LIKE 模糊匹配数据一多直接卡死。这篇笔记按“先立住数据模型再打通前后端”的顺序把 Spring Boot Vue 前后端分离的人事档案管理系统从建表到联调讲清楚适合正在做毕业项目、需要一套可复现方案的同学。2. 数据库设计人事档案管理系统的表结构怎么拆才不返工2.1 核心实体识别与 ER 关系梳理人事档案管理系统的数据模型核心实体不超过六个员工、部门、职位、合同、异动记录、附件。很多同学一上来就建一张大宽表把员工所有信息塞进去包括部门名称、职位名称、合同起止时间。这种设计在单表查询时很爽但一旦业务要求“一个员工有多份历史合同”或者“部门调整后要保留原部门名称”宽表就彻底失效。正确的做法是按第三范式拆表但也不要过度拆分。我一般会这样划分员工表employee存放基本身份信息如工号、姓名、性别、出生日期、身份证号、手机号、邮箱、入职日期、在职状态。部门表department部门 ID、部门名称、上级部门 ID、部门负责人 ID。职位表position职位 ID、职位名称、职级。合同表contract合同 ID、员工 ID、合同编号、合同类型、起始日期、结束日期、签订状态。异动记录表transfer_record记录 ID、员工 ID、原部门 ID、新部门 ID、原职位 ID、新职位 ID、异动类型、生效日期。附件表attachment附件 ID、关联业务类型、关联业务 ID、文件名、存储路径、上传时间。员工表里只保留 department_id 和 position_id 作为外键不冗余部门名称和职位名称。查询时通过 JOIN 获取或者在前端做字典映射。这样做的好处是部门改名后所有员工自动关联到新名称不需要批量更新。注意外键约束在毕业项目中建议保留虽然有些公司生产环境会去掉外键改由应用层保证但毕业项目里外键能帮你提前发现数据不一致问题。2.2 建表 SQL 与索引设计下面给出核心表的建表语句以 MySQL 8.0 为例。字段类型选择上工号用 VARCHAR(20) 而不是 INT因为工号可能带字母前缀身份证号用 VARCHAR(18)手机号用 VARCHAR(11)日期统一用 DATE时间戳用 DATETIME。-- 部门表支持树形结构parent_id 为 NULL 表示顶级部门 CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 部门名称, parent_id BIGINT DEFAULT NULL COMMENT 上级部门ID, leader_id BIGINT DEFAULT NULL COMMENT 部门负责人员工ID, sort_order INT DEFAULT 0 COMMENT 排序, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_parent_id (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT部门表; -- 员工表核心身份信息不冗余部门名称和职位名称 CREATE TABLE employee ( id BIGINT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL UNIQUE COMMENT 工号, name VARCHAR(30) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 1 COMMENT 1男 2女, birth_date DATE COMMENT 出生日期, id_card VARCHAR(18) COMMENT 身份证号, phone VARCHAR(11) COMMENT 手机号, email VARCHAR(50) COMMENT 邮箱, department_id BIGINT COMMENT 部门ID, position_id BIGINT COMMENT 职位ID, hire_date DATE COMMENT 入职日期, status TINYINT DEFAULT 1 COMMENT 1在职 2离职 3试用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_department_id (department_id), INDEX idx_status (status), INDEX idx_emp_no (emp_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表; -- 合同表一个员工可以有多份合同 CREATE TABLE contract ( id BIGINT PRIMARY KEY AUTO_INCREMENT, employee_id BIGINT NOT NULL COMMENT 员工ID, contract_no VARCHAR(30) NOT NULL COMMENT 合同编号, contract_type TINYINT COMMENT 1固定期限 2无固定期限 3实习, start_date DATE NOT NULL, end_date DATE, sign_status TINYINT DEFAULT 1 COMMENT 1已签 2未签 3已到期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_employee_id (employee_id), INDEX idx_end_date (end_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT合同表;索引设计上employee 表的 emp_no 唯一索引是必须的department_id 和 status 建普通索引因为按部门筛选和按在职状态筛选是最常见的查询条件。contract 表的 employee_id 索引用于查某个员工的所有合同end_date 索引用于定时任务扫描即将到期的合同。提示不要给 gender、status 这种低基数列单独建索引区分度太低MySQL 优化器大概率不会走索引反而增加写入开销。2.3 字段类型与约束的四个边界坑第一个坑是日期字段用 VARCHAR。有些同学图省事把 hire_date 存成 “2024-01-01” 字符串结果按日期范围查询时只能用字符串比较遇到 “2024-1-1” 这种格式就排序错乱。统一用 DATE 类型前端传 “yyyy-MM-dd” 格式Spring Boot 用 JsonFormat 注解转换。第二个坑是手机号和身份证号用 INT。手机号 11 位INT 最大 21 亿存不下身份证号 18 位更不可能。必须用 VARCHAR。第三个坑是部门表没有 parent_id 或者 parent_id 默认 0。树形结构用 NULL 表示顶级查询子部门时用WHERE parent_id ?如果默认 0顶级部门的 parent_id 就是 0查询时容易把顶级部门也带出来。第四个坑是合同表没有唯一约束。同一个员工同一时间段签了两份合同业务上不允许但数据库不拦。可以在应用层校验或者加一个联合唯一索引UNIQUE KEY uk_emp_contract (employee_id, contract_no)。3. Spring Boot 后端从实体类到分页查询接口的完整链路3.1 项目结构与依赖配置后端用 Spring Boot 3.x MyBatis-Plus MySQL 驱动。pom.xml 核心依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/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 groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesapplication.yml 配置数据源和 MyBatis-Plusspring: datasource: url: jdbc:mysql://localhost:3306/hr_archive?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver 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让数据库的emp_no自动映射到 Java 的empNo省去手写 ResultMap。log-impl开启 SQL 日志调试时能看到实际执行的 SQL联调阶段非常有用。3.2 实体类与 Mapper 接口Employee 实体类对应 employee 表用 Lombok 简化 getter/setterData TableName(employee) public class Employee { TableId(type IdType.AUTO) private Long id; private String empNo; private String name; private Integer gender; JsonFormat(pattern yyyy-MM-dd) private Date birthDate; private String idCard; private String phone; private String email; private Long departmentId; private Long positionId; JsonFormat(pattern yyyy-MM-dd) private Date hireDate; private Integer status; TableField(fill FieldFill.INSERT) private Date createTime; TableField(fill FieldFill.INSERT_UPDATE) private Date updateTime; }Mapper 接口继承 MyBatis-Plus 的 BaseMapper分页查询需要额外配置分页插件Mapper public interface EmployeeMapper extends BaseMapperEmployee { // 自定义分页查询关联部门名称 IPageEmployeeVO selectEmployeePage(PageEmployeeVO page, Param(query) EmployeeQuery query); }对应的 XML 写关联查询select idselectEmployeePage resultTypecom.example.vo.EmployeeVO SELECT e.*, d.name AS departmentName, p.name AS positionName FROM employee e LEFT JOIN department d ON e.department_id d.id LEFT JOIN position p ON e.position_id p.id WHERE e.deleted 0 if testquery.name ! null and query.name ! AND e.name LIKE CONCAT(%, #{query.name}, %) /if if testquery.departmentId ! null AND e.department_id #{query.departmentId} /if if testquery.status ! null AND e.status #{query.status} /if ORDER BY e.create_time DESC /select这里用 LEFT JOIN 而不是 INNER JOIN因为员工可能暂时没有分配部门或职位INNER JOIN 会导致这些员工查不出来。分页插件配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }3.3 分页查询接口与参数校验Controller 层接收前端传来的分页参数和筛选条件RestController RequestMapping(/api/employee) public class EmployeeController { Autowired private EmployeeService employeeService; GetMapping(/page) public ResultIPageEmployeeVO page( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String name, RequestParam(required false) Long departmentId, RequestParam(required false) Integer status) { EmployeeQuery query new EmployeeQuery(); query.setName(name); query.setDepartmentId(departmentId); query.setStatus(status); PageEmployeeVO page new Page(pageNum, pageSize); return Result.success(employeeService.selectEmployeePage(page, query)); } }参数说明pageNum 默认 1pageSize 默认 10但前端可以传 20、50。name 支持模糊查询departmentId 和 status 是精确匹配。返回的 Result 是统一响应体包含 code、message、data 三个字段。注意pageSize 不要开放无限制建议在 Service 层加一个上限判断比如超过 100 就强制设为 100防止前端传 pageSize10000 把数据库拖垮。4. Vue 前端档案列表、表单与部门树组件的联调细节4.1 项目初始化与 Axios 封装前端用 Vue 3 Element Plus Axios。初始化项目npm create vuelatest hr-archive-frontend cd hr-archive-frontend npm install npm install element-plus axiosAxios 封装在src/utils/request.jsimport axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } ) export default request拦截器统一处理 token 注入和错误提示业务代码里就不用每个请求都写 try-catch。baseURL 设为/api配合 Vite 的 proxy 解决跨域// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })4.2 档案列表页与部门树联动列表页用 el-table 展示员工数据el-pagination 做分页左侧放一个 el-tree 展示部门结构。点击部门树节点时把 departmentId 传给列表查询接口。template div classemployee-page el-tree :datadeptTree node-keyid :props{ label: name, children: children } node-clickhandleDeptClick classdept-tree / div classtable-area el-form :inlinetrue :modelquery el-form-item label姓名 el-input v-modelquery.name placeholder请输入姓名 clearable / /el-form-item el-form-item label状态 el-select v-modelquery.status clearable placeholder全部 el-option label在职 :value1 / el-option label离职 :value2 / el-option label试用 :value3 / /el-select /el-form-item el-form-item el-button typeprimary clickloadData查询/el-button /el-form-item /el-form el-table :datatableData border stripe el-table-column propempNo label工号 width120 / el-table-column propname label姓名 width100 / el-table-column propdepartmentName label部门 width150 / el-table-column proppositionName label职位 width150 / el-table-column prophireDate label入职日期 width120 / el-table-column label状态 width80 template #default{ row } el-tag :typerow.status 1 ? success : info {{ statusText(row.status) }} /el-tag /template /el-table-column /el-table el-pagination v-model:current-pagequery.pageNum v-model:page-sizequery.pageSize :totaltotal layouttotal, prev, pager, next current-changeloadData / /div /div /template逻辑说明部门树点击时把node.id赋给query.departmentId然后重新请求列表。el-table 的departmentName和positionName来自后端 VO 的关联查询结果前端不需要额外请求部门接口做映射。分页组件的current-change事件触发loadDataloadData里用request.get(/employee/page, { params: query })发请求。4.3 新增/编辑表单的字段校验与提交新增和编辑共用一个 dialog用isEdit区分。表单校验规则const rules { empNo: [{ required: true, message: 工号不能为空, trigger: blur }], name: [{ required: true, message: 姓名不能为空, trigger: blur }], phone: [ { pattern: /^1[3-9]\d{9}$/, message: 手机号格式不正确, trigger: blur } ], idCard: [ { pattern: /^\d{17}[\dXx]$/, message: 身份证号格式不正确, trigger: blur } ], departmentId: [{ required: true, message: 请选择部门, trigger: change }] }提交时把表单数据转成 JSON 发给后端const submitForm async () { await formRef.value.validate() const url isEdit.value ? /employee/update : /employee/add await request.post(url, formData) ElMessage.success(isEdit.value ? 修改成功 : 新增成功) dialogVisible.value false loadData() }提示编辑时工号通常不允许修改可以在编辑模式下给 empNo 输入框加disabled后端更新时也忽略 empNo 字段只更新其他字段。5. 避坑与排查前后端联调中最容易翻车的五个问题现象一前端传的日期是 “2024-01-01”后端接收报 400 错误。原因Spring Boot 默认不支持 “yyyy-MM-dd” 格式的字符串转 Date需要加JsonFormat(pattern yyyy-MM-dd)或者全局配置spring.jackson.date-format。 解决在实体类的 Date 字段上加JsonFormat或者在 application.yml 里配置spring.jackson.date-formatyyyy-MM-dd和spring.jackson.time-zoneGMT8。现象二分页查询返回的 total 是 0但数据库里明明有数据。原因MyBatis-Plus 分页插件没有配置或者配置了但没生效。检查MybatisPlusConfig类是否被 Spring 扫描到Configuration注解是否加上。 解决确认配置类在启动类的同级或子包下并且PaginationInnerInterceptor的 DbType 设置为 MYSQL。现象三部门树只显示顶级部门子部门不显示。原因后端返回的部门列表是平铺的前端 el-tree 需要嵌套结构。要么后端递归组装成树要么前端用listToTree函数转换。 解决后端写一个递归方法按 parent_id 分组或者前端引入list-to-tree工具函数在获取部门列表后转换一次。现象四新增员工时提示 “Duplicate entry for key ‘emp_no’”。原因工号重复。数据库 emp_no 字段有唯一索引插入重复值直接报错。 解决前端在提交前先调一个/employee/checkEmpNo接口校验工号是否存在或者捕获后端异常后提示 “工号已存在”。现象五跨域请求被浏览器拦截控制台报 CORS 错误。原因前端 5173 端口后端 8080 端口浏览器同源策略拦截。 解决开发环境用 Vite proxy 代理生产环境用 Nginx 反向代理。如果非要在后端解决加一个CorsConfig配置类允许http://localhost:5173跨域。6. 进阶技巧用逻辑删除和操作日志让档案系统更接近生产可用毕业项目答辩时老师经常会问 “删除的员工数据还能恢复吗” 和 “谁修改了这条档案”。这两个问题对应逻辑删除和操作日志两个功能加上之后项目完整度会明显提升。逻辑删除在 MyBatis-Plus 里只需要两步。第一步在 employee 表加deleted TINYINT DEFAULT 0字段。第二步在实体类的 deleted 字段上加TableLogic注解TableLogic private Integer deleted;配置好之后employeeMapper.deleteById(id)自动变成UPDATE employee SET deleted 1 WHERE id ?所有查询自动带上WHERE deleted 0。注意逻辑删除后唯一索引 emp_no 仍然会冲突因为已删除的记录还在表里。解决办法是把唯一索引改成联合唯一索引UNIQUE KEY uk_emp_no (emp_no, deleted)或者删除时把 emp_no 改成emp_no _del_ id。操作日志用 AOP 切面实现。定义一个Log注解在 Controller 方法上标记需要记录的操作Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Log { String module() default ; String action() default ; }切面类拦截带有Log注解的方法记录操作人、操作时间、请求参数、返回结果写入operation_log表。这样答辩时演示 “修改员工档案后日志里能看到修改前后的对比”比单纯说 “我做了 CRUD” 有说服力得多。我自己的习惯是毕业项目至少留一个 “后悔药” 功能。逻辑删除是后悔药操作日志是黑匣子。这两个加上去代码量增加不到 200 行但项目的工程感完全不一样。希望帮到你。本文还有配套的精品资源点击获取
返回列表