☰
Flask实战:企业员工绩效管理系统从设计到部署
2026/10/8 4:05:55 网站建设 项目流程

前段时间接了个企业内部的绩效管理系统需求,技术栈指定用 Python,框架方面没有硬性要求,最后我选了 Flask。整个系统包含员工信息管理、考核指标配置、批次管理、评分流程和结果统计,前后端用 Flask 自带的 Jinja2 模板加 Bootstrap,数据库用 MySQL,从零到部署完成一共三周。这个项目规模不大不小,正好把 Flask 常用组件、数据库设计和权限控制完整走了一遍。我把实现过程和踩过的坑整理出来,给准备做同类系统的朋友参考。不管你是毕业设计选了"企业员工绩效管理系统"这个题目,还是公司内网想搭一个轻量考核工具,这篇内容应该能帮你省掉不少弯路。

1. 接需求之前,先想清楚绩效系统要干哪些活

1.1 现状调研:Excel考核到底痛在哪里

这个需求并不是凭空冒出来的,甲方原来的考核方式很原始:每季度末人事打印一张考核表,员工手填自评分,部门经理再手写领导评分,最后人事收齐表格后用 Excel 汇总。听起来没什么问题,但实际用起来到处都是坑。表格在传递过程中经常丢,版本对不上,同一个员工上季度和本季度用的指标维度还不一样。最头疼的是统计环节,人事要把几十张纸质表的分数一个个录入 Excel,再手动算加权分,一次季度考核光统计就要折腾两三天。

所以在动手写代码之前,我花了大半天时间把上面的流程彻底问清楚,并且记下几个关键点:考核周期固定为季度;考核内容不是固定的,每个季度管理层可能调整指标体系;员工需要自评,但自评只占小比例;部门经理需要在系统里看到本部门所有人并逐项打分。这些听起来琐碎的细节决定了后面的数据模型和权限设计。

1.2 角色权限和功能边界

确认需求之后,我把系统里的角色定为三类,权限边界在第一个版本就写清楚:

  • 管理员(admin):管理员工信息、部门、考核指标、考核批次,查看所有部门的统计结果。
  • 部门经理(manager):只能查看本部门员工,录入领导评分,查看本部门的汇总数据。
  • 普通员工(employee):查看自己的考核指标和自评结果,提交自评分,查看最终得分。

这里有一个很容易犯的错误,就是角色划分不彻底。有人会在员工表里加一堆 is_manager 之类的小字段,后面做权限判断时到处都是 if,很难维护。我直接用一个 role 字段,配合两个装饰器解决,后面会具体讲。

功能边界同样重要。甲方一开始提了很多东西,比如想加考勤统计、想对接钉钉、想在系统里直接发工资条。我全部先放进了"待定需求"清单,第一期只做绩效闭环:批次管理、指标配置、员工/经理评分、结果统计导出。原因很简单,范围一旦扩大,项目交付时间至少翻倍,而且考勤、薪资这类功能和绩效的耦合度很高,处理不好会让流程变得极其复杂。

1.3 把核心流程串成一条线

需求梳理到最后,我用一句话总结了整个业务链路:管理员创建考核批次并配置指标,员工在批次开放期内完成自评,部门经理在开放期内完成领导评分,批次关闭后系统自动计算加权得分,管理员可以按部门、按批次查看汇总结果并导出。

这段话后来直接变成了数据库设计的蓝本,也是我拆分路由的依据。每次开发前把核心流程用一句话讲清楚,能避免很多"边做边加需求"带来的混乱。

2. 技术栈定下来了:Flask为主,配齐周边工具

2.1 为什么没选Django和FastAPI

框架选型是这个项目里甲方问得最多的问题。说实话,以这个系统的体量,Django、Flask、FastAPI 都能做,关键看你更在意什么。

我用一张表记录自己的选型思考:

  • Flask:轻量灵活,路由数量少、页面多,Jinja2 模板开箱即用,适合快速交付中小型内网系统,出问题好排查。
  • Django:功能全,自带 Admin、ORM、迁移工具,适合大型复杂系统,但这个项目只有十几个路由,用 Django 反而要处理大量默认配置。
  • FastAPI:性能好、支持异步,但这个场景全是同步数据库操作和页面渲染,异步优势发挥不出来,模板生态也相对弱。

所以最后选了 Flask。它不强制你用什么目录结构,自由度大,对于"毕业后还要二次开发"这种需求非常友好,代码量小,读起来不费劲。

