简介:简易人事管理系统基于PyQt5+MySQL开发,属于ERP人力资源管理模块,面向中小企业HR或相关专业学习者,用于实现员工、部门、薪资、考勤等信息的集成化管理与可视化操作。资源压缩包共49个文件、约295KB,包含40个Python源码文件、7张界面效果图、1个SQL数据库脚本和1个说明文档,py文件按员工管理、部门管理、职称管理、薪资台账、登录鉴权等模块划分,SQL脚本可快速初始化数据库表结构。资源已有2688人学习下载,读者从中可获取一套可直接运行的完整系统源码,包含登录、增删改查员工/部门/职称/管理员、薪资查询与历史台账、图片资源封装等常用功能实现,适合作为课程设计、毕业设计或PyQt5+MySQL初学者的对照练习项目。目录结构清晰,便于按模块阅读和二次开发。
1. 简易人事管理系统:PyQt5 加 MySQL 的桌面端最小闭环
PyQt5 配 MySQL 做一套简易人事管理系统,是桌面开发入门里最完整的闭环练习。它不像网页系统要分前后端部署,也不像 Excel 台账越堆越乱——员工档案、部门信息、入离职状态都落在本地 GUI 里,表单录入、表格查询、按钮操作就能完成日常维护。常见的落地组合是:PyQt5 画界面、MySQL 存数据、PyMySQL 做连接。适合想要一个能打包分发的内部工具的小团队,也适合想系统练一遍 GUI 加数据库技能的开发者。下面按表结构、连接层、界面、踩坑、交付的顺序展开,全程都是可复制的最小代码。
2. 从建表开始:人事系统的表结构怎么设计才不返工
2.1 为什么选 MySQL 而不是 SQLite
做桌面人事系统,很多人第一反应是 SQLite:零配置、单文件、Python 标准库自带支持,听起来最省事。但标题既然定了 MySQL,说明你面对的场景大概率不是“一个人在自己电脑上记台账”,而是“几个人在同一台服务器或局域网里共用一套数据”。SQLite 的并发写是全局锁,两个人同时录入员工信息时经常报 database is locked;MySQL 作为服务端数据库,天然支持多客户端并发,数据统一落在服务器上,每台电脑只要装客户端界面连过去就行。
另一个现实理由是备份和迁移。MySQL 有 mysqldump 可以定时导出、异地存放;SQLite 如果没人主动拷贝,一个误操作删掉文件就全没了。人事数据属于删了就没法恢复的敏感内容,给数据留“后悔药”的优先级很高。我的建议很简单:单机自用可以选 SQLite,但只要涉及多台机器、多人维护或数据要长期保留,直接上 MySQL,别给自己留返工的机会。
2.2 人事系统的最小表结构:员工主表加部门表
表结构是这套系统里返工成本最高的部分,界面写完了再改表,所有 SQL 和表单都要跟着动。我的经验是最小可用设计只需要两张表:department 和 employee。department 存部门名称和负责人,employee 存员工档案,通过 dept_id 外键关联。这样部门改名只改一条记录,不会在员工表里留下 N 处脏数据。
employee 表几个关键字段的设计理由如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| emp_no | VARCHAR(20) UNIQUE | 工号,业务唯一标识,界面增删改查都以它为准 |
| name | VARCHAR(30) NOT NULL | 姓名,必填项 |
| gender | ENUM('男','女') | 简单场景下枚举足够用 |
| dept_id | INT | 部门外键,关联 department 表 |
| phone | VARCHAR(20) | 电话用字符串存,避免前导零丢失 |
| hire_date | DATE | 入职日期,统一 YYYY-MM-DD |
| salary | DECIMAL(10,2) | 薪资,固定两位小数,不用浮点 |
| status | ENUM('在职','离职') | 软删除标记,离职不删数据 |
两个字段要特别强调。emp_no 必须 UNIQUE,员工重名很常见但工号不会重,所有增删改查都以 emp_no 定位数据。salary 用 DECIMAL(10,2) 而不是 FLOAT,浮点数的二进制存储会让 0.1 加 0.2 出现尾差,薪资数据出现小数点误差就是事故。status 字段做软删除——离职员工不是从库删掉,而是标记状态,历史数据留着,后面查考勤、查薪资记录都有依据。
2.3 建表 SQL 和初始化数据
下面是完整的建库建表脚本,直接拷到 MySQL 客户端执行即可:
CREATE DATABASE IF NOT EXISTS hr_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE hr_system; CREATE TABLE department ( dept_id INT AUTO_INCREMENT PRIMARY KEY, dept_name VARCHAR(50) NOT NULL UNIQUE, manager VARCHAR(30) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE employee ( emp_id INT AUTO_INCREMENT PRIMARY KEY, emp_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(30) NOT NULL, gender ENUM('男','女') DEFAULT '男', dept_id INT, position VARCHAR(30), phone VARCHAR(20), email VARCHAR(50), hire_date DATE, salary DECIMAL(10,2), status ENUM('在职','离职') DEFAULT '在职', FOREIGN KEY (dept_id) REFERENCES department(dept_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO department (dept_name, manager) VALUES ('技术部', '某主管'), ('人事部', '某主管'); INSERT INTO employee (emp_no, name, gender, dept_id, position, phone, hire_date, salary, status) VALUES ('E001', '员工A', '男', 1, '开发工程师', '13800000001', '2023-03-15', 12000.00, '在职'), ('E002', '员工B', '女', 2, '招聘专员', '13800000002', '2022-07-01', 9000.00, '在职');这里的字符集参数一个都不能省。数据库、表、连接三个层面的字符集如果不一致,中文写到一半就会变成问号,而且已经写坏的字符很难恢复。utf8mb4 是 utf8 的超集,能存表情符号和生僻字,人事备注里偶尔会出现这些,直接用 utf8mb4 是零成本的选择。外键建议保留,尽管 PyQt5 代码里不一定用得上,它至少能在 SQL 层面阻止你插入不存在的 dept_id,相当于给数据完整性上了一道保险。
注意:如果公司有现成的工号规则,emp_no 不要用自增 ID 冒充。自增 ID 在数据迁移时很容易变化,工号是打印在工牌上的,一旦和系统对不上,人事那边会天天来找你。emp_no 用业务规则生成,比如 E001、E002 顺序递增,或按“部门前缀加序号”拼。
3. 打通 MySQL 连接层:PyMySQL 封装与 CRUD 最小实现
3.1 PyMySQL 还是 mysql-connector-python
Python 连 MySQL 有两类驱动,一类是官方维护的 mysql-connector-python,一类是社区常用的 PyMySQL。前者功能全但包比较重,安装时会带上不少依赖;后者是纯 Python 实现,pip 安装零编译,几乎所有环境都能跑。做简易人事系统这种规模的应用,两者功能没有本质差别,我一般选 PyMySQL,理由只有一个:装机成本低,打包分发时不用处理底层库的兼容问题。
安装命令就一条:
pip install pyqt5 pymysqlPyQt5 安装包比较大,下载慢是正常的,建议配国内镜像源加速。装完用python -c "import PyQt5, pymysql; print('ok')"验证,能打印 ok 说明环境正常,后面报错就可以排除依赖缺失这一层。
3.2 桌面程序的连接管理:单连接加自动重连
Web 后端喜欢用连接池,因为要应对大量短请求;桌面程序只有一个客户端、一个用户操作,用连接池纯属多余。我的做法是启动时建一个长连接,每次执行 SQL 前先 ping 一下,断了就重连。核心是一个 DB 封装类,把连接、查询、执行统一收口:
import pymysql class DB: def __init__(self, config): self.config = config # dict: host/user/password/database self.conn = None def connect(self): self.conn = pymysql.connect( host=self.config['host'], user=self.config['user'], password=self.config['password'], database=self.config['database'], charset='utf8mb4', # 与建表字符集保持一致 cursorclass=pymysql.cursors.DictCursor, # 返回字典,按字段名取值 autocommit=True # 每条 SQL 自动提交 ) return self.conn def ping(self): try: self.conn.ping(reconnect=True) # 底层自动重连 except Exception: self.connect() # ping 失败就重建连接 def query(self, sql, params=None): self.ping() with self.conn.cursor() as cur: cur.execute(sql, params) return cur.fetchall() def execute(self, sql, params=None): self.ping() with self.conn.cursor() as cur: cur.execute(sql, params) return cur.rowcount这段代码里有三个参数是踩过坑之后才加上的。charset 不指定,默认可能是 latin1,中文直接乱码。cursorclass 用 DictCursor 后,查询结果是一条条字典,写代码时row['name']比row[2]可读性强得多。autocommit 设为 True 意味着不用手动 commit,适合人事系统这种单条操作语义清晰的场景;如果后面要做批量导入、需要事务回滚,再改成手动 commit。
注意:MySQL 服务端有个 wait_timeout 参数,连接空闲超过阈值,服务端会主动断开但客户端不知道。加了 ping(reconnect=True) 后,每次执行前都检查,断了自动重连,报错 “MySQL server has gone away” 就再也见不到。ping 抛出异常时要重建连接,不能只依赖 reconnect=True,连接完全失效时它不保证能恢复。
3.3 CRUD 最小实现:查询、新增、修改、删除
有了 DB 封装,业务层就是几个函数。建议把这块独立放一个模块,和界面代码分开,后面维护时不用翻一整页控件代码。核心实现如下:
def list_employees(db, keyword=''): sql = """ SELECT e.emp_no, e.name, e.gender, d.dept_name, e.position, e.phone, e.hire_date, e.salary, e.status FROM employee e LEFT JOIN department d ON e.dept_id = d.dept_id WHERE e.status = '在职' AND (e.name LIKE %s OR e.emp_no LIKE %s) ORDER BY e.emp_no """ like = f'%{keyword}%' return db.query(sql, (like, like)) def add_employee(db, data): sql = """ INSERT INTO employee (emp_no, name, gender, dept_id, position, phone, email, hire_date, salary, status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ return db.execute(sql, ( data['emp_no'], data['name'], data['gender'], data['dept_id'], data['position'], data['phone'], data['email'], data['hire_date'], data['salary'], data['status'] )) def update_employee(db, emp_no, data): sql = """ UPDATE employee SET name=%s, gender=%s, dept_id=%s, position=%s, phone=%s, email=%s, hire_date=%s, salary=%s, status=%s WHERE emp_no=%s """ return db.execute(sql, ( data['name'], data['gender'], data['dept_id'], data['position'], data['phone'], data['email'], data['hire_date'], data['salary'], data['status'], emp_no )) def delete_employee(db, emp_no): sql = "DELETE FROM employee WHERE emp_no=%s" return db.execute(sql, (emp_no,))list_employees 里 LEFT JOIN 保证员工即使还没分部门也能查出来;LIKE 查询把关键字两头加 %,配合 dept_name 可以做到“搜部门也能找到人”。hire_date 直接传 'YYYY-MM-DD' 格式的字符串,MySQL 的 DATE 类型能自动转换。关于日期格式,第 5 章还会专门讲。
注意:delete_employee 是真删除。前面表设计里提过 status 字段做软删除,界面上的“删除”按钮默认应该走
UPDATE employee SET status='离职',而不是 DELETE。保留这个函数是给管理员做数据清理用的,界面别随便暴露。
3.4 参数化查询:写 SQL 的时候别用 f-string 拼参数
很多初学者习惯这么写:
# 错误示范:SQL 注入风险 + 引号转义地狱 sql = f"SELECT * FROM employee WHERE name = '{keyword}'" db.query(sql)员工叫“O'Brien”这种带单引号的名字,或者搜索框里输入一个' OR 1=1 --,这条 SQL 要么直接报错,要么把整张表拖出来。参数化查询的 %s 占位符不是简单的字符串替换,而是驱动层面的预处理,特殊字符会被安全转义。上面所有代码都用元组传参,这个习惯从第一行 SQL 开始就要养成。
另一个隐藏好处是代码可读性。SQL 模板和参数分离后,SQL 里不用再拼一长串引号和加号,出问题也好排查——参数就在元组里,print 出来就能看。
4. 把界面搭起来:PyQt5 主窗口、表格与表单的落地写法
4.1 Qt Designer 还是纯代码布局
PyQt5 做界面有两条路线:用 Qt Designer 拖控件,生成 .ui 文件再加载;或者纯代码写控件和布局。我推荐后者做这种简易人事管理系统,原因很直接:纯代码里所有控件都在同一个文件里可见,改间距、加按钮,直接搜索定位;而 .ui 文件是 XML,加载后调试时看不到控件层级,改完还要重新编译,无形中多一道工序。
人事系统的界面复杂程度有限,不外乎一个主窗口、一个表单对话框、几个按钮,纯代码的可读性完全够用。反过来,如果做一整套带复杂报表、多 Tab 的管理后台,Qt Designer 能省大量排版时间。两条路线不冲突,按项目规模选。
4.2 主窗口:搜索栏加 QTableView 的核心布局
主窗口的标准布局是顶部一条搜索栏,中间一整块 QTableView 表格,底部三个操作按钮。代码如下:
from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QTableView, QMessageBox, QDialog, QComboBox, QFormLayout, QDialogButtonBox) from PyQt5.QtGui import QStandardItemModel, QStandardItem class MainWindow(QMainWindow): def __init__(self, db): super().__init__() self.db = db self.setWindowTitle('简易人事管理系统') self.resize(1000, 650) self._build_ui() self.refresh_table() def _build_ui(self): central = QWidget(self) self.setCentralWidget(central) layout = QVBoxLayout(central) # 搜索栏 bar = QHBoxLayout() self.search_edit = QLineEdit() self.search_edit.setPlaceholderText('输入姓名、工号或部门搜索') self.search_btn = QPushButton('搜索') self.search_btn.clicked.connect(self.refresh_table) bar.addWidget(self.search_edit) bar.addWidget(self.search_btn) layout.addLayout(bar) # 员工表格 self.table = QTableView() self.model = QStandardItemModel(self) self.model.setHorizontalHeaderLabels( ['工号', '姓名', '性别', '部门', '职位', '电话', '入职日期', '薪资', '状态'] ) self.table.setModel(self.model) self.table.setSelectionBehavior(QTableView.SelectRows) # 整行选中 self.table.setEditTriggers(QTableView.NoEditTriggers) # 禁止表格内直接编辑 layout.addWidget(self.table, 1) # 操作按钮 btn_bar = QHBoxLayout() add_btn = QPushButton('新增员工') edit_btn = QPushButton('编辑') quit_btn = QPushButton('离职/删除') add_btn.clicked.connect(self.add_employee) edit_btn.clicked.connect(self.edit_employee) quit_btn.clicked.connect(self.quit_employee) for b in (add_btn, edit_btn, quit_btn): btn_bar.addWidget(b) layout.addLayout(btn_bar) def refresh_table(self): keyword = self.search_edit.text().strip() rows = list_employees(self.db, keyword) self.model.removeRows(0, self.model.rowCount()) for r in rows: self.model.appendRow([QStandardItem(str(r[k])) for k in ('emp_no', 'name', 'gender', 'dept_name', 'position', 'phone', 'hire_date', 'salary', 'status')])两个 set 方法值得多说一句。setSelectionBehavior(SelectRows) 让用户点击任意一列都选中整行,编辑和离职按钮取当前行数据时不会出现“只选到一个单元格”的困惑。setEditTriggers(NoEditTriggers) 禁止直接在表格里改数据,所有修改必须走编辑对话框,这样校验逻辑统一收敛在对话框里,不会有人在表格里填出非法日期。
refresh_table 每次重新拉全量数据再整体刷 model,几千行以内完全没压力。注意 appendRow 里把所有字段都转成了 str,因为 QStandardItem 只接受字符串,日期和数字不转会显示成空串或类型异常。
4.3 新增和编辑:一个表单对话框复用
新增和编辑的区别只是“有没有默认值”,所以用一个对话框类,构造时传 data 参数区分。这样校验逻辑只维护一份,不会出现新增能过、编辑不能过这种不一致:
class EmployeeDialog(QDialog): def __init__(self, parent=None, data=None): super().__init__(parent) self.data = data self.setWindowTitle('编辑员工' if data else '新增员工') form = QFormLayout(self) self.emp_no = QLineEdit() self.name = QLineEdit() self.gender = QComboBox() self.gender.addItems(['男', '女']) self.dept_id = QComboBox() self.position = QLineEdit() self.phone = QLineEdit() self.hire_date = QLineEdit() self.hire_date.setPlaceholderText('YYYY-MM-DD') self.salary = QLineEdit() form.addRow('工号', self.emp_no) form.addRow('姓名', self.name) form.addRow('性别', self.gender) form.addRow('部门', self.dept_id) form.addRow('职位', self.position) form.addRow('电话', self.phone) form.addRow('入职日期', self.hire_date) form.addRow('薪资', self.salary) btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) btns.accepted.connect(self._validate_and_accept) btns.rejected.connect(self.reject) form.addRow(btns) if data: self.emp_no.setText(data['emp_no']) self.name.setText(data['name']) self.gender.setCurrentText(data['gender']) self.position.setText(data['position']) self.phone.setText(data['phone']) self.hire_date.setText(str(data['hire_date'])) self.salary.setText(str(data['salary'])) self.emp_no.setReadOnly(True) # 工号建好后不允许改 def _validate_and_accept(self): if not self.name.text().strip(): QMessageBox.warning(self, '校验失败', '姓名不能为空') return try: from datetime import date date.fromisoformat(self.hire_date.text().strip()) except ValueError: QMessageBox.warning(self, '校验失败', '日期格式必须是 YYYY-MM-DD') return self.accept() def get_data(self): return { 'emp_no': self.emp_no.text().strip(), 'name': self.name.text().strip(), 'gender': self.gender.currentText(), 'dept_id': self.dept_id.currentData(), 'position': self.position.text().strip(), 'phone': self.phone.text().strip(), 'hire_date': self.hire_date.text().strip(), 'salary': float(self.salary.text() or 0), 'status': '在职', }这里有两个细节是真实使用中才会发现的。dept_id 是下拉框,但显示的是部门名称,入库的是 dept_id,所以要用addItem(dept_name, dept_id)把两个值绑在一起,get_data 里用 currentData() 取数值。工号在编辑模式下 setReadOnly(True),防止改资料时顺手改了工号导致历史关联全部失效——这是人事系统里最隐蔽的破坏性操作。
校验放在对话框里而不是主窗口,是刻意为之:新增和编辑走同一个入口,校验只维护一份。日期校验用 date.fromisoformat,它只接受 YYYY-MM-DD 格式,用户填 2024/1/1 或 2024年1月1日 都会被拦截,错误提示直接告诉用户正确格式。
4.4 增删改的交互闭环
主窗口里三个按钮的槽函数把 UI 和数据库串起来:
def add_employee(self): dlg = EmployeeDialog(self) if dlg.exec_() == QDialog.Accepted: add_employee(self.db, dlg.get_data()) self.refresh_table() def edit_employee(self): row = self.table.currentIndex().row() if row < 0: QMessageBox.information(self, '提示', '请先在表格中选择一行') return emp_no = self.model.item(row, 0).text() # 第 0 列是工号 rows = self.db.query( "SELECT * FROM employee WHERE emp_no=%s", (emp_no,)) if rows: dlg = EmployeeDialog(self, data=rows[0]) if dlg.exec_() == QDialog.Accepted: update_employee(self.db, emp_no, dlg.get_data()) self.refresh_table() def quit_employee(self): row = self.table.currentIndex().row() if row < 0: QMessageBox.information(self, '提示', '请先在表格中选择一行') return emp_no = self.model.item(row, 0).text() if QMessageBox.question( self, '确认', '将员工标记为离职?') == QMessageBox.Yes: self.db.execute( "UPDATE employee SET status='离职' WHERE emp_no=%s", (emp_no,)) self.refresh_table()这套闭环的要点是:取当前行工号 → 按工号查详情或改状态 → 操作完统一 refresh_table。用工号而不是行号作为业务主键,是因为刷新之后行号会变,而工号永远不变。所有按钮都做了空选择保护,没选中行时弹提示而不是抛异常,这是桌面工具给人“能用”感觉的基本门槛。
到这里,最小可用的简易人事管理系统已经能跑通:启动连库、表格展示、搜索过滤、新增编辑、离职标记。第 5 章把实际运行中最高频的 5 个翻车现场挨个说一遍。
5. 避坑手册:PyQt5 + MySQL 最常见的 5 个翻车现场
以下问题我在这套系统的开发和使用过程中都真实遇到过,按“现象 → 原因 → 解决”写,方便对照排查。
5.1 数据库里的中文变成问号
现象:界面里录入中文姓名,刷新后表格里显示一串问号,或者用 MySQL 客户端查看时全是乱码。
原因:字符集在三个层面不一致。最常见的是建库用了默认 latin1,或 PyMySQL 连接时没指定 charset,再或者 SQL 文件本身保存成了 GBK 编码。任何一层不一致,中文都会在写入时被静默转坏,而且已经写坏的字符很难恢复——这才是最要命的。
解决:三层统一到 utf8mb4。建库时 CHARACTER SET utf8mb4,连接时 charset='utf8mb4',SQL 文件保存为 UTF-8 无 BOM。如果已经写坏,先把表里数据导出备份,然后ALTER TABLE employee CONVERT TO CHARACTER SET utf8mb4,再重新导入干净数据。转换前必须备份,数据量大时这条命令可能锁表,选在非工作时间执行。
5.2 MySQL server has gone away 报错
现象:程序启动后放着不动,半小时或几个小时后点搜索,直接抛 pymysql.err.OperationalError,提示 server has gone away。
原因:MySQL 服务端的 wait_timeout 默认 8 小时,但很多自建实例和托管数据库会把超时时间调短,有些甚至只有 30 分钟。连接空闲超过阈值,服务端主动断开,客户端还拿着过期的连接对象,第一次操作就炸。
解决:3.2 节的 ping(reconnect=True) 就是标准解法。这是客户端行为,也可以把服务端 wait_timeout 调大,比如set global wait_timeout=28800,但调服务端会影响所有连接,不如客户端每次操作前 ping 来得稳。有个细节:ping 失败后必须重建连接,不能只调 ping,因为 reconnect=True 在连接完全失效时不保证能恢复,最稳妥是 catch 到异常就 connect() 一次。
5.3 界面卡死或崩溃:在子线程里操作 UI
现象:数据量变大后,你决定把查询丢到线程里执行,结果程序要么直接崩溃,要么界面变成“未响应”,偶尔还弹出一堆看不清的线程报错。
原因:PyQt5 的 UI 只能在主线程里操作。子线程里调用 setText、appendRow 这类方法,本质是跨线程访问对象,Qt 的线程模型不保证安全,轻则数据错乱,重则段错误。加线程锁解决不了这个问题,因为根因是 QWidget 和事件循环的线程亲和性。
解决:子线程做完查询后,通过信号把结果发回主线程,在主线程的槽函数里更新界面:
from PyQt5.QtCore import QThread, pyqtSignal class QueryThread(QThread): result_ready = pyqtSignal(list) def __init__(self, db, keyword): super().__init__() self.db = db self.keyword = keyword def run(self): rows = list_employees(self.db, self.keyword) self.result_ready.emit(rows)主窗口里把 result_ready 连接到刷新 model 的槽函数,槽函数里只做 appendRow,不碰数据库。这里还有个容易被忽略的点:PyMySQL 的游标并不是严格线程安全的,多个线程共用同一个连接存在风险。最稳妥的做法是每个线程用独立连接,或者对人事系统这种低频操作干脆保持单线程——单线程加信号回传完全够用,不必引入线程池。
5.4 按日期筛选查不到数据
现象:界面里按入职日期筛选,表格明明显示有 2024-05-01 入职的人,但查出来是空的,没有任何报错。
原因:hire_date 是 DATE 类型,传入的字符串一旦不是严格的 YYYY-MM-DD,MySQL 会把它转成 '0000-00-00' 或直接忽略条件。常见来源是 QDateEdit 的 toString 格式没指定,Qt 默认可能输出“2024年05月01日”,数据库当然对不上。
解决:所有日期进 SQL 之前统一转成 ISO 格式。如果界面用 QDateEdit,取值时写self.date_edit.date().toString('yyyy-MM-dd');如果像 4.3 节那样用 QLineEdit 手输,就在 get_data 里用 date.fromisoformat 校验并再格式化一次。查日期区间建议写hire_date BETWEEN %s AND %s,两边都传 'YYYY-MM-DD' 字符串,避免在 SQL 里用 TO_DATE 之类的函数,保证不同 MySQL 版本下行为一致。
5.5 表格数据多了以后滚动卡顿
现象:数据从几百条涨到上万条,每次滚动表格都掉帧明显,刷新一次要等好几秒。
原因:如果用的是 QTableWidget,它是控件模式,每个单元格都持有一个 QTableWidgetItem 对象,一万条记录就是上万个控件实例,内存和事件处理的开销全压在 GUI 线程上。逐行 setItem 填数据,本质是创建对象再布局,慢是必然的。
解决:换 QTableView + QStandardItemModel,这是 4.2 节已经在用的方案。QTableView 是视图,数据在 model 里,滚动时只渲染可见行,性能不在一个量级。要更快就换成 QAbstractTableModel 自定义 model,用重写的 data() 方法按需返回数据,内存占用再降一档——但对简易人事系统,QStandardItemModel 已经足够。配套习惯是批量更新时用self.model.setRowCount(0)而不是逐行 removeRows,前者一次清空,后者逐行触发信号,数据量大时差距明显。真到几万行规模,直接把查询改成分页,LIMIT 加 OFFSET,界面只展示当前页。
6. 收尾交付:打包、日志与数据备份的实战习惯
系统能跑只是第一步,真正能交给人事部门用的是另外三件事:打包成可执行文件、留操作日志、定时备份数据。
打包用 PyInstaller,命令很简单:
pip install pyinstaller pyinstaller -w --onefile --name HRSystem main.py-w 去掉控制台窗口,--onefile 打成单文件,--name 指定名字。这里有两个坑:一是 PyQt5 的插件和样式资源比较大,第一次打出来的可执行文件可能上百 MB,属正常现象,别怀疑代码有鬼;二是 main.py 里如果用相对路径加载配置文件或图标,打包后工作目录会变,建议所有外部资源用os.path.dirname(sys.argv[0])拼接绝对路径。PyMySQL 是纯 Python 库,PyInstaller 一般能自动收集,不用额外处理。
日志是这套系统的后悔药。我见过太多“数据没了我也不知道怎么没的”的现场,最小成本方案是给操作打点:
import logging logging.basicConfig( filename='hr_system.log', level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s' ) def log_op(action, emp_no, detail=''): logging.info(f'{action} {emp_no} {detail}')每条增删改都记一行:什么时间、做了什么、改了哪个工号、改成了什么。出现数据争议时,日志能快速定位是哪一次操作引起的。别嫌这写法土,日志文件在关键时刻比数据库备份还值钱。
数据备份方面,我现在的习惯是每周自动导一次,写入系统的计划任务:
mysqldump -u root -p**** hr_system > backup_$(date +%Y%m%d).sql同时每月把员工表导出 Excel 存档,一是给人事做离线备份,二是万一 MySQL 服务挂了,Excel 能临时顶住日常查询。备份脚本跑完要检查生成文件的大小,0 字节的备份等于没有备份,这条是血泪经验。
这套简易人事管理系统做到这里已经接近完整交付:PyQt5 画界面,PyMySQL 连 MySQL,表结构可查可改,操作有日志,数据有备份。我最早一次给部门内部使用时就因为没做日志,某天发现一条员工记录被改动,查了半天也没查出是谁,最后只能翻数据库日志非常被动。从那以后,日志和备份成了我所有小型管理系统的默认配置,哪怕程序只有 500 行也不省略。希望帮到你。
本文还有配套的精品资源,点击获取