拓冰建站拓冰建站
首页 / 资讯中心 / 正文

ThinkPHP+Vue前后端分离的人事应聘培训系统开发实践

做企业人力这块的项目市面上成熟产品一大堆但真正落地到中小公司你会发现买来的系统不好用、改起来还嫌贵才是常态。我在企业信息部门待了几年亲眼看着公司从几十人涨到两百人员工档案、招聘进度、培训记录这三件事从几份Excel慢慢变成几十个版本数据对不上、流程断在某个聊天记录里都是常有的事。这套基于ThinkPHPVue的人事应聘培训管理系统就是我从实际需求出发从零设计和实现的一套内部系统。系统解决的是人事管理里最典型的三个问题员工档案散落在表格里难以追溯应聘过程靠口头和微信推进培训效果停留在纸质签到上。核心功能对应三大模块——员工档案管理、招聘进度跟踪、培训计划与签到统计整个项目采用前后端分离架构后端ThinkPHP负责业务逻辑和权限控制前端Vue搭建交互页面。如果你是开发同学想练手信息管理系统或者正在准备类似的毕业设计、企业小工具这篇复盘把我从需求、设计到编码、排坑的过程都写出来希望能够帮你绕开一些弯路。1. 项目背景与需求梳理1.1 为什么还要自研一套人事系统先泼一盆冷水假如公司人数不到五十业务形态又相对稳定直接用现成的办公套件就够了没必要自研。但如果你所在的公司处于快速扩张阶段——我在的这个情况就是从七八十人涨到两百多人你会发现通用系统的建模方式和你实际的管理习惯完全对不上。比如通用系统里的入转调离流程要按大厂职级体系设计光角色权限、审批流就要配置很久而小公司根本没有精力去适应它最终结果就是大家回到Excel系统变成摆设。自研这套人事系统的核心价值在于数据模型完全贴合实际业务。部门树就按公司的汇报线走招聘阶段就按公司习惯拆成简历筛选、初试、复试、终试培训签到也不需要复杂的审批链培训专员建计划后员工自己报名即可。说白了做这个系统不是为了做惊天动地的功能而是把例行事务里的记录、查询、统计三件事做到顺手。我在需求梳理阶段拉上了人事经理、招聘专员、培训专员三个人聊了两轮整理出来的核心需求其实非常朴素人事档案能查、能改、能导入导出按部门、状态筛选关键字段比如身份证号、薪资要有权限分级。招聘管理每个候选人从投递到入职的状态要能一眼看到面试安排挂在候选人名下不再依赖微信翻聊天记录。培训管理培训计划发布后员工能在线报名到场签到时要有记录月底能够直接出统计报表。把这三条主线理清楚以后再谈技术实现才有意义。不然很容易做成一个功能飘在空中、数据落不了地的壳子。1.2 三个核心模块的边界怎么划模块边界的划分直接决定后端接口怎么拆、前端页面怎么组织。我的做法是先从角色入手因为不同角色对系统的期望是完全不同的。角色关注点主要操作系统管理员用户、角色、菜单权限分配权限、重置密码、查看日志人事专员员工档案完整性与准确性新增/编辑员工、导入导出、调动处理招聘专员应聘进度与面试安排录入候选人、安排面试、记录结果、发offer培训专员计划发布与签到统计建培训计划、发布公告、查看签到率普通员工查看个人资料、报名培训查看档案、报名、扫码签到部门主管部门人力结构与培训参与情况查看部门员工列表、查看培训统计角色理清后模块归属就清晰了。员工档案和部门树归在人事管理下应聘者、面试记录、offer归在招聘管理下培训计划、报名、签到归在培训管理下用户、角色、菜单归在系统管理下。页面结构也自然对应这四个一级菜单没有做过度耦合。这里有个容易被忽视的点员工的入职流程是跨模块的。候选人通过终试并接受offer之后应该一键转正为员工把基础信息带入职档案表。这个动作放在招聘模块还是人事模块需要提前约定。我最后放在了招聘模块因为触发点是招聘专员完成的员工档案模块只负责接收和补全信息。2. 技术选型拆解ThinkPHP与Vue各自负责什么2.1 后端选ThinkPHP的底气后端选择ThinkPHP 6很大程度上是基于效率和维护成本的考虑。ThinkPHP在国内中小型管理系统里的生态积累很深ORM查询器、表单验证、中间件、事件机制都属于开箱即用不需要像主流框架那样做大量初始化配置这对一个内部管理系统来说非常合适。举个例子列表页的分页查询ThinkPHP里几行就能搞定$list Db::name(employee) -where(dept_id, $deptId) -order(emp_no, asc) -paginate([ list_rows $pageSize, query $request-get(), ]);而且框架自带了一个很实用的能力模型关联。比如候选人表里要同时拿出应聘岗位、所属部门相关字段只需要在模型里把关联定义好class Candidate extends Model { public function position() { return $this-belongsTo(Position::class, position_id); } public function interviews() { return $this-hasMany(InterviewRecord::class, candidate_id); } }查询的时候用with一起取出来避免手写一堆join。对于招聘列表这种要展示岗位、面试官、最近进度的页面这种方式写起来非常轻快。2.2 前端选Vue的理由前端选择Vue 3 Vite Element Plus是最贴近后台管理系统开发效率的组合。Vue的响应式数据模型天然适合表单和列表这种高频交互场景组件化开发也让人事、招聘、培训三个模块之间的公共部分可以复用比如人员选择器、部门树选择、文件上传组件写一次到处用。Vue在这类项目里最大的优势是生态齐全。Vue Router处理菜单页面的路由跳转Pinia存登录态和个人信息Axios负责接口请求Element Plus把表格、弹窗、表单、日期选择器这些后台系统高频使用的组件都封装好了。我用Element Plus的表格组件加自定义列插槽配合一些业务状态标签就可以把候选人列表、培训报名列表做得很直观。Vite作为构建工具也让开发体验提升非常明显热更新几乎是秒级不像以前改一个变量要等整个页面重新编译。2.3 前后端分离的边界约定项目采用前后端分离但分离不是把代码分开就完了得提前把接口口径定死不然联调时天天扯皮。我这个项目里面做了三条硬约定接口统一使用/api/v1前缀后端在路由注册时统一加上避免前端误配静态资源路径。所有接口返回统一JSON结构{code: 0, msg: 操作成功, data: {...}}。业务成功code为0权限异常401无权限403参数错误1业务失败2。前端只在Axios拦截器里处理一次各页面不需要重复判断。分页参数统一为page和pageSize返回格式同一为{list, total, current_page, total_page}后端封装一个统一的ok()、page()方法。这些约定看起来简单但真的能省掉大量联调时间。我见过太多项目因为返回结构不统一前端页面到处写response.data.data.list这种代码一旦后端调整字段前端就要全局改一遍。当然前后端分离也意味着部署时要处理跨域、静态文件独立发布这些问题后面第6部分我会展开讲。3. 数据库设计人事、招聘、培训核心表结构3.1 权限体系三张表加两张关联表权限设计直接影响整个系统的边界我采用了经典的RBAC模型一共五张核心表表名说明主要字段sys_user用户表id, username, password, real_name, dept_id, status, last_login_timesys_role角色表id, role_name, description, statussys_permission权限菜单表id, parent_id, title, path, component, is_menu, sortsys_user_role用户角色关联表user_id, role_idsys_role_permission角色权限关联表role_id, permission_id用户和角色是多对多因为实际情况里一个人可能同时兼任招聘专员和培训专员角色和菜单权限是多对多目的是让不同角色的菜单和按钮维度都可以组合配置。实际设计菜单权限时我初期只做了菜单级的权限控制也就是某个角色能看到哪些菜单和路由后来才补充了按钮级权限比如删除按钮的显示与隐藏。建表时有个容易踩的坑sys_permission表里的path字段要和Vue前端路由的path保持一致否则动态路由生成会找不到组件。这个我在后面第5部分会讲。3.2 人事模块组织架构和员工档案人事模块的基础是部门树和员工档案表。部门表非常简单只有id、名称、父id、排序四个字段靠parent_id形成树形结构。员工档案表则要细致一些CREATE TABLE hr_employee ( id INT(11) NOT NULL AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL COMMENT 工号, user_id INT(11) DEFAULT NULL COMMENT 关联系统用户id, name VARCHAR(30) NOT NULL, gender TINYINT(1) DEFAULT 0 COMMENT 0未知 1男 2女, birthday DATE DEFAULT NULL, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号需加密, phone VARCHAR(20) NOT NULL, email VARCHAR(50) DEFAULT NULL, dept_id INT(11) NOT NULL, position_name VARCHAR(50) DEFAULT NULL COMMENT 岗位名称, education VARCHAR(20) DEFAULT NULL COMMENT 学历, entry_date DATE NOT NULL COMMENT 入职日期, status TINYINT(1) DEFAULT 1 COMMENT 1在职 2试用 3离职, created_at DATETIME DEFAULT NULL, updated_at DATETIME DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;员工表和用户表通过user_id做关联这样员工登录系统后可以看到自己的档案但只有人事专员和管理员能编辑。字段层面身份证号、手机号这类隐私信息在后端统一做了加密处理脱敏后返回给前端避免普通员工接口能直接拉到全量明文信息。3.3 招聘模块从候选人到offer的完整轨迹招聘模块核心是候选人表加面试记录表状态变化是贯穿整个模块的主线。候选人表关键字段包括字段描述name, gender, phone, email基础信息position_id应聘岗位关联hr_positionsource招聘渠道内推、招聘网站、猎头、校招resume_path简历文件路径stage当前阶段apply, first, second, final, offer, entry, rejectedstatus数据状态1在用0删除面试记录表记录每一次面试的情况包括第几轮、面试官是谁、面试时间、结果、评语。之所以用单独的表是因为一个候选人会有多轮面试每一轮的面试官和结果都要留存后续面试官在下一轮之前可以先看上一轮的评价避免信息断层。招聘模块最核心的点是stage字段的状态流转逻辑。比如只有候选人到了final终试且结果是通过才能发offer岗位人数满了就不能流转到entry。这个状态机不放前端全部收敛在后端服务层里保证任何入口进来的操作都遵循同一条逻辑。3.4 培训模块计划、报名、签到一条线拉通培训模块设计相对简单但需要把签到闭环考虑清楚。核心三张表hr_training_plan培训计划表字段包含标题、内容、开始时间、结束时间、地点、讲师、报名截止时间、人数上限、状态。hr_training_signup报名表记录哪个员工报了哪场培训报名时间。hr_training_attendance签到表记录到场时间、签到方式一个员工对一场培训只有一条签到记录。签到环节我采用了二维码定位范围双校验方案培训专员在后台开放签到期后系统生成一个有时效的二维码员工扫码后前端获取当前位置判断是否在培训地点附近通过后写入签到记录。这样能避免纸质签到表代签的情况也能看到到场时间是否准点。统计报表直接基于这三张表做聚合查询比如某场培训报名多少人、签到多少人、签到率多少按部门分组还能看出不同团队的参与热度。4. 后端接口设计与ThinkPHP实现4.1 登录认证与全局异常处理登录接口我用JWT做无状态认证。ThinkPHP 6里写一个全局中间件拦截所有/api路径的请求做token校验。用户登录成功后签发token前端每次请求带在Authorization头里中间件解析后注入当前用户上下文。public function handle($request, \Closure $next) { $token $request-header(Authorization, ); $token str_replace(Bearer , , $token); if (!$token) { return json([code 401, msg 未登录或登录状态已过期]); } try { $payload Jwt::decode($token, $this-secret, [HS256]); $request-userId $payload-uid; $request-userRoleId $payload-rid; } catch (\Exception $e) { return json([code 401, msg 登录状态已失效请重新登录]); } return $next($request); }token里我放的是uid和角色id不要放更多的用户信息因为用户信息修改后token里的旧数据不会同步更新会引发权限判断不准确的问题。全局异常处理是ThinkPHP 6里一个值得好好配置的点。默认情况下参数校验异常、数据库查询异常会暴露一堆堆栈信息到响应里这对于接口调用方不友好也不安全。我重写了异常处理器的render方法把业务异常、验证异常、系统异常分别映射到对应的code和msg让前端拿到的永远是统一格式的错误信息。4.2 RBAC权限怎么落到接口上RBAC权限不是只在前端隐藏菜单就完事真正的控制要落在接口层面。我的做法是每个权限节点对应一个path和请求方法中间件在认证通过后再根据当前用户的角色取到权限列表判断当前请求的路径是否在权限集合内。$path / . $request-pathinfo(); $method $request-method(); if (!PermissionService::check($this-roleId, $path, $method)) { return json([code 403, msg 您没有权限执行该操作]); }权限的缓存一定要做否则每个请求都去查数据库高并发场景下扛不住。我的做法是把角色对应的权限集合存入Rediskey是role_permission:{roleId}角色权限变更时主动删除缓存下次请求自动重新加载。实测下来接口响应时间能稳定在几十毫秒以内。有一类权限坑值得提醒列表接口和详情接口的权限分开控制。比如普通员工能看到自己的档案详情但不能看到全量列表部门主管能看到本部门员工的列表但不能跨部门看。所以权限系统里节点要分得细一点而不是只粗粒度控制员工模块可访问。4.3 核心业务接口的实现要点业务接口里我认为最难的是招聘流程的状态机。候选人stage字段的流转我写了一个独立的服务类来做统一处理class CandidateService { protected $transitionMap [ apply [first [简历通过, rejected], rejected [不合适]], first [second [初试通过, rejected], rejected [初试不通过]], second [final [复试通过, rejected], rejected [复试不通过]], final [offer [终试通过, rejected], rejected [终试不通过]], offer [entry [已接受offer], rejected [拒绝offer]], entry [rejected [入职后离职]], ]; public function transition($candidateId, $newStage, $result) { // 校验状态流转是否合法 // 写操作日志 // 更新候选人记录 } }这个设计保证了候选人不会从初试直接跳进offer每一轮操作都会留下记录。实际开发中招聘专员最常问的问题就是这个人之前面到哪了、结果怎么样有了状态机和面试记录表这些信息一目了然。培训模块的接口相对常规注意点是签到接口要判断签到时间是否在签收开放期内以及学员是否已经报过名。我加了双重校验签到表唯一索引(plan_id, emp_id)防止并发重复签到业务层判断培训状态防止过期补签。5. Vue前端的架构与页面实现5.1 前端工程搭建与目录结构前端我用Vite初始化Vue 3项目然后安装Vue Router、Pinia、Axios、Element Plus。目录结构按业务模块划分src/ ├── api/ │ ├── auth.js │ ├── employee.js │ ├── recruit.js │ └── training.js ├── components/ │ ├── DeptTree.vue │ ├── EmployeeSelect.vue │ └── FileUpload.vue ├── layout/ │ ├── index.vue │ └── Sidebar.vue ├── router/ │ ├── index.js │ └── dynamic.js ├── store/ │ └── user.js ├── views/ │ ├── system/ │ ├── employee/ │ ├── recruit/ │ └── training/ └── utils/ ├── request.js └── auth.js这个结构对后台管理系统来说比较清爽业务代码都落在views下公共的请求逻辑、组件、工具函数独立出来。随着模块增加不要把所有东西塞进一个巨大的目录里否则后期维护会非常痛苦。5.2 动态路由与登录态管理登录态管理我放在Pinia的user store里。登录成功后后端返回token和用户权限节点列表前端根据权限节点生成可访问的路由表动态添加进Vue Router。// store/user.js const userStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , name: , roles: [], permissions: [], }), actions: { async login(loginForm) { const res await loginApi(loginForm) this.token res.data.token localStorage.setItem(token, this.token) const info await getUserInfoApi() this.name info.data.name this.permissions info.data.permissions } } })权限节点数据怎么来是关键。后端返回的permission列表里每个节点带有path和component信息前端把这些节点根据parent_id构造成树结构然后映射成Vue Router的路由配置。这里有一个非常重要的细节component字段要和实际views目录下的文件路径对应起来我在后端存的就是类似system/User/index这样的相对路径前端用() import(../views/${component}.vue)动态加载。5.3 招聘看板页面的实现思路招聘看板是招聘专员每天打开的第一屏我按stage字段把候选人分成几列类似看板风格一眼能看到现在有多少人在简历筛选、多少人在初试、多少人卡在offer环节。页面实现上每一列就是一个筛选条件相同的组件共用一个CandidateList组件通过props传入stage。对招聘人员来说最实用的功能是候选人详情抽屉。点击一个候选人卡片右侧滑出抽屉里面展示基本信息、简历预览、面试时间线。面试时间线我用了一个简单的时间线组件把候选人的各轮面试记录按时间倒序排列每次状态变化都附上操作人和备注。前端和联调接口时有个注意点接口返回的候选人生日、面试时间格式是2025-01-01 10:00:00这样的字符串而Element Plus的日期组件需要Date对象或者特定格式的字符串我统一在请求拦截器里做了时间格式转换工具避免每个组件里重复处理。这个习惯帮我省掉了非常多联调时的时间/时区错乱问题。5.4 培训签到页面的实现思路培训签到页面是给手机端用的Vue项目里我单独做了响应式布局在手机上用二维码扫描方式打开。整条流程是培训开始时培训专员在后台点击开启签到系统生成一个带计划ID和有效时间的二维码员工扫码后进入H5页面进行定位验证和确认签到。前端获取定位使用HTML5 Geolocation API判断是否在培训地点一定范围内例如500米通过后调后端签到接口function getLocation() { return new Promise((resolve, reject) { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(pos resolve(pos.coords), err reject(err)) } else { reject(new Error(不支持定位)) } }) } async function handleSignin(planId) { const coords await getLocation() const distance calcDistance(coords.latitude, coords.longitude, store.state.plan.lat, store.state.plan.lng) if (distance 500) { ElMessage.error(您不在培训签到范围内) return } await signinApi(planId) ElMessage.success(签到成功) }这个流程做下来整体体验很流畅也避免了代签问题。不过你要注意一点浏览器获取定位需要对HTTPS的页面才生效开发环境用localhost没关系部署到测试环境就需要配置SSL证书。这个坑我在上线前才遇到临时弄了个Nginx证书配置才解决。6. 开发中踩过的坑与排查实录6.1 跨域问题排查两小时前后端分离项目绕不开CORS问题。ThinkPHP 6的全局中间件里加上跨域处理代码很简单public function handle($request, \Closure $next) { $response $next($request); $response-header([ Access-Control-Allow-Origin *, Access-Control-Allow-Headers Authorization, Content-Type, X-Requested-With, Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS, ]); return $response; }坑出在一个细节上前端请求带上了Authorization头而预检请求OPTIONS在进入业务中间件之前就被拦截返回了401。我一开始没在意结果就是前端浏览器控制台一直报CORS error实际上后端日志里根本没有到跨域中间件的输出。解决办法是让OPTIONS请求提前返回不要走权限校验流程。排查这种问题我的经验是先在浏览器直接访问接口看返回头再打开后端调试日志确认请求到底走到哪一步。不要被CORS三个字带跑偏很多时候问题出在中间件顺序上。6.2 动态路由刷新后页面白屏动态路由的一个经典坑页面正常跳转没问题一旦按F5刷新Vue Router报match错误页面白屏。原因很简单动态路由是通过addRoute加进去的刷新后路由表重新初始化动态路由还没加进去但地址栏里的路径已经指向了动态路由。解决办法有两个方向。一个是在Router的beforeEach守卫里加逻辑如果发现当前访问路径不在静态路由表中先查后端权限接口把动态路由加上后再放行另一种是后端把动态路由数据放在本地localstorage里缓存刷新时直接读取本地数据重建路由。我最后选择了baseStatic路由 刷新时动态补齐的方式因为这种方式更可靠localStorage里的权限信息过期又没清掉的话会出现显示菜单但接口全部403的情况。还有一个细节是addRoute之后要调用next({...to, replace: true})重新进入本次导航否则刚刚添加的路由并不会立即生效这其实是最容易遗漏的一行代码。6.3 日期和数组参数传递的隐形坑另一个常见问题是接口参数格式不对。Vue Axios默认把参数序列化成JSON而ThinkPHP接收数组参数时前端通常要这样传// 前端删除候选人传id数组 await deleteCandidateApi({ ids: selectedIds })后端接的时候注意如果ids是数组ThinkPHP里$request-param(ids)拿到的默认不是数组要处理成$request-param(ids/a)才能正确识别。同理日期范围筛选前端传[2025-01-01, 2025-01-31]后端要用$request-param(dateRange/a)。这些坑在文档里不太起眼但联调时百分之百会遇到。我的建议是在API文档阶段就把这些参数格式定成明确规范比如日期范围必须传两个字符串、数组参数必须传ids参数名/a不要等到前端问一句后端再改一个。6.4 接口超时和慢查询定位系统运行一段时间后我发现招聘列表页打开变慢大概要两三秒。排查过程是这样的先用ThinkPHP的SQL日志看执行的查询发现列表查询做了候选人表、岗位表、部门表、面试表四层关联而且每一条都会查一次面试时间线导致N1次查询。解决办法是把面试时间线这种一对多数据在列表页做聚合$list Candidate::with([position, interviews]) -latestStage(create_time) -paginate(20);再配合数据库里的覆盖索引查询时间从900ms降到80ms左右。经验就是不要迷信ORM的关联查询一定高效一对多场景下要单独设计查询策略特别是列表页这种高频入口。7. 部署上线与后期维护心得7.1 部署环境准备与项目配置部署的核心原则是前后端分开部署。前端打包出来的dist目录放到Nginx的静态站点下接口请求通过反向代理转发到PHP服务server { listen 80; server_name hr.example.com; location / { root /var/www/hr-frontend/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8088/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }PHP服务我用ThinkPHP内置的start命令跑在8088端口生产环境建议用supervisor守护进程避免进程挂掉没人拉起。这里要重点检查配置后端baseURL要写相对路径/api/而不是写死成IP否则跨环境部署时前端又要重新构建一套。7.2 后期维护与模块扩展系统上线后最常做的扩展有两个方向一个是流程配置化另一个是数据分析。流程配置化的典型场景是招聘状态节点不同部门想自定义流程目前的状态机是写死的后续可以把转移规则挪到数据库里。数据分析方向则是按月出具招聘漏斗、培训签到率、部门人力结构变化这三类报表给管理决策提供依据。我个人的体会是这类内部管理系统的价值不在于技术多炫而在于数据的可追溯性。员工档案谁改的、候选人从哪一步被淘汰的、培训签到率高的讲师又是谁这些在Excel和微信里问半天都问不出来的问题系统上线后顺手就能回答这才是它真正的价值所在。最后再分享一个实战习惯现在每做一个模块我都会顺手把业务流程画成一张状态图连同接口文档一起放到项目的docs目录下。回头维护的时候靠代码注释几乎不可能还原当时的业务决策但一张状态图加一份简要说明就够了。这个习惯帮我省了太多重新读代码的时间强烈建议你也试试。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门