2.2 周边选型逐一说清

Flask 本身很简洁,一个路由就是一个函数,但做完整系统还需要配套组件。这次的选型是:

  • Flask-SQLAlchemy:ORM,避免手写 SQL,也顺便防了注入问题。
  • 手写 session 登录,没有用 Flask-Login:因为角色就三个,逻辑简单,自己控制更透明。
  • 表单处理用的 Flask-WTF,主要看重它的 CSRF 防护和表单校验,否则很多人提交空分数或者超范围分数会很麻烦。
  • 数据库用 MySQL 8,Python 访问层用 PyMySQL。
  • 前端用 Bootstrap 5,通过 CDN 引入,表格和表单都靠它;图表用了 ECharts 的单个 js 文件。
  • 模板是 Jinja2,因为 Flask 默认就带它,功能足够,不需要额外引入别的模板引擎。

这套组合最大的好处是"用得着的地方全都有,用不着的一样不多",非常适合这种以页面为主的管理系统。

2.3 项目目录结构

Flask 项目的目录结构如果不提前规划好,后面一定会乱。我这次用的是工厂模式加蓝图拆分:

project/ ├── run.py # 入口文件 ├── config.py # 配置(数据库连接、密钥等) ├── requirements.txt ├── app/ │ ├── __init__.py # create_app 工厂 │ ├── models.py # 所有数据库模型 │ ├── decorators.py # 登录、管理员装饰器 │ ├── views/ │ │ ├── auth.py # 登录、注销 │ │ ├── admin.py # 员工/部门/指标/批次管理 │ │ ├── assessment.py # 评分相关 │ │ └── report.py # 统计报表 │ ├── templates/ │ │ ├── base.html │ │ └── ... │ └── static/ │ ├── css/ │ └── js/

工厂函数的目的是让 app 的创建、数据库初始化、蓝图注册都集中在app/__init__.py里,测试环境和生产环境共用一套代码。这个结构虽然不是唯一的解法,但对我来说是最好维护的。

3. 数据库设计:用五六张表把考核闭环撑起来

3.1 组织架构基础表:部门和用户

系统里最基础的数据是员工,员工属于部门。我建了两张表:departments 和 users。users 里除了基本的 name、username、password_hash 之外,加了 role 字段表示前面说的三种角色,增加 department_id 外键关联部门。

password_hash 用 werkzeug.security 的 generate_password_hash 生成,绝对不存明文密码,这是最基础的底线。

class Department(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), unique=True, nullable=False) class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, nullable=False) password_hash = db.Column(db.String(128), nullable=False) real_name = db.Column(db.String(64), nullable=False) role = db.Column(db.String(16), nullable=False, default='employee') department_id = db.Column(db.Integer, db.ForeignKey('department.id')) department = db.relationship('Department', backref='users')

3.2 考核指标和批次:配置和流程分开

绩效系统最核心的一点,是指标体系和考核流程要解耦。指标是可复用的配置数据,考核批次是每次考核的实例。

performance_items 表存指标名称、描述、满分值、权重。assessment_batches 表存批次信息,包括名称、起止时间、状态。一个批次可以引用多个指标,所以中间还需要一张关联表,或者直接在指标表里加 batch_id 字段。我在第一版用的是在指标表里加 batch_id,因为每个季度的指标都会重新配置,不太需要跨批次复用同一套指标的复杂关系。

class PerformanceItem(db.Model): id = db.Column(db.Integer, primary_key=True) batch_id = db.Column(db.Integer, db.ForeignKey('assessment_batch.id')) name = db.Column(db.String(64), nullable=False) max_score = db.Column(db.Float, nullable=False) weight = db.Column(db.Float, nullable=False, default=1.0) description = db.Column(db.Text) class AssessmentBatch(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), nullable=False) start_date = db.Column(db.Date, nullable=False) end_date = db.Column(db.Date, nullable=False) status = db.Column(db.String(16), default='draft') # draft/active/closed self_weight = db.Column(db.Float, default=0.2) manager_weight = db.Column(db.Float, default=0.8)

这里给批次加上 self_weight 和 manager_weight 两个字段,是为了让权重可配置。如果哪天管理层想调成自评占三成,经理占七成,直接在批次上改数字就行,不需要动代码。

3.3 评分明细与结果记录

评分数据我拆成了两张表。assessment_records 记录"某员工在某个批次下的考核记录",score_details 记录"这条考核记录下每个指标的得分和评语"。

