简介:这是一款基于 PySide6 开发的实用刷题复习软件源代码,面向备考各类考试、需要反复刷题巩固知识点的学生与自学者,解决题库零散、答题记录难以追踪、复习效率低的问题。资源包共 9 个文件,以 5 个 js、2 个 py 脚本为主,辅以 1 个 xls 题库模板与 1 个 html 界面文件,压缩包约 92KB,界面由 HTML+CSS 渲染,核心逻辑由 Python 与 JavaScript 协同控制。软件支持导入 xlsx 与 xls 格式题库,可同时管理多个题库并自由切换,自动统计已答、正确、错误、未答题目,并保存历次答题记录供随时回看。作者持续迭代,先后将选项扩展至 ABCDEF 六项、修复非 GBK 编码导入为空与参考答案显示异常问题,并增加右侧滚动条以应对题目过多时的查阅困难。目前已有 448 人学习下载,适合希望快速搭建个人刷题工具或二次开发定制复习软件的读者参考借鉴。
1. 从 Excel 题库到 PySide6 桌面刷题工具:为什么我最终放弃了在线题库
去年带新人备考内部认证时,我试过市面上七八款刷题 App,最后都卡在同一个地方:题库不能自己定。要么只能选官方那几套,要么导入格式限制得死死的,想把自己整理的错题本塞进去,得先手动录几百道。更麻烦的是答题记录散在各处,今天在手机上刷了三十道,明天换电脑就查不到哪些题错过、哪些题压根没碰过。
后来我干脆用 PySide6 写了个桌面版刷题工具,核心逻辑就三件事:从 Excel 读题、把每次答题结果写回本地、用表格实时显示已答/正确/错误/未答的分布。Excel 题库的格式我自己定,一行一道题,题干、选项、答案、解析各占一列,用 pandas 读进来直接转成题目对象。PySide6 负责界面,QTableWidget 展示题目列表,QStackedWidget 切换答题和统计两个视图。答题记录用 SQLite 存,每次交卷写一条 session 记录,题目级别的对错另存一张表,这样既能看单次成绩,也能回溯某道题的历史正确率。
这套东西适合谁?如果你手头有现成的 Excel 题库——不管是培训资料、错题整理还是考试大纲拆解——又不想被在线平台绑住,愿意花一个周末把环境跑通,那这个方案能省掉大量重复录入的时间。下面我把从环境搭建到打包分发的完整路径拆开讲,中间踩过的坑也会标出来。
2. 用 pandas 读 Excel 题库:列名映射与脏数据清洗
2.1 题库 Excel 的列结构设计
我试过三种列名方案,最后固定成下面这套,原因是它跟大多数人的整理习惯最接近,迁移成本最低:
| 列名 | 含义 | 是否必填 | 示例 |
|---|---|---|---|
| 题干 | 题目正文 | 是 | 下列关于 TCP 三次握手的说法正确的是 |
| 选项A | 第一个选项 | 是 | 第一次握手客户端发送 SYN |
| 选项B | 第二个选项 | 是 | 第二次握手服务端发送 SYN+ACK |
| 选项C | 第三个选项 | 否 | 第三次握手客户端发送 ACK |
| 选项D | 第四个选项 | 否 | 三次握手完成后才能传输数据 |
| 答案 | 正确选项字母 | 是 | B |
| 解析 | 答案解析 | 否 | 第二次握手服务端同时确认客户端序列号 |
| 分类 | 题目所属章节 | 否 | 网络基础 |
答案列只填字母,多选的话用逗号分隔,比如A,C。分类列用来做筛选,不填就归到「未分类」。这套结构的好处是,你从任何文档里复制题目,只要按这个顺序粘进 Excel 就能用,不需要改代码。
2.2 读取与清洗的完整代码
import pandas as pd from dataclasses import dataclass, field from typing import List, Optional @dataclass class Question: qid: int stem: str options: List[str] answer: List[str] explanation: str = "" category: str = "未分类" def load_questions_from_excel(file_path: str) -> List[Question]: # 只读第一个 sheet,跳过完全空行 df = pd.read_excel(file_path, sheet_name=0, dtype=str) df = df.dropna(how="all") # 列名去空格,防止 Excel 里手滑多敲了空格 df.columns = [c.strip() for c in df.columns] required = ["题干", "选项A", "选项B", "答案"] for col in required: if col not in df.columns: raise ValueError(f"缺少必填列: {col}") questions = [] for idx, row in df.iterrows(): stem = str(row["题干"]).strip() if not stem or stem == "nan": continue # 空题干直接跳过,不报错 options = [] for opt_col in ["选项A", "选项B", "选项C", "选项D"]: if opt_col in df.columns: val = str(row.get(opt_col, "")).strip() if val and val != "nan": options.append(val) answer_raw = str(row["答案"]).strip().upper() # 支持逗号、顿号、空格分隔 answer = [a.strip() for a in answer_raw.replace("、", ",").replace(" ", ",").split(",") if a.strip()] explanation = str(row.get("解析", "")).strip() if explanation == "nan": explanation = "" category = str(row.get("分类", "未分类")).strip() if category == "nan": category = "未分类" questions.append(Question( qid=idx, stem=stem, options=options, answer=answer, explanation=explanation, category=category )) return questions这段代码的关键点有三个。第一,dtype=str强制所有列按字符串读,避免 pandas 把「1」和「1.0」当成不同东西,也避免答案列被转成浮点数。第二,dropna(how="all")只删全空行,不删部分空行,因为有些题目解析为空但题干和选项是完整的。第三,答案解析里做了分隔符归一化,、和空格都转成逗号再切分,这样用户从不同来源复制答案时不用手动改格式。
2.3 参数说明与常见读取失败
pd.read_excel的sheet_name参数默认是 0,也就是第一个工作表。如果你的题库在第二个 sheet,改成sheet_name=1或者直接写 sheet 名。dtype=str在 pandas 2.0 之后行为有变化,如果遇到TypeError,可以改成dtype=str, keep_default_na=False,这样空单元格会变成空字符串而不是nan,后面就不用反复判断== "nan"了。
读取失败最常见的原因是 Excel 文件被其他程序占用。Windows 上如果文件在 Excel 里开着,pandas 会抛PermissionError。解决办法是在代码里加一层重试,或者提示用户先关闭文件。另一个坑是.xls和.xlsx的引擎不同,.xls需要xlrd,.xlsx需要openpyxl,装 pandas 的时候不会自动装这两个,得手动pip install openpyxl xlrd。
3. PySide6 界面搭建:QTableWidget 展示题目与答题状态
3.1 主窗口布局与控件选型
界面我拆成左右两块:左边是题目列表,用QTableWidget,列分别是题号、题干摘要、状态、分类;右边是答题区,用QStackedWidget切换「答题」和「统计」两个页面。顶部放一个工具栏,包含「导入题库」「开始新练习」「查看统计」三个按钮。
为什么用QTableWidget而不是QListView?因为刷题场景需要一眼看到每道题的状态,表格天然适合展示多列结构化信息。状态列用不同背景色区分:未答灰色、已答但错误浅红、已答且正确浅绿。这个颜色映射在QTableWidgetItem.setBackground()里设置,比自定义 delegate 简单得多。
from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QStackedWidget, QLabel, QTextEdit, QRadioButton, QButtonGroup, QToolBar, QFileDialog, QMessageBox ) from PySide6.QtCore import Qt from PySide6.QtGui import QColor class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Excel 刷题工具") self.resize(1100, 700) self.questions = [] self.current_index = -1 self.answer_status = {} # qid -> "correct" / "wrong" / "unanswered" self._build_toolbar() self._build_central() def _build_toolbar(self): toolbar = QToolBar("主工具栏") self.addToolBar(toolbar) import_action = toolbar.addAction("导入题库") import_action.triggered.connect(self.import_excel) new_action = toolbar.addAction("开始新练习") new_action.triggered.connect(self.start_new_session) stats_action = toolbar.addAction("查看统计") stats_action.triggered.connect(lambda: self.stack.setCurrentIndex(1)) def _build_central(self): central = QWidget() self.setCentralWidget(central) layout = QHBoxLayout(central) # 左侧题目列表 self.table = QTableWidget(0, 4) self.table.setHorizontalHeaderLabels(["题号", "题干", "状态", "分类"]) self.table.setColumnWidth(0, 50) self.table.setColumnWidth(1, 320) self.table.setColumnWidth(2, 70) self.table.setColumnWidth(3, 100) self.table.cellClicked.connect(self.on_table_click) layout.addWidget(self.table, 3) # 右侧堆叠页面 self.stack = QStackedWidget() self.answer_page = self._build_answer_page() self.stats_page = self._build_stats_page() self.stack.addWidget(self.answer_page) self.stack.addWidget(self.stats_page) layout.addWidget(self.stack, 2)工具栏用QToolBar而不是普通按钮,是因为后面加「导出记录」「切换主题」这类功能时,直接addAction就行,不用改布局。左侧表格的列宽我写死了,实际用的时候可以根据屏幕分辨率调整,或者用setSectionResizeMode让题干列自动拉伸。
3.2 答题页的选项渲染与提交逻辑
答题页从上到下是题干、选项组、提交按钮、解析区。选项用QRadioButton还是QCheckBox取决于题目是单选还是多选。我的做法是:如果答案列只有一个字母,用QRadioButton;如果有多个,用QCheckBox。这个判断在加载题目时做一次,存到题目对象里。
def _build_answer_page(self): page = QWidget() layout = QVBoxLayout(page) self.stem_label = QLabel("请导入题库后开始答题") self.stem_label.setWordWrap(True) self.stem_label.setStyleSheet("font-size: 16px; font-weight: bold;") layout.addWidget(self.stem_label) self.option_group = QButtonGroup(page) self.option_widgets = [] self.option_layout = QVBoxLayout() layout.addLayout(self.option_layout) self.submit_btn = QPushButton("提交答案") self.submit_btn.clicked.connect(self.submit_answer) self.submit_btn.setEnabled(False) layout.addWidget(self.submit_btn) self.explanation_box = QTextEdit() self.explanation_box.setReadOnly(True) self.explanation_box.setPlaceholderText("提交后显示解析") layout.addWidget(self.explanation_box) return page def load_question_to_view(self, index: int): # 清空旧选项 for w in self.option_widgets: self.option_layout.removeWidget(w) w.deleteLater() self.option_widgets.clear() for btn in self.option_group.buttons(): self.option_group.removeButton(btn) q = self.questions[index] self.stem_label.setText(f"[{index+1}] {q.stem}") is_multi = len(q.answer) > 1 for i, opt_text in enumerate(q.options): letter = chr(ord("A") + i) if is_multi: from PySide6.QtWidgets import QCheckBox w = QCheckBox(f"{letter}. {opt_text}") else: w = QRadioButton(f"{letter}. {opt_text}") w.setProperty("letter", letter) self.option_group.addButton(w) self.option_layout.addWidget(w) self.option_widgets.append(w) self.explanation_box.clear() self.submit_btn.setEnabled(True) self.current_index = index这里有个细节:每次切换题目时,必须把旧的选项控件从布局里移除并deleteLater(),否则选项会越堆越多。QButtonGroup也要清空,不然上一题的选中状态会串到下一题。setProperty("letter", letter)是为了提交时能直接拿到用户选了哪个字母,不用去解析控件文本。
3.3 提交答案与状态回写
提交逻辑分三步:收集用户选中的字母、跟正确答案比对、更新表格状态和颜色。
def submit_answer(self): if self.current_index < 0: return q = self.questions[self.current_index] selected = [] for w in self.option_widgets: if w.isChecked(): selected.append(w.property("letter")) if not selected: QMessageBox.warning(self, "提示", "请至少选择一个选项") return is_correct = sorted(selected) == sorted(q.answer) self.answer_status[q.qid] = "correct" if is_correct else "wrong" # 更新表格状态列 status_item = self.table.item(self.current_index, 2) status_item.setText("正确" if is_correct else "错误") color = QColor(200, 255, 200) if is_correct else QColor(255, 200, 200) for col in range(4): self.table.item(self.current_index, col).setBackground(color) # 显示解析 correct_str = "".join(q.answer) self.explanation_box.setPlainText( f"正确答案:{correct_str}\n\n{q.explanation or '暂无解析'}" ) self.submit_btn.setEnabled(False)比对时用sorted()是为了忽略选项顺序,用户先选 C 再选 A 和先选 A 再选 C 应该算一样。状态回写只改当前行,不刷新整个表格,这样性能好,也不会丢失滚动位置。颜色用浅绿和浅红,对比度够但不刺眼,长时间刷题眼睛不会太累。
4. 答题记录持久化:SQLite 表结构与历次成绩查询
4.1 两张表的设计:session 与 answer_detail
答题记录我用 SQLite 存,因为它是 Python 内置的,不需要额外装数据库服务,打包成 exe 之后也能直接用。表结构分两张:session记录每次练习的整体情况,answer_detail记录每道题的作答明细。
CREATE TABLE IF NOT EXISTS session ( session_id INTEGER PRIMARY KEY AUTOINCREMENT, start_time TEXT NOT NULL, end_time TEXT, total_count INTEGER DEFAULT 0, correct_count INTEGER DEFAULT 0, wrong_count INTEGER DEFAULT 0, unanswered_count INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS answer_detail ( detail_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, qid INTEGER NOT NULL, stem TEXT, user_answer TEXT, correct_answer TEXT, is_correct INTEGER, answer_time TEXT, FOREIGN KEY (session_id) REFERENCES session(session_id) );session表里的unanswered_count是交卷时算出来的,不是实时更新。answer_detail里存了题干和正确答案的快照,这样即使以后题库 Excel 改了,历史记录还能还原当时答的是什么题。is_correct用 0 和 1 存,查询的时候直接SUM(is_correct)就能算正确率。
4.2 写入与查询的代码实现
import sqlite3 from datetime import datetime class RecordStore: def __init__(self, db_path="quiz_records.db"): self.conn = sqlite3.connect(db_path) self.conn.execute("PRAGMA foreign_keys = ON") self._create_tables() def _create_tables(self): self.conn.executescript(""" CREATE TABLE IF NOT EXISTS session (...); CREATE TABLE IF NOT EXISTS answer_detail (...); """) self.conn.commit() def start_session(self, total_count: int) -> int: cur = self.conn.execute( "INSERT INTO session (start_time, total_count) VALUES (?, ?)", (datetime.now().isoformat(), total_count) ) self.conn.commit() return cur.lastrowid def save_answer(self, session_id: int, q, user_answer: list, is_correct: bool): self.conn.execute( """INSERT INTO answer_detail (session_id, qid, stem, user_answer, correct_answer, is_correct, answer_time) VALUES (?, ?, ?, ?, ?, ?, ?)""", (session_id, q.qid, q.stem, ",".join(user_answer), ",".join(q.answer), 1 if is_correct else 0, datetime.now().isoformat()) ) self.conn.commit() def finish_session(self, session_id: int, correct: int, wrong: int, unanswered: int): self.conn.execute( """UPDATE session SET end_time=?, correct_count=?, wrong_count=?, unanswered_count=? WHERE session_id=?""", (datetime.now().isoformat(), correct, wrong, unanswered, session_id) ) self.conn.commit() def get_recent_sessions(self, limit=10): cur = self.conn.execute( """SELECT session_id, start_time, total_count, correct_count, wrong_count, unanswered_count FROM session ORDER BY session_id DESC LIMIT ?""", (limit,) ) return cur.fetchall()start_session在用户点「开始新练习」时调用,返回的session_id贯穿整场练习。每答一道题就调一次save_answer,这样即使程序崩溃,已经答过的题也不会丢。finish_session在用户点「结束练习」或者关闭窗口时调用,把统计数据补全。
4.3 统计页面的数据展示
统计页用QTableWidget展示最近十次练习的成绩,列包括开始时间、总题数、正确、错误、未答、正确率。正确率是算出来的,不存库,避免数据不一致。
def _build_stats_page(self): page = QWidget() layout = QVBoxLayout(page) self.stats_table = QTableWidget(0, 6) self.stats_table.setHorizontalHeaderLabels( ["开始时间", "总题数", "正确", "错误", "未答", "正确率"] ) layout.addWidget(self.stats_table) refresh_btn = QPushButton("刷新") refresh_btn.clicked.connect(self.refresh_stats) layout.addWidget(refresh_btn) return page def refresh_stats(self): rows = self.store.get_recent_sessions() self.stats_table.setRowCount(len(rows)) for i, row in enumerate(rows): sid, start, total, correct, wrong, unanswered = row rate = f"{correct/total*100:.1f}%" if total else "0%" values = [start[:19], str(total), str(correct), str(wrong), str(unanswered), rate] for j, v in enumerate(values): self.stats_table.setItem(i, j, QTableWidgetItem(v))时间只取前 19 个字符,把 ISO 格式里的毫秒和时区去掉,看起来清爽。正确率保留一位小数,空题库时显示 0% 而不是报错。
5. 避坑与排查:Excel 导入、界面卡顿、打包后的五个血泪教训
5.1 坑一:Excel 里答案列是数字,读进来变成浮点数
现象:答案列填的是1,pandas 读进来变成1.0,跟选项字母对不上,所有题都判错。
原因:pandas 默认会推断列类型,纯数字列会被转成 float。
解决:pd.read_excel加dtype=str,或者在读取后对答案列做astype(str).str.replace(".0", "", regex=False)。我两种都用了,双保险。
5.2 坑二:QTableWidget 加载上千道题时界面卡死
现象:导入一个 2000 行的 Excel,点「导入题库」后窗口白屏好几秒,鼠标转圈。
原因:setItem逐行调用会触发大量重绘,而且每设置一个 item 都发一次信号。
解决:导入前先self.table.setUpdatesEnabled(False),全部填完后setUpdatesEnabled(True)。另外把setSortingEnabled关掉,排序在数据量大时很耗性能。如果还卡,就分批加载,每 200 行QApplication.processEvents()一次。
5.3 坑三:PySide6 打包成 exe 后找不到 Excel 引擎
现象:源码跑得好好的,用 PyInstaller 打包后一导入 Excel 就报ImportError: Missing optional dependency 'openpyxl'。
原因:PyInstaller 不会自动收集 pandas 的可选依赖,openpyxl和xlrd没被打进去。
解决:打包命令加--hidden-import openpyxl --hidden-import xlrd,或者写一个 hook 文件。我一般直接在命令行里加,省事。
5.4 坑四:SQLite 数据库文件路径在打包后变成只读
现象:开发时记录能存,打包安装到Program Files后,答题记录写不进去,报sqlite3.OperationalError: attempt to write a readonly database。
原因:exe 所在目录没有写权限,SQLite 默认在脚本目录建库。
解决:把数据库路径改到用户目录,比如os.path.join(os.path.expanduser("~"), ".quiz_tool", "records.db"),启动时先os.makedirs确保目录存在。
5.5 坑五:多选题提交后,选项状态没清空导致下一题串选
现象:第一题是多选,选了 A 和 C,提交后切到第二题单选,发现 A 还是选中的。
原因:QButtonGroup只对QRadioButton有互斥效果,QCheckBox不归它管,切换题目时没有重置勾选状态。
解决:在load_question_to_view里,移除旧控件之前,先遍历self.option_widgets把setChecked(False)调一遍。或者更彻底一点,每次切换题目时重建整个选项布局,旧控件deleteLater()掉。
6. 进阶技巧:用 QSettings 记住上次题库路径与窗口布局
6.1 为什么需要 QSettings
每次打开软件都要重新选 Excel 文件、重新调窗口大小,用几次就烦了。QSettings是 Qt 自带的轻量配置存储,Windows 上写注册表,Linux 上写~/.config,不用自己管文件路径。我一般存三个东西:上次打开的题库路径、窗口尺寸和位置、最近一次练习的 session_id。
from PySide6.QtCore import QSettings class MainWindow(QMainWindow): def __init__(self): super().__init__() self.settings = QSettings("MyCompany", "QuizTool") self._restore_settings() # ... 其他初始化 def _restore_settings(self): geometry = self.settings.value("window/geometry") if geometry: self.restoreGeometry(geometry) last_excel = self.settings.value("file/last_excel", "") if last_excel and os.path.exists(last_excel): self.last_excel_path = last_excel def closeEvent(self, event): self.settings.setValue("window/geometry", self.saveGeometry()) if hasattr(self, "last_excel_path"): self.settings.setValue("file/last_excel", self.last_excel_path) super().closeEvent(event)restoreGeometry和saveGeometry是成对的,存的是二进制数据,QSettings 会自动处理序列化。last_excel存字符串路径,恢复时先判断文件还在不在,不在就忽略,避免报错。
6.2 自动加载上次题库的完整流程
在__init__里恢复完设置后,如果last_excel_path存在,就自动调一次load_questions_from_excel,把题目加载到表格里,但不自动开始练习。用户点「开始新练习」时才创建 session。
def _try_auto_load(self): path = getattr(self, "last_excel_path", None) if not path: return try: self.questions = load_questions_from_excel(path) self._populate_table() self.statusBar().showMessage(f"已自动加载 {len(self.questions)} 道题") except Exception as e: self.statusBar().showMessage(f"自动加载失败: {e}")这里用try/except包住,因为自动加载失败不应该弹窗打断用户,状态栏提示一下就行。_populate_table里记得用setUpdatesEnabled(False)包住,避免卡顿。
6.3 一个我用了很久的小习惯
每次改完代码,我会先跑一遍「导入一个 500 行的测试 Excel → 答 10 道题 → 关掉重开 → 看记录还在不在」这个流程。这个习惯帮我提前发现了至少三次数据库路径问题和两次打包后的权限问题。刷题工具这种东西,功能不复杂,但数据丢了就是大事,宁可多花两分钟验证,也别等用户反馈「记录没了」再回头查。
希望帮到你。
本文还有配套的精品资源,点击获取