去年给一家做机械加工的制造企业做了一套内部管理系统,核心需求就一个:员工工资管理。客户那边的 HR 每个月要用 Excel 算两百多号人的工资,每次都要折腾好几天,还经常出现算错、漏算、核对不上的情况,财务和人事互相甩锅。客户提了几个硬性要求:前后端分离、必须用 Node.js + Vue 这套技术栈、部署在他们自己的 Windows 服务器上。当时我就明白,这活儿的难点不在业务有多复杂,而在于怎么把 Node.js 生态里那些零碎的环境问题、依赖问题、权限问题在真实环境里稳妥落地。这篇就把整个项目从环境配置、数据库设计、API 实现到前端页面、以及我实际踩过的一堆坑完整记录下来,给准备做类似系统的朋友一份可以直接参考的实操笔记。
这套系统说到底是给三类人用的:人事算工资、财务管发放、员工查工资条。加上管理员做基础数据和权限维护,角色划分很清晰。市面上现成的 SaaS 工资系统不少,但客户数据敏感、要私有化部署,还得跟他们已有的 OA 流程打通,所以自研是更合理的路径。整个项目从需求确认到上线大概花了一个月,开发本身不算慢,真正耗时的是那些环境配置和联调问题。下面按项目的实际推进顺序来讲。
1. 项目整体设计与技术选型思路
1.1 核心需求拆解
动工之前先花了一周把需求理清楚。企业工资管理系统看着简单,真正列出来功能点还是相当多的:
- 组织架构管理:部门增删改查、部门负责人设置
- 员工档案管理:工号、姓名、性别、部门、岗位、入职日期、银行卡号、联系方式
- 薪资项目配置:基本工资、岗位工资、绩效工资、加班费、餐补、交通补贴;扣款项目有社保、公积金、个税、缺勤扣款等
- 月度工资核算:选择月份,批量计算所有在职员工的应发工资、实发工资
- 工资条查看:员工只能看到自己的工资明细,HR 能看到全公司的
- 报表统计:部门人力成本、月度薪资总额趋势、个税汇总等
需求里最容易被忽略的是权限控制。员工和 HR 看到的工资数据完全不同,公司领导层可能要看报表但不能看个人明细,这些都要在数据库设计和接口设计阶段就留好扩展位,不然后期改起来特别痛。
1.2 为什么选 Node.js + Vue 而不是 Java
客户明确要 Node.js + Vue,这个选型本身也是合理的。对比传统的 SpringBoot + Vue 前后端分离方案,Node.js + Express 在中小型内部系统上有几个明显优势:
- 开发效率高,JavaScript 全栈同语言,前端组和后端组沟通成本为零
- 运行时内存占用比 Java 低不少,在 4G 内存的 Windows 服务器上跑得很轻松
- 生态丰富,Express 中间件几乎能覆盖所有常见场景
- 对前端开发团队友好,转岗成本低
Vue 这边选的是 Vue 3 + Element Plus + Vite。Vue 3 的组合式 API 在组织业务逻辑时比 Vue 2 的选项式 API 清晰很多,Element Plus 的表单组件和表格组件对后台管理系统来说简直是量身定制的。Vite 的开发服务器启动速度比 Webpack 快了一个数量级,热更新体验也好得多。
1.3 总体架构设计
系统采用经典的前后端分离三层层级:
前端(Vue 3 + Element Plus + Vite) ↓ HTTP + JSON 后端(Node.js + Express) ↓ Sequelize ORM 数据库(MySQL 8.0)为什么用 MySQL 而不是 MongoDB?工资数据对事务性、一致性要求非常高,两条记录之间要对得上账,关系型数据库的强一致性和事务能力是刚需。ORM 选的 Sequelize,虽然它有些 API 设计比较绕,但胜在文档全、社区大、坑都被人踩过了,适合项目周期紧张的情况。
环境方面,Node.js 版本锁定为 16 LTS,这很重要。后面会专门讲版本踩坑。Redis 在这个项目里没有引入,因为内部系统用户量不大,JWT 无状态鉴权足够应付,少一个中间件就少一个部署排障的环节。
2. Node.js 环境配置:最多人卡住的第一关
这个项目第一个坑就出现在环境上。我在这台客户服务器上装 Node.js 的时候,真的有一瞬间想摔键盘。项目本身没这么复杂,但环境问题花了将就一天才彻底收拾干净。
2.1 Node.js 下载安装与版本选择
到官网下载 LTS 版本,我用的 16.x。这里有个经验:不要一看到新版本就手痒装 Current 版本,框架和依赖的兼容性是滞后的。很多 npm 包还停留在支持 LTS 版本的阶段,用太新的 Node.js 版本会遇到莫名其妙的编译错误。
安装时注意两点:第一,安装路径不要带中文和空格,否则后续有些原生模块编译会出问题;第二,不要勾选安装"部分自带工具",那个会调 PowerShell 去跑脚本,在很多公司默认策略下会失败。
安装完验证一下:
node -v npm -v能正确输出版本号就算成功一半了。
2.2 npm 源切换
国内直接连官方源下载依赖,速度能让你怀疑人生。项目刚开始跑npm install,卡在 node_modules 下载上,进度条半天不动。果断换淘宝镜像源:
npm config set registry https://registry.npmmirror.com换完之后下载速度立竿见影。这个配置写在用户目录下的.npmrc文件里,换一台电脑或者重装系统之后要记得重新设置。
2.3 npm.ps1 无法加载文件的终极解法
这是热搜里出现频率最高的一个问题,也是 Windows 上跑 Node.js 的人都会碰到的:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本原因很明确:Windows PowerShell 默认执行策略是 Restricted,不允许运行任何 .ps1 脚本。npm 在 PowerShell 里是以 npm.ps1 方式调用的,所以直接被拦截。
解决办法不是去改文件权限,而是放开 PowerShell 的执行策略,在管理员权限的 PowerShell 里执行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的含义是:本地创建的脚本可以运行,从网络下载的脚本必须要有可信签名。这个策略相对安全,只是放行了本地脚本。设置完执行Get-ExecutionPolicy -List确认生效。
如果你不想动 PowerShell 策略,也可以用 CMD 绕过:打开命令提示符跑 npm 命令就没这个问题。但治标不治本,建议还是把执行策略改好,后面跑构建脚本、自动化部署都省事。
2.4 多版本管理:nvm-windows
这个项目踩过环境多版本共存的坑之后,我回过头在开发机上装了 nvm-windows。不同老项目用的 Node 版本不一样,有的要求 12,有的要求 16,有的要 18,手工切换环境变量简直是灾难。nvm 可以轻松切换:
nvm install 16.20.2 nvm use 16.20.2注意:nvm-windows 和已安装的 Node.js 会有冲突,装 nvm 之前要把系统中已有的 Node 卸载干净,并手动清理环境变量里残留的 PATH 条目。
3. 数据库设计:工资系统的核心命脉
一套工资管理系统是否能撑住复杂的业务逻辑,数据库设计占了大头。这个部分设计得不好,后面写代码的时候每一个查询都会很难受。
3.1 核心表结构与关系
我设计的主要表有七张:用户表、部门表、员工表、薪资项目表、员工薪资标准表、月度工资单表、操作日志表。
员工表和用户表是分开的。员工表存的是企业真实员工档案,用户表存的是系统登录账号,两者通过employee_id关联。这样设计的好处是:不是所有员工都有系统账号,但所有员工都可以被算薪。
月度工资单表是整个系统的核心,字段大致如下:
CREATE TABLE payroll_records ( id INT PRIMARY KEY AUTO_INCREMENT, employee_id INT NOT NULL, month VARCHAR(6) NOT NULL, -- 如 202402 base_salary DECIMAL(10,2), position_salary DECIMAL(10,2), performance_salary DECIMAL(10,2), overtime_pay DECIMAL(10,2), allowance DECIMAL(10,2), social_security DECIMAL(10,2), housing_fund DECIMAL(10,2), income_tax DECIMAL(10,2), other_deductions DECIMAL(10,2), gross_pay DECIMAL(10,2), net_pay DECIMAL(10,2), status TINYINT DEFAULT 1, -- 1草稿 2已确认 3已发放 created_at DATETIME, updated_at DATETIME );关键点来了:工资单表里存的是每一项算好的金额快照,而不是指向薪资标准和考勤记录的引用。这是为了让历史数据不可变——你五月份的工资单不会因为后来调整了薪资标准而发生变化。
3.2 薪资计算逻辑:别把公式写散
工资计算的基本公式是:
应发工资 = 基本工资 + 岗位工资 + 绩效工资 + 加班费 + 补贴(餐补/交通/全勤...) 实发工资 = 应发工资 - 社保个人部分 - 公积金个人部分 - 个税 - 其他扣款个税是这里最容易写错的地方。目前政策是累计预扣法,按照年度累计收入逐月计算税率,专业工资软件都是这么算的。我的系统刚上线时为了图省事只做了按月单次计算,后来财务反馈说量大月份个税对不上,才改成正规的累计预扣算法。
实现的时候不要把这些公式散落在各种业务代码里,应该单独抽一个salary-calculator模块,输入员工薪资标准和当月考勤扣款列表,输出计算后的各项目金额。这样单元测试容易写,调起来也方便。
3.3 为什么工资数据要留快照
刚接触这个业务的时候我想过:工资单不就是薪资标准加考勤算出来的吗?那每次要查历史工资,重新算一遍不就行了?后来被现实教育了。薪资标准会调整、个税起征点会变、社保基数每年都变、甚至公司福利政策都会改。如果依赖实时计算,三年前的工资条永远说不清楚当初是怎么算出来的。
所以工资单数据一旦确认,就不允许修改,只能作废重算。这个机制和发票处理逻辑很像——发票开错了不能改,只能作废重开。这也是财务审计的要求。这个设计决策在需求沟通阶段就要和客户讲清楚,不然产品经理会说"工资条难道不能改吗"。
4. 后端 API 实现:从登录鉴权到工资计算
后端的整体结构是 Express 应用,按模块划分目录:controllers、routes、models、middleware、services。所有 API 统一返回{ code, msg, data }结构,前端拿到之后根据 code 做统一处理。
4.1 JWT 登录鉴权与密码加密
密码存储直接用 bcryptjs,密码在数据库里永远以哈希形式存在。加盐由库内部处理,不需要自己造轮子。登录签发 JWT:
const jwt = require('jsonwebtoken'); function signToken(user) { return jwt.sign( { id: user.id, employeeId: user.employeeId, role: user.role }, process.env.JWT_SECRET, { expiresIn: '8h' } ); } function authMiddleware(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ code: 401, msg: '未登录' }); try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (e) { return res.status(401).json({ code: 401, msg: '登录已过期' }); } }这里有个实际项目经验:JWT_SECRET 绝对不能写在代码里,从环境变量读取。系统上线的时候部署脚本里自动生成一个随机字符串写入.env文件。客户服务器上如果只是自己用还好,一旦有违规操作的风险,密钥泄漏等于全系统裸奔。
4.2 员工管理和批量导入
员工模块是最标准的 CRUD,但有一个点值得单独说:批量导入。
客户的 HR 手里有一份现成的员工 Excel 表,几百个员工一条条录进去不现实。所以系统做了 Excel 批量导入功能,用 exceljs 解析上传的 xlsx 文件,逐行校验必填字段、工号唯一性、部门是否存在,然后把校验通过的记录批量写入数据库。
导入结果要给出清晰的统计反馈:成功多少条、失败多少条、失败原因是什么。不然后台管理的人根本不知道导入没成功的原因。第一次做的时候我没有区分"部分成功"的情况,导入一报错就全量回滚,后来才改成逐条记录错误、部分成功的策略。
4.3 月度工资核算与导出的实现细节
工资核算接口是一次批处理操作:选择一个月份,系统找到当前所有在职员工,读取他们的薪资标准和当月的考勤扣款记录,逐人计算并写入 payroll_records。
这里要注意避免重复生成。用户手一抖点了两次生成,就会出现同一员工同一月份两条工资单。解决办法是在employee_id + month上加唯一索引,并在代码里先查再写,处于并发状态时也能被数据库兜住。
工资导出同样用 exceljs,生成标准的工资条 Excel:表头是员工姓名工号部门,下面按项目列出各金额项。还有一个功能是给员工发工资条 PDF 或邮件,这个需求当时排期不够,后来以在系统内查看替代了。
4.4 三级权限控制和数据范围隔离
系统有三类角色:管理员、HR、普通员工。权限控制分两级,一级是接口级权限,用中间件做角色校验:
function requireRole(...roles) { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ code: 403, msg: '无权限' }); } next(); }; }二级是数据级权限,也就是行级隔离。普通员工只能查询自己的工资单,这个不能只靠前端隐藏按钮来实现,后端接口里必须强制带上employeeId = req.user.employeeId条件。我当时特意做了个测试:用一个普通员工的账号直接调用查看别人工资条的接口,验证返回的是 403 还是数据泄漏。没有这套验证,权限设计就是白做。
5. Vue 前端实现:页面不只是展示
前端这个部分技术栈是 Vue 3 + Element Plus + Vite。整体页面的骨架是左侧菜单栏、右侧内容区,顶部是用户信息和退出登录。这种后台管理系统的布局已经很标准化了,直接基于 Element Plus 的布局组件搭。
5.1 路由设计与导航守卫
路由这块用 Vue Router 4,页面按模块组织:登录页、仪表盘、员工管理、部门管理、薪资设置、工资核算、工资单列表、工资条详情、系统设置。
导航守卫用来处理登录状态和角色限制:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); return; } const role = localStorage.getItem('role'); if (to.meta.roles && !to.meta.roles.includes(role)) { next('/dashboard'); return; } next(); });meta.roles写在路由定义里,比如工资条详情页是{ roles: ['admin', 'hr'] },普通员工登录之后点这个入口直接被重定向。这个属于体验层的权限控制——真正安全底线还是在后端接口。
5.2 Axios 封装与 token 管理
整个项目所有接口请求都通过一个封装好的 axios 实例走,不搞散装调用。请求拦截器自动从 localStorage 取 token 放进Authorization头,这样每个接口都不用手动带 token:
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) => response.data, (error) => { if (error.response?.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );响应拦截器统一处理 401,用户登录过期后自动踢回登录页,不用每个页面单独去判断。这里有个容易踩的坑:后端返回的业务错误 code 可能是 500、400,这些在 HTTP 层还是 200,需要在前端业务层再判断一次code字段。两层判断容易漏,要约定好规则。
5.3 工资条查看与月度报表可视化
普通员工的工资条页面,我做了个类似"请假条"样式的卡片,把应发项和扣款项分左右两列展示,底部突出显示实发工资。每一条工资单后面有个展开按钮,可以查看当月各项明细。现金额统一用toFixed(2)格式化,避免 JS 浮点精度搞出 0.1 + 0.2 ≠ 0.3 这种低级问题。
报表页面用 ECharts 做了三个核心图表:近一年月度工资总额折线图、各部门人力成本柱状图、薪资构成饼图。数据接口返回的是聚合后的 JSON,前端只管渲染,不在浏览器端做复杂计算。做图表之前记得先和 UI 或者客户确认清楚看哪些维度,我第一次全按自己的想法做了一堆图表,结果客户最关心的是"哪个部门人力成本涨得最多",其他都在好看不实用。
5.4 表单校验与细节体验
工资系统里面表单免不了和数字打交道,Element Plus 的表单校验规则用起来很方便。数字输入框要限制只能输数字和小数点,金额输入框最好做成保留两位小数。我用了一个自定义指令,在 input 的输入事件里做实时过滤,防止用户手敲出不合法的字符。
开发环境如果装了 Vue DevTools,调组件状态会方便很多。这个工具在 Chrome 扩展商店直接下,Vue 3 项目对应的是 Vue.js devtools 新版本。调试路由跳转参数、监听 Pinia 状态变化都靠它,装好之后排查问题效率翻倍。
6. 常见问题排查与避坑实录
这个项目最大的收获其实全在踩坑里。下面这几个问题我基本都实测遇到过,解决方案也是验证过的。
6.1 npm install 慢到抓狂或者直接失败
排查步骤按这个顺序走:
- 确认是否换了国内源:
npm config get registry - 如果目录结构被之前失败的安装弄乱了,删掉
node_modules和package-lock.json重新装 - 清理缓存:
npm cache clean --force
不要轻易尝试 cnpm。cnpm 虽然快,但安装出来的依赖结构和官方 npm 不一致,一些包会挂掉,到时候排查更痛苦。优先用官方 npm 配国内镜像源,速度和稳定性都能接受。
6.2 node-sass 编译报错
这是前端依赖里出现频率极高的一个问题。症状是到处报错:Module build failed、Python not found、node-gyp各种编译错误。根因就是 node-sass 的原生绑定只针对特定的 Node.js 版本编译,你升级了 Node.js 版本,它就不匹配了。
解决方案从根上做:不要再碰 node-sass,用 sass 也就是 dart-sass 替代。同样是写 SCSS,sass 是纯 JS 实现,没有原生编译环节,和 Node 版本完美兼容。项目里如果已经在用 sass,把node-sass从依赖里删干净,全局搜一下有没有漏网引用。
6.3 前后端跨域问题
开发环境用 Vite 的 proxy 解决:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, }, }, }, });前端所有请求都写/api/xxx,Vite 代理到后端服务,浏览器看到的请求是同源的,绕开跨域。生产环境更简单,Nginx 反代同时托管前端静态文件和/api接口转发,根本不用后端开 CORS。
如果后端一定要开跨域,用cors这个 npm 包,设置origin: true让任意来源可访问,但这只适合内网内部系统。别在生产环境不加限制地开放跨域,太危险。
6.4 数据库中文乱码
MySQL 连接上之后写入中文变???,基本是字符集没配对。第一,建库时指定 utf8mb4;第二,连接串带上 charset:?charset=utf8mb4;第三,Sequelize 里define.charset也统一写 utf8mb4。三层都对齐,基本不会乱码。
6.5 金额精度与日期时区
金额运算永远不要用 JS 的浮点数。后端计算工资时,ORM 里的 DECIMAL 类型到 JS 里会变成字符串或数字,加减乘除处理不好就有精度问题。我的做法是计算过程统一转为"分"为单位做整数运算,最后再除以 100 转回"元"。这个方案虽然写起来稍微麻烦一点,但绝对不出错。
日期方面,月份字段用202402这种字符串而不是 Date 类型,避免了时区导致的月份错位。处理日期时间用 dayjs,体积小、API 顺手,比 moment 强多了。
最后说点实际体会
这套系统做完交付之后,我最大的感受是:企业内部的工资管理系统,技术难度真的不大,难点全在业务规则和环境适配。比如"工资单一旦生成不能直接改"这类需求,数据库的表结构设计就要提前配合,不能等开发到一半才临时加约束。再比如 Windows 服务器上跑 Node.js 应用,那些 PowerShell 策略、Nginx 配置、端口占用问题,会消耗比写业务代码更多的时间。
如果现在让我重新做一遍类似的系统,我会在动工前把环境问题一次性排查干净,把 Node.js 版本用 nvm 管理起来,从一开始就用严格的快照表结构设计工资数据。另外,权限控制这种安全底线,不要依赖任何人"记得做",而是先在文档里列出清晰的矩阵,再按矩阵逐项实现和校验。整个过程下来,真正值钱的不是那几段业务代码,而是从"能跑"到"稳定跑"之间那些说不太清楚的细节经验。希望这篇记录能帮你绕开其中一部分,把精力留给真正有价值的业务设计上。