简介简易人事管理系统基于PyQt5与MySQL实现面向初学桌面端开发与数据库联调的开发者也适合作为ERP系统中人事模块的参考方案。资源包含完整的员工、部门、薪资、登录验证等管理界面与逻辑通过Python脚本和SQL文件即可运行与二次开发。压缩包共49个文件以40个Python程序文件为主涵盖主界面、增删改查、表格展示及数据库连接等模块另含7张界面图标、1份SQL建库脚本和1个说明文档整体约295KB轻量易部署。目前已吸引2688人学习适合用来拆解PyQt5的窗体设计、信号槽机制以及MySQL数据交互思路。从内容预览看目录结构按功能拆分清晰从登录、员工管理到薪资记录均有对应实现可帮助读者快速掌握一个迷你人事系统的完整开发路径。1. 简易人事管理系统PyQt5MySQL中小团队摆脱 Excel 混乱的轻量方案先说你会在什么场景下需要它部门负责人发现员工花名册在 Excel 里被改了三版离职人员的删除逻辑全靠手工老板想要一张按部门统计的薪资表行政要加一列学历整个表格就乱掉。这种规模下没必要上重型的 SaaS 人事系统用 PyQt5MySQL 拼一个桌面工具反而是更务实的选择。这套代码是完整可运行的项目覆盖登录权限、员工档案管理、部门检索、薪资统计和离职管理五大块新人照着改就能部署上线。适合有 Python 基础的开发者做二次开发也适合课程设计阶段拿来做功能演示。2. 项目骨架与数据库表设计先看清代码是怎么分的层拿到源码包不要急着 F5先花十分钟把目录结构读一遍。PyQt5 项目的常见问题是所有代码都堆在 main.py 里这个资源刻意拆成了 ui、models、utils 三层后期维护逻辑清楚很多。2.1 目录结构和分层思路典型的目录是这样的hrms/ ├── main.py # 程序入口负责启动和异常兜底 ├── db.py # 数据库连接单例 ├── ui/ │ ├── login_window.py # 登录窗口 │ └── main_window.py # 主界面员工管理、统计、导出 ├── models/ │ ├── employee.py # 员工增删改查、搜索、分页 │ └── statistics.py # 部门统计、薪资聚合 └── utils/ ├── session.py # Session 全局对象保存登录状态 └── hashers.py # 密码哈希工具我一般会坚持这样分层UI 文件里只放 PyQt5 的控件创建与事件绑定models 层放 SQL 与业务逻辑db.py 是唯一能连接数据库的模块。新手最容易犯的毛病是直接在按钮回调里拼 SQL等模块一多参数传错一个就开始互相污染。2.2 MySQL 三张核心表的设计这套系统只围绕三个业务主体部门、员工、用户。部门表和员工表是一对多关系用户表与员工表一对一绑定。先看建表脚本CREATE DATABASE IF NOT EXISTS hrms DEFAULT CHARACTER SET utf8mb4; USE hrms; CREATE TABLE departments ( id INT AUTO_INCREMENT PRIMARY KEY, dept_name VARCHAR(50) NOT NULL UNIQUE, manager VARCHAR(20) DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE employees ( emp_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL DEFAULT 1, phone VARCHAR(11), dept_id INT NOT NULL, position VARCHAR(50), salary DECIMAL(10,2), hire_date DATE, status TINYINT NOT NULL DEFAULT 1, PRIMARY KEY (emp_no), KEY idx_dept (dept_id), KEY idx_emp_name (name), CONSTRAINT fk_dept FOREIGN KEY (dept_id) REFERENCES departments(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, password_hash VARCHAR(64) NOT NULL, password_salt CHAR(16) NOT NULL, role TINYINT NOT NULL DEFAULT 2, emp_no VARCHAR(20), CONSTRAINT fk_user_emp FOREIGN KEY (emp_no) REFERENCES employees(emp_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个容易被新手忽略的细节。员工表我用 emp_no 做业务主键而不是自增 id因为工号在业务上天然唯一比如“EMP001”这类字符串直接用做主键能避免系统里出现两个同号员工如果你用自增 id就还需要额外加一个唯一索引去兜底。phone 字段从一开始就规划为 VARCHAR(11)而不是 BIGINT。BIGINT 存手机号在两个场景下会翻车一是员工手机号里有特殊长号或将来升级到 12 位二是前端展示时想保留 011 开头的区号或 001 的前缀数值型会把前导零抹掉。外键有意识地被保留下来。当你尝试删除一个还有在职员工的部门时MySQL 会直接拒绝并报 1451 错误这就逼迫业务层先处理好员工转移或离职标记。实际使用中很多系统为了省事去掉了外键结果部门删了员工落到孤儿数据报表对不上账。2.3 数据库连接驱动选型与 db.py 单例Python 连 MySQL 的驱动日常是 pymysql、mysql-connector-python 二选一。选型包名优点缺点pymysqlpymysql纯 Python 实现pip 安装即用性能比官方 C 扩展略低mysql-connector-pythonmysql-connector-pythonMySQL 官方维护协议兼容度高包体积大部分机器存在运行时依赖问题这个项目用的是 pymysql因为典型的中小团队内部系统并发不会超过 20pymysql 的表现完全够用而且打包时体积更小。数据库连接模块写成了单例避免每次点击按钮都新建连接、用完又释放的开销# db.py import pymysql from pymysql.cursors import DictCursor class MySQLHelper: _instance None def __init__(self, hostlocalhost, port3306, userroot, password123456, databasehrms): self.conn pymysql.connect( hosthost, portport, useruser, passwordpassword, databasedatabase, charsetutf8mb4, autocommitFalse, cursorclassDictCursor ) classmethod def get_instance(cls): if cls._instance is None: cls._instance cls() return cls._instance def query(self, sql, paramsNone): with self.conn.cursor() as cur: cur.execute(sql, params or ()) return cur.fetchall() def execute(self, sql, paramsNone): with self.conn.cursor() as cur: affected cur.execute(sql, params or ()) self.conn.commit() return affected def close(self): if self.conn and self.conn.open: self.conn.close()几点说明cursorclass 设为 DictCursor这样每一行结果在 Python 里是一个 dict按字段名取值可读性和维护性都比 tuple 好。charset 必须写 utf8mb4 而不是 utf8否则表情字符或特殊生僻字入库会报错后面第 5 章会展开说。autocommitFalse 是刻意控制的目的是让“修改员工 记录日志”这类强一致的业务可以包在同一个事务里只要在业务层统一调用 execute 并捕获异常就能保证要么都成功、要么都回滚。3. 登录与员工管理PyQt5 与 MySQL 联动的核心模块3.1 登录窗口、密码哈希与权限标识登录窗口在 Qt 里就是 QLineEdit 加两个按钮。关键并不在界面上而在认证逻辑的写法。这套代码没有把密码明文存库而是用带盐的 pbkdf2_hmac 做单向哈希# utils/hashers.py import hashlib import os SALT_BYTES 16 ITERATIONS 100_000 def make_password(password: str) - tuple: salt os.urandom(SALT_BYTES) hashed hashlib.pbkdf2_hmac( sha256, password.encode(utf-8), salt, ITERATIONS ) return hashed.hex(), salt.hex() def check_password(password: str, hashed: str, salt_hex: str) - bool: salt bytes.fromhex(salt_hex) trial hashlib.pbkdf2_hmac( sha256, password.encode(utf-8), salt, ITERATIONS ) return trial.hex() hashed登录回调里拿用户名去 users 表查记录查到后调 check_password 做校验。校验通过以后把用户 id、角色写进 utils/session.py 里的全局 Session 对象。Session 对象的核心作用不是“安全认证”而是给界面层做一个权限开关——比如 role 为 1 的管理员可以看到“删除员工”按钮普通 HR 只有查询和编辑权限。新手经常在这里直接把密码做单个 MD5。MD5 的问题是攻击者可以拿彩虹表秒破而且两个同密码用户的哈希值完全一致等于把明文写在门牌号上。用 pbkdf2_hmac 迭代到 10 万次单个调用耗时在几十毫秒登录体验没有明显差别安全性却高一截。登录的边界情况也要处理连续输错三次是关闭窗口还是轻量冻结这个项目里用的是界面禁用 30 秒。实际上更好的方案是在 users 表加一个 failed_attempts 字段但那种程度是后端系统的追求桌面端做到冻结输入框就够了。3.2 员工列表加载用原生 SQL 替代 QSqlTableModel很多 PyQt5 教程推荐 QSqlTableModel 或 QSqlQueryModel 做表格映射。但这套系统最终没有用它们原因是员工列表需要 JOIN departments 表拿中文部门名且还要在加载流程里做状态翻译。用 QSqlTableModel 时写 JOIN 会比较别扭回头还得靠 setQuery 去拼原始 SQL既然最终都是拼 SQL不如直接用 pymysql 取数据然后塞进 QStandardItemModel控制力更强、代码也更直观。# models/employee.py from PyQt5.QtGui import QStandardItemModel, QStandardItem from db import MySQLHelper def load_employee_model(keyword): db MySQLHelper.get_instance() if keyword.strip(): sql SELECT e.emp_no, e.name, d.dept_name, e.position, e.salary, e.status FROM employees e LEFT JOIN departments d ON e.dept_id d.id WHERE e.name LIKE %s OR e.emp_no LIKE %s ORDER BY e.emp_no ASC like f%{keyword.strip()}% rows db.query(sql, (like, like)) else: sql SELECT e.emp_no, e.name, d.dept_name, e.position, e.salary, e.status FROM employees e LEFT JOIN departments d ON e.dept_id d.id ORDER BY e.emp_no ASC rows db.query(sql) model QStandardItemModel(len(rows), 6) model.setHorizontalHeaderLabels([工号, 姓名, 部门, 职位, 薪资, 状态]) status_map {1: 在职, 0: 离职} for row_idx, rec in enumerate(rows): model.setItem(row_idx, 0, QStandardItem(rec[emp_no])) model.setItem(row_idx, 1, QStandardItem(rec[name])) model.setItem(row_idx, 2, QStandardItem(rec[dept_name])) model.setItem(row_idx, 3, QStandardItem(rec[position])) model.setItem(row_idx, 4, QStandardItem(f{rec[salary]:.2f})) model.setItem(row_idx, 5, QStandardItem(status_map.get(rec[status], 未知))) return model这里要重点看的是 LIKE 查询的参数化写法。我在 WHERE 里用了%s占位符配合 pymysql 的参数绑定而不是把 keyword 直接拼进 SQL。直接拼接的后果是搜索词里出现单引号或分号时MySQL 会直接把它当作 SQL 执行属于经典的注入漏洞。# 错误示范工程上千万不要这么用 sql fSELECT * FROM employees WHERE name LIKE %{keyword}%3.3 新增、编辑与删除员工表单校验和事务新增员工在界面层是一个 QDialog里面放 QLineEdit 和 QComboBox。提交时先把表单数据聚合成一个 dict然后调 models 层的方法# models/employee.py def add_employee(emp_no, name, gender, phone, dept_id, position, salary, hire_date): db MySQLHelper.get_instance() sql INSERT INTO employees (emp_no, name, gender, phone, dept_id, position, salary, hire_date, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 1) try: return db.execute(sql, (emp_no, name, gender, phone, dept_id, position, salary, hire_date)) except pymysql.err.IntegrityError as exc: if exc.args[0] 1062: # duplicate entry raise ValueError(工号已存在请检查后重试) if exc.args[0] 1451: # foreign key constraint fails raise ValueError(部门不存在或已被停用) raise在 UI 回调里捕获 ValueError 并弹 QMessageBox.warning。注意这里没有在每个按钮里分别写连接和提交逻辑而是统一走 models 层方便复现和测试。性别在库里存的是 TINYINT 0/1界面上显示出来的却是“男/女”我在新增面板里用 QComboBox 做映射提交前把 text 转成数字反过来编辑时要把数字填回 ComboBox 的当前索引。很多二次开发的读者在这一步会直接出问题——setCurrentText(男) 在 ComboBox 里找不到对应 item 并不报错只有肉眼确认。一个建议是统一封装两个方法gender_to_text(code)和text_to_gender(text)所有界面调用同一对函数就不会出现这个窗口显示男、下一个窗口显示 1 的割裂。3.4 刷新后选中行丢失与排序状态重置员工列表变更是高频动作每次新增完都要重新 load。问题来了刷新后用户正在编辑的位置被顶掉了数据改完了还要重新翻页找人。常规做法是刷新前记录当前选中行的主键 emp_no刷新完成后重新定位def refresh_and_restore(self): selected self.tableView.selectionModel().currentIndex() if selected.isValid(): emp_no self.model.index(selected.row(), 0).data() else: emp_no None new_model load_employee_model(self.search_keyword.text()) self.tableView.setModel(new_model) if emp_no is not None: for r in range(new_model.rowCount()): if new_model.index(r, 0).data() emp_no: self.tableView.selectRow(r) self.tableView.scrollTo(new_model.index(r, 0)) break这一段的逻辑很简单但几乎是我见过二次开发代码里缺失最多的环节。没有 restore 的话每次编辑完表格都跳回第一行用户在 500 条员工数据的场景里操作体验会很糟。这种细节往往影响整个系统的可用性评价。4. 统计与检索把人事数据变成决策可用的数字4.1 部门人数与薪资汇总的 SQL 写法统计模块是这套系统的加分项。先看 SQLSELECT d.dept_name, COUNT(e.emp_no) AS emp_count, SUM(e.salary) AS total_salary, ROUND(AVG(e.salary), 2) AS avg_salary FROM employees e INNER JOIN departments d ON e.dept_id d.id WHERE e.status 1 GROUP BY e.dept_id, d.dept_name ORDER BY emp_count DESC;这段 SQL 在 models/statistics.py 里被封装为一个函数返回 list of dict界面层接住之后塞进 QTableWidget 展示。需要注意的是 INNER JOIN 与 LEFT JOIN 的选择这里只统计“在职”员工所以 INNER JOIN 天然过滤掉已经没有实际人员的空部门如果你的报表需要把“空部门也展示出来”这种边界需求就得改成 LEFT JOIN并在 SELECT 里用 IFNULL(SUM(e.salary), 0) 兜底。界面展示上我建议先放一个 QTableWidget 展示数字表格再做“按部门人数占比”的图形化展示。不少读者想直接上 QChart 的饼状图就可以用如下代码把它放出来# ui/main_window.py 中的片段 from PyQt5.QtChart import QChart, QChartView, QPieSeries def show_dept_pie(self, dept_stats): series QPieSeries() for rec in dept_stats: if rec[emp_count] 0: series.append(f{rec[dept_name]} ({rec[emp_count]}人), rec[emp_count]) chart QChart() chart.addSeries(series) chart.legend().setVisible(True) chart.legend().setAlignment(Qt.AlignBottom) view QChartView(chart) view.setRenderHint(QPainter.Antialiasing) self.chart_container.setCentralWidget(view)实用性比较强但必须提醒一点QChart 的 series 数据里如果混入 None 或 NaN图会在运行时异常退出所以从 MySQL 取出的数据先做类型转换确保 emp_count 是 int。4.2 分页加载和关键字搜索随着员工数量增长到几千一次性把所有行塞进 QStandardItemModel 就会带来肉眼可见的卡顿。真正的原生问题不在 Qt而在 SQL 的LIMIT/OFFSET分页写法。这个项目实现了 page 参数def load_page(self, page1, page_size20, keyword): offset (page - 1) * page_size db MySQLHelper.get_instance() like f%{keyword.strip()}% rows db.query( SELECT e.emp_no, e.name, d.dept_name, e.position, e.salary, e.status FROM employees e LEFT JOIN departments d ON e.dept_id d.id WHERE e.name LIKE %s OR e.emp_no LIKE %s ORDER BY e.emp_no ASC LIMIT %s OFFSET %s , (like, like, page_size, offset) ) total db.query( SELECT COUNT(*) AS cnt FROM employees WHERE name LIKE %s OR emp_no LIKE %s, (like, like) )[0][cnt] return rows, total分页的关键是每次翻页都要重新执行 COUNT 语句因为上一页数据可能在这期间被另一个客户端修改过。如果省略这一步页面上“共 5 页”的显示就可能对不上实际数据量。pymysql 的 LIMIT 和 OFFSET 都支持参数化可以放心绑定。分页控件在 Qt 里没有现成的组件常见实现是拿 QSpinBox 加“上一页/下一页”两个按钮自己拼。务必在翻页函数里重新获取总记录数并更新 SpinBox 的最大值否则用户会从第 1 页一路点“下一页”直到超出最大页才报错。4.3 导出 Excel 与一键备份统计完数据下一步往往是把报表丢给领导。该资源用 pandas openpyxl 导出import pandas as pd def export_employees_to_excel(file_path): db MySQLHelper.get_instance() sql SELECT e.emp_no, e.name, d.dept_name, e.position, e.salary FROM employees e LEFT JOIN departments d ON e.dept_id d.id WHERE e.status 1 df pd.read_sql(sql, db.conn) df.columns [工号, 姓名, 部门, 职位, 薪资] df.to_excel(file_path, indexFalse, engineopenpyxl)pd.read_sql 直接把 pymysql 的 connection 对象接进去比逐行 fetch 再排版方便很多。导出后可以在回调里用 QDesktopServices.openUrl 直接打开文件位置。备份功能我一般不建议在 GUI 线程里调用 mysqldump因为大库 dump 会阻塞界面 5 到 10 秒。更稳妥的做法是放进 QThread 里执行import subprocess def backup_db(out_path): subprocess.run([ mysqldump, -uroot, -p123456, --default-character-setutf8mb4, hrms, --result-file, out_path ], checkTrue)参数里务必带上--default-character-setutf8mb4否则导出的 SQL 文件会在源头丢失部分生僻字。5. 避坑 / 常见问题排查五个高频事故现场与解法这一章是实战里沉淀的每一个坑我都对应看过真实运行环境中的表现。5.1 现象程序放置 8 小时后再点击查询界面弹出 “Lost connection to MySQL server”原因MySQL 服务端有一个 wait_timeout 参数默认通常是 8 小时28800 秒。在这期间该连接没有任何查询操作服务器会主动断开这个空闲连接。pymysql 自身不会自动重连于是下一次 query 时直接抛异常。解决在 db.py 里给连接增加一个“探测 重连”机制def ensure_connected(self): try: self.conn.ping(reconnectTrue) except Exception: # ping 失败后重建连接 self.__init__()在每次 query 和 execute 之前调用 ensure_connected()这也是大型桌面应用常见的做法。一个小经验是不要只在程序启动时做一次 ping因为 MySQL 断开的时间点不可控所有的数据库操作入口都要先走一遍。5.2 现象新增员工时明明提交的是 “001”查询出来变成 “1”原因数据库中 emp_no 字段被定义成了 INT可能是从 MySQL Workbench 里把字符串手误选成了 int。数字类型在存储时不会保留前导零。解决把表结构改成 VARCHAR并重新导入数据ALTER TABLE employees MODIFY emp_no VARCHAR(20) NOT NULL; UPDATE employees SET emp_no LPAD(emp_no, 3, 0) WHERE LENGTH(emp_no) 3;同时在新增表单里校验工号只能输入字母和数字并限制长度。这类坑往往在系统上线一两周后有员工离职、工号进入两位数时才突然暴露。5.3 现象本地运行正常用 PyInstaller 打包后移到别的电脑上双击提示 ModuleNotFoundError: No module named pymysql原因PyInstaller 不会自动把纯 Python 包之外的动态依赖收集齐全。pymysql 虽是纯 Python但有时因为打包参数--onefile的处理方式它没有进入打包图里或者被当作隐藏导入漏掉了。解决在打包命令中显式加上 hidden-importpyinstaller -w -F main.py \ --hidden-import pymysql \ --hidden-import openpyxl \ --hidden-import pandas打包完成后在干净的虚拟机或另一台没有安装 Python 环境的机器上冒烟测试。只在原开发机运行成功并不能证明打包产物可用。5.4 现象界面上的中文显示正常但写入数据库后显示为乱码“??”原因连接时 charset 写成了 utf8。MySQL 的 utf8 字符集最多支持 3 字节编码而现代汉字及 emoji 是 4 字节utf8mb4。用 utf8 写入时包含某些特殊字符的数据会被丢弃部分字节变成问号。解决统一改为 utf8mb4。除了代码里的连接参数还建议检查 MySQL 配置文件[mysqld] character-set-server utf8mb4 collation-server utf8mb4_unicode_ci改完编码后已经乱掉的数据要先把表里的旧数据导出再在 utf8mb4 下重新导入。只改连接参数救不回已乱码的历史数据。5.5 现象新增员工很慢或者在 MySQL 命令行中看到大量 “Waiting for table metadata lock”原因员工表或部门表被一个未提交的长事务锁住了。很常见的情况是某人开启了一个事务执行了查询或修改但没有 commit 也没有 rollback就离开了。这个长事务持有的锁会阻塞其他会话的写操作。解决在代码里严格按“开启-提交-捕获异常回滚”三明治方式管理事务边界try: db.execute(INSERT INTO ..., params) db.execute(INSERT INTO log ..., log_params) db.conn.commit() except Exception: db.conn.rollback() raise另外用下面这条 SQL 排查长时间未提交的事务SELECT * FROM information_schema.INNODB_TRX;找到 trx_started 时间特别早的线程KILL 掉对应的连接后锁就释放了。之前遇到过一次测试窗口开了一整天没关事务挂了一整天导致部门表一直删不掉。6. 打包与部署交到使用人手里之前的必要准备把代码跑通只是第一步真正交付给行政或人事使用时还得把可执行文件打到目标机器上。6.1 PyInstaller 打包命令与参数说明下面是针对 Windows 桌面场景下比较稳妥的打包参数pyinstaller -w -F main.py \ --name Hrms \ --hidden-import pymysql \ --hidden-import openpyxl \ --hidden-import pandas \ --collect-all PyQt5.QtChart-w去掉控制台窗口-F打包成单文件。--collect-all PyQt5.QtChart是为了把 QtChart 的插件和加载器一起收进去如果漏了这一步在目标机器上打开统计图表时会报找不到相关插件。打包完成后检查 dist 目录下的文件大小一般 30-60MB 属于正常区间。如果体积异常小大概只有 5MB多半是 Qt 的插件目录没被带进包启动时会白屏或直接闪退。6.2 交付前对 MySQL 连接参数的最终配置桌面软件和 Web 服务不一样它会长时间挂在后台。建议交付前把 MySQL 的几个超时参数调大一些[mysqld] wait_timeout 28800 interactive_timeout 28800 max_connections 200这几个参数配置的是 MySQL 服务端不随程序打包。如果部署机器上的 MySQL 由其他同事统一管理请提前沟通好否则又会连续触发第 5 章那个“闲置一段时间后提示连接断开”的问题。最后我建议拿到这套源码后的第一次二次开发别急着改界面先去跑通登录、建好库、造好数据。能在真实数据库上跑通核心链路之后你遇到的技术问题就不再是“程序逻辑怎么写”这种宽泛问题而是能直接定位到某个模块、某个字段、某个参数。每改完一个功能从头走一遍“登录→新增员工→按部门统计→导出”二十步回归流程。这已经是我每次交付桌面系统都强制执行的一套流程了几次大翻车后养成的习惯希望帮到你。本文还有配套的精品资源点击获取