这套系统我从需求梳理、技术选型到落地部署一共花了三周时间。起因是公司行政每个月底都要用Excel把打卡记录、请假单、绩效评分汇总到工资表里,VLOOKUP串行、公式改坏、漏算迟到是家常便饭。后来我直接用 Python + Vue3 做了一套企业员工考勤打卡薪酬绩效管理系统,把员工、主管、HR、系统管理员四个角色的流程全部打通。这篇文章把这套系统的业务设计、数据库建模、前后端核心实现和踩过的真实坑完整写出来,适合正在做毕业设计、企业内部系统,或者想搞懂考勤薪酬业务逻辑的开发者复现和参考。
1. 业务视角先想清楚:四个角色到底在折腾什么
很多人一上来就写代码,这是做管理系统最忌讳的。考勤、薪酬、绩效这种系统,业务规则比代码复杂得多,角色边界一旦没划清楚,后面每个页面都要返工。我先把四个角色在系统里的职责理了一遍,再做数据库和接口设计。
1.1 四个角色的权限边界
这个系统我定义了四种用户身份:普通员工、部门主管、HR管理员、系统管理员。别看角色少,权限差异非常大,我把功能权限和数据权限分开设计。功能权限是能不能点某个菜单,数据权限是看得到哪些数据,两者必须同时校验。
| 功能模块 | 普通员工 | 部门主管 | HR管理员 | 系统管理员 |
|---|---|---|---|---|
| 上下班打卡 | 本人打卡 | 本人打卡 | 全部考勤 | 全部考勤 |
| 考勤记录查询 | 仅本人 | 本部门成员 | 全公司 | 全公司 |
| 补卡/请假申请 | 发起 | 审批本部门 | 审核/归档 | 配置规则 |
| 绩效目标与评分 | 自评 | 部门成员评分 | 绩效结果管理 | 指标库配置 |
| 工资条查看 | 仅本人 | 仅本人 | 月度薪资核算 | 薪资项配置 |
| 用户与权限管理 | 无 | 无 | 用户信息维护 | 角色授权、菜单管理 |
实际开发时,我并没有在前端写死这些权限,而是把菜单和按钮都注册成权限码。比如员工端登录后返回的菜单只有打卡、我的考勤、我的绩效、我的工资条;主管端多出团队考勤、待办审批;HR端则有考勤汇总、薪资核算、绩效管理。后端每个接口都做权限注解校验,避免前端隐藏菜单后接口还能被直接调用。
1.2 考勤、薪酬、绩效三件事的流转关系
很多初学管理系统的人会把考勤、薪酬、绩效做成三个孤立模块,这是不对的。这三个模块的数据是串起来的:员工的打卡流水生成月度考勤汇总,考勤汇总里的迟到次数、缺勤天数、加班时长直接进薪资计算;绩效评分经过加权汇总得到绩效等级,绩效等级映射成绩效系数,再乘以绩效奖金基数,影响最终实发工资。
我把这条链路简化成一个公式:应发工资 = 基本工资 + 岗位工资 + 绩效奖金基数 × 绩效系数 + 加班费 - 缺勤扣款 - 五险一金个人部分 - 个人所得税。考勤和绩效都成为薪酬计算器的输入源,而不是各自独立的数据孤岛。这也是为什么系统里工资独立于打卡和绩效存在,但每个月核算时又必须读取这两个模块的结果。
1.3 先把Excel里的潜规则翻译成系统规则
我花了一天时间和HR对需求,发现Excel时代有很多“人肉规则”。比如迟到15分钟以内不扣钱只记录,超过15分钟按半小时扣;比如绩效评分自评占30%、主管评分占70%;比如工资条要求保留两位小数,但是必须四舍五入而非银行家舍入。如果这些潜规则不提前变成系统里的配置项,开发到一半就会因为“这不对,我们以前不是这么算的”反复推翻。
所以我在系统里专门建了基础配置模块,把考勤班次、迟到宽限分钟数、绩效系数映射表、薪资项配置全部做成数据库表,由管理员在界面上维护。代码里只写通用计算逻辑,具体规则全部读配置,这是这套系统能落地的关键一步。
2. 技术选型:Python + Vue3这个组合解了什么题
技术栈看起来是“python + vue3”,但Python生态里Web框架那么多,Vue3也有一堆配套库,真正决定开发效率的是具体选型。这一章我把我最终选的方案和理由讲清楚,顺便说说对比之后放弃的方案。
2.1 后端为什么用FastAPI而不是Flask或Django
后端框架我对比过三个:Flask、Django、FastAPI。Flask灵活但要自己拼很多组件,搭一个带数据库迁移、参数校验、API文档的项目需要额外装一堆扩展;Django生态最全,自带Admin后台和ORM,但比较重,对前端Vue3这种前后端完全分离的开发模式来说,它的模板系统基本用不上,还有点约束;FastAPI是后起之秀,基于Pydantic做参数校验能少写很多重复代码,自带Swagger文档方便联调,性能在纯Python框架里也很能打。
最终我选了 FastAPI + SQLAlchemy + MySQL。FastAPI还有两个很实用的特性:一个是依赖注入,我用来做角色权限校验;另一个是异步接口,打卡这类高频写入接口在上班高峰期不会因为数据库连接阻塞拖垮服务。对于中小型企业的考勤并发量,这个组合非常够用。
2.2 前端为什么直接上Vue3 + TypeScript + Pinia + Element Plus
Vue3的组合式API(Composition API)相比Vue2的选项式API,最大的优势是逻辑复用。考勤、审批、薪资核算这些页面都有“查询列表、加载状态、分页”的重复逻辑,我用组合式函数封装了通用的useTable、useForm,每个页面只用几行代码就能接上接口。因为这套系统角色多、权限逻辑复杂,我选了TypeScript,接口返回体、用户信息、表格数据全部定义类型,前端字段写错在编译期就暴露了,不用等到运行时一脸懵。
状态管理用的Pinia而不是Vuex。Pinia的API更简洁,没有Mutation那层概念,store之间互相调用很自然。我用一个userStore存登录用户信息和角色,一个permStore存动态路由和按钮权限。Element Plus负责后台管理系统的UI组件,表格、表单、弹窗、日期选择器这些都能满足;图表部分直接上ECharts,工资趋势、部门迟到率这些可视化报表用它画非常简单。
2.3 前后端联调约定
前后端分离的项目最怕接口规范不统一。我一开始就定了一套标准响应结构:所有接口统一返回 stateCode、message、data 三个字段,成功时 stateCode 为200,业务错误用400或自定义错误码。前端封装了一个axios实例,拦截器里统一处理token过期、错误提示,后端接口只需要返回数据或抛异常,前端不用在每个页面写重复的错误判断逻辑。
登录认证用的JWT,前端登录后把token存在localStorage,axios请求头自动带Authorization: Bearer 。前端路由守卫每次跳转前检查token和角色,无权限直接重定向到登录页或401页面。这套约定让前后端可以并行开发,后端写接口的同时前端用Mock联调,最后合在一起只处理少量字段差异。
3. 数据库建模:考勤流水、绩效表、薪酬表怎么设计才不打架
数据库设计是这类系统的地基。我的经验是:宁可多拆表,不要把什么都塞进一张大宽表。考勤流水是高频插入数据,薪资是每月结算数据,绩效是按季度或月度产生的评估数据,它们的数据特征完全不同,拆开建表不仅逻辑清晰,还能避免一张表字段爆炸导致后来加需求无从下手。
3.1 用户、部门与角色的表结构
用户这块我建了三张核心表:sys_user 用户表、sys_dept 部门表、sys_role 角色表。用户表不直接存角色名字符串,而是通过关系表把用户和角色关联起来,因为一个用户可能有多个角色。用户表里必须包含的基本信息包括:用户名、密码哈希、姓名、手机号、部门ID、岗位、入职日期、在职状态。
密码存储必须用哈希,我用的bcrypt,绝对不允许明文存密码。部门表做了一层父子级结构,用 parent_id 表示层级关系,方便主管能看到子部门成员的数据。角色表里除了角色名,还可以加一个 data_scope 字段,比如“仅本人”“本部门”“全公司”,HR的考勤汇总页就是靠这个字段控制数据范围的。
3.2 考勤流水与月度汇总
考勤模块我建了班次表、打卡流水表、月度汇总表。班次表存上下班时间规则,比如上午 09:00-12:00、下午 13:30-18:00,可配置弹性宽限分钟数。打卡流水表记录每一次打卡事件,字段包括用户ID、打卡日期、打卡时间、打卡类型(上班/下班),以及打卡来源(指纹机导入、手机定位、二维码),还有GPS坐标,方便后端做位置校验。
这里有个重要设计:打卡流水只负责“记”,不负责“算”。每天每个人可能多条流水,比如早上打了一次、中午补打了一次,判断哪条是有效打卡的逻辑比较复杂。所以我单独建了一张考勤月度汇总表,每天晚上通过定时任务自动计算每个人的出勤天数、迟到次数、早退次数、缺勤天数、请假天数、加班时长。这样HR核算薪资时直接查汇总表,不需要实时重算全部流水。
3.3 绩效指标、评分与等级映射
绩效模块我拆成三张表:绩效指标表存“客户满意度”“项目完成度”“团队协作”这类评分项,每个指标有名称、分值上限、权重;绩效评分表存具体的评分记录,包含被评人、评分类别(自评/主管评)、各项得分、评分状态;绩效结果表则存最终加权总分、绩效等级、绩效系数。
为什么要把评分记录和最终结果分开?因为评分过程是动态的,主管可以多次修改,自评未提交、主管未评分这些状态需要区分。而绩效结果一旦确认,就要冻结供薪资计算使用,不能因为改了某个评分项让历史工资跟着变。等级映射规则也是可配置的:总分≥90定A级,绩效系数1.2;80-89定B级,系数1.0;70-79定C级,系数0.8;低于70定D级,系数0.5。
3.4 薪酬表:薪资项配置与月度工资
薪酬模块我建了薪资项配置表和月度工资表。薪资项配置表用来定义有哪些薪资组成,比如基本工资5000、岗位工资3000、绩效奖金基数2000、全勤奖200,以及扣款项:养老保险、医疗保险、失业保险、公积金、个税。这些配置项通过一个 type 字段区分是加项还是减项,加项在计算时相加,减项在计算时扣除。
月度工资表保存每个员工某个月的核算结果,字段包括月份、用户ID、各薪资项金额、应发工资、应扣部分、实发工资、核算状态。核算状态我用了一个状态流:草稿、待审核、已发布、已归档。新版工资算出来后先存草稿,HR核对没问题审核,审核后员工才能看到工资条,归档就锁定数据不能修改。这个状态机帮我和HR省去了大量“手滑改错工资”的麻烦。
4. 后端核心逻辑:打卡判重、迟到计算、薪资核算怎么实现
数据库设计好后,真正见功夫的是业务接口里的核心算法。这一章全是能直接复用的Python逻辑,包括打卡接口怎么写才能防止重复提交、迟到早退怎么和班次配置关联、薪资计算怎么保证金额精度。
4.1 打卡API与防重复提交
打卡接口是员工每天用最多的接口,高频操作最需要防重。我设计的接口流程是:拿到当前登录用户的ID、打卡时的GPS坐标、定位半径,后端先查班次表得到今天的上下班时间范围,然后判断当前时间落在哪个时段。如果当前时间在上班时段内,打卡类型为上班;如果在下班时段内,打卡类型为下班。
判重逻辑很简单但容易被忽略:同一个用户、同一天、同一种打卡类型只能有一条记录。我用了一个数据库唯一约束 guarantee,在打卡时间上直接加“同一个 user_id、clock_date、clock_type 不能重复”的联合唯一索引,从数据库层面防止重复插入,而不是只靠代码里的if判断。GPS校验则使用 geopy 计算打卡坐标和公司坐标的距离,超过设置的半径(比如200米)就拒绝打卡,提示“不在考勤范围内”。
@router.post("/attendance/clock") async def clock_in(payload: ClockPayload, user: User = Depends(get_current_user)): today = datetime.now().date() # 同一用户同一天同类型只能打一次卡,数据库联合唯一索引兜底 exists = db.query(AttendanceRecord).filter( AttendanceRecord.user_id == user.id, AttendanceRecord.clock_date == today, AttendanceRecord.clock_type == payload.clock_type, ).first() if exists: raise HTTPException(status_code=400, detail="今日该时段已打卡,不能重复提交") distance = geopy.distance.distance((user.lat, user.lng), (payload.lat, payload.lng)).meters if distance > configured_radius: raise HTTPException(status_code=400, detail="不在考勤范围内") record = AttendanceRecord( user_id=user.id, clock_date=today, clock_time=datetime.now().time(), clock_type=payload.clock_type, lat=payload.lat, lng=payload.lng, source="APP", ) db.add(record) db.commit() return JSONResponse({"stateCode": 200, "message": "打卡成功"})4.2 迟到、早退、缺勤的判定逻辑
打卡流水记录好了,考勤汇总逻辑才是HR真正关心的。我用了两个配置参数:上班时间 09:00、宽限分钟数 15。打卡时间晚于9:00且不超过9:15判定为“正常但迟到一次”不扣款,只是记录;晚于9:15但不超过9:45判定为迟到,按迟到时长扣工资;超过9:45则直接判定为半天旷工。
早退的逻辑刚好反过来:下班时间早于18:00判定早退,早退超过1小时算半天旷工。这一整套判定我写成一个纯函数,输入是班次配置和打卡流水,输出是考勤状态,方便单元测试和复用。
def calc_daily_attendance(on_time, off_time, config): # 判断上班状态 if on_time is None: work_status = "ABSENT" # 无打卡记录,默认缺勤 elif on_time <= config.late_grace_deadline: # 比如 09:15 work_status = "NORMAL" elif on_time <= config.late_cutoff: # 比如 09:45 work_status = "LATE" else: work_status = "HALF_ABSENT" # 判断下班状态 if off_time is None: leave_status = "ABSENT" elif off_time >= config.off_time: # 18:00 leave_status = "NORMAL" elif off_time >= config.early_cutoff: # 17:00 以前走算早退严重 leave_status = "EARLY" else: leave_status = "HALF_ABSENT" return work_status, leave_status加班时长计算我用了这样的规则:工作日下班后超过30分钟开始累计,不足1小时按1小时计;周末加班需要走审批,有审批单才算加班。加班费倍率默认工作日1.5倍,休息日2倍,法定节假日3倍,这些倍率也全部放配置表。
4.3 薪资计算器的精度问题处理
工资计算最容易被忽视的问题就是浮点数精度。Python里 0.1 + 0.2 的结果不是0.3,而是0.30000000000000004,如果工资表用float直接算,最后实发工资会出现一分的误差。我所有金额字段在数据库里用 Decimal(10,2),后端代码里全程用 decimal.Decimal 运算,绝对不碰float。
四舍五入规则也要统一。Python内置的 round 做的是银行家舍入,round(2.675, 2) 结果不是2.68而是2.67,这在工资里会出大问题。我封装了一个统一换算函数,使用 ROUND_HALF_UP 模式,所有金额先算到小数点后4位,再四舍五入到2位。
from decimal import Decimal, ROUND_HALF_UP def money(value): return Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) def calc_salary(base, post, perf_base, perf_coef, overtime_hours, late_count): total = money(base) + money(post) + money(perf_base) * perf_coef total += money(overtime_hours) * money(25) # 每小时加班费示例 total -= money(late_count) * money(30) # 每次迟到扣30示例 return total薪资计算的输入数据来自三个地方:员工基础档案里的基本工资和岗位工资、考勤汇总表里的迟到次数与加班时长、绩效结果表里的绩效系数。我写了一个核算任务,先把全公司所有员工的薪资明细算成草稿,HR界面上能看到每个人的计算明细,确认无误后一键审核发布。算错了也不用慌,未发布之前可以重新核算覆盖草稿。
4.4 绩效评分加权汇总与系数联动
绩效评分做了一个加权汇总:每个员工每个指标有两个分,自评分和主管评分,最终指标分 = 自评分 × 0.3 + 主管评分 × 0.7。总分是所有指标分乘以权重的总和。比如“项目完成度”权重40%,自评90,主管评80,那这个指标分值就是 90×0.3+80×0.7=83,再乘以40%得到33.2分,所有这样算出来的分数加起来就是总分。
总分出来之后,用等第映射函数转换成A/B/C/D等级和绩效系数。这个系数会传到薪酬接口,作为绩效奖金基数的倍数。我在代码里明确返回等级和系数,避免HR手工填数字。绩效结果的每次修改都保留审计记录,谁改的、改了什么、什么时候改的,全部存在记录表里,防止绩效申诉时说不清。
5. 前端Vue3落地:四个角色怎么进入各自的页面
前端是我花时间最多的部分,不是因为Vue3难,而是角色权限和页面交互细节多。我的核心思路是:动态路由 + 组合式函数 + 组件化页面,让四个角色登录后看到完全不同的操作台。
5.1 动态路由、菜单权限与登录跳转
前端权限的核心逻辑在路由守卫里。我定义了一份静态路由,包含login、404、403这些公共页面;业务页面全部走动态路由,后端根据登录用户的角色返回对应的菜单和路由表。前端在Pinia里存路由表,每次路由跳转前先判断当前用户的路由是否存在,不存在就动态 addRoute。
四个角色登录后的默认首页我做了差异化:员工默认跳打卡页,主管默认跳待办审批,HR默认跳考勤汇总看板,系统管理员默认跳用户管理。这样登录后不用点来点去找入口。菜单权限用v-permission这样的自定义指令控制按钮级别,比如员工没有“导出工资表”按钮,渲染时直接过滤掉,不能只靠隐藏按钮来防越权,后端接口权限依然要严格校验。
router.beforeEach(async (to) => { const userStore = useUserStore(); if (!userStore.token) { if (to.path === "/login") return true; return { path: "/login", query: { redirect: to.fullPath } }; } if (!userStore.routesLoaded) { const menuRoutes = await userStore.fetchUserRoutes(); // 从后端拉角色路由 menuRoutes.forEach((route) => router.addRoute(route)); return { ...to, replace: true }; } if (to.meta?.roles && !to.meta.roles.includes(userStore.role)) { return "/403"; } return true; });5.2 员工端:定位打卡页和工资条页
打卡页我用了Vue3的响应式API,页面加载时调用浏览器的定位接口,拿到经纬度后实时显示当前位置和距公司的距离。如果定位失败(用户拒绝授权),页面只能手动输入定位验证码,或者改用公司WiFi名匹配方案,这个作为兜底。打卡按钮会根据今天是否已打卡自动置灰并显示打卡时间,避免员工反复点击。
工资条页是一个典型的“一人一张表”页面。我查接口拿到当前用户某个月份的应发项、扣款项、实发工资,用卡片式布局展示。下面再用ECharts画一个最近6个月的工资趋势折线图,应发和实发两条线,员工能直观看到自己的工资变化。因为工资数据敏感,前端拿到后我还要做脱敏显示,默认部分字段用星号遮挡,点击“查看”按钮才展示完整金额。
5.3 主管端:补卡、请假与绩效评分审批流
主管端最核心的页面是待办审批。我做了两个Tab:考勤审批(补卡、请假)和绩效评分。考勤审批列表用卡片显示申请人的姓名、申请类型、申请理由、申请时间,主管点进详情能看到该员工当天考勤流水和所在部门的平均考勤时间,作为审批参考。批准或驳回接口会更新申请单状态,同时通知员工(用的简短的站内信)。
绩效评分页我面对一个现实问题:主管同时给多个下属评分,单个页面来回切换效率低。所以评分页做了表格批量模式,一屏显示该主管名下的所有下属、所有评分指标,主管在表格里直接打分,支持草稿保存,全部打完再一键提交。提交后状态变更,员工端马上能看到自评分和最终分。
5.4 HR端:考勤看板与月度薪酬核算页
HR端的首页是考勤看板,我用ECharts做三块可视化:一个日历热力图显示全公司每天的打卡率,一个柱状图统计各部门迟到率排名,一个饼图展示出勤状态分布(正常、迟到、早退、缺勤、请假)。每张图都可下钻,点击某个部门跳转到部门明细列表,让HR能快速定位考勤异常集中点。
薪酬核算页则用了类似Excel的表格界面:左侧勾选要核算的部门,点“生成草稿”后,表格里列出每个员工的基本工资、绩效奖金、加班费、扣款、应发、实发。HR双击某个单元格可以查看这笔金额的计算明细,比如绩效奖金后面有个小链接,点开能看到绩效系数来源。确认无误后点“审核通过”,系统给所有员工发工资条生成通知,这个页面是最能体现系统替代Excel价值的。
6. 联调、部署与踩坑记录
一个项目做完不难,难的是联调阶段遇到的问题排查。这一章记录我在这个项目里真实踩过并且解决了的坑,每一个都有具体原因和修复办法,复现概率很高。
6.1 时区Bug:打卡时间凭空多了8小时
部署上线第一天,HR就发现所有员工的打卡时间显示成了下午而不是上午。这个Bug的根因是:前端浏览器用的是本地时区,而服务器默认时区是UTC,前端传时间戳给后端,后端直接 new Date() 格式化得到的是UTC时间,导致显示时间比真实时间少了8小时。
修复方案是在后端启动时强制设置时区为Asia/Shanghai,同时数据库连接串里也要加 serverTimezone=Asia/Shanghai。前端统一用时间戳传参,展示时再按客户端时区格式化。日期字符串不要用“YYYY-MM-DD HH:mm:ss”这种格式跨端传,解析歧义太大,我直接全换成了时间戳。
6.2 计算金额出现一堆小数点
联调时发现工资明细表里出现一串6666.666666666666之类的数字。排查后确认是薪资计算时把 Decimal 和 float 混用了,比如绩效奖金基数存的是Decimal,但绩效系数是float,两者相乘后精度被污染。我定了一个硬规则:金额字段在数据库、后端、前端任何环节都统一字符串或Decimal,不在计算中途用float。前端表格拿到后端返回的数字,也用toFixed(2)显示,防止出现浮点尾巴。
6.3 前端跨域与生产环境部署
开发环境通过Vite的 proxy 把 /api 代理到后端地址,能友好解决跨域问题。但部署到生产环境时,不能依赖前端的代理配置,我用Nginx做了统一的反向代理:前端静态文件放在 /dist,后端 uvicorn 监听 127.0.0.1:8000,Nginx把 /api 开头的请求转发到后端服务。这样浏览器访问的始终是同一个域名,不会产生跨域。
Nginx配置里我特别加了处理history路由的规则,Vue3用history模式时,前端路由刷新会出现404,需要在location / 里加 try_files $uri $uri/ /index.html;。这个配置忘了加,生产环境刷新页面就白屏,排查了半天。
6.4 权限缓存:员工切换角色后跳回旧菜单
后台测试时发现一个隐蔽问题:用户退出登录后,再换一个账号登录,显示的还是上一个账号的菜单权限。原因是动态路由在Pinia和Vue Router里已经注册过了,退出登录时只清空了token,没有重置路由表和新账号的权限store。
修复方式是封装一个 resetPermission 函数,退出登录时遍历当前路由表,把动态添加的路由逐条移除,再重置Pinia里对应的store状态。不能只是页面跳转,整个应用状态必须重新初始化。同样的逻辑也适用于账号被管理员修改角色之后,必须重新登录才能生效,因为旧的路由表带着旧的权限码。
我还遇到过pandas方式导入Excel考勤数据时时间字段被识别成数字的情况,排查下来是Excel里的时间列被设置成了自定义格式,标准pandas read_excel解析出来是datetime格式,但有些导出工具生成的是文本,需要在导入时先做统一转换。我的建议是规范导入模板列的格式,并写try-except兜住异常数据,宁可让HR手动改一行,也不要导入时静默丢弃。
这套系统内部跑了三个月,累积了上千条打卡记录,每月工资核算从过去的大半天缩短到十几分钟。给还在犹豫选什么技术栈的朋友一个建议:管理系统这类业务系统,别追求新框架,Python + FastAPI + Vue3这套组合从开发效率、类型安全、生态成熟度上都够用了。里面最难的不是写接口,而是把考勤迟到怎么算、绩效系数怎么映射这类业务规则真正做成可配置的能力,这一层想透了,系统才算真正立得住。