这两张表拆开的直接原因:评分页是一个表格,每行一个指标,用户填完分数和评语后一次性提交。如果只存一个总分数,后期员工对考核结果有异议时,完全没法追溯是哪个指标被打了低分。拆开之后,明细和总分都在,查看和导出都很方便。

class AssessmentRecord(db.Model): id = db.Column(db.Integer, primary_key=True) batch_id = db.Column(db.Integer, db.ForeignKey('assessment_batch.id')) employee_id = db.Column(db.Integer, db.ForeignKey('user.id')) self_score = db.Column(db.Float) # 自评总分 manager_score = db.Column(db.Float) # 领导评分总分 final_score = db.Column(db.Float) # 加权后的最终得分 status = db.Column(db.String(16), default='pending') self_assessed = db.Column(db.Boolean, default=False) manager_assessed = db.Column(db.Boolean, default=False) items = db.relationship('ScoreDetail', backref='record', cascade='all, delete-orphan') class ScoreDetail(db.Model): id = db.Column(db.Integer, primary_key=True) record_id = db.Column(db.Integer, db.ForeignKey('assessment_record.id')) item_id = db.Column(db.Integer, db.ForeignKey('performance_item.id')) score = db.Column(db.Float, nullable=False) comment = db.Column(db.Text)

final_score 是在批次关闭时根据权重算出来写回存储的,而不是每次展示都现算。这么做的原因是统计报表要按部门平均、按批次排名,如果每次都从明细表聚合,SQL 会越写越复杂,性能也不划算。反正数据量不大,在批次状态变成 closed 时统一计算一次,逻辑清晰,查询也快。

4. Flask后端:登录权限和评分流程是重头戏

4.1 用session做一套轻量登录认证

权限设计我没有引入 Flask-Login,而是手写了一个 session 认证,原因是这个系统角色固定、逻辑简单,手写反而更容易理解和定制。登录成功之后,把 user_id 塞进 session,其他接口靠装饰器判断当前登录人是谁、是什么角色。

from functools import wraps from flask import session, redirect, url_for, abort def require_login(fn): @wraps(fn) def wrapper(*args, **kwargs): if 'user_id' not in session: return redirect(url_for('auth.login')) return fn(*args, **kwargs) return wrapper def require_manager(fn): @wraps(fn) def wrapper(*args, **kwargs): if 'user_id' not in session: return redirect(url_for('auth.login')) user = User.query.get(session['user_id']) if user.role not in ('admin', 'manager'): abort(403) return fn(*args, **kwargs) return wrapper

这里有一个特别值得注意的点:装饰器只是拦截入口,真正要防的是"越权访问"。比如员工 A 不能看到员工 B 的评分记录。这个校验不能只靠前端隐藏按钮,服务端每次查询和写入时都要带上部门或身份的条件。第一版我在这里吃过亏,后面复盘会专门讲。

4.2 评分接口:校验、幂等、权重计算

评分是这个系统里业务逻辑最重的一环。员工先提交自评,经理再提交领导评分。提交时接口要做四件事:确认批次处于 active 状态、确认当前登录人有权限评这个员工、校验每条分数没有超过指标满分、把 submit 的数据写入明细表和记录表。

分数校验不能偷懒。前端虽然用 HTML 的 max 属性做了限制,但还是有人会绕过,所以后端必须再校验一遍。

@app.route('/assessment/submit', methods=['POST']) @require_login def submit_scores(): data = request.form batch = AssessmentBatch.query.get(data.get('batch_id')) if not batch or batch.status != 'active': flash('当前批次不在评分开放期', 'danger') return redirect(url_for('assessment.list')) target_user = User.query.get(data.get('employee_id')) if not target_user: abort(404) # 权限校验:经理只能评本部门员工,员工只能自评 current_user = get_current_user() if current_user.role == 'manager' and target_user.department_id != current_user.department_id: abort(403) if current_user.role == 'employee' and target_user.id != current_user.id: abort(403) # 校验分数范围 item_ids = data.getlist('item_id') scores = data.getlist('score') for item_id, score in zip(item_ids, scores): item = PerformanceItem.query.get(item_id) if float(score) < 0 or float(score) > item.max_score: flash('分数超出范围') return redirect(...) # 写入记录 ...

