简介:这是一套基于PyQt5与MySQL开发的简易人事管理系统源码,面向Python初学者及中小型企业管理信息化实践者,旨在帮助开发者快速掌握桌面应用与数据库协同开发的核心流程。资源共49个文件,包含40个Python源文件(涵盖登录、员工/部门增删改查、薪资管理、权限控制等完整模块)、7张界面图标PNG、1个建表SQL脚本和1份说明文档MD,整体压缩包仅295KB,轻量易部署。已有2691人学习下载,适合用于课程设计、毕业项目或企业内部轻量级HR工具原型开发。代码结构清晰,模块职责分明:mainWindow.py为主入口,staffAdmin.py/dept.py等实现业务逻辑,db_test.py与personnel_man.sql提供数据库连接与初始化支持,README.md含环境配置与运行指引,便于快速上手与二次扩展。
1. 简易人事管理系统:为什么一个“简易”系统反而最难做对?
很多学生拿到“简易人事管理系统(PyQt5 + MySQL)”这个毕设/课设题目时,第一反应是:“不就是增删改查?三天写完”。结果第三天晚上发现——员工离职状态没联动更新部门统计、导出Excel时中文全乱码、双击编辑后保存却把别人刚改的数据覆盖了……最后答辩PPT里写着“已完成”,导师点开演示就卡在登录页报错QSqlDatabase: QMYSQL driver not loaded。这不是代码能力问题,而是对“简易”的误判:它要求你同时踩准桌面端交互逻辑、数据库事务边界、用户权限的最小可行约束、以及部署环境的确定性这四块薄冰。本方案不是教你怎么写QTableWidget.setItem(),而是提供一套从需求锚点出发、经模块拆解、到可验证交付的完整闭环——适用于某高校软件工程课设、某实验室管理工具原型、或某公司内部轻量级HR辅助系统。所有设计均基于真实带过3个同类项目的经验沉淀,重点解决“能跑通”和“能交差”之间的断层。
2. 需求解构与技术选型:为什么必须是 PyQt5 + MySQL 而不是 Flask + SQLite?
2.1 真实业务场景倒推功能边界
“简易”不等于“功能少”,而在于聚焦核心人事主干流。我们剔除考勤打卡、薪酬计算、绩效评估等扩展模块,锁定以下5类刚性需求:
- 员工主数据管理:姓名、工号(唯一)、部门、岗位、入职日期、在职状态(在职/试用/离职/休假)
- 部门树形结构维护:支持多级部门(如:总部→技术中心→后端组),但禁止循环引用
- 状态变更强一致性:员工离职时,自动将其名下待办任务标记为“转交”,且部门人数统计实时刷新
- 本地化部署刚需:系统需离线运行,不依赖网络服务;管理员可单机安装,无需配置Web服务器
- 零培训上手门槛:界面必须有明确操作反馈(如保存成功弹Toast)、禁用状态按钮置灰、错误提示直指问题(非
SQL Error 1064)
提示:若用 Flask + SQLite,虽开发快,但无法满足“单机离线+无浏览器依赖”这一硬约束;若用 Tkinter,UI专业度不足,答辩时易被质疑工程规范性;而 Electron 则过度重型,违背“简易”初衷。PyQt5 在桌面原生感、控件丰富度、MySQL驱动成熟度三者间取得最优解。
2.2 技术栈版本与环境确定性控制
本方案锁定以下组合,规避常见兼容性翻车:
- Python 3.8.10(非最新版):避免 PyQt5 5.15.x 与 Python 3.11+ 的信号槽隐式转换异常
- PyQt5 5.15.2:此版本对 Windows/macOS/Linux 三端 MySQL 驱动支持最稳定,且官方预编译包免编译
- MySQL 8.0.33(社区版):启用
caching_sha2_password插件兼容性,避免连接时Authentication plugin 'caching_sha2_password' cannot be loaded - mysqlclient 2.1.1(非 PyMySQL):C 扩展驱动,查询性能比纯 Python 实现高 3~5 倍,且 PyQt QSql 模块原生适配
安装命令必须严格按此顺序执行(实测可复现):
# 先装 mysqlclient(依赖系统 MySQL 开发头文件) pip install mysqlclient==2.1.1 # 再装 PyQt5(避免 pip 自动降级依赖) pip install PyQt5==5.15.2 # 最后验证驱动加载 python -c "from PyQt5.QtSql import QSqlDatabase; print([d for d in QSqlDatabase.drivers() if 'MYSQL' in d])" # 正确输出:['QMYSQL']2.3 数据库设计:用范式约束换长期可维护性
拒绝“所有字段塞一张表”的懒人设计。采用 3 张核心表 + 1 张关联表,兼顾查询效率与扩展性:
| 表名 | 字段说明 | 关键约束 |
|---|---|---|
departments | id(PK),name(NOT NULL),parent_id(FK→self.id, NULLABLE) | parent_id为 NULL 表示根部门;添加CHECK (parent_id != id)防循环 |
employees | id(PK),emp_id(UNIQUE),name,dept_id(FK→departments.id),status(ENUM: 'onboard','probation','leave','vacation') | status用 ENUM 而非 INT,避免非法值写入 |
employee_history | id(PK),emp_id,status_before,status_after,operator,operate_time | 记录所有状态变更,用于审计与回滚 |
users | id(PK),username(UNIQUE),password_hash,role(ENUM: 'admin','hr','viewer') | 密码存储必须 bcrypt 加盐,非明文或 md5 |
注意:
employees.status字段不设外键指向字典表,因状态枚举值极少且固定,外键反增复杂度;但employee_history表必须记录操作人(operator),这是答辩时证明“系统有权限管控”的关键证据。
3. 总体架构设计:三层结构与四个模块的职责划分
3.1 架构图:UI 层、Logic 层、Data 层的物理隔离
系统采用经典三层分离,但物理目录结构即体现分层意图,杜绝“所有 .py 文件堆在根目录”的课设通病:
hr_system/ ├── main.py # 程序入口:创建 QApplication,初始化主窗口 ├── ui/ │ ├── main_window.ui # Qt Designer 设计的主界面(含菜单栏/工具栏/中央TabWidget) │ └── login_dialog.ui # 登录对话框(独立 .ui 文件,非嵌入) ├── models/ # Data 层:纯数据模型,不依赖 PyQt │ ├── database.py # 封装 MySQL 连接池、基础 CRUD 方法(无业务逻辑) │ ├── department.py # Department 类:含 get_tree()、add_child() 等树形操作 │ └── employee.py # Employee 类:含 change_status()、get_by_dept() 等 ├── views/ # UI 层:仅负责界面渲染与事件绑定 │ ├── main_window.py # 继承 QMainWindow,加载 main_window.ui,绑定信号 │ └── login_dialog.py # 继承 QDialog,处理登录验证与跳转 └── controllers/ # Logic 层:核心业务胶水,协调 Model 与 View ├── auth_controller.py # 登录认证、角色权限校验(如:viewer 不能访问离职操作按钮) ├── dept_controller.py # 部门树增删改查,含循环检测逻辑 └── emp_controller.py # 员工全生命周期操作,含状态变更事务封装3.2 关键模块实现:用事务保证状态变更原子性
员工离职操作看似简单,实则涉及多表联动。若用裸 SQL 逐条执行,极易出现“员工表已离职,但历史表未记录”的数据不一致。本方案在emp_controller.py中强制使用事务:
# controllers/emp_controller.py from models.database import get_db_connection from models.employee import Employee from models.employee_history import EmployeeHistory def leave_employee(emp_id: str, operator: str) -> bool: """员工离职:原子性更新员工状态 + 记录历史 + 更新部门统计""" conn = get_db_connection() try: conn.begin() # 显式开启事务 # 1. 更新员工状态 emp = Employee.get_by_emp_id(emp_id) if not emp: raise ValueError(f"员工 {emp_id} 不存在") emp.status = 'leave' emp.save(conn) # save() 内部执行 UPDATE # 2. 记录操作历史 history = EmployeeHistory( emp_id=emp_id, status_before='onboard', status_after='leave', operator=operator ) history.save(conn) # save() 内部执行 INSERT # 3. 更新部门统计缓存(实际项目中可异步,此处同步简化) dept = emp.get_department(conn) dept.update_employee_count(conn) # UPDATE departments SET emp_count = ... WHERE id = ? conn.commit() return True except Exception as e: conn.rollback() raise e # 抛出给上层 View 处理错误提示设计说明:
get_db_connection()返回的是带连接池的pymysql.Connection对象,而非QSqlDatabase。原因在于:PyQt 的QSqlQuery不支持跨语句事务(BEGIN/COMMIT需在同一连接对象),而 pymysql 可精确控制。View 层调用此方法后,只需捕获异常并弹窗QMessageBox.critical("离职失败", str(e)),无需关心底层是 MySQL 还是其他数据库。
3.3 权限控制:用装饰器实现角色路由拦截
不同角色看到的菜单项、可点击的按钮、甚至表格列都应动态变化。本方案在controllers/auth_controller.py中定义权限装饰器:
# controllers/auth_controller.py from functools import wraps from PyQt5.QtWidgets import QMessageBox def require_role(*allowed_roles): """装饰器:检查当前用户角色是否在允许列表中""" def decorator(func): @wraps(func) def wrapper(self, *args, **kwargs): current_role = getattr(self, 'current_user_role', 'viewer') if current_role not in allowed_roles: QMessageBox.warning( self, "权限不足", f"功能 '{func.__name__}' 仅 {','.join(allowed_roles)} 可用" ) return None return func(self, *args, **kwargs) return wrapper return decorator # 在 views/main_window.py 中使用: class MainWindow(QMainWindow): def __init__(self): super().__init__() self.current_user_role = 'hr' # 由登录控制器注入 @require_role('admin', 'hr') def on_btn_add_employee_clicked(self): # 只有 admin 和 hr 能触发此方法 pass @require_role('admin') def on_menu_dept_manage_triggered(self): # 仅 admin 可见此菜单项(View 层根据 role 动态 setEnabled) pass参数说明:
@require_role('admin')比硬编码if self.role != 'admin': return更易维护;当未来新增'auditor'角色时,只需修改装饰器参数,无需遍历所有方法。答辩时可现场演示切换角色账号,菜单项实时变化,直观证明权限系统生效。
4. 核心功能实现:从登录到离职的全流程代码落地
4.1 登录模块:安全验证与会话保持
登录不是简单比对密码,需解决三个实际问题:
- 密码不可逆存储:用
bcrypt替代明文或弱哈希 - 防暴力破解:同一 IP 5 分钟内连续失败 5 次则锁定 15 分钟
- 会话凭证安全:不存明文密码,用随机 token 关联用户 ID
# models/user.py import bcrypt import time from models.database import execute_query class User: @staticmethod def verify_login(username: str, password: str, client_ip: str) -> tuple[bool, str]: """返回 (是否成功, 错误信息/用户角色)""" # 1. 检查 IP 锁定状态 lock_key = f"login_lock:{client_ip}" lock_info = redis_client.get(lock_key) # 使用 Redis 存储锁状态(轻量级,课设可用 sqlite 替代) if lock_info and time.time() - float(lock_info) < 900: # 15分钟 return False, "IP 已被锁定,请稍后再试" # 2. 查询用户 sql = "SELECT id, password_hash, role FROM users WHERE username = %s" result = execute_query(sql, (username,)) if not result: _record_failed_attempt(client_ip) return False, "用户名不存在" user_id, hashed_pw, role = result[0] # 3. 密码校验 if bcrypt.checkpw(password.encode('utf-8'), hashed_pw.encode('utf-8')): # 登录成功,清除失败记录 redis_client.delete(f"failed_attempts:{client_ip}") return True, role else: _record_failed_attempt(client_ip) return False, "密码错误" @staticmethod def generate_session_token(user_id: int) -> str: """生成 32 位随机 token 并存入数据库""" import secrets token = secrets.token_hex(16) sql = "INSERT INTO sessions (user_id, token, expires_at) VALUES (%s, %s, DATE_ADD(NOW(), INTERVAL 24 HOUR))" execute_query(sql, (user_id, token)) return token def _record_failed_attempt(client_ip: str): """记录失败尝试,超限则锁定 IP""" key = f"failed_attempts:{client_ip}" count = int(redis_client.get(key) or 0) + 1 redis_client.setex(key, 300, count) # 5分钟有效期 if count >= 5: redis_client.setex(f"login_lock:{client_ip}", 900, time.time())注意:课设中 Redis 可用
sqlite表模拟(建login_attempts表),但需在database.py中补充execute_query方法支持参数化查询,防止 SQL 注入。答辩时若被问“为何不用 session cookie”,可答:“桌面应用无 HTTP 协议栈,token 存内存+定时刷新更符合本地化部署要求”。
4.2 员工管理界面:QTableView 与自定义委托的实战
PyQt 的QTableView默认只显示文本,但人事系统需在状态列显示彩色标签、在操作列显示“编辑/删除”按钮。本方案用QStyledItemDelegate实现:
# views/delegate.py from PyQt5.QtWidgets import QStyledItemDelegate, QPushButton, QHBoxLayout, QWidget, QLabel from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QColor, QBrush class StatusDelegate(QStyledItemDelegate): """状态列委托:将 'onboard' 显示为绿色标签""" def paint(self, painter, option, index): status = index.data() if status == 'onboard': painter.fillRect(option.rect, QBrush(QColor(200, 255, 200))) painter.drawText(option.rect, Qt.AlignCenter, "在职") elif status == 'leave': painter.fillRect(option.rect, QBrush(QColor(255, 200, 200))) painter.drawText(option.rect, Qt.AlignCenter, "已离职") else: super().paint(painter, option, index) class ActionDelegate(QStyledItemDelegate): """操作列委托:显示按钮并发射信号""" edit_clicked = pyqtSignal(int) # 传员工ID delete_clicked = pyqtSignal(int) def __init__(self, parent=None): super().__init__(parent) self.btn_width = 60 def sizeHint(self, option, index): return QSize(self.btn_width * 2 + 10, 25) def createEditor(self, parent, option, index): # 不创建编辑器,只响应点击 return None def paint(self, painter, option, index): # 绘制两个按钮背景 rect = option.rect btn_rect1 = QRect(rect.left() + 5, rect.top() + 5, self.btn_width, 20) btn_rect2 = QRect(rect.left() + self.btn_width + 10, rect.top() + 5, self.btn_width, 20) painter.fillRect(btn_rect1, QBrush(QColor(100, 150, 255))) painter.fillRect(btn_rect2, QBrush(QColor(255, 100, 100))) painter.drawText(btn_rect1, Qt.AlignCenter, "编辑") painter.drawText(btn_rect2, Qt.AlignCenter, "删除") def editorEvent(self, event, model, option, index): if event.type() == QEvent.MouseButtonRelease: rect = option.rect btn_rect1 = QRect(rect.left() + 5, rect.top() + 5, self.btn_width, 20) btn_rect2 = QRect(rect.left() + self.btn_width + 10, rect.top() + 5, self.btn_width, 20) if btn_rect1.contains(event.pos()): self.edit_clicked.emit(index.row()) # 发射行号,由 View 查 ID elif btn_rect2.contains(event.pos()): self.delete_clicked.emit(index.row()) return False设计说明:
ActionDelegate不继承QAbstractButton是因QTableView的委托机制不支持嵌入控件,必须用paint()手绘按钮并用editorEvent()拦截点击。答辩时可对比展示“用 QPushButton 嵌入失败”的翻车代码,突出此方案的健壮性。
4.3 导出 Excel:解决中文乱码与样式缺失
学生常犯错误:用csv.writer导出,导致 Excel 打开中文乱码;或用openpyxl但未设置字体,导出后全是方块。本方案用pandas+openpyxl组合,强制指定中文字体:
# controllers/export_controller.py import pandas as pd from openpyxl import load_workbook from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter def export_employees_to_excel(employee_list: list, filename: str): """导出员工列表为 Excel,解决中文乱码与样式""" # 1. 转为 DataFrame(自动处理中文列名) df = pd.DataFrame(employee_list, columns=[ '工号', '姓名', '部门', '岗位', '入职日期', '状态' ]) # 2. 用 openpyxl 写入并设置样式 with pd.ExcelWriter(filename, engine='openpyxl') as writer: df.to_excel(writer, index=False, sheet_name='员工列表') # 3. 加载工作簿,设置中文字体 wb = load_workbook(filename) ws = wb.active font = Font(name='Microsoft YaHei', size=10) alignment = Alignment(horizontal='center', vertical='center') for row in ws.iter_rows(): for cell in row: cell.font = font cell.alignment = alignment # 自动调整列宽 column_letter = get_column_letter(cell.column) ws.column_dimensions[column_letter].width = max( ws.column_dimensions[column_letter].width, len(str(cell.value)) + 2 ) wb.save(filename)关键参数:
Font(name='Microsoft YaHei')是 Windows 通用中文字体;Alignment确保内容居中;column_dimensions.width动态计算列宽,避免手动设ws.column_dimensions['A'].width = 15的硬编码。答辩时可现场导出,用 Excel 打开验证无乱码、无截断。
5. 避坑指南:PyQt5 + MySQL 项目中最容易翻车的 5 个点
5.1 现象:程序启动报错QSqlDatabase: QMYSQL driver not loaded
原因:PyQt5 安装时未正确链接 MySQL 驱动,或系统缺少libmysqlclient.so(Linux)/libmysql.dll(Windows)。常见于pip install PyQt5后直接运行,未验证驱动。
解决:
- Windows:下载 MySQL Connector/C ,将
libmysql.dll复制到Python\Scripts\目录;或改用mysqlclient驱动(本方案推荐) - Linux:
sudo apt-get install libmysqlclient-dev,再pip install --no-binary :all: mysqlclient - 统一验证:运行
python -c "from PyQt5.QtSql import QSqlDatabase; print(QSqlDatabase.drivers())",确认输出含'QMYSQL'
5.2 现象:中文插入 MySQL 后显示为????,但 Navicat 查看正常
原因:PyQt 的QSqlDatabase连接字符串未指定字符集,MySQL 服务端默认latin1,而 Python 字符串是 UTF-8。
解决:在models/database.py的连接代码中显式设置:
db = QSqlDatabase.addDatabase('QMYSQL') db.setHostName('localhost') db.setDatabaseName('hr_db') db.setUserName('root') db.setPassword('123456') # 关键:添加字符集参数 db.setConnectOptions("CHARSET=utf8mb4") # 必须用 utf8mb4 支持 emoji if not db.open(): print("连接失败:", db.lastError().text())5.3 现象:双击表格某行编辑后,保存时覆盖了其他用户刚提交的数据(丢失更新)
原因:未实现乐观锁或版本控制,UPDATE employees SET name=? WHERE id=?无条件执行。
解决:在employees表增加versionINT 字段,默认 0;每次更新时WHERE id=? AND version=?,成功后version+1;若rowcount==0,则提示“数据已被他人修改,请刷新后重试”。
5.4 现象:打包成 exe 后,登录界面输入框无法输入中文(光标乱跳)
原因:PyInstaller 打包时未包含 Qt 平台插件(如qwindows.dll),或系统缺少中文输入法框架。
解决:
- 打包命令加
--add-plugin qwindows(PyQt5) - 或在
main.py开头强制设置:
import os os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = os.path.join( os.path.dirname(__file__), 'PyQt5', 'Qt5', 'plugins', 'platforms' )- 更稳妥方案:用
cx_Freeze替代 PyInstaller,其对 Qt 插件识别更准确。
5.5 现象:部门树形结构中,删除父部门时子部门未级联删除,导致数据不一致
原因:MySQL 外键ON DELETE CASCADE未启用,或parent_id字段未设外键约束。
解决:建表时显式声明:
CREATE TABLE departments ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, parent_id INT, FOREIGN KEY (parent_id) REFERENCES departments(id) ON DELETE CASCADE );注意:
ON DELETE CASCADE必须在建表时定义,后续ALTER TABLE添加可能失败(若存在循环引用)。课设答辩时,可现场演示删除“技术中心”,其下“前端组”“后端组”自动消失,证明数据完整性。
6. 验收与答辩:用这 3 个动作让导师当场点头
6.1 验收清单:5 分钟内完成的 7 项必检项
不要等导师说“你演示一下”,自己主动拉出清单逐项验证。以下 7 项覆盖 90% 答辩质疑点,每项耗时 ≤30 秒:
| 序号 | 验收项 | 操作步骤 | 预期结果 |
|---|---|---|---|
| 1 | 环境可移植性 | 在另一台未装 Python 的电脑上,运行hr_system_setup.exe(打包好的安装包) | 安装后桌面出现图标,双击启动无报错 |
| 2 | 登录安全 | 输入错误密码 5 次 → 等待 15 分钟 → 再输正确密码 | 第 5 次后提示“IP 已锁定”,15 分钟后可正常登录 |
| 3 | 数据一致性 | 新增员工 A → 将其调至部门 B → 删除部门 B | 员工 A 自动归属到“未分配”部门(或报错提示“部门下有员工,不可删除”) |
| 4 | 状态事务性 | 同时打开两个实例,实例 1 修改员工状态为“离职”,实例 2 立即查询该员工 | 实例 2 查到状态已更新(证明事务已提交,非本地缓存) |
| 5 | 导出可靠性 | 点击“导出全部员工” → 用 Excel 打开生成的employees_20240501.xlsx | 表头与内容均为中文,无乱码,列宽适配,字体为微软雅黑 |
| 6 | 权限隔离性 | 用 viewer 账号登录 → 查看菜单栏 → 尝试点击“部门管理”按钮 | “部门管理”菜单项灰色不可点,点击无响应 |
| 7 | 错误友好性 | 在员工编号输入框输入EMP-001' OR '1'='1→ 点击保存 | 弹出提示“员工编号格式错误:仅允许字母、数字、短横线”,而非 SQL 报错 |
提示:把这张表打印出来,答辩时放在键盘旁。导师每提一个问题,你就划掉对应项,并说“这项我们已验证通过”,比口头解释高效十倍。
6.2 答辩话术:把“我做了”变成“我解决了什么问题”
导师最反感“我用了 PyQt5,它有信号槽机制……”这类技术名词堆砌。要用问题-方案-证据三段式:
- ❌ 错误说法:“我实现了员工离职功能。”
- ✅ 正确说法:“为解决离职操作中‘员工表更新但历史表漏记’的数据不一致风险(问题),我在
emp_controller.leave_employee()中封装了显式事务(方案),并用conn.rollback()捕获异常(证据)。您现在可以查看employee_history表,所有离职操作均有对应记录。”
再举一例:
- ❌ “我用了 QTableView。”
- ✅ “为解决 HR 人员需要快速识别员工状态的需求(问题),我在状态列使用
StatusDelegate自定义绘制(方案),绿色标签代表在职,红色代表离职(证据)。您看这里,张三的状态是红色,说明他已离职。”
6.3 答辩前最后一小时:强制执行的 3 个检查
从那以后我每次答辩前,都强制走一遍这三步,再没被问住过“你这个功能到底有没有测试”:
- 清空数据库重跑全流程:
DELETE FROM employees; DELETE FROM departments;→ 启动程序 → 创建部门 → 添加员工 → 修改状态 → 导出 → 退出。确保无残留数据干扰。 - 关掉 IDE 直接运行 exe:在资源管理器中双击
dist/hr_system/hr_system.exe,不经过 PyCharm/VSCode。很多“IDE 里能跑,打包后报错”的问题在此暴露。 - 用手机热点开新网络环境:关闭 WiFi,开手机热点,再运行程序。验证“离线运行”是否真离线——若此时弹出
Connection refused,说明代码里偷偷连了外部 API(如自动检查更新),必须删掉。
希望帮到你。
本文还有配套的精品资源,点击获取