
简介简易人事管理系统是一套基于PyQt5与MySQL构建的桌面端人事管理解决方案面向中小企业管理者、初学者与课程设计者覆盖员工信息、部门、职位、薪资等常用模块可帮助理解ERP中人力资源部分的落地实现。资源共49个文件其中Python脚本40个承担登录认证、增删改查、界面展示与数据库交互等核心逻辑另有1份SQL建库脚本、7张界面截图和1篇README说明压缩包约295KB结构清晰适合直接导入PyCharm运行。目前已有2688人学习下载。借助该系统可掌握PyQt5界面编写与MySQL联调方法学习表格控件、对话框、多窗口跳转及管理员权限管理等典型写法配套数据库文件与说明文档便于快速初始化并二次开发既能支撑课程设计、毕业设计也可作为初学桌面数据库开发的完整参考。1. 简易人事管理系统PyQt5MySQL 到底能做什么某公司的人力专员还在用 Excel 管员工台账几百行数据入离职改来改去公式一拉就串行更别说按部门筛选或查历史记录。你找到我说想做个「简易人事管理系统」我的建议是 PyQt5 MySQL——不是因为它新正因为它足够稳、足够轻恰好卡在「比 Excel 高级、比 Web 轻量」的中间地带。这套方案不是给大厂做 SaaS 平台它面向的是数据量在几千条以内、要放内网或单机跑、又不想折腾 Web 服务和部署流程的团队。核心代码两三千行一个人一到两周能落地跑起来就是一个不依赖浏览器的桌面程序。新手可以照步骤搭出第一个能用的版本熟手可以把连接层、线程和事务抽出来直接复用到别的桌面端项目。2. 选型与数据建模为什么是 PyQt5MySQL员工表怎么设计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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明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_dataNone, parentNone): 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_idNone): 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 刷新不是 clearContentssetRowCount 与 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. PyQt5MySQL 避坑指南六个翻车场景与排查路径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 CHARSETutf8mb4连接参数里写 charsetutf8mb4Windows 下还要确保代码文件本身以 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 备份狼狈得很。如果你要照着这个方向做一个给团队用的人事系统一定记住——数据库里只加数据、不加「删除」动作才是这类小系统的长久之计。希望帮到你。本文还有配套的精品资源点击获取