幂等处理用的是"先查再更新":提交之前先查 assessment_records 里有没有这条记录,有就更新 status 和明细,没有再插入。这里要小心多线程同时提交的问题,好在内网系统并发极低,我用一个事务包住整个写入块,再加上唯一约束(batch_id + employee_id + assessor_type)就能兜底。

4.3 统计报表的查询与导出

统计报表实现起来不算难,但查询方式要有讲究。按部门汇总平均分,我用一条简单的 SQLAlchemy 查询加 group_by 完成。

from sqlalchemy import func result = db.session.query( Department.name, func.avg(AssessmentRecord.final_score) ).select_from(AssessmentRecord).join(User, AssessmentRecord.employee_id == User.id) \ .join(Department, User.department_id == Department.id) \ .filter(AssessmentRecord.batch_id == batch_id) \ .group_by(Department.id).all()

导出 Excel 或者 CSV 也很简单,CSV 用 Python 自带的 csv 库就能写。我这次先做了 CSV 导出,因为内网用户普遍装了 Excel,CSV 打开没有任何门槛。导出的时候记得用response.headers['Content-Disposition']带上 UTF-8 编码的 BOM,否则中文会乱码。

5. 前端页面:Jinja2模板和Bootstrap的务实组合

5.1 模板继承与页面骨架

前端我坚持一个原则:能用服务端渲染搞定的,就不要为了炫技引入前后端分离。这个系统的页面以表格、表单为主,没有复杂的实时交互,Jinja2 渲染是效率最高的方案。

base.html 定义整体骨架,包括顶部导航、左侧菜单和内容区。导航菜单根据当前登录用户的角色动态渲染:员工只看到"我的考核",经理多一个"部门评分",管理员看到完整的管理菜单。

<!doctype html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>{% block title %}绩效管理系统{% endblock %}</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/bootstrap/5.3.0/css/bootstrap.min.css"> </head> <body> <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> {% if session.get('user_id') %} <div class="navbar-nav"> {% if session.get('role') == 'admin' %} <a class="nav-link" href="{{ url_for('admin.departments') }}">部门管理</a> <a class="nav-link" href="{{ url_for('admin.batches') }}">考核批次</a> {% endif %} ... </div> {% endif %} </nav> <div class="container mt-3"> {% with messages = get_flashed_messages() %} ... {% endwith %} {% block content %}{% endblock %} </div> </body> </html>

模板继承最大的好处是每个页面只需要写自己独有的那部分,公共的东西不会重复。我后面加页面的时候,基本就是复制一个模板文件改 block 内容,效率很高,也避免了改导航要动所有页面的问题。

5.2 评分页:表格加防重复提交

评分页是前端最重要的页面。员工自评和经理评分共用一个模板,但根据角色显示不同的只读/可编辑状态。页面结构是一张表,表头是"指标名称、满分、权重、得分、评语",每一行是一个指标。数据从后端视图函数传进来,模板里用 for 循环渲染。

提交按钮的防重复处理也很关键。评分这种事,用户很容易点两下或者刷新页面又提交一次,如果后端幂等没做好,就会出现两条评分记录。除了后端"先查再更新",前端也可以在点击后把按钮设成 disabled,等到提交成功提示再恢复,体验会好很多。

5.3 数据展示:表格优先,图表按需加

绩效汇总页面,我第一时间想到的不是堆图表,而是先做好表格。管理员要的是 "某个季度每个部门平均分、最高分、最低分、参与人数",这些用一张表就能表达得明明白白。等表格逻辑稳定之后,我再用 ECharts 加了一个部门平均分的柱状图,作用纯粹是让老板看得更直观,不是技术需求,而是沟通需求。

这里给个建议:先用纯表格把报表需求跑通,图表放到最后按需增加,千万不要一上来就引一堆图表库。很多管理系统最后图表面板美化了半天,反而连最基本的数据导出都没做利索,这是本末倒置。

6. 从本地到上线:部署细节和踩坑记录

6.1 本地开发环境快速跑起来

项目开发阶段,本地跑 Python + Flask 很简单,核心是把依赖装齐。requirements.txt 里把 Flask、Flask-SQLAlchemy、PyMySQL、Flask-WTF 这几个列好,然后一次性安装:

pip install -r requirements.txt python create_tables.py python run.py

create_tables.py 里就干了一件事:从 app 导入 db 和所有模型,然后db.create_all()。开发阶段用 create_all 足够,正式上线前可以再用 Flask-Migrate 做迁移,不过这个项目表结构上线后基本没动过。

