简介:万维题库管理系统单机版是一套面向教育机构、培训讲师及自学者设计的本地题库管理工具,无需联网即可完成试题录入、分类存储、随机组卷与答案解析,适合网络不稳定或注重数据隐私的办公场景。资源压缩包共11个文件,约14.21MB,以安装主程序(exe)、动态链接库(dll)及配置文件(ini/ins)为主,配套HTM格式说明文档,涵盖安装引导、功能模块与使用须知,用户运行SETUP后即可部署使用。已有2255人下载学习。凭借这套安装包,读者可快速搭建本地题库系统,借助系统提供的导入导出与组卷功能,提升日常教学或考试准备的资源管理效率;单机模式也便于自主备份与维护,适合中小型教育机构长期沉淀试题资产。
1. 单机题库管理系统:为什么本地存储依然是刚需
题库管理听起来不像在线刷题那样时髦,但真正管过几百上千道题的人都知道,单机版反而是最不容易踩坑的方案。不用服务器、不依赖网络、数据全在自己手里,一套“万维题库管理系统单机版”就能把题目录入、分类、组卷、导出、练习全部跑通。这篇文章就把它拆开讲:题目数据怎么组织、题型怎么扩展、随机组卷的参数怎么设、导出试卷会遇到哪些鬼问题,以及单机版特有的几个坑。适合学校、培训机构里有离线出题需求的人,也适合想自己搭一套题库工具的开发者。
2. 题库数据模型:先想清楚题目怎么存,后面才不返工
2.1 题型有差异,但一张题表能装下
单选、多选、判断、填空、简答,这五种题型几乎覆盖了日常考试。如果给每种题型单独建表,后续加一个题型就要改一次代码,这在单机版项目里是给自己找麻烦。常见做法是“一表通吃”:所有题目统一放进 question 表,选项、答案、标签都用 JSON 文本存储。这样新增题型基本不影响表结构,查询和写入也都简洁。
建表 SQL 在资源包里已经写好,核心结构如下:
CREATE TABLE IF NOT EXISTS question ( id INTEGER PRIMARY KEY AUTOINCREMENT, subject_id INTEGER NOT NULL, chapter_id INTEGER, type TEXT NOT NULL CHECK(type IN ('single','multi','judge','fill','essay')), content TEXT NOT NULL, options TEXT DEFAULT '[]', answer TEXT NOT NULL, difficulty INTEGER DEFAULT 3, tags TEXT DEFAULT '[]', analyze TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );逻辑说明:subject_id 关联科目表,chapter_id 关联章节表,用于做第一层过滤;type 用 CHECK 约束保证只能写入预设题型,避免脏数据;options 和 tags 都存 JSON 数组,answer 根据题型不同存 JSON 数组或纯文本。例如单选题 answer 存选项下标["1"],判断题存["1"](1 表示正确,0 表示错误),填空题存["二分查找"]这种多空答案列表。difficulty 取 1 到 5,默认 3,方便组卷时按难度控制比例。analyze 是题目解析,练习模式里会用到。
参数说明:没有把 options 单独拆表,是因为单机版的数据量通常不会超过几万题,JSON 文本读写的性能完全可以接受,而且编辑一道题时只需要一次 UPDATE 就能改完所有字段。如果将来要做复杂的按选项内容筛选,再考虑拆表也不迟。
对于填空题和简答题,answer 可能包含多个得分点,比如简答的参考答案是“尽量按要点给分”。资源包里约定 answer 存 JSON 数组,每个元素是一个要点,批量导入时也按这个格式解析。
2.2 科目、章节、标签:三层索引怎么设计
查题最常用的过滤条件是“某科目下的某章节,难度几颗星,带某个标签”。科目和章节是一对多关系,标签是多对多关系。单机版不必把多对多做成三张表,直接在 question 表里存 tags 数组即可。查询时用 JSON 包含匹配,SQLite 3.38 以上支持 json_each,老版本也可以用 LIKE 兜底。
科目表和章节表很简单:
CREATE TABLE IF NOT EXISTS subject ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS chapter ( id INTEGER PRIMARY KEY AUTOINCREMENT, subject_id INTEGER NOT NULL, name TEXT NOT NULL, UNIQUE(subject_id, name) );为什么不把章节也塞进 question 表里冗余存储?因为组卷时经常要按章节树浏览,单独建表可以避免章节重名混乱,而且在界面上左边的科目树直接查询这两张表就能渲染出来。章节的 UNIQUE 约束能防止同一科目下出现两个同名章节。
标签的存法,我一般建议直接用逗号分隔或 JSON 数组,然后配合 LIKE 查询。例如筛选同时带“基础”和“指针”标签的题:
SELECT * FROM question WHERE tags LIKE '%"基础"%' AND tags LIKE '%"指针"%';逻辑说明:LIKE 匹配 JSON 字符串中的值,%"基础"%这种写法能匹配到包含“基础”元素的 JSON 数组,误匹配概率比直接 LIKE 不加引号低很多。缺点是查不到“基础”作为子串的情况,比如标签是“基础语法”,这种误匹配反而是好事,因为标签通常是精确词。
参数说明:如果标签数量巨大或者查询频繁,可以给 tags 建一个普通索引,但单机版里更多是选择题量不大的场景,索引收益不大。真正需要索引的是 subject_id、chapter_id 和 type,这三个字段是组卷查询的高频条件,资源包里的建表脚本默认给这三列建了复合索引。
2.3 数据库初始化与备份:单机版的后悔药
单机版数据库就是一个文件,通常叫 wanjuan.db,放在项目根目录下。资源包在启动时会自动执行init_db(),检查表是否存在,不存在就建表并插入两个示例科目。数据库初始化脚本里会设置PRAGMA journal_mode=WAL;,这个参数让读写可以并发,避免界面卡顿。
备份是单机版最容易被忽略的一环。因为数据都在本地,不备份就意味着硬盘坏了全完蛋。我一般会建议在程序退出时自动复制一份带日期的数据库文件:
def backup_db(db_path: str, backup_dir: str) -> str: os.makedirs(backup_dir, exist_ok=True) ts = time.strftime("%Y%m%d_%H%M%S") backup_path = os.path.join(backup_dir, f"wanjuan_{ts}.db") # 使用 sqlite3 backup API 代替文件复制,避免复制到不一致状态 src = sqlite3.connect(db_path) dst = sqlite3.connect(backup_path) src.backup(dst) dst.close() src.close() return backup_path逻辑说明:直接复制 db 文件在 Windows 上经常遇到文件占用报错,因为数据库连接没关闭。这里用 sqlite3 的 backup API,可以在数据库运行中做安全备份,生成的文件是一致的快照。返回值是备份路径,存到日志里方便审计。
参数说明:WAL 模式会额外生成 -wal 和 -shm 文件,备份时如果只复制主文件会漏掉未合并的数据。用 backup API 就没有这个问题。资源包默认每 30 分钟自动备份一次,备份目录保留最近 5 份,旧文件自动清理,避免磁盘爆掉。
3. 把第一道题录进去:环境、界面与核心代码走读
3.1 环境准备与启动
资源包基于 Python 3 + Tkinter + SQLite,Windows、macOS、Linux 都能跑。前提是先有 Python 3.8 以上环境。启动步骤:
cd wanjuan_standalone python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt python main.py逻辑说明:venv 是虚拟环境,避免依赖污染系统 Python;requirements.txt 里主要就是 pyinstaller 打包需要的依赖,运行时其实只用标准库,但为了导出 Excel 和 PDF 会带上 openpyxl 和 weasyprint。安装完依赖后运行 main.py,会先检查数据库文件是否存在,不存在则自动执行建表脚本并写入两个示例科目。
参数说明:如果 Tkinter 提示缺少 _tkinter,Windows 上重新安装 python 时勾选 tcl/tk 即可;Linux 上需要sudo apt install python3-tk。这是最常见的环境问题之一,后面避坑章还会再提。
3.2 界面操作:从建科目到录题
主界面分成三块:左侧是科目章节树,右侧是题目列表,底部是状态栏。要录入一道题,常规操作顺序是:
- 在左侧科目树上右键“新建科目”,输入科目名,比如“高等数学”。
- 在科目下右键“新建章节”,输入“第一章 函数与极限”。
- 点击工具栏的“新增题目”,弹窗里选择题型、难度,填写题干、选项(多选/单选时才需要勾选正确项)、答案和解析。
- 点击保存,题目进入列表,同时落到本地 SQLite 文件里。
这套流程没什么玄学,但你如果直接开始录题而不先建科目,保存时会提示“缺少科目”,因为 subject_id 不允许为空。资源包里多选题录入时,选项是用 A/B/C/D 加勾选框,保存时会把勾选的下标组转换成 JSON 数组,比如勾了 A 和 C,answer 存["0","2"]。
3.3 核心代码走读:题目保存与列表刷新
资源包里题目保存的入口是save_question()方法,所有题型共用这一个函数。关键实现:
def save_question(self, data: dict) -> bool: """ 保存题目:data 里包含 subject_id, type, content, options, answer 等 options 和 answer 在进入这里之前已经被序列化成 JSON 字符串 """ conn = sqlite3.connect(self.db_path) conn.execute("BEGIN") try: if data.get("id"): conn.execute( """UPDATE question SET subject_id=?, chapter_id=?, type=?, content=?, options=?, answer=?, difficulty=?, tags=?, analyze=? WHERE id=?""", (data["subject_id"], data.get("chapter_id", 0), data["type"], data["content"], data["options"], data["answer"], data["difficulty"], data["tags"], data["analyze"], data["id"]) ) else: conn.execute( """INSERT INTO question (subject_id, chapter_id, type, content, options, answer, difficulty, tags, analyze) VALUES (?,?,?,?,?,?,?,?,?)""", (data["subject_id"], data.get("chapter_id", 0), data["type"], data["content"], data["options"], data["answer"], data["difficulty"], data["tags"], data["analyze"]) ) conn.commit() return True except Exception as e: conn.rollback() logger.error("save_question failed: %s", e) return False finally: conn.close()逻辑说明:函数先连接数据库,开启显式事务。如果 data 里有 id 就走 UPDATE,没有就走 INSERT。注意 UPDATE 里 chapter_id 可能为空,默认填 0,表示没有章节,这比 NULL 好处理,因为 0 不参与外键匹配。commit 成功才返回 True,任何异常都会回滚,避免写了一半的坏数据留在库里。logger 用的是 Python 标准 logging,错误信息会被截断到日志文件,方便排查。
参数说明:这里有个容易被忽略的细节——data 里的 options 和 answer 在调用这个方法之前必须已经是 JSON 字符串。如果直接传 Python 列表,sqlite3 会报 “Python type list” 类型错误。资源包界面上已经做了序列化,但如果你要写脚本批量导入,一定要先json.dumps(options, ensure_ascii=False)。
列表刷新逻辑更简单:查询当前科目章节下的题目,按 updated_at 倒序排列,然后把每道题的 type 转成中文显示。代码片段:
rows = conn.execute( """SELECT id, content, type, difficulty, updated_at FROM question WHERE subject_id=? AND (chapter_id=? OR ?=0) ORDER BY updated_at DESC""", (subject_id, chapter_id, chapter_id) ).fetchall()参数说明:chapter_id=0时表示查看该科目下所有章节的题,这种写法比动态拼接 SQL 更安全,也不会出现 SQL 注入问题。Tkinter 的 ttk.Treeview 直接加载这些 rows 即可,数据量在 2000 行以内界面不会卡,超过的话就需要分页,资源包里在查询语句里加了 LIMIT 和 OFFSET,默认每页 100 行。
3.4 批量导入:从 Excel 一次灌入上千道题
手工录入适合少量题,几百道以上就必须用 Excel 模板。资源包里提供了一个 import_excel.py 脚本,模板字段是:科目、章节、题型、题干、选项A、选项B、选项C、选项D、正确答案、难度、标签、解析。导入时先读取模板,逐行校验,再把行数据转成字典传给 save_question。
def import_excel(path: str, db_path: str) -> dict: wb = openpyxl.load_workbook(path, read_only=True) ws = wb.active result = {"success": 0, "failed": 0} for row in ws.iter_rows(min_row=2, values_only=True): subject, chapter, qtype, content, opt_a, opt_b, opt_c, opt_d, answer, diff, tags, analyze = row # 校验必填 if not subject or not qtype or not content: result["failed"] += 1 continue options = [] for opt in (opt_a, opt_b, opt_c, opt_d): if opt: options.append(opt) # 单选题转成下标 JSON answer_index = ["A", "B", "C", "D"].index(answer) if qtype == "single" else None data = { "subject_id": get_or_create_subject(subject, db_path), "chapter_id": get_or_create_chapter(subject, chapter, db_path), "type": qtype, "content": content, "options": json.dumps(options, ensure_ascii=False), "answer": json.dumps([answer_index], ensure_ascii=False), "difficulty": diff or 3, "tags": json.dumps([t.strip() for t in tags.split(",") if t.strip()], ensure_ascii=False), "analyze": analyze or "", } if save_question_with_conn(data, db_path): result["success"] += 1 else: result["failed"] += 1 return result逻辑说明:模板里正确答案写成 A/B/C/D,导入时先转成选项下标,再序列化存储。get_or_create_subject 和 get_or_create_chapter 是辅助函数,不存在科目就新建,避免重复劳动。标签用逗号分隔,导入时拆成数组。difficulty 为空就默认 3。
参数说明:批量导入最容易踩的坑是 Excel 里单元格有换行符或空格,导致题干出现隐蔽差异。导入脚本里没有做 strip 处理,我在实际使用中会在content.strip()之后再入库。资源包的版本里已经修正了这一点,你拿到源码后可以检查一下。
4. 随机组卷与导出:把题库变成能用的试卷
4.1 随机组卷的抽题逻辑
组卷的核心是分层抽样。先确定每类题型、章节、难度各出几题,然后按条件随机抽取。资源包里的组卷界面让你配置如下参数:题型、章节范围、难度范围、每题分值、题量。生成试卷时,内部先把计划拆成一个个小请求,再合并。
给一个简化但可用的生成函数:
def generate_paper(plan: list, conn) -> list: """ plan 每一项形如: {"type": "single", "chapter_id": 1, "difficulty": 3, "count": 5} 返回题目列表,保证同一题不重复 """ paper = [] seen_ids = set() for item in plan: rows = conn.execute( """SELECT * FROM question WHERE type=? AND chapter_id=? AND difficulty=? ORDER BY RANDOM() LIMIT ?""", (item["type"], item["chapter_id"], item["difficulty"], item["count"]) ).fetchall() for row in rows: if row["id"] not in seen_ids: paper.append(row) seen_ids.add(row["id"]) return paper逻辑说明:ORDER BY RANDOM() 是 SQLite 里最简单的随机抽题方式,数据量在几千行内一般几十毫秒能完成。LIMIT 的数量就是该分类下的期望题数。如果题库里符合条件的不够,SQLite 不会报错,只返回实际存在的行数,所以调用方要检查 paper 长度是否等于计划总数,否则提示“该分类下题目不足”。
参数说明:一个容易被忽略的问题是,如果计划里两个条目引用了同一章节但不同难度,那么它们之间不会重复;但如果两个条目都是同类型同章节同难度,它们有可能抽到同一道题。上面用 seen_ids 去重,但去重发生在抽取之后,所以仍可能返回少于 count。更稳妥的做法是在 SQL 里用WHERE id NOT IN (SELECT id FROM temp_paper)每次动态排除,不过单机版没必要为了这么小的性能损失写复杂 SQL。我一般会在组卷前先统计每个分类的题量,不够就弹窗提示。
4.2 导出 Word 与 PDF:最稳妥的是先生成 HTML
单机版导出试卷最常见的翻车现场是:用 python-docx 直接生成复杂表格时,图片排列乱、页眉页脚错位、中文字体变成方块。血泪经验告诉你,不要跟 Word 的 XML 死磕,老老实实先生成 HTML,再通过浏览器或 LibreOffice 转成 PDF 或 Word。资源包里 export_paper.py 支持两种导出:HTML 和 CSV。
HTML 生成的简化版:
def export_html(paper: list, out_path: str) -> None: html = """<html><head><meta charset="utf-8"></head><body>""" for i, q in enumerate(paper, 1): html += f"<p><b>{i}.</b> {q['content']}</p>" options = json.loads(q["options"]) for j, opt in enumerate(options): html += f"<p style='margin-left:20px'>{chr(65 + j)}. {opt}</p>" if q["type"] == "single": html += "<p>答案:____</p>" html += "</body></html>" with open(out_path, "w", encoding="utf-8") as f: f.write(html)逻辑说明:HTML 导出时不把答案写进去,而是留空,便于学生答题。如果你需要带答案版,就再加一个参数,在题目下方显示答案:{q['answer']}。这里特别要注意chr(65 + j)是把选项下标转成 A/B/C/D,但多选题选项超过 4 个时就得用字母数组处理,资源包固定支持 4 个选项。
参数说明:转 PDF 时,Windows 上可以用 Edge 或 Chrome 打开 HTML 然后打印为 PDF,Linux 上可以用wkhtmltopdf或weasyprint。资源包的 requirements.txt 里默认装了 weasyprint,它会尝试直接转 PDF,但 WeasyPrint 对 CSS 的支持有限,太复杂的表格样式会变形。我的建议是:如果出题量少,直接用 Word 模板;题目带图片多,就导出 HTML 后手工调整。
CSV 导出就简单多了,按固定列把题干、选项、答案、解析写进 CSV,方便日后迁移到其他题库软件。CSV 的坑主要是编码:必须用encoding='utf-8-sig',否则 Excel 打开中文乱码。资源包代码里已经写了 utf-8-sig。
5. 避坑:单机版题库管理系统的五个雷区
5.1 现象:题录进去了,但查询出不来
原因通常是录入时科目或章节没选对,或者保存时 subject_id 为空,但界面没有刷新显示报错。我见过有人录了 100 道题,结果左侧树选的是“复习资料”,而题目挂在“正式题库”下,列表当然显示不出来。
解决:在资源包的题目列表上方加一个“当前科目/章节”过滤器,如果列表为空,先切换到“全部章节”再看。另外检查日志文件,如果save_question返回 False,说明是写入失败,要看异常信息。资源包默认把日志写到 logs/ 目录,每次启动会生成带日期的日志文件。
5.2 现象:随机组卷抽到的题偏科
原因在于计划拆得不够细。比如用户只想“第一章出 5 道,第二章出 3 道”,如果组卷界面把“章节范围”作为一个下拉框,内部只根据起始章节和结束章节去随机,抽题数可能全砸在第一章,第二章一题没有。SQLite 的 RANDOM() 不会保证均匀分布。
解决:组卷前先按章节统计题目数量,再把 count 按章节配额分配。最简单的方式是:让用户按章节逐个设置题量,禁止“范围式”配置。资源包的组卷面板默认就是每个章节单独一行,这样每个 chapter_id 独立查询,分发均匀。
5.3 现象:导出的 Word 打不开
原因一般是直接改扩展名或用了错误的编码。有些版本用 python-docx 导出时,生成的文件实际上是一个 HTML 文件,但扩展名改成 .doc,Word 打开会提示“文件格式与扩展名不匹配”。
解决:不要用假 .doc,要么导出为真正的 .docx(python-docx 生成),要么就用 HTML + 打印转 PDF。如果资源包里的 export_paper.py 输出的是 .html,那你就老老实实打开 HTML。我通常把 “导出” 按钮分成两个:一个生成 HTML,一个用 weasyprint 转 PDF,绝不提供假 Word。
5.4 现象:多标签筛选失效
原因:标签存的是 JSON 数组,如果界面用WHERE tags LIKE '%基础%'筛选,当标签是“基础语法”时也能匹配到,但实际上标签并没有这个元素,导致筛选结果比预期多。反过来,如果你要精确匹配“基础”标签,LIKE 匹配%"基础"%是 OK 的,但如果某个标签叫“基础精讲”,也会被匹配进去。
解决:如果必须精确匹配,建议改用 SQLite 的 json_each:
SELECT * FROM question, json_each(question.tags) AS tag WHERE tag.value = '基础' AND tag.value = '指针';注意这条 SQL 其实要求同一行同时满足两个 tag,实际上不存在,所以多条件匹配要改成 EXISTS 子查询。资源包里用的是 LIKE 方案,因为单机版对误匹配容忍度较高。你要精确就用 json_each,代价是查询变慢。
5.5 现象:数据库文件越用越大
原因:SQLite 删除数据不会立即释放磁盘空间,这是它的正常行为。单机版如果频繁增删题目,wanjuan.db 会膨胀到实际数据量的数倍。很多人误以为是内存泄露,其实不是。
解决:定期执行一次 VACUUM。资源包在菜单里放了一个“压缩数据库”的按钮,背后就是执行VACUUM。注意 VACUUM 会重写整个库文件,几千道题还好,几万道题可能需要几秒钟,期间会锁库,所以要放在空闲时手动触发。另外备份时不要直接复制在运行中的数据库文件,应该先关闭程序,或者用 SQLite 的 backup API。
6. 进阶:给单机版加一个“错题本”闭环
答题之后把做错的题单独拎出来,隔几天重新练一遍,这是题库管理系统最有价值的功能。单机版实现错题本不需要复杂的算法,建一张答题记录表再加上一个视图就够了。
CREATE TABLE IF NOT EXISTS practice_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, user_answer TEXT NOT NULL, is_correct INTEGER NOT NULL, practiced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE VIEW wrong_question AS SELECT q.*, COUNT(*) AS wrong_times FROM practice_log pl JOIN question q ON pl.question_id = q.id WHERE pl.is_correct = 0 GROUP BY q.id ORDER BY wrong_times DESC;逻辑说明:每次答题界面上点“提交答案”,程序把那道题的用户作答、正确与否写入 practice_log。is_correct 存 0 或 1,0 表示错误。wrong_question 视图统计每道题答错的次数,按次数倒序排列,错得越多排越靠前。在左侧树里新增一个“错题本”节点,点击时查询这个视图,把题目列表加载到右侧表格。重新练习时如果答对,就再插入一条 is_correct=1 的记录,视图里仍然会统计过去的错题次数,适合做重复训练。
还有一个实用参数:错题本过滤条件可以加一个“错题次数大于等于 2”的选项,避免一次性做完就下一轮时看到所有错题。我通常把练习模式设置成:当场答错的题立即进错题本,之后每隔 3 天自动把错题本中的前 10 道题重新推送一次。资源包里用定时器实现这个,每次启动程序时检查是否有到期需要复习的错题。
从那以后我每次给机构搭题库管理系统,都强制先跑一遍错题闭环,因为出题不是目的,练会才是。希望帮到你。
本文还有配套的精品资源,点击获取