
去年年底朋友公司的HR还在用Excel算工资。每月月底那几天几个文件夹来回发改一个加班系数整个表就乱了还有一次绩效权重填错工资条发出去又撤回来。我决定给他们搭一套人事工资管理系统。技术选型上没有走极端全栈自研而是用了团队最熟悉的组合Vue搭前端ThinkPHP做核心业务后端Node.js跑每月批量计算和Excel导入导出。这套系统从搭建到上线跑了大约半年员工管理、绩效考核、工资核算、福利发放全链路都稳定跑通了。这篇文章把完整的架构思路、表结构设计、前后端实现细节和上线后踩过的坑写出来想自己搭一套的可以直接抄作业。1. 立项背景与技术选型为什么把人事工资系统做成Vue Node.js ThinkPHP的混搭架构1.1 传统Excel套路到底卡在哪30人以内的小团队用Excel算工资确实没什么问题但超过50人就开始乱了。最明显的三个痛点第一工资公式散落在不同表里人事一个单元格拖拽错下个月没人发现第二绩效打分靠邮件和微信来回传月底HR要手动把主管评分汇总到工资表里工作量非常大第三福利发放记录是纯线下登记谁领了端午礼品、谁还没领体检卡全凭一张纸质表。朋友公司当时的流程是考勤表从钉钉导出绩效自评表用腾讯文档收集工资配置在Excel里手动改然后财务再手工算个税。每到一个节点就有人催问数据什么时候给我最夸张的一次工资条已经发出去了发现一位员工的绩效系数填错了HR硬着头皮发更正通知很尴尬。所以这套系统核心要解决的不是做一个多炫酷的软件而是把月度工资结算这条链路从人工搬运变成自动流转让每一条工资数据都有据可查、有版本记录、有流程状态。1.2 三个框架各自负责什么技术选型阶段我没有追求统一技术栈而是按模块特点来分工。前端选Vue是因为中后台系统的组件生态实在太成熟了表格、表单、日期选择器、弹窗Element Plus开箱即用后端主业务选ThinkPHP原因是PHP在管理后台CRUD开发效率极高团队成员都熟悉文档也多Node.js的定位很特别——它不负责主业务专门承接Excel导入导出、月度工资单批量生成、定时任务这类IO密集操作。分工方式可以用一张表说清楚技术服务负责模块选型理由Vue 3 Element Plus整个管理前端中后台组件多、开发快、响应式处理表单交互顺手ThinkPHP MySQL员工、部门、工资方案、绩效配置、福利管理PHP写CRUD快ThinkPHP自带验证器、模型关联适合业务系统Node.js ExpressExcel批量导入导出、工资单生成、定时任务事件驱动适合IO密集任务写批处理脚本比PHP更轻量有人会问为什么不用一个框架全干也完全可以但现实中很多团队是PHP和JavaScript两拨人都有硬统一反而增加学习成本。这套混合架构的核心逻辑是ThinkPHP管业务规则Node.js管批处理与文件前端Vue管展示与交互。三者通过HTTP接口协作边界清晰出了问题也容易定位。1.3 什么样的团队适合参考这个方案如果你也在做内部管理系统人员规模不大、业务规则不算特别复杂、但每个月有固定批处理任务这套架构就很合适。反过来如果公司有标准化运维团队、项目上线后要长期维护我个人建议把所有服务统一部署到Docker容器里保持部署方式一致即可。这里有一个关键原则混搭架构不等于让两个后端随意访问数据库。Node.js服务可以直接连同一个MySQL库做只读查询但所有写入操作必须走ThinkPHP的接口否则事务、权限、日志都会乱掉。这个边界在后文会具体展开。2. 数据库设计工资快照、绩效打分、福利发放背后的表结构2.1 员工与组织架构怎么建模人事系统的根基是员工数据我把员工、部门、岗位分成三张表员工表通过dept_id和position_id关联另外两张表。这种设计的目的是避免部门改名或岗位级别调整时需要修改一堆员工记录。员工表的核心字段示例CREATE TABLE staff ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, staff_no VARCHAR(32) NOT NULL COMMENT 工号, name VARCHAR(50) NOT NULL COMMENT 姓名, dept_id INT UNSIGNED NOT NULL COMMENT 部门ID, position_id INT UNSIGNED NOT NULL COMMENT 岗位ID, hire_date DATE NOT NULL COMMENT 入职日期, base_salary DECIMAL(10,2) NOT NULL COMMENT 基本工资, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在职 0离职, UNIQUE KEY idx_staff_no (staff_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表;我在员工表里直接冗余了base_salary字段没有完全依赖岗位工资配置。原因很简单同一个岗位的人工资可能不同比如老员工和新员工而且调薪是常见操作直接在员工表改值最直观。岗位表里的薪资配置可以当作默认值员工入职或调岗时自动带出来再允许人工微调。2.2 工资明细为什么要冗余存储工资表设计是整个系统里最容易踩坑的地方。很多第一次做工资系统的人会直接写员工ID月份基本工资绩效工资这样一张表所有值都实时去配置表取。这样做的后果是三个月前已经结算过的工资如果这个月调整了岗位工资配置历史数据也跟着变了财务报表、个人工资记录全对不上。正确做法是生成工资快照每个月把所有明细字段都固定存下来。工资项配置表salary_config只负责这个月该怎么算而工资明细表salary_detail负责这个月算出来是什么。共创一张工资明细分表字段示意字段名类型说明idint主键staff_idint员工IDmonthvarchar(7)账期月份如2025-06base_salarydecimal(10,2)基本工资快照performance_salarydecimal(10,2)绩效基数快照performance_coefficientdecimal(3,2)本月绩效系数allowancedecimal(10,2)补贴合计deductiondecimal(10,2)扣款合计gross_salarydecimal(10,2)应发工资social_securitydecimal(10,2)社保扣款income_taxdecimal(10,2)个税扣款net_salarydecimal(10,2)实发工资statustinyint1草稿 2已确认 3已封锁注意status字段有三个值代表工资数据的生命周期。每月25号自动生成草稿HR核对后确认确认之后就不能再改等于财务上过账。这个状态机是工资表里最容易忽略但最重要的设计。2.3 绩效和福利表的状态流转绩效模块我拆成了模板表和结果表。模板表存的是考核规则比如权重设置、等级区间每年可以调整结果表存的是员工每个月的评分。绩效结果表至少要保留四个状态草稿员工自评中、待复核提交给主管、已确认HR锁定、已归档参与工资计算。福利模块稍微特殊一点发福利往往有截止时间和库存两个概念。所以我设计了福利项目表welfare_item和发放记录表welfare_record发放记录里有个status字段待领取、已领取、已过期。每次发放操作都要先锁库存再插入记录避免超发。我强烈推荐把状态字段当作所有业务表的标配。一开始做系统的时候很多表我都没加状态后面补逻辑非常难受。有状态才能做审核流才能在UI上清晰地显示这个工资单现在走到哪一步了。3. Vue前端从环境搭建到页面落地npm.ps1报错与动态路由这些坑我替你踩了3.1 Node.js、npm和Vue环境准备前端的第一个坎其实不是写代码而是把环境跑起来。Node.js安装没什么好说的去官网下载LTS版本的安装包Windows直接用.msi一路下一步就行。装完在命令行里执行node -v和npm -v验证。最经典的问题来了——很多人在PowerShell里执行npm命令会看到这样一句报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。我第一次遇到时也对PowerShell策略不熟悉第一反应是重装Node.js重装完发现没用。这个报错的根因是Windows PowerShell默认禁止执行脚本npm.ps1这个脚本文件没有执行权限跟Node.js本身没有任何关系。解决办法是在PowerShell里执行一次指令Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser执行后输入Y确认关掉PowerShell重新打开npm命令就能正常用了。如果你用cmd终端实际上根本不会遇到这个报错所以很多教程默认不提这茬。接下来创建Vue项目。现在创建新项目我优先推荐Vite比vue-cli启动快很多npm config set registry https://registry.npmmirror.com npm create vitelatest hrms-web -- --template vue cd hrms-web npm install npm run dev配置镜像源是国内开发环境的基本操作不赘述。等项目跑起来浏览器里看到Vite默认页面说明前端基础环境已经OK了。3.2 请求封装与Token登录态管理系统里所有页面都向后端要数据如果每个页面都自己写一遍axios那光处理错误状态就够喝一壶的。我习惯先封装一个request实例把baseURL、超时时间、请求拦截器、响应拦截器全部放进去。import axios from axios; import { ElMessage } from element-plus; const service axios.create({ baseURL: /api, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; }); service.interceptors.response.use( res res.data, err { if (err.response err.response.status 401) { localStorage.removeItem(token); window.location.href /login; } else { ElMessage.error(err.response?.data?.message || 请求失败); } return Promise.reject(err); } );这里两个关键点。第一Token统一从localStorage里取登录后写入所有业务请求都自动带上页面里不用重复处理。第二401响应统一做跳转登录页处理这样即使某个接口过期了用户也只会看到一次登录提示而不是每个页面的接口轮流报错。3.3 动态路由和按钮权限的实现细节人事工资系统涉及的角色有好几种HR管理员、部门主管、普通员工。不同角色能看到的路由和按钮完全不同普通员工只能看自己的工资条部门主管只能看本部门的绩效评分HR管理员才能动系统配置。我采用动态路由方案登录接口返回用户角色前端根据角色从路由表里筛选出可访问的路由再用router.addRoute动态注册。关键代码router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); return; } if (token !store.state.user.roles.length) { store.dispatch(fetchUserInfo).then(() { store.dispatch(generateRoutes).then(accessRoutes { accessRoutes.forEach(route router.addRoute(route)); next({ ...to, replace: true }); }); }); } else { next(); } });这个方案有个最常见的坑页面刷新后VueRouter里动态添加的路由会丢失用户明明登录了一刷新就白屏。解决办法是在全局路由守卫里判断是否已有动态路由没有的话在用户登录态有效时重新加载一次路由表再放行到目标页面而不是直接拒绝访问。我在项目上线第三天就遇到用户反馈刷新后白屏排查了一晚上就是这个原因。按钮级的权限我用了自定义指令v-permission在指令的mounted钩子里判断用户角色列表没有权限的直接移除DOM元素。3.4 工资管理页面组件化实战工资管理页面是整个前端最核心的页面整体由三块组成月份选择栏、汇总统计卡片、工资明细表格。我把月份选择器封装成独立的组件MonthPicker.vue把工资表格封装成SalaryTable.vue列通过props传入。SalaryTable里最实用的特性是插槽比如工资状态列用插槽渲染el-table-column label状态 width90 template #default{ row } el-tag :typestatusTypeMap[row.status]{{ statusTextMap[row.status] }}/el-tag /template /el-table-column这样写的好处是表格组件不需要关心业务状态的长相只需要暴露插槽给父组件父组件可以随时改展示样式。我在项目里把福利领取记录、绩效待办列表也都复用这套思路维护成本低很多。4. ThinkPHP与Node.js双后端协作核心接口、定时任务与数据一致性4.1 ThinkPHP上的核心接口设计ThinkPHP端负责所有写入操作和业务规则。我按模块拆分控制器StaffController管员工增删改查SalaryController管工资结算PerformanceController管绩效流程WelfareController管福利发放。每个控制器只做校验参数、调用Service、返回JSON三件事。以工资结算为例ThinkPHP的接口设计是让节点调用触发计算但不直接写死算法细节namespace app\controller; use app\service\SalaryService; use think\response\Json; class SalaryController { public function generate(string $month): Json { $service new SalaryService(); $result $service-generateMonth($month); return json([code 0, data $result]); } }Service层单独放业务逻辑控制器保持瘦身。这套分层最大的好处是计算逻辑改个公式不用动控制器接口被调用方权限校验不通过也不会误触发计算。ThinkPHP自带验证器我也用了比如员工添加时工号必填、base_salary必须大于等于0。在控制器里统一调用validate效果比在Service里手工拼if判断好得多。4.2 Node.js服务做哪些批处理任务Node.js服务在这套系统里承担了三类任务Excel导入导出、工资条文件生成、定时任务调度。我用了Express起了一个轻量HTTP服务端口和ThinkPHP分开专门暴露几个内部接口给前端或ThinkPHP调用。Excel导入的场景主要是员工批量录入。HR整理好一张标准模板的Excel上传后Node.js用exceljs解析每行数据校验必要字段清洗后调用ThinkPHP的创建员工接口逐行写入。这样避免了直接在Node服务里连数据库写员工表也方便ThinkPHP做统一的权限和日志。月度工资条生成是另一件事。每月工资明细确认后Node服务读取salary_detail表按员工维度生成PDF或Excel工资条压缩成zip给HR下载。工资条内容来自已经确认的快照字段绝不实时计算保证员工看到的和财务结算的完全一致。定时任务用node-cron实现典型的调用方式const cron require(node-cron); cron.schedule(0 9 25 * *, async () { // 每月25号上午9点调ThinkPHP接口生成工资草稿 await fetch(http://127.0.0.1:8080/api/salary/generateDraft, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ month: currentMonth() }) }); });node-cron的表达式和Linux crontab一致五分钟就能上手。比在操作系统层面写cron脚本更可控因为任务失败还能在服务日志里看到报错。4.3 双服务之间安全通信与数据一致性Node服务调用ThinkPHP内部接口这个接口一定不能裸奔否则任何知道URL的人都能触发工资生成。我用的方案是签名验证调用方带上appKey、timestamp、sign三个参数sign是MD5(appKey timestamp secretKey)计算出来的。ThinkPHP端用同一个secretKey再算一遍比对一致才放行。const crypto require(crypto); function buildSign(timestamp) { const raw ${appKey}${timestamp}${secretKey}; return crypto.createHash(md5).update(raw).digest(hex); }时间戳有效期设置为5分钟避免请求被重放。数据一致性上最容易出问题的场景是工资明细生成和工资状态更新如果分两步走中途失败会导致明细有了但状态还是草稿。我在ThinkPHP的Service里把整个生成过程包在事务里Db::transaction(function () use ($month) { SalaryDetail::where(month, $month)-delete(); $this-generateMonthDetails($month); SalaryConfig::toReady($month); });事务内的操作要么全部成功要么全部回滚杜绝了半成品状态。5. 工资、绩效、福利三大业务逻辑的实现细节5.1 工资自动计算公式分解与代码示例工资计算是这套系统的核心公式我拆成应发和实发两层。应发工资 基本工资 绩效工资基数 × 绩效系数 补贴总额 - 缺勤扣款实发工资 应发工资 - 社保个人部分 - 公积金 - 个人所得税。绩效系数不是直接拿绩效分而是先做等级映射。默认映射规则是90分以上系数1.280到89分系数1.070到79分系数0.860到69分系数0.660分以下绩效工资为0。这套规则放在salary_config配置表里阈值和系数都可以后台改。核心计算代码如下public function calculateStaff($staffId, $month, $config) { $performance PerformanceResult::where(staff_id, $staffId) -where(month, $month) -where(status, 已归档) -find(); $coefficient 1.0; if ($performance) { $coefficient $this-scoreToCoefficient($performance-final_score); } $gross $config-base_salary $config-performance_base * $coefficient $config-allowance - $this-getDeduction($staffId, $month); $social $gross * 0.105; // 简化示例实际按当地社保比例 $tax $this-calcTax($gross - $social - 5000); return [ gross_salary round($gross, 2), social_security round($social, 2), income_tax round($tax, 2), net_salary round($gross - $social - $tax, 2), ]; }这里必须强调一个原则月度工资生成后一律先进入草稿状态HR在界面上核对无误后手动点击确认确认后才生成工资条。绝对不能全自动直接确认因为再完美的公式也会出现边界数据问题比如社保基数调整、新员工首月五险一金补扣人工复检这一步不能省。5.2 绩效考核自评、复评与系数映射绩效流程我把它做成三阶段员工自评、主管复评、HR锁定。每阶段对应结果表的不同status前端按照当前状态展示不同的操作按钮。比如status是待复核时主管角色能看到开始复评按钮其他人看不到。最终得分采用加权计算自评占20%主管复评占80%。不过权重不是写死在代码里的而是存在performance_template表里后台可以调整。我之前见过有人把权重写死在SQL里第二年调整考核规则时改代码改到崩溃。绩效系数映射实现时有一个容易踩的边界问题——分数恰好落在临界值上。比如规则是大于等于90分系数1.2代码里如果写if ($score 90)那考90分的员工就变成了系数1.0差一个分数差了20%绩效工资。我现在的写法是区间判断加闭区间private function scoreToCoefficient($score) { $config SalaryConfig::where(status, 1)-first(); foreach ($config-performance_rule as $rule) { if ($score $rule[min] $score $rule[max]) { return $rule[coefficient]; } } return 0; }所有映射规则都数据化SQL语句不写死业务逻辑是我做了这套系统之后最深刻的体会之一。5.3 福利发放配置化与过期控制福利模块我做成项目批次员工领取记录三级结构。福利项目是模板比如端午节礼品年度体检套餐每个项目在特定周期生成一个批次批次的发放范围按员工状态、部门、入职时间筛选生成待发放记录。发放时最关键的是库存校验。以员工领取节日礼盒为例如果项目限量200份后端必须先在一笔事务里锁住项目行再插入记录Db::transaction(function () use ($itemId, $staffId) { $item WelfareItem::lock(true)-find($itemId); if ($item-stock 0) { throw new \Exception(库存不足); } WelfareRecord::create([ staff_id $staffId, item_id $itemId, status 待领取 ]); $item-stock - 1; $item-save(); });lock(true)在ThinkPHP里对应SELECT ... FOR UPDATE行级锁两个员工同时提交领取请求时数据库层面保证只有一个请求能扣掉最后一个库存不会超发。另外还要加一个过期自动回滚的定时逻辑。福利批次会有截止领取时间到点后把待领取的记录批量变成已过期同时把对应库存加回去。这个逻辑不复杂但非常容易漏——不做回滚的话50份中秋节礼盒30人没领库存却一直显示0下个批次没法发。我一开始就漏了后来被HR追着问才发现。6. 部署上线与运维心得开发一时爽上线要思量6.1 开发环境与生产环境的差异开发阶段大家都用localhost前端和后端各跑各的端口靠Vite的代理解决跨域。一旦上了服务器事情就变多了域名、Nginx、反向代理、静态资源路径、数据库连接串、日志切割全都要一起考虑。我最终的生产部署架构是一台Linux服务器Nginx托管Vue构建后的dist目录同时把/api转发到ThinkPHP服务把/export转发到Node.js服务。Nginx关键配置大概长这样server { listen 80; server_name hrms.example.com; location / { root /var/www/hrms-web/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /export/ { proxy_pass http://127.0.0.1:3100/; } }注意Vue Router如果开了history模式Nginx必须配置try_files回退到index.html否则用户直接访问/salary/detail刷新会收到404。我把这个配置写在部署文档第一行就是因为线上真实遇到过。Node.js服务用PM2托管进程崩了自动拉起开机自启也靠PM2的startup命令搞定。ThinkPHP端还是传统PHP-FPM方式没有引入额外的进程守护因为PHP-FPM本身足够稳只要把日志和慢查询监控做好问题不大。6.2 数据安全与备份工资数据比普通业务数据敏感得多谁改了工资都应该有迹可循。我在ThinkPHP里加了一个操作日志中间件记录请求路径、请求体、操作人、时间、IP五类信息。谁在深夜把某个员工的base_salary从8000改成10000一查日志全部现形。public function handle($request, \Closure $next) { $response $next($request); if (in_array($request-pathinfo(), $this-logRoutes)) { OperateLog::create([ user_id $request-userId, path $request-pathinfo(), params json_encode($request-param()), ip $request-ip(), created_at time() ]); } return $response; }数据库备份我用的是最简单的crontab定时任务每天凌晨3点用mysqldump全量备份保留最近30天0 3 * * * mysqldump -u hrms -p密码 hrms_db | gzip /backup/hrms_$(date \%F).sql.gz这个方案对内部系统已经够用了。如果后续要求更高可以加binlog增量备份但前期不推荐过度设计。6.3 性能优化实测系统上线初期查询某个月全部员工工资明细的接口老是卡顿最多一次耗时接近1.2秒。用EXPLAIN一看salary_detail表按month字段查询时没有走索引导致全表扫描。这个表每个月几千条数据虽然总量不大但全表扫描依然慢。优化方式很简单给month和staff_id建了联合索引ALTER TABLE salary_detail ADD INDEX idx_month_staff (month, staff_id);加完之后同一个接口的响应时间直接降到了30毫秒左右。另外我把列表页查询改成分页加条件过滤禁止select *只查出表格需要的列。这些优化都是非常基础的操作但对实际体验的提升非常明显。还有一类优化是逻辑层面的。工资明细表每个月的记录都是只读数据确认之后不会再修改所以我把三个月前的数据按月归档到salary_detail_history表。这样列表页面对的都是当月数据查询量大幅下降同时历史数据还多了一份隔离保护。7. 写在最后这套系统在我手上的真实效果系统上线运行大半年我最直观的感受是月度结算从过去的两天人工核对压缩到大约两小时走完流程25号Node服务自动生成草稿HR花半小时抽查确认财务导出发放清单员工在自助端看工资条。曾经那种工资条发出去又撤回的场面再没出现过。如果让我重新做一遍会坚持两个原则第一先把主流程跑通再谈细节不要一上来就做复杂的审批流和消息通知第二所有规则都要配置化无论是绩效系数还是福利领取条件写死在代码里就等于埋雷。后续我计划把企业微信通知接进来工资确认时自动给员工推一条消息再给部门主管加一个绩效复评的移动端快捷入口让这套系统的使用体验再往前一步。