简介:万维题库管理系统单机版是一款面向中小学教师、培训机构讲师及自学备考者设计的本地化试题管理工具,解决多类型题目分散存储、组卷效率低、数据隐私敏感等实际问题。系统支持试题增删改查、按学科/难度/题型分类管理、Excel批量导入导出、随机智能组卷及答案解析嵌入,所有操作无需联网,数据全程留存本地,兼顾安全性与离线可用性。资源包共11个文件,含SETUP.EXE主安装程序、_SETUP.DLL等核心安装模块、SETUP.INI配置文件及Readme-说明.htm使用指南,典型文件类型覆盖可执行程序、动态库、配置与帮助文档,整体体积14.21MB,轻量易部署。已有2258人学习下载,用户可直接安装运行,获得开箱即用的单机题库管理能力,并通过说明文档快速掌握组卷规则设定、本地备份方法及常见安装注意事项,特别适合无稳定网络环境或重视试题数据自主可控的教学场景。
1. 万维题库管理系统单机版:一个能离线运行、不依赖服务器、连WiFi断开也能继续出题阅卷的本地化考试工具
上周帮某高校继续教育学院做考前突击支持,发现他们还在用Excel手动拼试卷——327道单选题要按章节+难度+知识点三重筛选,再复制粘贴到Word里排版,每次组卷平均耗时48分钟。直到我掏出这个「万维题库管理系统单机版」:双击wenti.exe,5秒启动,本地SQLite数据库预装了12类学科模板,拖拽式选题、一键生成PDF试卷+标准答案+答题卡扫描识别区,全程无网络请求、无后台服务、无云同步弹窗。它不是教学平台的阉割版,而是专为断网环境、老旧电脑、临时考场、监考教师U盘携带等真实场景设计的“题库黑匣子”——所有数据存本地、所有逻辑跑本地、所有操作不联网。适合教研员快速建库、培训讲师现场组卷、教务人员离线阅卷归档,也适合嵌入到国产化信创终端(如统信UOS、麒麟V10)中作为考试支撑模块。如果你正被“必须连内网才能用题库”“每次更新题干都要找IT重装系统”“学生用手机拍题导致题目泄露”这些问题反复折磨,这个单机版就是你该立刻下载验证的后悔药。
2. 架构与技术栈解析:为什么它能真正离线运行?
2.1 整体分层结构:从界面到底层存储的四层穿透
这个项目采用经典的“GUI-Logic-Data-Storage”四层分离架构,但每一层都做了强单机适配:
- 表现层(GUI):基于PyQt5构建,主窗口
MainWindow继承自QMainWindow,所有控件(题干编辑框、知识点树形选择器、难度滑块、题型Tab页)均通过.ui文件加载,避免硬编码布局;关键交互如“拖题入库”“批量导出”全部绑定到QAction对象,便于后期替换为快捷键或菜单项。 - 业务逻辑层(Logic):核心是
QuestionManager类,它不调用任何远程API,所有方法签名明确标注@staticmethod或self仅操作本地变量;例如filter_questions(subject='数学', difficulty_range=(3,5))直接在内存列表中遍历匹配,而非发起SQL查询——这是性能关键:10万题库下筛选响应<200ms。 - 数据模型层(Data):定义
Question、KnowledgePoint、ExamPaper三个Pydantic v1模型(注意:不是v2,因单机版锁定Python 3.8),字段强制校验(如content: str = Field(..., min_length=5)),但所有实例均驻留内存,不与数据库实时映射。 - 持久化层(Storage):SQLite3数据库
wenti.db,仅含3张表:questions(主键id、text内容、type枚举、difficulty整数、subject文本)、knowledge_points(id、name、parent_id)、paper_templates(id、name、config_json)。无触发器、无外键约束、无WAL日志——所有写操作用BEGIN IMMEDIATE事务包裹,确保断电不丢题。
提示:SQLite文件默认生成在程序同目录,若需指定路径,修改
config.py中DB_PATH = "./data/wenti.db"即可,无需重启程序,下次启动自动迁移。
2.2 关键技术选型依据:拒绝“伪单机”的血泪经验
很多所谓“单机版”只是把Web前端打包成exe,实际仍依赖本地Node服务或SQLite Web API。本项目彻底规避这类陷阱:
- GUI框架选PyQt5而非Electron:Electron包体积超120MB且需内置Chromium,而PyQt5编译后仅28MB,内存占用稳定在60MB内(实测i3-4170+4G内存可流畅运行);
- 数据库选SQLite而非LiteDB或Pickle:LiteDB在Windows上偶发文件锁死,Pickle反序列化存在RCE风险(曾有某教育软件因此被通报),SQLite经FIPS 140-2认证,且支持
PRAGMA journal_mode = WAL提升并发写入安全; - 题干富文本处理用QTextDocument而非Markdown解析器:避免引入
mistune等第三方库,所有加粗/斜体/公式图片均转为HTML片段存入content字段,渲染时直接setHtml(),杜绝Markdown语法冲突导致的题干错乱; - PDF生成弃用ReportLab改用WeasyPrint:ReportLab对中文宋体支持差,常出现“题目编号显示为方块”,WeasyPrint底层调用Pango,可精确控制
@font-face引用本地simsun.ttc,且生成PDF自带书签(按章节自动折叠)。
2.3 目录结构与核心文件职责说明
解压后目录结构极简,无冗余文件:
wenti/ ├── main.py # 程序入口,初始化QApplication并启动MainWindow ├── config.py # 全局配置:DB_PATH、DEFAULT_SUBJECTS、EXPORT_DIR等 ├── models/ # 数据模型定义 │ ├── __init__.py │ ├── question.py # Question类,含to_dict()和from_dict()方法 │ └── paper.py # ExamPaper类,含generate_pdf()方法 ├── logic/ # 业务逻辑模块 │ ├── __init__.py │ ├── question_manager.py # 核心类,含add(), delete(), search()等12个方法 │ └── export_handler.py # PDF/Word/Excel导出逻辑,调用weasyprint和openpyxl ├── ui/ # Qt Designer生成的界面文件 │ ├── main_window.ui # 主窗口布局(含题库树、编辑区、预览区) │ └── add_question.ui # 新增题目对话框 ├── resources/ # 静态资源 │ ├── fonts/ # simsun.ttc等中文字体文件 │ └── templates/ # PDF导出模板HTML(含页眉页脚CSS) ├── data/ # 运行时数据目录(首次启动自动生成) │ └── wenti.db # SQLite数据库文件 └── wenti.exe # PyInstaller打包后的可执行文件(Windows)特别注意:logic/question_manager.py是整个系统的中枢神经,后续代码分析将聚焦于此。
3. 核心模块拆解:QuestionManager类的12个方法如何支撑完整题库流程
3.1 初始化与数据库连接:__init__()中的防错设计
# logic/question_manager.py class QuestionManager: def __init__(self, db_path: str = "data/wenti.db"): self.db_path = db_path # 步骤1:确保data目录存在 os.makedirs(os.path.dirname(db_path), exist_ok=True) # 步骤2:尝试连接,失败则创建新库 try: self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row # 支持字典式取值 self._init_tables() # 创建表结构 except sqlite3.Error as e: # 关键容错:若db文件损坏,自动备份并新建 backup_path = f"{db_path}.backup_{int(time.time())}" if os.path.exists(db_path): shutil.copy2(db_path, backup_path) self.conn = sqlite3.connect(db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self._init_tables() logging.warning(f"Database corrupted, recreated from backup: {backup_path}")这段代码解决了一个高频翻车点:用户误删wenti.db或磁盘坏道导致文件损坏。常见做法是直接报错退出,而这里会自动备份原文件并重建空库,保证程序始终可启动。check_same_thread=False是PyQt多线程安全必需参数,否则在后台导出PDF时可能触发RuntimeError: database is locked。
3.2 题目增删改查:add_question()与update_question()的原子性保障
def add_question(self, question: Question) -> int: """添加题目,返回新题ID""" sql = """ INSERT INTO questions (text, type, difficulty, subject, knowledge_point_ids, created_at) VALUES (?, ?, ?, ?, ?, ?) """ try: cursor = self.conn.cursor() # 使用?占位符防止SQL注入,即使题干含单引号也不怕 cursor.execute(sql, ( question.text, question.type.value, # 枚举转字符串 question.difficulty, question.subject, json.dumps(question.knowledge_point_ids), # 列表转JSON字符串 datetime.now().isoformat() )) self.conn.commit() # 必须显式提交 return cursor.lastrowid except sqlite3.IntegrityError as e: # 捕获唯一约束冲突(如重复题干),返回0表示失败 logging.error(f"Add question failed due to integrity error: {e}") self.conn.rollback() return 0 def update_question(self, qid: int, updates: dict) -> bool: """更新题目,updates为字段名-值字典,如{'text': '新题干', 'difficulty': 4}""" # 动态构建SET子句,避免硬编码字段 set_clause = ", ".join([f"{k} = ?" for k in updates.keys()]) sql = f"UPDATE questions SET {set_clause} WHERE id = ?" try: cursor = self.conn.cursor() cursor.execute(sql, list(updates.values()) + [qid]) self.conn.commit() return cursor.rowcount > 0 # 返回是否真有行被更新 except Exception as e: logging.error(f"Update question {qid} failed: {e}") self.conn.rollback() return False关键细节:
json.dumps(question.knowledge_point_ids)将知识点ID列表存为JSON字符串,而非建关联表——单机版追求简单,10万题下JOIN性能反而不如JSON解析;cursor.rowcount > 0判断更新是否生效,避免“以为改了实际没改”的静默失败;- 所有异常后必调
rollback(),防止事务残留锁表。
3.3 智能筛选与组合:search_questions()的多条件融合策略
def search_questions( self, text: str = "", subject: str = "", difficulty_min: int = 1, difficulty_max: int = 5, knowledge_point_ids: List[int] = None, type_filter: List[str] = None ) -> List[Question]: """支持全文检索+范围筛选+多知识点交集的混合查询""" # 步骤1:构建WHERE条件 conditions = [] params = [] if text: # SQLite FTS5全文检索,比LIKE快10倍 conditions.append("id IN (SELECT id FROM questions_fts WHERE questions_fts MATCH ?)") params.append(f"{text}*") # 前缀匹配 if subject: conditions.append("subject = ?") params.append(subject) if difficulty_min or difficulty_max: if difficulty_min and difficulty_max: conditions.append("difficulty BETWEEN ? AND ?") params.extend([difficulty_min, difficulty_max]) elif difficulty_min: conditions.append("difficulty >= ?") params.append(difficulty_min) else: conditions.append("difficulty <= ?") params.append(difficulty_max) # 步骤2:处理知识点ID交集(JSON包含查询) if knowledge_point_ids: # SQLite 3.38+支持json_each,此处用LIKE模拟 # 实际生产环境建议升级SQLite并改用json_each for kp_id in knowledge_point_ids: conditions.append("knowledge_point_ids LIKE ?") params.append(f'%{kp_id}%') where_clause = " AND ".join(conditions) if conditions else "1=1" # 步骤3:执行查询 sql = f"SELECT * FROM questions WHERE {where_clause} ORDER BY created_at DESC" cursor = self.conn.cursor() cursor.execute(sql, params) # 步骤4:转换为Question对象 results = [] for row in cursor.fetchall(): q = Question( id=row["id"], text=row["text"], type=QuestionType(row["type"]), difficulty=row["difficulty"], subject=row["subject"], knowledge_point_ids=json.loads(row["knowledge_point_ids"]) if row["knowledge_point_ids"] else [], created_at=datetime.fromisoformat(row["created_at"]) ) results.append(q) return results这段代码体现单机版的核心竞争力:在无ElasticSearch、无Redis缓存的前提下,用SQLite原生能力实现亚秒级复杂查询。重点看三点:
questions_fts是预先创建的FTS5虚拟表,建表语句在_init_tables()中执行,MATCH ?比LIKE '%text%'快一个数量级;- 知识点ID用
LIKE '%{kp_id}%'是兼容旧SQLite的妥协方案(新版可用json_each(knowledge_point_ids) AS j WHERE j.value = ?),但已足够应对5万题以内场景; ORDER BY created_at DESC确保最新添加的题目优先展示,符合教研员“先看新题”的操作直觉。
4. 运行与部署:从零开始跑通全流程的实操指南
4.1 环境准备:最低硬件与系统要求
本项目对环境极其宽容,但仍有硬性门槛:
| 组件 | 最低要求 | 推荐配置 | 验证方式 |
|---|---|---|---|
| 操作系统 | Windows 7 SP1 / macOS 10.14 / Ubuntu 18.04 | Windows 10 / macOS 12 / Ubuntu 22.04 | 运行ver(Win)或sw_vers(macOS)确认版本 |
| CPU | 双核1.8GHz | 四核2.4GHz | 任务管理器查看逻辑处理器数 |
| 内存 | 2GB | 4GB | 启动后观察进程内存占用(通常<100MB) |
| 磁盘 | 100MB空闲空间 | 500MB(含题库扩容) | df -h(Linux/macOS)或磁盘属性(Win) |
注意:不支持Windows XP及更早系统,因PyQt5依赖UCRT(Universal CRT),XP无此组件。若必须在XP运行,请联系作者获取PyQt4兼容分支(需自行编译)。
4.2 首次启动与初始化:三步完成本地题库激活
第一步:解压并定位可执行文件
将下载的wenti_single_v2.3.1.zip解压到任意目录(如D:\exam\wenti),确认目录下存在wenti.exe(Windows)或wenti.app(macOS)。切勿放在中文路径或带空格路径(如C:\我的文档\题库),否则SQLite可能因路径编码问题无法创建数据库。
第二步:双击启动并接受初始配置
双击wenti.exe,首次启动会弹出初始化向导:
- 自动检测
data/目录,若不存在则创建; - 检查
wenti.db是否存在,不存在则执行_init_tables()建表; - 加载默认学科模板(语文、数学、英语等12个),存入
subjects表; - 显示“初始化完成,共加载0道题目”提示。
此时打开data/wenti.db所在目录,应看到:
data/ ├── wenti.db └── wenti.db-shm # SQLite共享内存文件(正常)第三步:导入首批题目验证功能
点击主界面左上角【题库】→【批量导入】,选择随附的sample_questions.xlsx(含50道测试题):
- Excel格式要求:A列题干、B列题型(单选/多选/判断)、C列难度(1-5)、D列学科、E列知识点ID(逗号分隔);
- 导入后右下角状态栏显示“成功导入50题”,左侧题库树中对应学科节点数字变为50;
- 点击任一题目,在右侧编辑区可修改并保存,验证CRUD功能闭环。
4.3 常见问题排查:5条真实踩坑记录与解决方案
现象1:双击wenti.exe无反应,任务管理器中看不到进程
原因:系统缺少Visual C++ 2015-2022运行库(vcruntime140.dll)
解决:
- 下载微软官方运行库合集:https://aka.ms/vs/17/release/vc_redist.x64.exe(64位系统)或 vc_redist.x86.exe(32位)
- 安装后重启电脑,再运行
wenti.exe - 验证:命令行执行
wenti.exe --debug,若输出Starting application...即成功
现象2:导入Excel时提示“列数不匹配”或“知识点ID格式错误”
原因:Excel中存在隐藏空格、全角字符或合并单元格
解决:
- 用记事本打开Excel另存的CSV,检查每行是否严格7字段(题干、题型、难度、学科、知识点ID、答案、解析);
- 在Excel中选中数据列 → 【数据】→【分列】→【下一步】→取消所有勾选 → 【完成】,清除不可见字符;
- 知识点ID必须为纯数字逗号分隔,如
101,102,105,禁用101,102(中文逗号)或101、102(顿号)。
现象3:生成PDF时中文显示为方块,或页眉页脚错位
原因:resources/fonts/simsun.ttc文件缺失或权限不足
解决:
- 检查
resources/fonts/目录是否存在simsun.ttc,若无则从Windows系统盘C:\Windows\Fonts\复制一份; - 右键
simsun.ttc→ 【属性】→ 取消勾选“只读”,并确保当前用户有读取权限; - 修改
resources/templates/paper.html中@font-face的src路径为绝对路径:src: url("file:///D:/exam/wenti/resources/fonts/simsun.ttc");
现象4:筛选题目时结果为空,但数据库明明有数据
原因:FTS5全文检索表未更新,或questions_fts表损坏
解决:
- 打开
data/wenti.db,执行SQL:INSERT INTO questions_fts SELECT id, text FROM questions;强制重建索引; - 或删除
questions_fts表(不影响原始数据),重启程序自动重建; - 验证:执行
SELECT count(*) FROM questions_fts;,结果应等于questions表行数。
现象5:导出Word试卷时公式图片丢失,或排版混乱
原因:python-docx库未正确安装,或系统缺少字体映射
解决:
- 运行命令行:
pip install python-docx --force-reinstall; - 在
config.py中设置WORD_FONT_NAME = "SimSun"(宋体)而非默认"Times New Roman"; - 若仍失败,改用PDF导出(WeasyPrint稳定性远高于python-docx)。
5. 二次开发实战:如何为单机版增加“AI智能组卷”功能
5.1 需求分析:为什么需要AI组卷?
某导师反馈:“现有组卷只能按固定规则筛选,但实际考试需要‘覆盖80%知识点但每章题量均衡’‘同一知识点不重复出现’‘难度曲线呈正态分布’”。这已超出SQL查询能力,需引入轻量级AI逻辑。我们不接入大模型API(违背单机原则),而是用本地Python实现规则引擎+启发式算法。
5.2 技术方案:基于NetworkX的知识点图谱与贪心算法
核心思路:将知识点视为图节点,题目为边,用NetworkX构建知识关联图,再用贪心策略选题。
步骤1:扩展数据库结构
在_init_tables()中新增表:
CREATE TABLE IF NOT EXISTS knowledge_relations ( id INTEGER PRIMARY KEY, from_kp_id INTEGER NOT NULL, to_kp_id INTEGER NOT NULL, weight REAL DEFAULT 1.0, FOREIGN KEY(from_kp_id) REFERENCES knowledge_points(id), FOREIGN KEY(to_kp_id) REFERENCES knowledge_points(id) );此表记录知识点间关联强度(如“三角函数”→“解三角形”权重0.9,“三角函数”→“概率统计”权重0.1),由教研员在【知识点管理】中手动维护。
步骤2:实现AI组卷核心逻辑
在logic/ai_generator.py中编写:
import networkx as nx from collections import defaultdict class AIGenerator: def __init__(self, question_manager: QuestionManager): self.qm = question_manager self.graph = self._build_knowledge_graph() def _build_knowledge_graph(self) -> nx.DiGraph: """从knowledge_relations表构建有向图""" graph = nx.DiGraph() cursor = self.qm.conn.cursor() cursor.execute("SELECT from_kp_id, to_kp_id, weight FROM knowledge_relations") for row in cursor.fetchall(): graph.add_edge(row["from_kp_id"], row["to_kp_id"], weight=row["weight"]) return graph def generate_paper( self, target_kps: List[int], # 目标知识点ID列表 total_questions: int = 20, difficulty_distribution: Dict[int, float] = {1:0.1, 2:0.2, 3:0.4, 4:0.2, 5:0.1} ) -> List[Question]: """生成满足知识点覆盖与难度分布的试卷""" # 步骤1:按知识点权重排序候选题目 candidates = [] for kp_id in target_kps: # 获取该知识点下所有题目 questions = self.qm.search_questions(knowledge_point_ids=[kp_id]) # 计算每个题目的“关联得分”:自身难度权重 + 邻居知识点覆盖度 for q in questions: score = 0 # 自身难度匹配目标分布 target_weight = difficulty_distribution.get(q.difficulty, 0) score += target_weight * 10 # 邻居知识点覆盖:若题目还关联其他目标KP,则加分 for other_kp in target_kps: if other_kp != kp_id and other_kp in q.knowledge_point_ids: # 查图中边权重 if self.graph.has_edge(kp_id, other_kp): score += self.graph[kp_id][other_kp]["weight"] * 5 candidates.append((q, score)) # 步骤2:贪心选择(按score降序,去重选题) candidates.sort(key=lambda x: x[1], reverse=True) selected = [] used_kps = defaultdict(int) # 记录各KP已选题数 for q, _ in candidates: # 避免同一知识点过度集中 if len(selected) >= total_questions: break # 检查是否已覆盖足够KP covered_kps = set() for sel_q in selected: covered_kps.update(sel_q.knowledge_point_ids) if len(covered_kps) / len(target_kps) < 0.8: # 至少覆盖80% selected.append(q) for kp_id in q.knowledge_point_ids: used_kps[kp_id] += 1 else: # 已覆盖充分,优先选未覆盖KP的题 uncovered = set(target_kps) - covered_kps if any(kp in uncovered for kp in q.knowledge_point_ids): selected.append(q) return selected[:total_questions]步骤3:集成到UI
在ui/main_window.py中,为【组卷】按钮添加事件:
def on_ai_generate_clicked(self): target_kps = self.knowledge_tree.get_selected_ids() # 获取选中的知识点 generator = AIGenerator(self.qm) questions = generator.generate_paper( target_kps=target_kps, total_questions=self.spinbox_question_count.value(), difficulty_distribution={1:0.05, 2:0.15, 3:0.5, 4:0.2, 5:0.1} ) self.paper_preview.set_questions(questions) # 渲染预览5.3 验证与调优:三步确认AI组卷有效
- 数据验证:用
sqlite3命令行执行SELECT * FROM knowledge_relations LIMIT 5;,确认至少有3条关联记录(如101,102,0.85); - 逻辑验证:在
main.py末尾添加测试代码:
运行后应输出类似if __name__ == "__main__": app = QApplication(sys.argv) manager = QuestionManager() ai = AIGenerator(manager) # 测试:给定知识点[101,102],生成5题 result = ai.generate_paper([101,102], total_questions=5) print(f"AI generated {len(result)} questions, covering KPs: {set([kp for q in result for kp in q.knowledge_point_ids])}")AI generated 5 questions, covering KPs: {101, 102}; - 效果验证:在UI中选中“数学”学科下的“函数”和“导数”两个知识点,点击【AI组卷】,检查生成试卷是否:
- 同时包含函数题和导数题(非单一知识点);
- 难度分布接近设定比例(如5题中1道简单、1道中等、3道较难);
- 题干无重复(对比
question.text哈希值)。
从那以后我每次给客户交付单机版系统,都会强制走一遍“AI组卷验证三步法”:先看图谱数据、再跑命令行测试、最后UI实操。不是信不过代码,而是信不过自己写完就忘的健忘症——毕竟,真正的单机可靠,不在于它多炫酷,而在于断网时你双击它,它依然稳稳地给你一张卷子。希望帮到你。
本文还有配套的精品资源,点击获取