简介:简易人事管理系统是一套基于PyQt5与MySQL构建的桌面端人事管理解决方案,面向中小企业管理者、初学者与课程设计者,覆盖员工信息、部门、职位、薪资等常用模块,可帮助理解ERP中人力资源部分的落地实现。资源共49个文件,其中Python脚本40个,承担登录认证、增删改查、界面展示与数据库交互等核心逻辑;另有1份SQL建库脚本、7张界面截图和1篇README说明,压缩包约295KB,结构清晰,适合直接导入PyCharm运行。目前已有2688人学习下载。借助该系统可掌握PyQt5界面编写与MySQL联调方法,学习表格控件、对话框、多窗口跳转及管理员权限管理等典型写法;配套数据库文件与说明文档便于快速初始化并二次开发,既能支撑课程设计、毕业设计,也可作为初学桌面数据库开发的完整参考。
1. 简易人事管理系统:PyQt5+MySQL 到底能做什么
某公司的人力专员还在用 Excel 管员工台账:几百行数据,入离职改来改去,公式一拉就串行,更别说按部门筛选或查历史记录。你找到我说想做个「简易人事管理系统」,我的建议是 PyQt5 + MySQL——不是因为它新,正因为它足够稳、足够轻,恰好卡在「比 Excel 高级、比 Web 轻量」的中间地带。
这套方案不是给大厂做 SaaS 平台,它面向的是数据量在几千条以内、要放内网或单机跑、又不想折腾 Web 服务和部署流程的团队。核心代码两三千行,一个人一到两周能落地,跑起来就是一个不依赖浏览器的桌面程序。新手可以照步骤搭出第一个能用的版本,熟手可以把连接层、线程和事务抽出来直接复用到别的桌面端项目。
2. 选型与数据建模:为什么是 PyQt5+MySQL,员工表怎么设计
2.1 桌面端选型:PyQt5 卡在「轻量」与「够用」之间
做人事管理系统,第一步不是写代码,是先决定这东西长成什么样。Web 方案要装服务器、配域名、担心接口鉴权,对一个几百人规模的小团队来说明显过重;而 Tkinter 虽然不需要额外装库,但控件少、样式丑,做个带筛选和联动的界面要多写不少代码。
PyQt5 的优势在于控件层已经帮你铺好了路:QTableWidget 直接展示二维数据,QComboBox 做部门筛选,QLineEdit 做关键字搜索,QMessageBox 做确认弹窗,这些恰好是人事台账的核心交互。信号槽机制让「用户在界面上点按钮 → 程序执行 SQL → 刷新回表格」这个循环写起来非常直接,不像在 Web 里还要处理请求路由和页面状态。
另外打包也方便。PyInstaller 对 PyQt5 的支持已经很成熟,打出来的 exe 在没装 Python 的机器上也能跑。对「给部门里其他同事用」这个需求来说,这是个很实际的加分项。
2.2 存储选型:MySQL 替代 SQLite 的理由
有人会问:SQLite 不是更省事吗?确实,SQLite 单文件、零配置,数据量小的时候跑得飞快。但人事数据有几个特征:需要同时写入(多个人事专员操作)、需要按时间回溯(入离职记录)、未来大概率要对接工资或考勤数据。SQLite 在并发写入上偏弱,备份和迁移虽然简单,但真到要导给其他系统时,反而是 MySQL 这种成熟的客户端-服务端架构更顺手。
MySQL 的安装不复杂,开发机上装一个社区版,默认端口 3306,新建一个库和专用账号就行。把账号密码写进配置文件而不是写死在代码里,后续换环境只需要改配置文件。这里说的「专用账号」很重要,不要用 root 去连业务库,权限控制是这种小系统最容易跳过的一步。
我一般会建议把连接参数独立成一份 config.py,里面只放数据库主机、端口、用户名、密码、库名,其他代码一律从这份配置里取值。这样不管是开发环境、测试环境还是同事的电脑,换库就只改一个文件。
2.3 数据库先行:员工表与部门表的设计
先建部门表还是先建员工表?答案是先建部门表,因为员工表要用外键引用它。两张表就够撑起一个简易系统的核心,不需要一上来设计七张表八张表的复杂模型。
CREATE DATABASE hr_sys DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE hr_sys; CREATE TABLE department ( dept_id INT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(64) NOT NULL UNIQUE, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE employee ( emp_id INT PRIMARY KEY AUTO_INCREMENT, emp_no VARCHAR(32) NOT NULL UNIQUE COMMENT '工号', name VARCHAR(64) NOT NULL, gender TINYINT DEFAULT 1 COMMENT '1-男 2-女', dept_id INT NOT NULL, position VARCHAR(64) COMMENT '职位', phone VARCHAR(20), email VARCHAR(128), hire_date DATE, status TINYINT DEFAULT 1 COMMENT '1-在职 0-离职', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_emp_dept FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:department 表先创建,employee 表通过 dept_id 外键关联部门。emp_no 工号字段加了唯一约束,避免同一个工号重复录入。status 字段用 0/1 表示离职和在职,这是人事系统的关键设计——员工离职不应该 DELETE 记录,而是标记状态,这样才能保留历史台账。
参数说明:字符集统一用 utf8mb4,而不是 utf8,因为后者存不了 emoji 和部分生僻姓名;updated_at 配合 ON UPDATE CURRENT_TIMESTAMP,每次更新都会自动刷新,省去业务代码里手动维护修改时间。外键约束在这个规模下不会带来性能问题,反而能挡住「删了部门但员工还挂在上面」这类脏数据。
建完表后顺手插入两条测试部门:技术部、市场部。后面所有联调都基于这两条数据。
3. 接数据进界面:pymysql 连接层与增删改查的完整链路
3.1 最小可用的连接配置:pymysql 连接串与连接池
PyQt5 只负责界面,数据库访问要靠 pymysql 这个纯 Python 驱动。它不需要编译,pip 安装就能用,是当前最省事的方案。
# db.py import pymysql DB_CONFIG = { "host": "127.0.0.1", "port": 3306, "user": "hr_app", "password": "your_password", "database": "hr_sys", "charset": "utf8mb4", "cursorclass": pymysql.cursors.DictCursor, "connect_timeout": 5 } def get_conn(): return pymysql.connect(**DB_CONFIG)逻辑说明:cursorclass 用 DictCursor,查询结果会变成字典列表,而不是元组列表,写代码时用 row["name"] 取字段,可读性高很多。connect_timeout 设 5 秒,数据库连不上时不会让界面傻等几十秒才报错。
参数说明:host 在开发时用 127.0.0.1,部署到同事电脑时改成数据库服务器 IP;密码建议只用字母数字和下划线,如果包含 @ 或 ! 这类特殊字符,在配置文件里容易遇到转义问题,别在这种小系统上给自己挖坑。
注意:每次操作都新建连接、用完就关闭,这个写法在小系统里完全够用。等到并发操作明显变多,再考虑引入连接池。这里不过早上连接池,是避免把简单系统搞复杂。
3.2 员工列表加载:从 SELECT 到 QTableWidget
主界面核心是一个 QTableWidget,加载数据的逻辑就是:查询 employee 表,把结果按行列填进表格。这段代码要放在窗口初始化后调用,不要在init里直接用阻塞查询,否则窗口会白屏等数据。
# main_window.py from PyQt5.QtWidgets import QTableWidgetItem, QTableWidget from db import get_conn class MainWindow(QMainWindow): def __init__(self): super().__init__() self.table = QTableWidget(self) self.load_data() def load_data(self): try: conn = get_conn() with conn.cursor() as cursor: sql = """ SELECT e.emp_id, e.emp_no, e.name, e.gender, d.dept_name, e.position, e.phone, e.email, e.hire_date, e.status FROM employee e LEFT JOIN department d ON e.dept_id = d.dept_id WHERE e.status = 1 ORDER BY e.emp_id DESC """ cursor.execute(sql) rows = cursor.fetchall() finally: conn.close() self.table.setRowCount(len(rows)) self.table.setColumnCount(10) self.table.setHorizontalHeaderLabels( ["ID", "工号", "姓名", "性别", "部门", "职位", "电话", "邮箱", "入职日期", "状态"] ) for i, row in enumerate(rows): self.table.setItem(i, 0, QTableWidgetItem(str(row["emp_id"]))) self.table.setItem(i, 1, QTableWidgetItem(row["emp_no"])) self.table.setItem(i, 2, QTableWidgetItem(row["name"])) self.table.setItem(i, 3, QTableWidgetItem("男" if row["gender"] == 1 else "女")) self.table.setItem(i, 4, QTableWidgetItem(row["dept_name"])) self.table.setItem(i, 5, QTableWidgetItem(row["position"] or "")) self.table.setItem(i, 6, QTableWidgetItem(row["phone"] or "")) self.table.setItem(i, 7, QTableWidgetItem(row["email"] or "")) self.table.setItem(i, 8, QTableWidgetItem(str(row["hire_date"]))) self.table.setItem(i, 9, QTableWidgetItem("在职" if row["status"] == 1 else "离职"))逻辑说明:查询里 join 了 department 表,是为了直接拿到部门名称,而不是部门 ID,这样表格里不用再写一次字典映射。WHERE status = 1 默认只显示在职员工,离职员工切到「全部」或「离职」时才显示。
参数说明:setRowCount(len(rows)) 先按查询结果的行数设置表格行数,再逐行 setItem。注意 hire_date 是 date 类型,要包一层 str();position 和 phone 可能为 NULL,用 or "" 兜底,否则 QTableWidgetItem 会收到 None 直接抛异常。
我一般会在 load_data 外面包一层 try-except,捕获数据库连接异常后弹一个 QMessageBox.warning,而不是让程序直接崩溃。这一步别省,数据库服务没启动是新人最常见的启动事故。
3.3 新增与编辑员工:表单对话框与 INSERT/UPDATE
不直接在表格里编辑,而是弹一个表单对话框,这样字段校验、必填项检查都在对话框里完成,代码结构清晰,也避免用户在表格里乱改。
from PyQt5.QtWidgets import QDialog, QFormLayout, QLineEdit, QComboBox, QDateEdit, QPushButton class EmployeeDialog(QDialog): def __init__(self, dept_list, emp_data=None, parent=None): super().__init__(parent) self.setWindowTitle("编辑员工" if emp_data else "新增员工") self.emp_data = emp_data # 编辑时传入原记录,新增时传 None form = QFormLayout(self) self.emp_no = QLineEdit() self.name = QLineEdit() self.gender = QComboBox() self.gender.addItems(["男", "女"]) self.dept = QComboBox() self.dept.addItem("请选择部门", None) for dept_id, dept_name in dept_list: self.dept.addItem(dept_name, dept_id) self.position = QLineEdit() self.phone = QLineEdit() self.email = QLineEdit() self.hire_date = QDateEdit() self.hire_date.setCalendarPopup(True) form.addRow("工号", self.emp_no) form.addRow("姓名", self.name) form.addRow("性别", self.gender) form.addRow("部门", self.dept) form.addRow("职位", self.position) form.addRow("电话", self.phone) form.addRow("邮箱", self.email) form.addRow("入职日期", self.hire_date) if emp_data: self.emp_no.setText(emp_data["emp_no"]) self.name.setText(emp_data["name"]) self.gender.setCurrentText("男" if emp_data["gender"] == 1 else "女") idx = self.dept.findData(emp_data["dept_id"]) self.dept.setCurrentIndex(idx) self.position.setText(emp_data["position"] or "") self.phone.setText(emp_data["phone"] or "") self.email.setText(emp_data["email"] or "") save_btn = QPushButton("保存") save_btn.clicked.connect(self.accept) form.addRow(save_btn) def get_data(self): return { "emp_no": self.emp_no.text().strip(), "name": self.name.text().strip(), "gender": 1 if self.gender.currentText() == "男" else 2, "dept_id": self.dept.currentData(), "position": self.position.text().strip(), "phone": self.phone.text().strip(), "email": self.email.text().strip(), "hire_date": self.hire_date.date().toString("yyyy-MM-dd"), }逻辑说明:对话框的 get_data 返回一个字典,保存按钮点击后主窗口拿到这个字典,再决定是 INSERT 还是 UPDATE。这里把「界面收集数据」和「数据落库」拆开了,主窗口不需要关心对话框内部布局。
注意点:工号、姓名在保存前要做非空校验,部门必须选到具体项(currentData 不能是 None)。这些校验放在 get_data 里做,或者放在主窗口点击保存的回调里做,看个人习惯。
配套的保存代码:
def save_employee(self, data, emp_id=None): conn = get_conn() try: with conn.cursor() as cursor: if emp_id is None: sql = """INSERT INTO employee (emp_no, name, gender, dept_id, position, phone, email, hire_date) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""" cursor.execute(sql, ( data["emp_no"], data["name"], data["gender"], data["dept_id"], data["position"], data["phone"], data["email"], data["hire_date"] )) else: sql = """UPDATE employee SET emp_no=%s, name=%s, gender=%s, dept_id=%s, position=%s, phone=%s, email=%s, hire_date=%s WHERE emp_id=%s""" cursor.execute(sql, ( data["emp_no"], data["name"], data["gender"], data["dept_id"], data["position"], data["phone"], data["email"], data["hire_date"], emp_id )) conn.commit() except Exception: conn.rollback() raise finally: conn.close()逻辑说明:这里的关键点是所有用户输入都通过 %s 占位符传参,而不是拼字符串。拼字符串的写法在员工姓名里出现单引号时直接 SQL 报错,更危险的是存在注入风险。人事系统里员工随时可能输入各种字符,参数化是底线。
3.4 删除员工:逻辑删除与主从表约束的处理
删除不是 DELETE,而是把 status 从 1 改成 0。
def delete_employee(self, emp_id): conn = get_conn() try: with conn.cursor() as cursor: cursor.execute( "UPDATE employee SET status = 0 WHERE emp_id = %s", (emp_id,) ) conn.commit() except Exception: conn.rollback() raise finally: conn.close()逻辑说明:这样删除后,历史数据还在库里,只是默认查询里看不见了。如果以后要做「已离职员工列表」或者统计离职率,直接查 status = 0 就行,这是人事系统里隐蔽的需求,一开始做 DELETE 的后面都会返工。
参数说明:employee 表的外键指向 department,如果某个部门下还有在职员工,直接 DELETE 部门会触发外键约束报错。这个方案里根本不用 DELETE 部门表,只需要给 department 也加一个 status 字段,思路一致。
4. 表格刷新与筛选:QTableWidget 在人事系统里的正确用法
4.1 刷新不是 clearContents:setRowCount 与 repaint 的边界
表格刷新的误用很常见。很多人刷新时先调用 clearContents(),结果发现表头还在、行数也还在,但数据没了,再往里面 setItem 时行数对不上,出现一行数据下面全是空白。
原因很简单:clearContents 只清空单元格内容,不清除行列结构。正确做法是先用 setRowCount(0) 把行数归零,再重新填入新数据,或者直接按新查询结果的行数 setRowCount。
def refresh_table(self, rows): self.table.setRowCount(0) # 先把旧数据行清掉 self.table.setRowCount(len(rows)) # 再按新结果设置行数 self.table.setHorizontalHeaderLabels([...]) for i, row in enumerate(rows): ...逻辑说明:setRowCount(0) 这一步让表格结构和数据一起重置,再 setRowCount(len(rows)) 重新铺行。如果查询结果为空,表格会显示成只有表头的空状态,而不是残留上次的数据。
实际使用时我还会在刷新前加 self.table.setSortingEnabled(False),因为如果用户点了表头排序,表格内部会把行顺序打乱,这时再去 setItem 会出现数据错位。刷新完再恢复 self.table.setSortingEnabled(True)。
4.2 搜索与筛选:LIKE 参数化的正确写法
搜索框的作用是按下回车或点击搜索时,把 QLineEdit 里的关键字拼进 SQL。
def search_employee(self, keyword): conn = get_conn() try: with conn.cursor() as cursor: sql = """ SELECT e.emp_id, e.emp_no, e.name, d.dept_name, e.position, e.phone FROM employee e LEFT JOIN department d ON e.dept_id = d.dept_id WHERE e.status = 1 AND (e.name LIKE %s OR e.emp_no LIKE %s OR e.phone LIKE %s) ORDER BY e.emp_id DESC """ like_kw = f"%{keyword}%" cursor.execute(sql, (like_kw, like_kw, like_kw)) rows = cursor.fetchall() return rows finally: conn.close()逻辑说明:关键字是拼到 %s 参数里的,三个字段共用同一个参数值。注意 LIKE 里的 % 写在参数里,而不是写在 SQL 语句里,这是参数化的惯例,避免关键字里本身带 % 或 _ 时被当成通配符处理。
参数说明:如果希望搜索区分大小写字段,MySQL 默认排序规则下字符串比较不区分大小写,基本够用;如果要精确匹配工号,去掉 % 或者改成等值查询即可。搜索框每次变化都触发查询也可以,但建议只在回车时触发一次,避免每敲一个字母就查一次库。
4.3 部门联动:QComboBox 数据源刷新
部门下拉框放两个位置:一个是主界面的筛选栏,一个是员工表单对话框。筛选栏的选择变化时,表格数据要跟着过滤。
def load_departments(self): conn = get_conn() try: with conn.cursor() as cursor: cursor.execute("SELECT dept_id, dept_name FROM department WHERE status = 1 ORDER BY dept_id") depts = cursor.fetchall() finally: conn.close() self.filter_dept.clear() self.filter_dept.addItem("全部部门", None) for d in depts: self.filter_dept.addItem(d["dept_name"], d["dept_id"])def on_dept_changed(self): dept_id = self.filter_dept.currentData() if dept_id is None: rows = self.query_all() else: rows = self.query_by_dept(dept_id) self.refresh_table(rows)逻辑说明:addItem 的第二个参数是 userData,也就是部门 ID,界面显示的是部门名。通过 currentData() 取值,比通过当前文本去查字典要稳得多,因为部门重名的情况虽然不多,但不是没有。
这里有个容易忽略的细节:部门改名之后,下拉框重新 load 一次就行,员工表里存的还是 dept_id,不会因为部门改名影响到历史数据。
4.4 只读防误改:QTableWidget 的三个相关设置
表格默认是可编辑的,双击单元格就能改。人事数据不适合在表格里直接改,误操作会把整条记录改坏,所以要把表格设为只读。
self.table.setEditTriggers(QTableWidget.NoEditTriggers) self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setAlternatingRowColors(True) self.table.setColumnWidth(1, 100)逻辑说明:NoEditTriggers 禁止双击编辑,SelectRows 让点击时选中整行而不是单个单元格,AlternatingRowColors 开启隔行变色,长列表视觉上不容易看串行。这三行代码加不加不影响功能,但实际使用体验差别很大。
注意:只读表格要配合「行号定位」使用。我一般会把表格的第一列设置成合适的宽度,让 emp_id 和工号列不挤压姓名列。运行久了如果想要列宽自适应,可以用 resizeColumnsToContents(),但数据量几千行时这个操作会明显卡一下,所以只在加载完成后调用一次。
5. PyQt5+MySQL 避坑指南:六个翻车场景与排查路径
5.1 现象:窗口一打开就白屏转圈,几秒后才出来
原因:把数据库查询写在了窗口的init里,而且是同步执行。MySQL 连接如果慢(网络不通、认证延迟),界面就卡在创建阶段,用户以为程序崩了。
解决:把首次加载放到 showEvent 或者用一个 QTimer.singleShot(0, ...) 延迟到窗口显示后再执行;更彻底的做法是放子线程,后台查完通过信号刷新表格。至少也要在查询外层加 try-except,数据库连不上时弹窗提示而不是卡死。
5.2 现象:程序跑了一个多小时,再操作时报「Lost connection to MySQL server during query」
原因:MySQL 默认的 wait_timeout 是 8 小时,但如果服务器或者网络设备配置了更短的超时,长连接闲置后会被服务端断开。程序里持有同一个连接,下一次查询自然失败。
解决:不要持有常驻连接。每次查询都重新 get_conn(),用完就 close,短连接对 MySQL 的开销完全可以接受;如果非要复用连接,在 execute 前先执行一次 SELECT 1 探活,失败就重连。这个玄学问题在开发时很少出现,因为开发时操作频繁,部署到同事电脑上挂机一上午就会碰到。
5.3 现象:界面上全是问号和乱码
原因:三处字符集不一致。连接串没指定 charset,或者建库时用了默认 latin1,或者表结构是 utf8 而连接用的是 utf8mb4。任何一个环节不对,中文就会变成乱码。
解决:从头统一:建库建表时显式 DEFAULT CHARSET=utf8mb4,连接参数里写 charset="utf8mb4",Windows 下还要确保代码文件本身以 UTF-8 保存。检查顺序是:先看 SQL 里写入的原始中文正不正确,再看 MySQL 客户端查出来正不正确,最后看 PyQt5 界面显示正不正确,逐层定位。
5.4 现象:按钮点击了但没反应,信号槽像断了
原因:多数情况下是自定义窗口类里重写了某个方法,或忘了调用父类构造。比如写了一个 QMainWindow 的子类,init里第一行不是 super().init(),部分初始化被跳过,信号槽的连接自然不正常。
解决:自定义 PyQt5 子类的init第一行永远是 super().init(),然后才是自己的逻辑。排查时可以在按钮回调里加一个 print,确认回调到底有没有被触发。如果 print 不输出,问题在信号连接;输出但界面没变化,问题才在业务代码里。
5.5 现象:PyInstaller 打包的 exe 在别的电脑上连不上数据库
原因:代码里写的数据库地址是 127.0.0.1,打包到同事电脑上还是指向本机,而同事电脑上并没有 MySQL。另一个常见原因是配置文件没被一起打包,程序找不到配置就用了默认值。
解决:数据库地址写成局域网内真实的 MySQL 服务器 IP,别用 127.0.0.1;配置文件用 os.path 拼接绝对路径,打包时把配置文件和 exe 放在同一目录。PyInstaller 的 --onefile 模式会把资源解压到临时路径,这时候要用 sys._MEIPASS 去定位资源文件,很多人的包打出来缺文件就是这个原因。
5.6 现象:删除部门时报外键约束错误
原因:department 表还有员工引用,直接 DELETE 部门触发了外键约束。这个错误其实是数据库在保护数据完整性,但新人不理解,以为外键是麻烦。
解决:要么在界面上限制「有员工的部门不允许删除」,要么给 department 也加 status 字段做逻辑删除。做人事系统,所有删除操作都应该优先考虑逻辑删除,数据完整性和历史回溯都保住了,外键约束也不会成为拦路虎。
6. 事务、回滚与批量导入:让这个简易系统真正耐用
功能跑通只是第一步,真正能交出去的版本,至少还要补两块:批量导入和事务保护。
批量入职是人事系统最典型的高频操作,一次导入几十个人,如果逐个 INSERT,中间任何一条数据出错都会留下半截数据。正确做法是一条事务包住整个导入过程:
def batch_import(self, employee_list): conn = get_conn() try: with conn.cursor() as cursor: sql = """INSERT INTO employee (emp_no, name, gender, dept_id, position, phone, email, hire_date) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""" cursor.executemany(sql, employee_list) conn.commit() except Exception as e: conn.rollback() raise e finally: conn.close()executemany 一次执行多条插入,比循环单条插入快得多。事务的回滚保证任何一条失败,整批都不落库,不会出现导入一半、离职一半的脏数据。
验证这个系统的核心链路有个简单方法:往 employee 表里灌几百条测试数据,然后反复切换部门筛选、搜索、编辑、删除,观察界面卡不卡。几百条数据量下 PyQt5 的表格刷新毫无压力,如果你明显感觉到卡,基本可以断定是查询没有走索引,或者界面线程被数据库操作堵住了。
我自己的教训是:第一版交付时没做逻辑删除,上线两周后要查一名离职员工的历史记录,只能去翻 MySQL 备份,狼狈得很。如果你要照着这个方向做一个给团队用的人事系统,一定记住——数据库里只加数据、不加「删除」动作,才是这类小系统的长久之计。希望帮到你。
本文还有配套的精品资源,点击获取