我习惯在 config.py 里区分开发配置和生产配置,数据库连接串从环境变量读取。这样可以避免把生产数据库密码写死在代码里。

6.2 Gunicorn+Nginx+systemd这条成熟路线

Flask 自带的开发服务器启动时会明晃晃地打一行警告:不要在生产环境使用。内网系统也一样,规范做法是 Gunicorn 做 WSGI 服务器,Nginx 处理静态文件和反向代理,systemd 守护进程保证服务挂了自动拉起。

Gunicorn 启动命令:

gunicorn -w 2 -b 127.0.0.1:5000 run:app

-w 2 表示两个 worker,对这个系统来说绰绰有余。Nginx 的配置核心是把 / 代理到本机的 5000 端口,同时把 /static/ 直接映射到项目 static 目录:

server { listen 80; server_name perf.example.com; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static/ { alias /srv/perf/app/static/; } }

systemd 服务文件负责守护 Gunicorn 进程,日志输出到文件,方便出了问题排查。这三件套组合非常成熟,网上资料特别多,我实际遇到的坑基本都在配置细节上。

6.3 部署中实际踩过的四个坑

第一个坑是 debug 模式没关。有一次我部署完忘了把debug=True拿掉,Nginx 配置又没做对,结果用户在页面上直接看到了堆栈信息,这是既丢人又不安全的低级失误。排查方法很直接:检查启动命令里有没有--reload或者代码里 debug 参数,上线环境一定要显式写debug=False。

第二个坑是中文乱码。CSV 导出时如果不写utf-8-sig,Excel 打开就会乱麻一片。处理方式很简单:

response = make_response(csv_str) response.headers['Content-Type'] = 'text/csv; charset=utf-8' response.headers['Content-Disposition'] = 'attachment; filename=report.csv'

第三个坑是静态文件 404。Flask 开发时自带 static 路由,但走 Nginx 之后,如果 alias 路径写错或者忘记映射,CSS、JS 全部失效,页面丑陋无比。排查顺序就是先看浏览器控制台加载哪个文件报 404,再去核对 Nginx 路径和项目真实路径是否一致。

第四个坑是时区。MySQL 默认存储的是本地时间,但 Python 里如果用 datetime.now() 拿到的是系统本地时间,两个环境时区不一致的话,考核批次的起止时间会整体偏移。这次我把所有业务时间都统一用 Python 的datetime.now()写入,数据库连接串也加上?charset=utf8mb4保证字符集正确。

7. 做完之后的复盘:哪些设计让我后来省了很多事

7.1 权限校验必须放在服务端

第一版系统上线测试那天,有个普通员工直接手动改 URL 访问了/admin/users页面,看到了全公司员工列表。虽然他只是好奇,没有造成什么实际损失,但这个事故让我立刻意识到:前端隐藏菜单只是给人看的,真正的安全边界必须在服务端每个接口都要校验身份和角色。从那以后,所有涉及数据的路由我都统一加了 require_manager 或 require_login 装饰器,涉及部门数据的地方还额外检查了 department_id。

7.2 权重和分数配置要能被管理员修改

我在第二版给批次加上了 self_weight 和 manager_weight 两个字段,事实证明这个改动非常值。第一次季度考核结束后,管理层希望把自评权重从 20% 降到 10%,如果权重写死在代码里,就得改代码再重启服务。现在只需要在批次管理页面改个数字,重新关闭批次让它重算一次就行。像这种"业务参数",从一开始就就应该设计成可配置的。

7.3 状态流转要明确,宁蠢勿炫

批次状态我用简单的 draft / active / closed 三个值,不做复杂的状态机。有人可能觉得 draft 到 active 到 closed 只单向流转太简单,但它足够可靠。后来有同事建议加"允许部分退回重评"的功能,我评估之后还是暂时没加。原因是:绩效数据一旦和历史报表挂钩,反复改写会造成数据口径混乱,宁可要求管理人员在关闭批次前逐条确认,也比出一个"已关闭但又被改回去"的中间状态要好维护。

这个项目的核心链路其实不复杂,难的是把每个环节里那些容易忽略的校验、权限和状态控制做扎实。我做完之后最大的体会是:Flask 或者 Python 本身只是工具,真正决定系统好不好用的是前期的需求边界拆解和数据模型设计。希望这篇记录对准备做同类系统的人有点帮助,特别是那些第一次上手 Flask 的同学,少踩一个坑就是赚到。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询