ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于PySide6与Excel题库的桌面刷题工具开发实战

基于PySide6与Excel题库的桌面刷题工具开发实战 简介这是一款基于 PySide6 开发的实用刷题复习软件源代码面向备考各类考试、需要反复刷题巩固知识点的学生与自学者解决题库零散、答题记录难以追踪、复习效率低的问题。资源包共 9 个文件以 5 个 js、2 个 py 脚本为主辅以 1 个 xls 题库模板与 1 个 html 界面文件压缩包约 92KB界面由 HTMLCSS 渲染核心逻辑由 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第二个选项是第二次握手服务端发送 SYNACK选项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_name0, dtypestr) df df.dropna(howall) # 列名去空格防止 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( qididx, stemstem, optionsoptions, answeranswer, explanationexplanation, categorycategory )) return questions这段代码的关键点有三个。第一dtypestr强制所有列按字符串读避免 pandas 把「1」和「1.0」当成不同东西也避免答案列被转成浮点数。第二dropna(howall)只删全空行不删部分空行因为有些题目解析为空但题干和选项是完整的。第三答案解析里做了分隔符归一化、和空格都转成逗号再切分这样用户从不同来源复制答案时不用手动改格式。2.3 参数说明与常见读取失败pd.read_excel的sheet_name参数默认是 0也就是第一个工作表。如果你的题库在第二个 sheet改成sheet_name1或者直接写 sheet 名。dtypestr在 pandas 2.0 之后行为有变化如果遇到TypeError可以改成dtypestr, keep_default_naFalse这样空单元格会变成空字符串而不是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[{index1}] {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_pathquiz_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, limit10): 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 里答案列是数字读进来变成浮点数现象答案列填的是1pandas 读进来变成1.0跟选项字母对不上所有题都判错。原因pandas 默认会推断列类型纯数字列会被转成 float。解决pd.read_excel加dtypestr或者在读取后对答案列做astype(str).str.replace(.0, , regexFalse)。我两种都用了双保险。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 道题 → 关掉重开 → 看记录还在不在」这个流程。这个习惯帮我提前发现了至少三次数据库路径问题和两次打包后的权限问题。刷题工具这种东西功能不复杂但数据丢了就是大事宁可多花两分钟验证也别等用户反馈「记录没了」再回头查。希望帮到你。本文还有配套的精品资源点击获取
返回列表