简介:面向学习桌面端数据库应用开发的学生与初级开发者,这套 PyQt5+MySQL 简易人事管理系统可作为课程设计、毕业设计原型或自学练手项目。系统围绕企业人事模块展开,覆盖员工、部门、职位、管理员及薪资当前与历史记录等信息的录入、查询、修改和删除,帮助理解图形界面与关系型数据库的联动方式。压缩包共 49 个文件,约 295KB,其中 40 个 Python 源码构成登录、主界面和各业务功能模块,7 张 PNG 图片提供界面素材,1 个 SQL 脚本用于建库建表,1 份 Markdown 文档辅助说明项目结构。已有 2689 人学习。读者可从中参考增删改查实现、表结构设计、登录校验、模块划分与界面布局,并借助数据库测试脚本排查连接和数据问题,适合需要一份结构完整、体量轻便的人事管理实战样例的读者。
1. 从一张 Excel 花名册到可运行的人事系统:这套 pyqt5+mysql 方案能顶什么用
上个月有个做行政主管的朋友找我,说她每个月月底要在 Excel 里手工核对 60 多号人的入职时间、部门归属和考勤天数,公式一多就互相引用,改一个单元格全表飘红。她要的东西其实很朴素:一个双击就能打开的桌面程序,左边选部门,右边出人,能增删改查,能按月份拉一张汇总表。这套 pyqt5+mysql 的简易人事管理系统,正好卡在这个需求区间——它不是那种带工作流引擎和审批链的 HR 平台,而是一个单机客户端直连数据库的小型信息管理骨架,代码量可控,逻辑透明,改字段、加报表都能自己动手。适合谁?适合要交课程设计的学生、要给小团队做个内部工具的开发者,以及想拿一个完整 CRUD 项目练手 Qt 界面与 SQL 配合的人。它解决的核心问题是「数据从散落的表格收敛到一张有关系约束的表里」,而不是「替代专业 HR 软件」。后面几章我把建表、连接层、界面绑定、打包、排错一条线拆开讲,你照着敲能跑起来。
2. 环境与数据表:pyqt5+mysql 的骨架先立住
很多人上手这类项目的第一反应是先把窗口拖出来,MainWindow 拉满一屏控件,然后才想起来数据库还没建。这个顺序会让你在后面反复改字段、改查询、改界面绑定,返工量翻倍。我的习惯是先定数据模型,再定连接层,最后才是界面——界面是数据的外衣,外衣随时能换,骨架换一次伤筋动骨。
2.1 依赖选型:为什么是 PyQt5 + PyMySQL 这套组合
先把依赖钉死,避免版本漂移带来的玄学问题。这套系统我只用四个直接依赖:PyQt5 负责界面、PyMySQL 负责通信、DBUtils 做连接池(可选但强烈建议)、passlib 或标准库 hashlib 做密码哈希。
为什么驱动选 PyMySQL 而不是 mysqlclient?血泪经验在 Windows 上。mysqlclient 需要本地有 C 编译器和 MySQL 开发头文件,装不上时报的错五花八门,从Microsoft Visual C++ 14.0 is required到找不到mysql.h,对只是想跑个课程设计的人极不友好。PyMySQL 是纯 Python 实现,pip install一把过,性能对几十到几百人的数据量完全够用。代价是它不支持某些底层特性(比如 LOAD DATA LOCAL 的某些模式),但人事系统用不到。
为什么界面选 PyQt5 而不是 PySide6?两者 API 高度相似,PySide6 是官方 LGPL 授权,商用更宽松;PyQt5 是 GPL/商业双授权,如果你要把程序闭源分发给外部客户,理论上需要商业授权。自己做内部工具或课程作业不涉及这个。我选 PyQt5 的实际理由是资料多、示例全,遇到问题搜出来的答案基本都是 PyQt5 的写法,PySide6 的答案相对少。如果你明确要商用分发,建议直接从 PySide6 起步,省掉后面迁移的麻烦。
至于要不要上 SQLAlchemy 这类 ORM,我的判断是:这个体量的系统别上。人事系统的查询特点是「多表关联 + 聚合统计」居多,ORM 写出来的 join 和 group by 往往比裸 SQL 更难读,而且出问题时你得多理解一层它生成的 SQL。手写参数化 SQL,配合一个薄薄的连接封装,代码量和心智负担都更低。
依赖清单落成一个 requirements.txt,锁大版本不锁小版本,避免某次pip install -U之后 Qt 插件路径变了导致打包失败:
PyQt5==5.15.9 PyMySQL==1.1.0 DBUtils==3.0.3 passlib==1.7.4提示:PyQt5 5.15.x 是最后一个 5.x 系列,6.x 的 API 有断裂性变化(比如 QAction 的导入路径从 QtWidgets 挪到 QtGui),网上大量示例代码还是 5.x 写法,混用会报 ImportError。锁住 5.15 能省掉一半莫名其妙的错误。
2.2 建库建表:五张表撑起人事主数据
人事系统的实体关系其实很清晰:一个部门下有多个岗位,一个员工属于某个部门并担任某个岗位,员工每天有考勤记录,每月有薪资结算。围绕这条主线建五张表就够:department、position、employee、attendance、salary。再加一张sys_user存登录账号。字符集统一utf8mb4,排序规则utf8mb4_general_ci——别用utf8,那是 MySQL 里的三字节残废版本,存个带生僻字的姓名就会截断或报错。
-- 建库:字符集必须显式指定,否则继承服务器默认值,很容易踩到 latin1 CREATE DATABASE IF NOT EXISTS hr_simple DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hr_simple; -- 部门表:parent_id 预留了树形结构的可能,初期可以全填 0 CREATE TABLE department ( dept_id INT AUTO_INCREMENT PRIMARY KEY, dept_name VARCHAR(50) NOT NULL, parent_id INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_dept_name (dept_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 岗位表:挂在部门下,dept_id 建索引,级联查询靠它 CREATE TABLE position ( pos_id INT AUTO_INCREMENT PRIMARY KEY, pos_name VARCHAR(50) NOT NULL, dept_id INT NOT NULL, base_salary DECIMAL(10,2) NOT NULL DEFAULT 0.00, KEY idx_pos_dept (dept_id), CONSTRAINT fk_pos_dept FOREIGN KEY (dept_id) REFERENCES department (dept_id) ON DELETE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 员工表:工号唯一,身份证号唯一,状态用枚举而不是布尔 CREATE TABLE employee ( emp_id INT AUTO_INCREMENT PRIMARY KEY, emp_no VARCHAR(20) NOT NULL, emp_name VARCHAR(50) NOT NULL, gender TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女', id_card VARCHAR(18) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, dept_id INT NOT NULL, pos_id INT NOT NULL, hire_date DATE NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1在职 2离职 3试用', remark VARCHAR(255) DEFAULT NULL, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_emp_no (emp_no), UNIQUE KEY uk_id_card (id_card), KEY idx_emp_dept (dept_id), KEY idx_emp_name (emp_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 考勤表:一人一天一条,用联合唯一键挡住重复录入 CREATE TABLE attendance ( att_id BIGINT AUTO_INCREMENT PRIMARY KEY, emp_id INT NOT NULL, att_date DATE NOT NULL, work_days DECIMAL(4,1) NOT NULL DEFAULT 0.0, late_cnt INT NOT NULL DEFAULT 0, leave_days DECIMAL(4,1) NOT NULL DEFAULT 0.0, UNIQUE KEY uk_emp_date (emp_id, att_date), KEY idx_att_date (att_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段设计上有几个决定值得单独说明。status用 TINYINT 而不是is_active布尔,是因为真实场景里「试用」「在职」「离职」是三种状态,布尔值迟早要扩,扩的时候全表数据都要迁移,不如一开始就给足枚举空间。attendance的主键用 BIGINT,因为考勤是唯一会随人数和时间线性膨胀的表,100 人 × 365 天 × 3 年就是十万级,INT 虽然够用,但 BIGINT 没有额外成本。uk_emp_date这个联合唯一键很关键,它把「同一人同一天重复导入」这种脏数据在数据库层面就挡掉了,比在 Python 里写查重逻辑可靠得多。
employee上的uk_id_card有个坑要提前说:MySQL 的唯一索引允许多个 NULL 值,所以身份证号没填的员工再多也不会冲突,这恰好是我们要的行为。
建完表顺手插两条基础数据,不然后面界面联调时下拉框全是空的,你会误以为代码有 bug:
INSERT INTO department (dept_name) VALUES ('行政部'), ('技术部'), ('财务部'); INSERT INTO position (pos_name, dept_id, base_salary) VALUES ('人事专员', 1, 6000.00), ('开发工程师', 2, 12000.00), ('会计', 3, 8000.00);2.3 连接层封装:一个类管住所有 SQL 出口
界面代码里到处pymysql.connect(...)是最常见的写法,也是最容易出事的地方:连接泄漏、忘记 commit、异常时连接不关闭。我的做法是写一个薄封装,对外只暴露query_all、query_one、execute三个方法,内部用 DBUtils 的 PooledDB 管理连接,用上下文管理器保证归还。
# db.py —— 数据库访问的唯一出口 import pymysql from dbutils.pooled_db import PooledDB from contextlib import contextmanager class Database: def __init__(self, host, port, user, password, db, charset='utf8mb4'): # 连接池参数说明: # mincached 启动时预建的连接数,太少会造成冷启动卡顿 # maxcached 池中最多缓存的空闲连接数 # maxconnections 硬上限,超过会阻塞等待 # blocking=True 时池满阻塞而非抛异常,桌面端更友好 self.pool = PooledDB( creator=pymysql, mincached=2, maxcached=5, maxconnections=10, blocking=True, ping=1, # 取连接前 ping 一次,防止 MySQL 8 小时断连 host=host, port=port, user=user, password=password, database=db, charset=charset, cursorclass=pymysql.cursors.DictCursor, autocommit=False # 显式提交,避免半截写入 ) @contextmanager def _cursor(self): conn = self.pool.connection() cur = conn.cursor() try: yield conn, cur except Exception: conn.rollback() # 出异常整体回滚,绝不留半条数据 raise finally: cur.close() conn.close() # 归还到池,不是真正断开 def query_all(self, sql, args=None): with self._cursor() as (conn, cur): cur.execute(sql, args or ()) return cur.fetchall() def query_one(self, sql, args=None): with self._cursor() as (conn, cur): cur.execute(sql, args or ()) return cur.fetchone() def execute(self, sql, args=None): """单条写操作,返回自增主键或影响行数""" with self._cursor() as (conn, cur): rows = cur.execute(sql, args or ()) last_id = cur.lastrowid conn.commit() return last_id if last_id else rows这段代码有几个点是特意设计的。autocommit=False加上显式commit(),意味着任何一条写语句失败都会走rollback,不会出现「员工信息写进去了但部门关联没写」的脏状态。ping=1解决的是长时间挂机后连接被服务端掐断的问题——桌面程序经常开着一下午没人操作,回来点一下按钮就报InterfaceError (0, ''),这个参数就是挡它的。把cursorclass设成DictCursor是为了让查询结果直接是字典,界面层取值用row['emp_name']比row[2]可读得多,也不会因为改字段顺序而错位。
连接参数不要硬编码在代码里,后面 4.2 会讲怎么外置成 ini。初始化时一行搞定:
db = Database(host='127.0.0.1', port=3306, user='hr_app', password='***', db='hr_simple') rows = db.query_all( "SELECT emp_id, emp_name FROM employee WHERE dept_id = %s AND status = %s", (2, 1) )注意这里的%s占位符和args元组,PyMySQL 会在发送前做转义,这是防 SQL 注入的基本功。千万别用 f-string 把用户输入拼进 SQL——搜索框里敲一个' OR 1=1 --就能把全表拉出来,这种问题在课程设计答辩现场被问到会很难看。
3. 界面与业务打通:登录、CRUD 和统计报表
骨架立好之后,界面层要解决三件事:谁能进来、进来能改什么、改完怎么看到结果。这一章按这三个问题的顺序推。
3.1 登录鉴权:密码哈希与角色分流
登录窗口很多人写成「查库对比明文密码」,功能能跑,但一旦这个系统被部署到内网多台机器上,密码就等于裸奔。正确做法是库里只存哈希,登录时把用户输入的密码用相同算法算一遍再比对。别用 MD5,也不要用裸 SHA256——它们算得太快,暴力破解成本极低。用pbkdf2_hmac加盐,标准库自带,不引入额外依赖。
# auth.py —— 注册与登录校验 import hashlib, os, hmac ITERATIONS = 200_000 def hash_password(plain: str, salt: bytes = None) -> str: """返回 'salt_hex$hash_hex' 格式,盐随密码一起入库""" salt = salt or os.urandom(16) dk = hashlib.pbkdf2_hmac('sha256', plain.encode('utf-8'), salt, ITERATIONS) return f"{salt.hex()}${dk.hex()}" def verify_password(plain: str, stored: str) -> bool: """用 hmac.compare_digest 做恒定时间比较,避免时序攻击""" try: salt_hex, hash_hex = stored.split('$') except ValueError: return False salt = bytes.fromhex(salt_hex) dk = hashlib.pbkdf2_hmac('sha256', plain.encode('utf-8'), salt, ITERATIONS) return hmac.compare_digest(dk.hex(), hash_hex)salt每个账号独立随机生成,这样即使两个人密码相同,库里的哈希值也不一样,彩虹表失效。ITERATIONS设 20 万是有意的取舍:单次校验大约几十毫秒,用户无感,但让离线爆破的成本上升三个数量级。hmac.compare_digest而不是==,是因为字符串比较遇到第一个不同字符就返回,比较耗时和匹配前缀长度相关,理论上可被用来逐位猜测——这个攻击在本地桌面程序里实用性不高,但写对了不费事。
登录成功后不要只关掉登录窗就完事,要把当前用户的角色传下去。角色决定主窗口哪些按钮可点,比如普通人事专员能改员工信息但不能改部门结构。常见做法是给sys_user表加一个role字段(admin/hr/viewer),登录时查出来存进一个全局的会话对象,主窗口初始化时根据它setEnabled(False)掉无权按钮。注意这只是 UI 层的第一道拦,真正的权限校验还得在数据库写操作前再判一次,别把安全性全押在按钮灰不灰上。
3.2 员工信息 CRUD:QTableView 绑定的两种路线
Qt 里展示表格有两条路:QSqlTableModel直接绑定数据库表,或者自定义QAbstractTableModel自己喂数据。前者几行代码就能出增删改,后者代码多但可控。
我的建议是:员工列表用自定义模型 + 分页查询,部门岗位这类小字典表用 QSqlTableModel。原因是员工表数据会涨,QSqlTableModel.select()默认把整表拉进内存,几千条之后切换部门筛选会明显卡顿;而且它默认的提交策略是OnRowChange,用户点一下别的行就自动写库,误操作没有后悔药。
下面是一个精简的自定义模型,配合分页和模糊搜索:
# emp_model.py from PyQt5.QtCore import QAbstractTableModel, Qt class EmpTableModel(QAbstractTableModel): # 列顺序与 SELECT 的字段顺序严格对应,改一处必须改另一处 HEADERS = ['工号', '姓名', '性别', '部门', '岗位', '入职日期', '状态'] def __init__(self, db, page_size=20): super().__init__() self.db = db self.page_size = page_size self.rows = [] self.total = 0 def load(self, page=1, keyword='', dept_id=None): where, args = ["e.status <> 2"], [] if keyword: where.append("(e.emp_name LIKE %s OR e.emp_no LIKE %s)") args += [f"%{keyword}%", f"%{keyword}%"] if dept_id: where.append("e.dept_id = %s") args.append(dept_id) cond = " AND ".join(where) self.total = self.db.query_one( f"SELECT COUNT(*) AS c FROM employee e WHERE {cond}", args)['c'] offset = (page - 1) * self.page_size self.beginResetModel() self.rows = self.db.query_all( f"""SELECT e.emp_no, e.emp_name, e.gender, d.dept_name, p.pos_name, e.hire_date, e.status FROM employee e JOIN department d ON d.dept_id = e.dept_id JOIN position p ON p.pos_id = e.pos_id WHERE {cond} ORDER BY e.emp_id DESC LIMIT %s OFFSET %s""", args + [self.page_size, offset]) self.endResetModel() def rowCount(self, parent=None): return len(self.rows) def columnCount(self, parent=None): return len(self.HEADERS) def headerData(self, section, orientation, role=Qt.DisplayRole): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.HEADERS[section] return None def data(self, index, role=Qt.DisplayRole): if not index.isValid() or role != Qt.DisplayRole: return None row = self.rows[index.row()] key = ['emp_no', 'emp_name', 'gender', 'dept_name', 'pos_name', 'hire_date', 'status'][index.column()] val = row[key] if key == 'gender': return {0: '未知', 1: '男', 2: '女'}.get(val, '未知') if key == 'status': return {1: '在职', 2: '离职', 3: '试用'}.get(val, '未知') return str(val) if val is not None else ''beginResetModel()/endResetModel()这一对是告诉视图「数据整体换了,别做增量更新」,比自己发dataChanged信号省心,代价是视图会重建,行数上千时有点闪。分页page_size设 20 是配合窗口高度,一屏刚好放得下不用滚动。LIMIT %s OFFSET %s里的参数依然是占位符,PyMySQL 会把它当整数处理,不会有注入问题。
搜索用LIKE '%kw%',前置通配符会让idx_emp_name索引失效,但在这个数据量下无所谓;如果哪天员工涨到几万,就该换成LIKE 'kw%'或者上全文索引。这是典型的「先跑通再优化」,别一上来为了理论上的性能把代码写复杂。
删除操作务必做成软删除——把status置成 2 而不是DELETE FROM employee。人事数据涉及历史考勤和薪资,物理删除会让关联记录变成孤儿,而且真删错了没有任何恢复手段。列表查询里那个e.status <> 2就是配合软删除的过滤条件。
3.3 部门和岗位联动:级联下拉与事务写入
新增员工时,部门下拉框一变,岗位下拉框就得跟着换成该部门下的岗位。这是 Qt 里最典型的信号槽场景,但顺序写错就会翻车——如果在addItems之前连信号,程序初始化时就会触发一次查询,用户还没选部门呢,先报个空指针。
正确顺序是:先blockSignals(True)填充数据,填完再blockSignals(False)放开,最后手动触发一次联动。
# emp_form.py 片段 def init_dept_combo(self): self.cmb_dept.blockSignals(True) # 填充期间屏蔽信号 self.cmb_dept.clear() depts = self.db.query_all("SELECT dept_id, dept_name FROM department") for d in depts: self.cmb_dept.addItem(d['dept_name'], d['dept_id']) self.cmb_dept.blockSignals(False) self.cmb_dept.currentIndexChanged.connect(self.on_dept_changed) self.on_dept_changed() # 手动触发首次联动 def on_dept_changed(self): dept_id = self.cmb_dept.currentData() self.cmb_pos.clear() if dept_id is None: return for p in self.db.query_all( "SELECT pos_id, pos_name FROM position WHERE dept_id = %s", (dept_id,)): self.cmb_pos.addItem(p['pos_name'], p['pos_id'])addItem(text, userData)的第二个参数是关键,它把主键藏在条目里,取值时用currentData()直接拿 ID,不用再按名称反查一次数据库。这个习惯能避免同名部门带来的歧义——现实里「技术部」和「技术部(分部)」很容易同时存在。
保存员工信息时,如果业务上要求「写入员工 + 初始化当年考勤记录」一起成功,就必须用事务包起来:
def save_employee(self, form: dict): conn = self.db.pool.connection() cur = conn.cursor() try: cur.execute( """INSERT INTO employee (emp_no, emp_name, gender, id_card, phone, dept_id, pos_id, hire_date, status) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)""", (form['emp_no'], form['emp_name'], form['gender'], form['id_card'] or None, form['phone'], form['dept_id'], form['pos_id'], form['hire_date'], 1)) emp_id = cur.lastrowid # 同事务内初始化一条考勤占位记录 cur.execute( "INSERT INTO attendance (emp_id, att_date, work_days) " "VALUES (%s, %s, 0)", (emp_id, form['hire_date'])) conn.commit() except pymysql.err.IntegrityError as e: conn.rollback() # 1062 = 唯一键冲突,直接告诉用户哪一项重复了,比抛堆栈友好 if e.args[0] == 1062: raise ValueError('工号或身份证号已存在,请检查') raise finally: cur.close() conn.close()form['id_card'] or None这个写法有讲究:空字符串如果直接入库,第二个没填身份证的员工就会撞uk_id_card唯一键;转成 None 之后 MySQL 按「NULL 互不相等」处理,不触发约束。这是很多人在批量导入员工时踩的坑,报错信息还是一句干巴巴的Duplicate entry '' for key 'uk_id_card',不看表结构根本反应不过来。
3.4 月度统计:一条 GROUP BY 出报表
人事系统最有价值的功能其实是月底那张汇总表。别在 Python 里循环查每个人再累加,那是 N+1 查询,100 个人就是 101 次数据库交互。一条带GROUP BY和LEFT JOIN的 SQL 直接出结果:
SELECT e.emp_no, e.emp_name, d.dept_name, IFNULL(SUM(a.work_days), 0) AS total_days, IFNULL(SUM(a.late_cnt), 0) AS total_late, IFNULL(SUM(a.leave_days), 0) AS total_leave FROM employee e JOIN department d ON d.dept_id = e.dept_id LEFT JOIN attendance a ON a.emp_id = e.emp_id AND a.att_date BETWEEN %s AND %s WHERE e.status = 1 GROUP BY e.emp_id, e.emp_no, e.emp_name, d.dept_name ORDER BY d.dept_name, e.emp_no;必须用LEFT JOIN而不是INNER JOIN,因为当月新入职还没录考勤的员工也要出现在表里,只是天数为 0,IFNULL保证不会显示成 NULL。日期范围条件写在ON里而不是WHERE里,这个区别很致命:写在WHERE里会把左连接退化成内连接,没考勤记录的人直接消失。
导出这张表到 Excel 时,常见做法是openpyxl逐行写,但要注意把decimal.Decimal类型转成 float,否则写进去是字符串,用户在 Excel 里求和全是 0。金额类字段我一般统一在查询后做一次float()转换,宁可损失一点精度也不要在交付时被问「为什么合计不对」。
4. 打包与部署:让不装 Python 的同事也能双击运行
代码在自己机器上跑通只是及格线,真正交付时对方电脑上大概率没装 Python、没装 MySQL 客户端。这一步的坑比写业务代码多。
4.1 PyInstaller 打包:参数、隐藏导入与体积裁剪
基础命令很简单:
pyinstaller -F -w -n HRSystem main.py --icon=app.ico-F打成单文件,-w去掉控制台窗口(GUI 程序必须加,否则会拖一个黑框),-n指定 exe 名。但直接这么打,十有八九运行闪退,因为 PyQt5 的 Qt 插件目录(platforms、imageformats 这些 dll)默认不一定被完整收集。稳妥的做法是写 spec 文件,显式声明:
# HRSystem.spec 关键片段 a = Analysis( ['main.py'], pathex=[], binaries=[], datas=[('config.ini', '.'), ('resources', 'resources')], hiddenimports=['pymysql', 'dbutils'], hookspath=[], runtime_hooks=[], excludes=['matplotlib', 'numpy', 'pandas', 'tkinter'], noarchive=False, )hiddenimports里那两个必须加,因为 PyMySQL 和 DBUtils 有些模块是运行时动态导入的,静态分析扫不到。excludes是减体积的关键——PyQt5 环境里如果顺带装了 numpy、pandas,打包器会把它们整个塞进去,体积从 30MB 涨到 200MB 以上,启动还慢。datas负责把配置文件和图标资源带进包内,注意元组的第二个元素是目标目录,'.'表示 exe 同级。
打包完成后先在干净的虚拟机或另一台没装 Python 的机器上测一遍。很多人只在自己机器上双击能跑就以为完事,结果同事那边报Failed to execute script main,实际原因是缺VCRUNTIME140.dll——这是 Windows 上的 MSVC 运行库,PyQt5 依赖它。解决方式要么让用户装一遍 VC++ 运行库,要么在打包时把vcruntime140.dll一并带上。
4.2 配置外置:一份 ini 管住所有环境差异
数据库地址、端口、账号绝对不能写死在代码里再打包,否则换个环境就得重新打包一次。我的做法是 exe 同级放一个config.ini,启动时读,读不到就用默认值并在界面上提示。
[db] host = 127.0.0.1 port = 3306 user = hr_app password = change_me database = hr_simple charset = utf8mb4 [app] page_size = 20 auto_backup = 1读取用标准库configparser,注意它有个行为容易让人困惑:get()返回的永远是字符串,getint()/getboolean()才有类型转换。密码里有%字符时会触发插值报错,得用configparser.ConfigParser(interpolation=None)关掉插值。
import configparser, os, sys def load_config(): # 打包后 sys.executable 是 exe 路径,未打包时是 python.exe, # 用 __file__ 的目录更稳妥 base = os.path.dirname(os.path.abspath(sys.argv[0])) ini = os.path.join(base, 'config.ini') cp = configparser.ConfigParser(interpolation=None) cp.read(ini, encoding='utf-8') return cp数据库连不上时,别只弹一个「连接失败」。把pymysql抛出的错误码翻译成人话:2003 是服务没起或端口不对,1045 是账号密码错,1049 是库不存在。这三种情况在部署现场出现的频率远高于其他,直接告诉用户该改哪一行配置,能省掉大量来回沟通。
4.3 首次启动初始化:自动建表与默认账号
交付给非技术用户时,最好让对方只做一件事:装好 MySQL,双击 exe。程序启动时检查employee表是否存在,不存在就执行建表 SQL,并插入一个默认管理员账号,同时强制首次登录必须改密码。
def ensure_schema(db): exists = db.query_one( "SELECT COUNT(*) AS c FROM information_schema.tables " "WHERE table_schema = DATABASE() AND table_name = 'employee'") if exists['c'] == 0: with open('schema.sql', encoding='utf-8') as f: for stmt in f.read().split(';'): if stmt.strip(): db.execute(stmt) default_pwd = 'Init@123456' db.execute( "INSERT INTO sys_user (username, password, role) " "VALUES (%s, %s, %s)", ('admin', hash_password(default_pwd), 'admin')) return True # 返回 True 时弹窗提示初始密码 return False按分号切 SQL 是个偷懒但够用的做法,前提是schema.sql里不写存储过程(存储过程内部有分号,会被切碎)。这个小系统的建表脚本不含存储过程,所以没问题;如果后面要加触发器,就得换成正则匹配或者干脆把建表语句硬编码在 Python 列表里。
注意:默认密码一定要在首次登录后强制修改,并且
Init@123456这种写法只适合内网小范围使用。真正的交付流程里,更稳妥的是让程序在首次启动时随机生成一个密码,显示在弹窗里让管理员抄下来。
5. 避坑与排查:五个真实翻车现场
这一章记录的都是实际调试时撞过的墙,每个都按「现象 → 原因 → 解决」写,遇到了直接对号入座。
现象一:界面上中文全显示成问号???,或者保存进去的姓名变成乱码。原因有两层,很多人只改了一层。第一层是数据库和表的字符集,建库时如果没写DEFAULT CHARSET=utf8mb4,会继承服务器默认值,某些版本默认还是 latin1。第二层是连接字符集,pymysql.connect()里不传charset参数时默认用 latin1 通信,即使表是 utf8mb4 也会在传输层被转坏。解决:建库建表显式指定 utf8mb4,连接参数里加charset='utf8mb4',两处都改,缺一不可。改完重启程序,已经写坏的旧数据需要重新录。
现象二:打包后的 exe 双击后窗口一闪就没了,命令行运行却正常。原因是-w参数把控制台藏了,Python 的异常堆栈无处可去。解决:先去掉-w重新打包,在命令行里跑,看真实报错。90% 的情况是Could not find or load the Qt platform plugin "windows",根因是 PyQt5 的platforms/qwindows.dll没被收进去。可以在 spec 的binaries里手动加上,或者更简单的办法——用--add-data把整个PyQt5/Qt5/plugins/platforms目录带进去,并在程序入口最前面设置环境变量:
import os, sys if getattr(sys, 'frozen', False): os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = os.path.join( sys._MEIPASS, 'PyQt5', 'Qt5', 'plugins', 'platforms')现象三:修改了表格里的单元格,切到别的页面再回来,改动没了。用的是QSqlTableModel,但editStrategy没设。默认策略在某些 Qt 版本下是OnRowChange,某些场景下改动只在内存里,视图重建就丢。解决:显式设model.setEditStrategy(QSqlTableModel.OnManualSubmit),然后调submitAll()提交,失败时revertAll()回滚。注意OnManualSubmit下如果表没有主键,submitAll()会静默失败,所以绑定的视图必须来自带主键的真实表或可更新视图。
现象四:日期字段存进去少一天,或者显示成2024-03-01T00:00:00。QDateEdit.date()返回的是QDate,直接塞给 PyMySQL 会走默认的str()转换,格式是PyQt5.QtCore.QDate(2024, 3, 1),数据库解析不了。正确做法是.toString('yyyy-MM-dd')转成字符串再入库。反向从库里读DATE类型时,PyMySQL 返回的是datetime.date对象,要显示在QDateEdit上必须QDate(y, m, d)转换,中间少一步就会抛类型错误。
现象五:程序跑一段时间后点任何按钮都报InterfaceError (0, ''),重启又好。典型的连接空闲超时。MySQL 默认wait_timeout是 28800 秒(8 小时),但很多云数据库或者公司运维会调到几十秒到几分钟。桌面程序开着不动,连接在池里躺着,服务端先掐了,客户端还以为活着。解决分两层:连接池参数加ping=1(每次取连接前探活),同时把wait_timeout和程序的心跳对齐。如果用的是自己的封装而不是 PooledDB,就在取连接的地方加一个conn.ping(reconnect=True)。这个问题排查起来特别费劲,因为本地测试永远复现不了,只有用户挂机半天后才会报。
6. 进阶:用视图和索引把统计查询压下来,附一份上线前自检表
基础功能跑通之后,最先感到慢的往往是月度统计页。100 人规模时那条GROUP BY查询可能只要几十毫秒,等你导入三年的考勤数据(十万行级别),点一次「查询」界面会僵住两秒。这时候别急着上缓存,先把执行计划看一眼。
在 MySQL 里对那条统计 SQL 前面加EXPLAIN,重点看attendance表的type列。如果是ALL,说明在做全表扫描,rows列会显示扫描行数约等于总行数。加一个联合索引就能把它压到range:
-- 覆盖 att_date 范围扫描 + emp_id 关联,顺序不能反 ALTER TABLE attendance ADD INDEX idx_date_emp (att_date, emp_id);索引列的顺序在这里很关键。查询条件是att_date BETWEEN ? AND ?加emp_id关联,把范围查询的列放前面、等值关联的列放后面,才能让 MySQL 先用索引缩小日期区间,再在区间内做关联。反过来建(emp_id, att_date),因为emp_id是关联条件不是常量条件,优化器大概率还是走全表。这是索引设计里最容易搞反的一点,记住口诀:范围在前,等值在后,排序字段看情况。
第二个优化手段是把复杂统计固化成视图,让界面层只写SELECT * FROM v_monthly_report WHERE month = ?:
CREATE OR REPLACE VIEW v_monthly_report AS SELECT DATE_FORMAT(a.att_date, '%Y-%m') AS month, e.emp_id, e.emp_no, e.emp_name, d.dept_name, SUM(a.work_days) AS total_days, SUM(a.late_cnt) AS total_late, SUM(a.leave_days) AS total_leave FROM attendance a JOIN employee e ON e.emp_id = a.emp_id JOIN department d ON d.dept_id = e.dept_id GROUP BY month, e.emp_id, e.emp_no, e.emp_name, d.dept_name;视图的好处是逻辑只维护一份,界面、导出、报表三处调用同一套聚合规则,不会出现「界面上算出来 21.5 天、导出 Excel 是 21 天」这种对不上的尴尬。代价是视图不存数据,每次查都实时算,所以它必须配合上面的索引才有意义——这也是为什么我把索引放在视图前面讲。如果数据量再涨一个数量级,就该考虑加一张按月汇总的物理表,在每月结算时跑一次批处理写进去,查询直接读汇总表。但那属于下一个阶段的事,别提前优化。
最后是我每次交付前都会走一遍的自检表,逐项打勾,能挡掉大部分「用户第一次打开就报错」的场面:
| 检查项 | 判定标准 | 常见疏漏 |
|---|---|---|
| 字符集一致性 | 库、表、连接三处都是 utf8mb4 | 只改了表,连接还是 latin1 |
| 配置文件可写 | exe 同级 config.ini 存在且可读 | 装到 Program Files 下无写权限 |
| 首次启动建表 | 空库启动能自动建表并提示初始密码 | schema.sql 没打进包 |
| 默认账号可登录 | admin 能进,且强制改密流程生效 | 哈希算法与校验不一致 |
| 打包环境干净 | 无 Python 的机器上双击可运行 | 缺 vcruntime140.dll |
| 异常有兜底 | 断网、改错配置时弹人话提示 | 直接抛 traceback 白屏 |
| 软删除生效 | 删除后列表不显示,历史考勤仍在 | 误用物理 DELETE 造成孤儿数据 |
这张表里我最看重的是「异常有兜底」和「打包环境干净」两项,因为它们直接决定用户对你的第一印象。功能少一点用户能忍,第一次打开就崩、崩了还只给一串英文堆栈,基本就没人愿意再用第二次了。
从那以后我每次做这类桌面小系统,都会在交付前找一台从没装过开发环境的机器,从零走一遍「装 MySQL → 放 exe → 双击 → 登录 → 录一条数据 → 导出报表」的完整链路,任何一步卡住都当场改掉再重新打包。这个习惯帮我提前发现过插件缺失、权限不足、默认密码没改这三类问题,比事后远程支持省太多事。资源包里按章节分好了db/、ui/、pack/三个目录,建议的阅读顺序是先跑通db/schema.sql,再接ui/main.py,最后才碰打包配置——跳步只会让你在闪退时无从下手。希望帮到你。
本文还有配套的精品资源,点击获取