简介:这是一份基于PyQt5的学生信息管理系统Python源码,主要面向Python初学者、期末作业及毕业设计场景。项目实现了学生信息的增删改查,采用PyQt5构建GUI界面,pymysql连接MySQL数据库,xlwt模块支持导出Excel,完整覆盖桌面应用开发的常见技术链。压缩包内含34个文件,包括8个py源码、7个ui界面文件、对应pyc文件、数据库sql脚本、配置文件及说明文档,整体大小仅257KB,结构清晰便于学习。目前已有466人学习下载,适合希望快速上手PyQt5与数据库结合开发的读者,可直接运行sql脚本初始化数据库,并参考源码理解各模块调用关系。
1. PyQt5学生信息管理系统:烂大街的题目才是真正见功力的地方
PyQt5学生信息管理系统大概是毕业设计里出现频率最高的选题之一,很多人觉得它太普通,甚至想换个“看起来高级”的方向。但我的看法恰恰相反:一个能稳定运行的桌面管理系统,需要把界面线程、数据库事务、字符编码和打包发布四个环节同时处理干净,任何一个环节翻车,答辩现场都会很难看。这套“Python源码 + SQL脚本”的组合,其实是一套完整的地基,而不是一个敷衍的作业。
这篇文章我按自己做项目的顺序来讲:先说清楚为什么选PyQt5和SQLite,再给出一份能直接跑的建表SQL脚本和源码骨架,接着走完登录、增删改查、导出和打包的完整链路,最后把我踩过的几个坑挨个拆开。新手可以照着一步步做,熟手可以直接跳到第5章看边界、参数和那些真正让人头疼的细节。
2. 选型与工程结构:PyQt5 + SQLite 的组合为什么是毕业设计的稳妥答案
2.1 三种方案的取舍:Tkinter、Web 和 PyQt5 到底怎么选
先说结论:如果是单机演示、要现场双击就能跑的毕业设计,PyQt5 是三个方案里风险最低的。这不是说别的方案不行,而是答辩场景的特殊性决定的。
Tkinter 的问题不在功能,在观感。默认控件样式比较老旧,很难做出现代应用的质感,评委看到界面第一印象就会减分。虽然可以额外花时间做样式,但性价比不高。Web 方案刚好反过来:Flask 或 Django 写个管理后台很容易,前端组件也漂亮,但它把“能跑”这件事变复杂了——需要浏览器、需要启动服务、需要端口不被占用。评委随口一句“你现场启动一下”,如果你没有准备离线兜底,很容易当场翻车。
PyQt5 在中间取得了一个平衡:控件成熟、支持 QSS 换肤、可以打包成单个 exe,双击即用,不依赖外部环境。而且 Qt 的信号槽机制和数据库操作的回调逻辑很匹配,代码写起来直观。这里给一份对比表,方便你写论文里的“技术选型”章节直接用。
| 方案 | 界面观感 | 部署复杂度 | 答辩风险 | 学习成本 |
|---|---|---|---|---|
| Tkinter | 一般 | 低,但需要 Python 环境 | 中等 | 低 |
| Web (Flask) | 高 | 高,需浏览器加服务 | 高 | 中 |
| PyQt5 | 较高 | 低,可打包 exe | 低 | 中 |
如果你所在实验室没有指定技术栈,选 PyQt5 还有一个隐藏好处:它天然绑定“面向对象”“事件驱动”这些课程重点,论文里好展开,答辩时也好讲。如果题目明确要求 Web 技术栈,那就另说,但这条线不在本笔记范围内。
2.2 数据库选型:SQLite 默认够用,什么时候才需要换成 MySQL
数据库是这类系统的另一半。常见选择是 SQLite 和 MySQL,取舍标准其实很简单:你的系统要不要被评委要求“多人同时访问”。
SQLite 是单文件数据库,不需要安装服务,Python 标准库自带 sqlite3 模块,这意味着你的项目少一个依赖、少一个环境变量、少一次“数据库服务没启动”的尴尬。对于学生信息管理这种只有几十条测试数据的场景,SQLite 的性能没有任何压力。我一般默认用 SQLite,直到出现三种情况才换 MySQL:课程设计明确要求使用、演示时需要多台电脑同时连同一个库、导师要求体现 C/S 架构中的服务端概念。
要换 MySQL 也简单,前提是先把连接层隔离好。比如在 db.py 里写一个 get_connection() 函数,是返回 sqlite3.connect 还是 pymysql.connect 由配置决定,业务代码只认 cursor 和 execute。这样切换成本就只剩连接串和方言差异。一个容易忽略的点是:SQLite 自增主键用 AUTOINCREMENT,MySQL 用 AUTO_INCREMENT。如果 SQL 脚本想两套库都跑,要么写两份脚本,要么在建表语句里做模板拼接。常见做法是直接准备 init_sqlite.sql 和 init_mysql.sql 两份,反而最不容易出错。
2.3 源码目录怎么组织:main.py、db.py、ui 目录各管什么
这部分是给要交源码的同学准备的。很多毕业设计的源码只求能跑,全部逻辑堆在一个 main.py 里,功能少时没问题,一旦加了登录、查询、导出,几千行的单文件改起来很痛苦,论文里的“系统设计”章节也写不出层次感。
我一般用一个四层结构:
project/ ├── main.py # 程序入口:创建连接、拉起登录窗口 ├── db.py # get_connection / init_db / 执行SQL脚本 ├── models/ # 数据访问层,只写SQL,不写界面逻辑 │ └── student_dao.py ├── ui/ # 界面层,只处理控件和信号槽 │ ├── login_dialog.py │ └── main_window.py └── sql/ └── init.sql # 建表 + 管理员账号 + 示例数据main.py 只做三件事:初始化数据库、创建 QApplication、弹出登录窗口。db.py 负责所有和数据库文件打交道的逻辑,包括执行 sql/init.sql。models 层里的每一个函数对应一条业务操作,比如 add_student()、search_students(keyword)。ui 层只调用 models 的函数,不直接拼 SQL。这样分层之后,论文里的“表现层与数据访问层分离”你就有了实际内容可以论述。
关于界面是用 Qt Designer 拖还是纯代码写,我建议纯代码。Qt Designer 生成的 .ui 文件需要 pyuic5 转成 .py,多一道工具链,而且评委让你现场改个按钮时,你在 Design 界面和代码之间切换会很乱。纯代码虽然写起来慢一点,但所有控件都在一个文件里,改起来直接,答辩演示时底气更足。
2.4 连接数据库的最小骨架:外键开关和文件路径一起处理
无论怎么分层,db.py 里最核心的就一段逻辑:
import sqlite3 def get_connection(db_path): # SQLite默认不开启外键约束,这里手动打开,级联删除才能生效 conn = sqlite3.connect(db_path, timeout=5) conn.execute("PRAGMA foreign_keys = ON") return conn def init_db(db_path, sql_path): conn = get_connection(db_path) try: with open(sql_path, "r", encoding="utf-8") as f: script = f.read() conn.executescript(script) conn.commit() finally: conn.close()timeout=5 表示数据库被占用时最多等待 5 秒,避免立刻抛出 database is locked。PRAGMA 外键开关必须紧跟 connect 之后执行,否则建表语句里的 FOREIGN KEY 只写不生效。executescript 适合一次性执行整个初始化脚本,但它不支持参数占位,所以初始化数据里的密码哈希需要提前算好写进 SQL 里。
3. 把SQL脚本先立住:建表、外键和初始化数据这样写才不容易返工
3.1 核心表的设计:dept、student、score 三张表就够了
学生信息管理系统听起来功能很多,但落到数据库其实就是三张核心表:院系表 dept、学生表 student、成绩表 score,再加一张用户表 user 做登录。有些设计会把班级单独拎出来,但如果题目没有强调班级维度,我建议先不加,免得外键链条越长,后面增删改查的联动逻辑越难写。
先看建表脚本:
CREATE TABLE IF NOT EXISTS dept ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS student ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no TEXT NOT NULL UNIQUE, name TEXT NOT NULL, gender TEXT NOT NULL DEFAULT '男' CHECK(gender IN ('男', '女')), dept_id INTEGER, enroll_date TEXT, phone TEXT, FOREIGN KEY (dept_id) REFERENCES dept(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS course ( id INTEGER PRIMARY KEY AUTOINCREMENT, course_no TEXT NOT NULL UNIQUE, name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS score ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id INTEGER NOT NULL, course_id INTEGER NOT NULL, score REAL NOT NULL CHECK(score BETWEEN 0 AND 100), FOREIGN KEY (student_id) REFERENCES student(id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(id) ON DELETE CASCADE );几个字段设计上的关键点:student_no 用 TEXT 不用 INTEGER,因为学号可能包含字母且不做数学运算,TEXT 还能避免前导零被自动吞掉。phone 同样用 TEXT,否则超过 11 位或带区号时会出问题。score 表的成绩字段加了 0 到 100 的 CHECK 约束,从数据库层面拦截非法录入,比只在界面层校验要可靠。
外键策略的选择也值得在论文里写一笔:dept 被删除时,student 表的 dept_id 置为 NULL,这样不会因为删除院系而连带删除整个学生档案;而 student 被删除时,score 成绩记录级联删除,避免产生孤儿数据。这两条规则分别对应 ON DELETE SET NULL 和 ON DELETE CASCADE。
3.2 执行SQL脚本的两种姿势:executescript 与逐条 execute 的差别
第 2 章的 init_db 用的是 executescript,一把梭执行整个文件。优点是简单,适合初始化;缺点是一条语句报错,整个脚本中断,你却很难定位到具体哪一行。所以在开发阶段,我更推荐把脚本拆成小块用 execute 逐条跑,让错误信息精确到具体语句。
import sqlite3 def execute_sql_lines(conn, sql_path): with open(sql_path, "r", encoding="utf-8") as f: sql_text = f.read() statements = [s.strip() for s in sql_text.split(";") if s.strip()] cursor = conn.cursor() for stmt in statements: try: cursor.execute(stmt) except sqlite3.Error as e: print(f"执行失败: {stmt[:50]}...") print(f"错误信息: {e}") raise conn.commit()按分号切割是简单做法,只适用于自己的初始化脚本。如果脚本里写了触发器或者带分号的存储过程,这种粗暴切割就会出错,那种情况建议直接用 executescript 或使用官方推荐的脚本解析方式。毕业设计用分号切割已经够用。另外注意,每条语句失败时把前 50 个字符和错误信息打出来,这是定位问题最快的手段,比看一整块 traceback 舒服得多。
3.3 初始化数据:管理员账号的密码不能明文落在 SQL 里
初始化数据分两类:一类是演示用的业务数据,另一类是登录账号。业务数据随便造,但要贴近真实场景,比如院系名称、学号格式、入学日期尽量合理,因为评委可能会现场点开表格检查数据质量。登录账号则有一条硬要求:密码不能明文写进 SQL 脚本。
常见做法是预先把密码算成 MD5 再写进脚本:
import hashlib # 在本地算好哈希再填到SQL里,不要直接写明文 print(hashlib.md5("admin123".encode("utf-8")).hexdigest())SQL 脚本里存的是 hexdigest 结果,登录验证时对用户输入做同样的 MD5 再比对。MD5 在今天不算安全的加密方案,但在毕业设计场景里,它的意义是让论文能写一句“密码经过哈希后存储”,比直接暴露明文强一个档次。如果还有余力,用 hashlib.pbkdf2_hmac 加盐会更好,但代码量和论文章节都会增加,自己权衡。
初始化数据的写法有一些细节:
INSERT OR IGNORE INTO user (username, password, role) VALUES ('admin', '上面算出的哈希值', 'admin'); INSERT OR IGNORE INTO user (username, password, role) VALUES ('visitor', '上面算出的哈希值', 'user'); INSERT OR IGNORE INTO dept (name) VALUES ('计算机学院'), ('机械学院'), ('经管学院');INSERT OR IGNORE 的价值在于:重复执行脚本不会因为主键冲突而中断。你调试时要反复跑 init_db,没有这个保护,第二次执行就会撞唯一约束。让初始化脚本具备幂等性,跑两次、跑三次都不报错,这是脚本设计里很容易被忽略但很实用的习惯。
3.4 SQL脚本的路径问题:开发环境和打包后是两个世界
如果 SQL 脚本用相对路径,比如 sql/init.sql,开发时能跑,打包成 exe 之后大概率会崩。原因很简单:exe 运行时的工作目录不一定等于 exe 所在目录,脚本里写的相对路径可能根本找不到文件。
解决办法是给资源文件做一个“双环境路径”处理:
import sys import os def resource_path(relative): # PyInstaller打包后,资源文件会被解压到临时目录 _MEIPASS base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) return os.path.join(base, relative)getattr(sys, "_MEIPASS", ...) 的含义是:如果程序运行在 PyInstaller 解压出的临时目录里,就用临时目录;否则用当前代码文件所在目录。调用时写成 resource_path("sql/init.sql") 即可。这样开发时和打包后都指向同一个逻辑路径,避免“我电脑上正常,打包后双击闪退”的经典翻车。这条先放这里,第 5 章还会再踩一遍。
4. 用PyQt5把登录和增删改查跑通:从QDialog到QTableWidget的完整链路
4.1 登录窗口:QDialog + 参数化查询,先把鉴权链路立起来
登录窗口是系统的门面,代码量不大,但把两个关键设计固化下来了:密码哈希后比对、SQL 全部参数化。
import hashlib from PyQt5.QtWidgets import (QDialog, QLineEdit, QPushButton, QVBoxLayout, QMessageBox) class LoginDialog(QDialog): def __init__(self, conn): super().__init__() self.conn = conn self.role = None self.setWindowTitle("学生信息管理系统 - 登录") self.username_edit = QLineEdit() self.username_edit.setPlaceholderText("用户名") self.password_edit = QLineEdit() self.password_edit.setPlaceholderText("密码") self.password_edit.setEchoMode(QLineEdit.Password) # 掩码显示 login_btn = QPushButton("登录") login_btn.clicked.connect(self.check_login) layout = QVBoxLayout() layout.addWidget(self.username_edit) layout.addWidget(self.password_edit) layout.addWidget(login_btn) self.setLayout(layout) def check_login(self): username = self.username_edit.text().strip() password = hashlib.md5(self.password_edit.text().strip().encode()).hexdigest() cursor = self.conn.cursor() cursor.execute( "SELECT role FROM user WHERE username = ? AND password = ?", (username, password) ) row = cursor.fetchone() if row: self.role = row[0] self.accept() # 关闭登录框,进入主窗口 else: QMessageBox.warning(self, "登录失败", "用户名或密码错误")setEchoMode(QLineEdit.Password) 让密码输入显示为圆点,这是评委一眼能看到的细节。参数化查询用 ? 占位符把输入值和 SQL 语句分开传给数据库,避免在字符串里拼引号导致的注入或语法错误。登录成功后通过 accept() 关闭对话框,再由 main.py 里的返回值决定是否进入主窗口。
main.py 里衔接登录和主窗口的逻辑看这段:
import sys from PyQt5.QtWidgets import QApplication from db import get_connection, init_db, resource_path from ui.login_dialog import LoginDialog from ui.main_window import MainWindow if __name__ == "__main__": app = QApplication(sys.argv) db_path = "students.db" init_db(db_path, resource_path("sql/init.sql")) conn = get_connection(db_path) login = LoginDialog(conn) if login.exec() == login.Accepted: window = MainWindow(conn, login.role) window.show() sys.exit(app.exec_())exec() 是模态执行的入口,返回 Accepted 表示用户登录成功,这时才创建主窗口。整个程序生命周期只维护数据库里这一个连接对象并向下传递,避免多处 connect 带来的资源泄漏问题。
4.2 主窗口与 QTableWidget:数据刷新函数要单独抽出来
主窗口的核心是一个 QTableWidget,所有查询结果都往里面填。一定要把“查询数据库 + 填表格”的逻辑抽成一个 load_data() 方法,因为增加、删除、修改之后都要重新调它来刷新界面,写重复代码最容易出漏刷新或者查错数据的 bug。
from PyQt5.QtWidgets import (QMainWindow, QTableWidget, QTableWidgetItem, QAbstractItemView, QLineEdit, QPushButton, QHBoxLayout, QVBoxLayout, QWidget) class MainWindow(QMainWindow): def __init__(self, conn, role): super().__init__() self.conn = conn self.role = role self.setWindowTitle("学生信息管理系统") self.resize(800, 500) self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("输入姓名或学号搜索") self.search_edit.textChanged.connect(self.load_data) self.add_btn = QPushButton("新增") self.edit_btn = QPushButton("编辑") self.delete_btn = QPushButton("删除") self.export_btn = QPushButton("导出CSV") self.table = QTableWidget() self.table.setColumnCount(6) self.table.setHorizontalHeaderLabels( ["学号", "姓名", "性别", "院系", "入学日期", "电话"]) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) # 表格只读 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) # 整行选中 top_bar = QHBoxLayout() top_bar.addWidget(self.search_edit) top_bar.addWidget(self.add_btn) top_bar.addWidget(self.edit_btn) top_bar.addWidget(self.delete_btn) top_bar.addWidget(self.export_btn) layout = QVBoxLayout() layout.addLayout(top_bar) layout.addWidget(self.table) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) self.add_btn.clicked.connect(self.add_student) self.edit_btn.clicked.connect(self.edit_student) self.delete_btn.clicked.connect(self.delete_student) self.export_btn.clicked.connect(self.export_csv) self.load_data()setEditTriggers(NoEditTriggers) 让表格内容不可直接修改,编辑必须走“编辑”按钮,这是数据一致性上的考虑,也避免用户误触改坏数据。setSelectionBehavior(SelectRows) 保证点击任意单元格都选中整行,方便后面的编辑和删除操作取当前行数据。search_edit 的 textChanged 信号在输入变化时自动触发 load_data,实现实时搜索,不需要单独放一个“搜索”按钮。
4.3 load_data 与模糊查询:LIKE 的通配符不要拼在 SQL 里
load_data 是主窗口最常被调用的方法,写不好就会出现“搜索后空数据”“中文搜索不到”这类问题。
def load_data(self, keyword=""): cursor = self.conn.cursor() if keyword: cursor.execute( "SELECT s.student_no, s.name, s.gender, d.name, s.enroll_date, s.phone " "FROM student s LEFT JOIN dept d ON s.dept_id = d.id " "WHERE s.name LIKE ? OR s.student_no LIKE ? " "ORDER BY s.student_no", (f"%{keyword}%", f"%{keyword}%") ) else: cursor.execute( "SELECT s.student_no, s.name, s.gender, d.name, s.enroll_date, s.phone " "FROM student s LEFT JOIN dept d ON s.dept_id = d.id " "ORDER BY s.student_no" ) rows = cursor.fetchall() self.table.setRowCount(len(rows)) for i, row in enumerate(rows): for j, value in enumerate(row): self.table.setItem(i, j, QTableWidgetItem(str(value)))注意 WHERE 里用的是 ? 占位,通配符 % 拼接在参数里传进去。如果写成 "WHERE s.name LIKE '%{keyword}%'",一旦姓名或学号里出现单引号,SQL 就会语法错误,这也是 SQL 注入最常见的入口之一。LEFT JOIN 保证学生即使还没有分配院系,dept.name 是 NULL,记录照样正常显示。str(value) 做一层转换,防止数据库返回的 None 直接传给 QTableWidgetItem 导致异常。
4.4 新增和编辑共用一个 QDialog:下拉框的值怎么取才是对的
新增和编辑的字段完全一样,我一般共用一个 StudentDialog 类,通过是否传入 student_no 来区分是更新还是插入。关键点在于院系下拉框要能映射回数据库里的 dept_id。
from PyQt5.QtWidgets import (QDialog, QFormLayout, QLineEdit, QComboBox, QDialogButtonBox, QMessageBox) class StudentDialog(QDialog): def __init__(self, conn, student_no=None, parent=None): super().__init__(parent) self.conn = conn self.student_no = student_no self.setWindowTitle("编辑学生" if student_no else "新增学生") self.no_edit = QLineEdit() self.name_edit = QLineEdit() self.gender_combo = QComboBox() self.gender_combo.addItems(["男", "女"]) self.dept_combo = QComboBox() cursor = conn.cursor() cursor.execute("SELECT id, name FROM dept ORDER BY id") for dept_id, dept_name in cursor.fetchall(): self.dept_combo.addItem(dept_name, dept_id) # 显示名称,数据存id self.enroll_edit = QLineEdit() self.enroll_edit.setPlaceholderText("如 2023-09-01") self.phone_edit = QLineEdit() form = QFormLayout() form.addRow("学号", self.no_edit) form.addRow("姓名", self.name_edit) form.addRow("性别", self.gender_combo) form.addRow("院系", self.dept_combo) form.addRow("入学日期", self.enroll_edit) form.addRow("电话", self.phone_edit) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.save) buttons.rejected.connect(self.reject) form.addRow(buttons) self.setLayout(form) def save(self): dept_id = self.dept_combo.itemData(self.dept_combo.currentIndex()) cursor = self.conn.cursor() try: if self.student_no: cursor.execute( "UPDATE student SET student_no=?, name=?, gender=?, dept_id=?, " "enroll_date=?, phone=? WHERE student_no=?", (self.no_edit.text().strip(), self.name_edit.text().strip(), self.gender_combo.currentText(), dept_id, self.enroll_edit.text().strip(), self.phone_edit.text().strip(), self.student_no) ) else: cursor.execute( "INSERT INTO student (student_no, name, gender, dept_id, " "enroll_date, phone) VALUES (?, ?, ?, ?, ?, ?)", (self.no_edit.text().strip(), self.name_edit.text().strip(), self.gender_combo.currentText(), dept_id, self.enroll_edit.text().strip(), self.phone_edit.text().strip()) ) self.conn.commit() self.accept() except Exception as e: self.conn.rollback() QMessageBox.critical(self, "保存失败", str(e))addItem(dept_name, dept_id) 把显示文本和数据库 id 绑在一起,取值时用 itemData(currentIndex()) 拿 dept_id,不依赖显示文本。这个映射关系是这类表单最常见的疏漏——直接从下拉框取 text() 拿去存库,表结构一变就出错。更新和插入共用同一个 save(),通过 self.student_no 是否有值来分支,代码量直接减半。事务用 commit() 和 rollback() 保证失败时数据不会半截入库。
另外记得编辑时要回填当前行数据到表单里,否则用户看到的是一张空白的新增表单。回填逻辑通过 student_no 查一次库,然后 setText 和 setCurrentText 即可,漏掉这个“编辑”按钮就形同虚设。
4.5 删除学生:先确认再动手,外键约束的坑提前避开
删除功能的代码很短,但有一个顺序问题值得注意:如果 score 表里已有该学生的成绩,外键策略是 ON DELETE CASCADE,删除学生会自动清掉成绩。这看起来很省事,但前提是数据库连接开启了外键 PRAGMA,否则 SQLite 会直接报 FOREIGN KEY constraint failed。
def delete_student(self): row = self.table.currentRow() if row < 0: return student_no = self.table.item(row, 0).text() reply = QMessageBox.question( self, "确认", f"确定删除学号为 {student_no} 的学生吗?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: cursor = self.conn.cursor() cursor.execute("DELETE FROM student WHERE student_no = ?", (student_no,)) self.conn.commit() self.load_data()QMessageBox.question 是模态确认框,这步能体现你对误操作保护有意识,答辩时是个加分细节。删除前先判断 currentRow() 是否有效,没有选中行就静默返回,比弹错误提示更友好。级联删除由数据库完成,不需要手动去删 score 表,但前提是 db.py 里 PRAGMA foreign_keys = ON 已经开启,这条和第 3 章的内容是直接呼应的。
5. PyQt5学生信息管理系统踩坑记录:5个最容易让程序当场翻车的问题
5.1 中文变乱码或“锟斤拷”:SQL脚本的编码没站对
现象:程序启动后,表格里的中文显示成乱码,或者 SQL 脚本里的中文注释变成“锟斤拷”;更隐蔽的是 Windows 控制台打印中文时直接报 UnicodeEncodeError。
原因:SQL 脚本文件本身不是 UTF-8 存储,或者 Python 打开文件时用了系统默认编码(Windows 下一般是 GBK)去读 UTF-8 内容;还有一种情况是文件被另存为带 BOM 的 UTF-8,导致第一条 SQL 语句前面多了一个看不见的字符,语法直接报错。
解决:所有源文件和 SQL 脚本统一用 UTF-8 保存,open 时显式传 encoding="utf-8"。我的排查顺序固定为:先用编辑器看 SQL 脚本右下角实际编码,再检查 open 的 encoding 参数,最后看数据库里存的原始字节。这个顺序能覆盖九成乱码场景。调试时还可以在 init_db 里捕获 UnicodeDecodeError 并打印文件路径,省得靠猜。
5.2 点击按钮后界面整个卡死:耗时操作堵住了 Qt 事件循环
现象:点击“查询”或“导入”按钮后,窗口变成半透明“未响应”状态,标题栏出现“无响应”,过几秒或几十秒才恢复,甚至直接崩溃。
原因:按钮 clicked 信号连接的槽函数在主线程里执行,如果槽函数里做了大量数据库查询或文件读取,Qt 的事件循环被阻塞,无法处理绘制和鼠标消息,操作系统就会判定程序无响应。
解决:耗时操作放进 QThread,通过信号把结果传回主线程更新界面。
from PyQt5.QtCore import QThread, pyqtSignal class LoadDataThread(QThread): finished = pyqtSignal(list) def __init__(self, conn, keyword): super().__init__() self.conn = conn self.keyword = keyword def run(self): cursor = self.conn.cursor() cursor.execute( "SELECT s.student_no, s.name, s.gender, d.name, s.enroll_date, s.phone " "FROM student s LEFT JOIN dept d ON s.dept_id = d.id " "WHERE s.name LIKE ?", (f"%{self.keyword}%",) ) self.finished.emit(cursor.fetchall()) # 使用:thread.finished.connect(主窗口的fill_table方法) # 然后 thread.start()QThread 只是一个工具,真正要做的是把复杂查询从 load_data 里挪到线程对象的 run() 方法中,用 finished 信号把结果带回主线程填充表格。只有几万条测试数据的场景不拆线程也跑得动,但答辩时如果评委现场导入一个大数据集,这个卡死会非常尴尬。另外提醒一句:子线程里不要再新建数据库连接,直接复用主线程传入的 conn,SQLite 在多线程下的隔离行为比界面卡死更深,别两头挖坑。
5.3 SQL注入和一删全库:字符串拼接把你的系统变透明
现象:搜索框输入 ' OR '1'='1 查出全部数据;输入包含 DROP TABLE 的语句时,程序报错或者数据直接消失。
原因:SQL 语句里用了字符串格式化,把用户输入直接拼进语句,数据库引擎把输入当成 SQL 代码执行了。
解决:所有 SQL 语句统一用参数化查询,execute 的第二个参数传元组。
# 危险写法 cursor.execute(f"SELECT * FROM student WHERE name = '{keyword}'") # 安全写法 cursor.execute("SELECT * FROM student WHERE name = ?", (keyword,))这不是理论问题,是毕业设计答辩时评委最爱提的问题之一。你只要在系统里有一处字符串拼接,评委就可能当场输入一个单引号试给你看。参数化之后,输入值会被当成纯数据传给数据库引擎,拼接由驱动层完成,这才是正解。顺带把 LIKE 的 % 通配符也放进参数里,一劳永逸。
5.4 PyInstaller打包后闪退:看不见控制台报错是最大的麻烦
现象:开发环境双击 main.py 一切正常,用 PyInstaller 打成 exe 后,双击直接闪退,没有任何提示。
原因:打包时用了 -w 参数隐藏控制台窗口,Python 的异常输出和 traceback 全被吞掉,你根本看不到程序崩在哪一行,只能干瞪眼。
解决:排查阶段先不要用 -w,改成控制台模式打包,用命令行运行 exe 看报错。
# 先这样打包,运行exe时能看到完整的traceback pyinstaller -F main.py # 确认无报错后再加-w隐藏控制台、加图标 pyinstaller -F -w -i app.ico main.py最常见的两类崩因:一是 SQL 脚本或图标等资源文件用了相对路径,打包后找不到,需要用第 3 章的 resource_path 处理;二是缺少 Qt 运行库,但 PyInstaller 通常会自动收集。还有一个隐藏坑:如果用了 Qt Designer 的 .ui 文件,打包时容易漏掉它,所以干脆用纯代码写界面,从根上避开资源文件缺失问题。
5.5 SQLite报错 database is locked:连接没关干净,事务没提交
现象:程序跑一段时间后,某个按钮触发操作时报 OperationalError: database is locked,重启程序又好了。
原因:某个连接执行完 SQL 后没有 close 或 commit,事务一直没结束,写锁一直被持有。Windows 下文件锁被进程占住,其他连接只能排队等,timeout 设短了就直接报错。
解决:统一用上下文管理器或者 try/finally 确保连接关闭。
# 写法一:with上下文管理事务 with get_connection(db_path) as conn: conn.execute("...") conn.commit() # 写法二:裸连接必须finally收尾 conn = get_connection(db_path) try: cursor = conn.cursor() cursor.execute("...") conn.commit() finally: conn.close()sqlite3 的 connection 对象虽然支持上下文管理器,但它只管理事务,不负责 close,所以需要配 closing 模块或者直接在 finally 里 close。我的习惯是继续在 db.py 里包一层 execute_query 和 execute_update 函数,把连接获取、执行、提交、关闭全部收拢进这两个函数,业务代码不直接碰连接对象,这样 database is locked 基本绝迹。如果项目真有多线程并发访问数据库,还需要给连接加锁,一般毕业设计到不了这一步,但知道有这回事。
6. 让系统从“能跑”变成“能答辩”:导出、打包参数和演示顺序
6.1 导出CSV:给评委一个能带走的数据文件
答辩时评委不会只看屏幕,他更想拿到一份数据文件自己翻。给导出按钮接一个 CSV 导出,用 utf-8-sig 编码,Excel 打开才不会乱码。
import csv from PyQt5.QtWidgets import QFileDialog def export_csv(self): path, _ = QFileDialog.getSaveFileName(self, "导出", "students.csv", "CSV (*.csv)") if not path: return with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) headers = [self.table.horizontalHeaderItem(i).text() for i in range(self.table.columnCount())] writer.writerow(headers) for row in range(self.table.rowCount()): writer.writerow([self.table.item(row, col).text() for col in range(self.table.columnCount())])text() 取无值单元格时会抛异常,所以 load_data 里一定要保证每个单元格都有值,或者导出前做空值判断。utf-8-sig 比 utf-8 多一个 BOM 头,Excel 靠它识别编码,这是导出文件不乱码的关键细节。openpyxl 功能更强但要给项目增加依赖,毕业设计用 CSV 已经足够。
6.2 打包参数:单文件、图标、静默窗口一次到位
最终打包命令参考这样写:
pyinstaller -F -w -i app.ico --add-data "sql/init.sql;sql" main.py-F 把所有内容打进一个 exe,-w 去掉黑色控制台,-i 指定图标,--add-data 把 sql 目录带进临时解压目录,配合第 3 章的 resource_path 就完整了。注意 Windows 下 --add-data 的分隔符是分号,Linux 和 macOS 是冒号,跨平台打包时容易踩。
6.3 答辩演示的黄金顺序:先讲表结构,再跑增删改查,最后导数据
我建议现场演示固定走四步:登录窗口输一次错误密码展示校验,再输正确密码进入;搜索框输入一个不存在的名字展示空结果,再输入真实名字展示命中;新增一个学生,刷新表格,再编辑这个学生;最后导出一份 CSV,用 Excel 打开给评委看。整个过程控制在 8 分钟以内,不现场写代码,但准备好关键代码截图,特别是参数化查询和数据库设计那段。
当初我第一次答辩时,在演示用的电脑上双击 exe 闪退,后来发现是那台机器缺系统运行库。从那以后,我养成一个习惯:每次打包完都换一台干净机器试跑一遍,这个习惯帮我避开了不少次答辩翻车。希望帮到你。
本文还有配套的精品资源,点击获取