资讯动态

PySide6打造本地刷题工具:Excel题库导入与自动统计实战

发布时间:2026/10/9 16:13:40 来源:尧图企业网站定制
简介一款基于PySide6开发的实用刷题复习软件源代码主要面向备考考生、考证人群以及Python桌面应用开发者。工具支持导入xlsx与xls格式的自定义Excel题库文件可实现多题库选择与切换自动统计已答、正确、错误、未答等答题情况并保存历次答题记录供随时查看复习。界面由HTML与CSS实现逻辑由Python与JavaScript协同控制答题选项可扩展至六项最新版修复了非gbk编码导入为空、参考答案显示异常等问题并增设右侧滚动条方便查阅较长题目。资源包共九个文件以五个js脚本、两个py程序、一个html界面文件与一个xls题库模板为主整个压缩包大小仅92KB结构清晰紧凑。已有449人学习下载适合直接运行练习或在此基础上二次开发也可用于理解PySide6与Web前端结合、Excel题库解析及答题统计的实现思路。1. 为什么我不再用手机 App 刷题Excel 题库 PySide6 的本地刷题工具手里有一份 800 题的 Excel 题库电脑上却没有一个能直接把它变成答题工具的软件这是很多备考人的真实处境。手机刷题 App 确实多但基本都绑定平台题库你想用自己那份 xlsx就得先转换格式再忍受它解析规则带来的错乱最后往往还是回到 Word 里翻答案。「实用刷题复习软件源代码可导入自定义excel题库自动统计已答、正确、错误、未答情况可保存历次答题记录使用pyside6开发」这个标题指向的正是把「Excel 题库」和「本地桌面刷题」打通的一条完整落地路径用 PySide6 写界面用 SQLite 存状态导入自定义 Excel 后每次刷题的对错、已答、未答都自动统计历史记录还能翻回来复盘。它解决的问题很具体考证、考研、职业资格备考或者公司内部培训考试手里已经有一份整理好的题库需要的不是「背答案」而是知道自己哪些没答、哪些答错、错在哪、下次怎么针对性重练。这篇文章会把这套方案的完整做法讲清楚从 PySide6 的选型理由开始到 Excel 题库的格式约定、解析代码、统计口径再到几个真实会翻车的边界问题。跟着做出来的不是 demo是一个能天天打开就用的桌面工具。2. 界面层选 PySide6、数据层选 SQLite先把技术组合想明白2.1 为什么界面层选 PySide6而不是 Tkinter 或本地 Web先想清楚刷题软件的使用形态打开即答窗口常驻鼠标点选项键盘切下一题答完立刻看到对错和统计。单次使用时间可能只有十来分钟但一天可能要开五六次。这种「高频短时交互」对界面框架的要求是启动快、控件够用、界面刷新不卡。在这几个指标上Tkinter 和本地 Web 方案都有明显短板。Tkinter 的问题是控件基础太薄弱。做刷题界面要频繁用到表格展示历史记录、列表展示题目分类、选项卡切换「答题页 / 统计页 / 历史页」Tkinter 的 Treeview 和 Notebook 能用但样式停留在上个时代高分屏下字体发虚、间距失调用户盯着刷几百道题眼睛先受不了。而 PySide6 是 Qt6 的官方 Python 绑定控件齐全QStackedWidget 管页面切换、QTableWidget 管历史记录、QListView 管科目分类再配一套 QSS 样式表就能做到接近现代桌面应用的观感。更关键的是 Qt 的 signal/slot 机制天然适合「点击选项 → 判分 → 刷新统计 → 自动下一题」这条事件链。对比一下三种常见方案的取舍方案启动速度表格/列表控件打包体积适合度Tkinter快弱样式老旧小能跑但体验糙PySide6快强QSS 可定制中等刷题工具首选本地 WebFlask 浏览器慢要先起服务强但依赖浏览器中管理成本高本地 Web 方案我一开始也考虑过Flask 起一个 localhost 服务浏览器打开就是界面HTML 写起来还灵活。但实际用下来很不顺手每次刷题要先启动服务、再开浏览器关窗口的时候得记住关服务不然后台挂着一个 Python 进程。Electron 倒是能解决这些问题打包出来体积比 PySide6 大一个量级为了一个刷题工具不值得。所以我最终选了 PySide6。验证 PySide6 环境是否就绪最简窗口代码是这样的import sys from PySide6.QtWidgets import QApplication, QStackedWidget, QWidget app QApplication(sys.argv) stack QStackedWidget() # 答题页放在 index 0统计页放在 index 1 stack.addWidget(QWidget()) stack.addWidget(QWidget()) stack.resize(900, 640) stack.show() sys.exit(app.exec())这段代码的逻辑是QStackedWidget 是 Qt 里的页面容器addWidget 按顺序给每个页面分配一个 index后面切换页面只需要 stack.setCurrentIndex(1) 一行。刷题的「答题页 / 统计页 / 历史页」结构用这个控件管理最省事。参数上resize(900, 640) 是给窗口一个合理的默认尺寸避免首次启动时布局挤在一起sys.exit(app.exec()) 让程序进入 Qt 事件循环所有按钮点击和信号响应都靠这个循环驱动。如果你运行时出现 ModuleNotFoundError先检查 PySide6 是否安装pip install pyside6 即可。2.2 为什么题库和记录存 SQLite而不是 JSON 或 CSV界面层定了数据层的选型同样关键。标题里「自动统计已答、正确、错误、未答」和「保存历次答题记录」这两条需求指向的是频繁的状态更新和结构化查询。如果只用 JSON 文件存题库每次答完一题要更新「这道题已被答过」的状态就得把整个 JSON 读出来改掉再写回去题库上千道题时能明显感觉到卡顿而且中途崩溃很容易把文件写坏。CSV 更不适合它没有条件更新能力只能全量重写。SQLite 是本地嵌入式数据库单条 UPDATE / INSERT 是毫秒级还支持事务和条件统计查询正好对应这个场景。表结构我一般拆成三张表questions 存题库原文sessions 存每一次刷题会话attempts 存每一道题的具体作答明细。为什么需要 sessions 这张表因为「保存历次答题记录」意味着要能区分「哪一次」——没有它你只能知道某道题答过却不知道是哪一轮答的、那一轮整体表现如何。初始化数据库的代码长这样import sqlite3 DB_PATH review.db def init_db(db_pathDB_PATH): conn sqlite3.connect(db_path) conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA foreign_keysON) conn.executescript( CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT DEFAULT 未分类, question TEXT NOT NULL, option_a TEXT DEFAULT , option_b TEXT DEFAULT , option_c TEXT DEFAULT , option_d TEXT DEFAULT , answer TEXT NOT NULL, analysis TEXT DEFAULT ); CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, total_count INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, question_id INTEGER NOT NULL, user_answer TEXT, is_correct INTEGER, answered_at TEXT NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id), FOREIGN KEY (question_id) REFERENCES questions(id) ); ) conn.commit() conn.close()两个 PRAGMA 参数值得单独说明。journal_modeWAL 是 SQLite 的预写日志模式它让读操作不阻塞写操作后面如果做「定时自动保存」和「用户手动点击」同时触发的情况不至于出现 database is locked。foreign_keysON 开启外键约束attempts 里不会出现指向不存在题目或会话的脏数据。注意 executescript 里是三条 CREATE TABLE IF NOT EXISTS重复运行不会报错所以这段代码可以放在程序启动时每次都执行一遍。3. Excel 题库的格式规范与导入实现表头设计、读取代码、解析边界3.1 题库表头怎么定列式布局是后续统计的地基「可导入自定义 excel 题库」这句话听起来简单实际做起来第一个坑就是格式约定。Excel 题库在用户手里的形态千奇百怪有人把题目和选项挤在一列里用顿号分隔有人把答案写在题目后面还有人用合并单元格把分类栏合并成一整片。如果你不定义一个最低限度的表头规范导入代码就没法写。我推荐的最小列式规范是 8 列列名字段含义是否必填示例分类科目或章节否法规 / 第一章题目题干是下列哪项属于行政行为选项A / 选项B / 选项C / 选项D四个选项否许可、处罚、复议、仲裁答案标准答案是A解析答案解析否行政处罚属于具体行政行为为什么不把选项写进同一列比如「A.许可 B.处罚 C.复议 D.仲裁」这种方式程序解析要靠 split 分隔符如果题干或选项里恰好出现顿号或竖线就会拆错。列式布局的好处是 Excel 自带的排序筛选功能还能正常用程序读取也不用猜。答案列填 A/B/C/D 而不是完整选项文本判分只需要字符串比对不用管用户选的是「许可」还是「处罚」。考虑到「自定义」这三个字导入代码不能硬编码列的顺序位置。我一般在代码里维护一份表头别名映射允许用户列名是「题目」「题干」「question」都能识别。这样用户只需要保证列名关键词匹配列的顺序可以任意打乱这比固定模板更接近真实使用习惯。3.2 openpyxl 读取并写入 SQLite一条命令跑通的导入流程导入流程分三步用 openpyxl 打开 xlsx按表头映射把每行数据清洗成元组最后批量写入 SQLite。openpyxl 是处理 xlsx 的事实标准库pip install openpyxl 安装即可。核心代码如下import sqlite3 from openpyxl import load_workbook DB_PATH review.db FIELD_ALIASES { category: [分类, 科目, category], question: [题目, 题干, question], option_a: [选项A, 选项a, option_a], option_b: [选项B, 选项b, option_b], option_c: [选项C, 选项c, option_c], option_d: [选项D, 选项d, option_d], answer: [答案, 正确答案, answer], analysis: [解析, 答案解析, analysis], } def import_excel_to_db(xlsx_path, db_pathDB_PATH): wb load_workbook(xlsx_path, data_onlyTrue) ws wb.active headers [str(c or ).strip() for c in next(ws.iter_rows(min_row1, max_row1, values_onlyTrue))] col_index {} for field, aliases in FIELD_ALIASES.items(): for i, h in enumerate(headers): if h in aliases: col_index[field] i break if question not in col_index or answer not in col_index: raise ValueError(Excel 表头必须包含 题目 和 答案 两列) def val(row, field): if field not in col_index or col_index[field] len(row): return v row[col_index[field]] return if v is None else str(v).strip() rows, skipped [], 0 for row in ws.iter_rows(min_row2, values_onlyTrue): q val(row, question) ans val(row, answer).upper() if not q or not ans: skipped 1 continue rows.append(( val(row, category) or 未分类, q, val(row, option_a), val(row, option_b), val(row, option_c), val(row, option_d), ans, val(row, analysis), )) conn sqlite3.connect(db_path) conn.execute(DELETE FROM questions) conn.executemany( INSERT INTO questions (category, question, option_a, option_b, option_c, option_d, answer, analysis) VALUES (?, ?, ?, ?, ?, ?, ?, ?), rows) conn.commit() conn.close() wb.close() print(f导入 {len(rows)} 道跳过 {skipped} 行空行)几个参数说明。load_workbook 里的 data_onlyTrue 很关键它让 openpyxl 读取公式单元格的缓存计算结果而不是返回公式字符串如果题库是别人用 WPS 生成的公式没保存过缓存值这里会读到 None所以 val() 里做了 None 兜底。iter_rows(min_row2, values_onlyTrue) 从第二行开始逐行读取直接拿值元组避免频繁访问 cell 对象。答案统一 upper() 是为了兼容 Excel 里填 a 还是 A 的差异。DELETE FROM questions 是覆盖式导入适合从零开始建库如果你要做增量导入把这行删掉即可主键自增会继续往后走。3.3 三个解析边界合并单元格、公式残留、判断题选项不齐导入这块最容易出事故的不是 openpyxl 本身而是题库文件本身的不规范。我碰到过三种典型情况。第一种是合并单元格。有些题库的分类列会纵向合并比如「法规」这一列合并了 50 行openpyxl 读合并区域时只有左上角第一个单元格有值其余全是 None结果导入后一大半题目的分类丢失。解决思路是读取前先用 ws.merged_cells.ranges 拿到所有合并区域把左上角的值填充到整个区域对应的行上。不过更省事的办法是在模板里明确要求用户不要合并单元格分类重复就重复填程序不依赖合并信息。第二种是公式残留。用户把 Excel 里的正确答案用公式生成比如 IF(B2A,正确,错误)data_onlyTrue 能读到缓存值但如果这个文件从没在 Excel 里打开保存过缓存就不存在读回来是 None。遇到这种情况导入日志里会看到大量跳过行。排查方向是让用户用 WPS 或 Excel 打开文件另存一次把公式计算结果固化。第三种是选项个数不齐。判断题只有 A/B 两个选项但表头仍然有选项C和选项D两列空着不填。我的处理是读取时不做「四个选项必须齐全」的校验空字符串照常入库判分时只对比用户点击的选项和标准答案字符串。真正要校验的是 answer 必须非空因为一道没有标准答案的题导入进去只会污染统计。4. 统计口径与历史记录已答、正确、错误、未答是怎么算出来的4.1 判分与状态互斥一道题只能属于一种统计状态统计要准确先得定义清楚状态边界。一套刷题会话里一道题的状态有四种未答、已答答对、已答答错。这里容易犯的错是把「已答」和「正确 / 错误」当成三个独立维度去统计结果一道题答错之后又改成答对统计表里同时出现「已答 1」和「正确 1」总数就超了。我的处理方式是已答、正确、错误是互斥的。每次用户点击选项都会往 attempts 表插入一条作答记录但统计时只取每个题目的最新一条记录来决定当前状态。判分逻辑本身很简单归一化后做字符串比对from datetime import datetime def judge_and_record(conn, session_id, question_id, user_answer): 判分并写入作答明细返回是否答对 cur conn.execute( SELECT answer FROM questions WHERE id ?, (question_id,)) row cur.fetchone() if row is None: raise ValueError(f题目不存在: {question_id}) standard row[0].strip().upper() picked (user_answer or ).strip().upper() is_correct 1 if picked standard else 0 conn.execute( INSERT INTO attempts (session_id, question_id, user_answer, is_correct, answered_at) VALUES (?, ?, ?, ?, ?), (session_id, question_id, picked, is_correct, datetime.now().isoformat(timespecseconds)) ) conn.commit() return is_correct 1这里有个细节值得注意user_answer 也做了 strip().upper() 归一化因为 PySide6 界面传进来的可能是用户在按钮上的文本多一个空格或少一个空格都会导致误判。answered_at 用 datetime.now().isoformat 存成字符串形如 2025-06-01T09:30:00字符串比较在 SQL 里也能直接按时间排序。每次 commit 是故意的——本地 SQLite 单条插入耗时在毫秒级换来的是「答完立即落盘」即使程序崩溃也只丢当前这一道题的显示状态不会丢整场会话的记录。4.2 会话级统计用 session_id 把每次刷题变成一条历史「保存历次答题记录」落地到数据模型就是每次开始刷题时往 sessions 表插入一行拿到 session_id之后所有 attempts 都挂在它下面。这样统计某一次刷题的情况只需要按 session_id 过滤。统计 SQL 要考虑「多次作答取最新」的问题。如果同一个 session 里用户反复改同一道题的答案attempts 里会有多行记录直接数行数会重复。正确做法是先收敛到每道题的最新一条记录再做聚合WITH latest_attempts AS ( SELECT a.* FROM attempts a JOIN ( SELECT question_id, MAX(id) AS max_id FROM attempts WHERE session_id ? GROUP BY question_id ) m ON a.id m.max_id ) SELECT (SELECT COUNT(*) FROM questions) AS total, COUNT(*) AS answered_count, SUM(CASE WHEN is_correct 1 THEN 1 ELSE 0 END) AS correct_count, SUM(CASE WHEN is_correct 0 THEN 1 ELSE 0 END) AS wrong_count FROM latest_attempts;这个 SQL 的逻辑分两层。内层子查询按 question_id 分组取 MAX(id)也就是每道题的最新一次作答外层用 JOIN 把 attempts 收敛到这些最新记录上。total 直接从 questions 表取总数因为「未答」不需要存状态它本来就是总量减去已答量的差。最后界面上要展示的已答、正确、错误、未答四个数字未答数等于 total - answered_count。为什么用 MAX(id) 而不是 MAX(answered_at)因为 id 自增是单调的而 answered_at 依赖系统时间用户改了系统时钟就会错乱。这是我在实际开发里踩过的一个小坑后来统一用 id 做版本判断再也不受时间影响。4.3 历史记录恢复再打开软件昨天错过的题还能找回来历史记录的价值在于复盘。sessions 表存着每次会话的开始时间和答题统计打开软件时历史页展示的是 sessions 列表每一行对应一次刷题。用户点击某一行展开看这次会话的题目明细和错题。核心查询是SELECT s.id, s.started_at, s.ended_at, (SELECT COUNT(*) FROM attempts a WHERE a.session_id s.id) AS answered, (SELECT SUM(is_correct) FROM attempts a WHERE a.session_id s.id) AS correct FROM sessions s ORDER BY s.id DESC LIMIT 50;LIMIT 50 是刻意的——历史记录会随着使用越攒越多一次拉全量数据不仅慢而且界面列表也加载不动。先展示最近 50 条底部放「加载更多」数据量再大也不会卡。这里还有个存储优化的原则attempts 表只存题目 ID、用户答案、判分结果不冗余题干和选项文本需要展示时再去 JOIN questions 表取题目内容。这样历史会话累积几千条记录数据库体积也保持在几 MB 级别。5. 刷题软件避坑指南5 个高频故障的排查记录5.1 改过答案的题目让已答数翻倍现象一道题答错之后改了两次答案统计面板上的「已答」数量超过总题数「正确」加「错误」也对不上。原因是统计时直接数了 attempts 表的总行数而 attempts 里同题多行记录——同题每改一次答案就多一行。解决方法是严格按 4.2 里的最新记录收敛方式统计先按 question_id 取 MAX(id)再对最新记录做聚合确保一道题只贡献一个统计值。把这个逻辑封装成视图所有统计入口统一走它就不会再出现口径不一致。5.2 随机抽题每次打开顺序都变位置记忆全乱现象开启随机刷题模式后用户退出软件再打开题目顺序重新洗牌之前用位置记忆背下来的「这道题后面是哪道」全部失效答题节奏被完全打乱。原因是随机顺序只存在于内存里的 random.shuffle 结果没有持久化到数据库。解决思路是给 sessions 表增加一个 order_seq 字段开始会话时把洗牌后的题目 ID 序列以逗号分隔写入恢复会话时按这个序列重新加载题目。这样即使用户退出重启只要选择「继续上次会话」题目顺序就和离开时完全一致。玄学的地方在于很多用户以为自己记住了题目内容实际记住的是题目位置顺序一变就露馅。5.3 Excel 里的日期被读成 datetime入库报错现象题干里有「2024.3.15」这样的内容openpyxl 自动把它读成 datetime.datetime 对象代码里直接 str() 拼接 SQL 时得到的不是原本的文本入库后变成一串时间对象表示形式。原因是 Excel 的自动类型识别把形似日期的单元格转换为日期类型openpyxl 忠实还原了这个类型。解决方式是在 val() 函数里加类型判断isinstance(v, datetime.datetime) 时用 v.strftime(%Y-%m-%d) 格式化为字符串。更彻底的办法是导入前检查题库文件把这类单元格的格式设置为文本但这需要用户配合不如代码里兜底来得省心。5.4 关闭窗口丢记录commit 时机没选对现象刷题过程中正常作答没问题但答完直接点窗口右上角关闭再打开发现有十几道题的状态没保存。原因是只依赖 closeEvent 里统一 commit但某些系统环境或异常路径下 closeEvent 不一定会执行比如断电、程序被强制结束、或者 closeEvent 里前面的代码抛出异常打断了后续逻辑。解决方式是绝不攒批提交——每一题作答完成就执行 conn.commit()让数据落盘成为常态closeEvent 里的 commit 只作为双保险。另外一个配套习惯是给数据库文件加 WAL 模式这样频繁 commit 也不会因为锁文件导致界面卡顿。5.5 QTableWidget 加载上万条历史记录卡死现象历史记录页一次把几千条会话全部加载进 QTableWidget界面卡顿数秒滚动时明显掉帧。原因是 QTableWidget 是控件级表格每格都要创建 QTableWidgetItem 对象五千条记录就是几万个对象控件初始化时间成倍放大。解决方法是换用分页加载SQL 里 LIMIT 50 OFFSET 分页滚动到底部时动态追加或者升级为 QTableView QAbstractTableModel 模式数据驱动绘制几千行也能流畅滚动。对刷题工具来说分页加载足够用代码改动也最小——历史记录这种低频查看场景用户并不需要一次看完全部。6. 进阶技巧乱序抽题、错题重练与复习报告导出6.1 乱序抽题把顺序落库而不是临时洗牌乱序的关键不是 random.shuffle 本身而是把洗好的顺序存下来。开始会话时生成题目 ID 列表洗牌后写入 sessions 表的 order_seq 字段形如 12,3,45,78。恢复会话时按这个顺序逐题加载不重新洗牌。这样「上一题」「下一题」的导航在重启后依然成立第五章说过的位置记忆问题也不会再出现。6.2 错题重练一条过滤条件错题重练的本质是「只加载最新一次作答是错误状态的题目」SELECT q.* FROM questions q JOIN attempts a ON a.question_id q.id WHERE a.session_id ? AND NOT EXISTS ( SELECT 1 FROM attempts a2 WHERE a2.question_id q.id AND a2.id a.id ) AND a.is_correct 0;NOT EXISTS 子查询确保取到的是每个题目的最新一次作答加上 is_correct 0 过滤出错误题。这套逻辑建立 4.2 的「最新记录」口径之上避免某道题先错后对仍然出现在错题本里的尴尬。6.3 导出复习报告UTF-8 编码选错会乱码导出报告我一般输出成 Markdown 表格方便直接贴进笔记软件。唯一要记住的坑是用 open 写文件时编码指定 utf-8-sig而不是 utf-8。Windows 上的 Excel 打开 UTF-8 无 BOM 文件会乱码utf-8-sig 带的 BOM 头能让 Excel、WPS 正确识别。导出格式包含本次正确率、错题清单和对应解析这就是一份可以直接用于考前冲刺的复习材料。这套工具做到后面我自己最深的教训是统计功能的成败不在界面而在数据口径是否一致。只要「一道题算一次」这个原则贯穿导入、判分、统计、错题重练的所有环节后续加任何功能都不会翻车。希望这些踩坑记录能帮你少走几段弯路做出一套自己真正愿意天天打开用的刷题工具。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