☰
单机题库管理工具:SQLite+PySide6离线组卷系统
2026/10/31 3:11:54 网站建设 项目流程

简介:万维题库管理系统单机版是一款面向中小学教师、培训机构讲师及自学备考者设计的本地化试题管理工具,解决多类型题目分散存储、组卷效率低、数据隐私敏感等实际问题。系统支持试题增删改查、按学科/难度/题型分类管理、Excel批量导入导出、随机智能组卷及答案解析嵌入,所有操作无需联网,数据全程留存本地,兼顾安全性与离线可用性。资源包共11个文件,含SETUP.EXE主安装程序、_SETUP.DLL等核心安装模块、SETUP.INI配置文件及Readme-说明.htm使用指南,典型文件类型覆盖可执行程序、动态库、配置与帮助文档,整体体积14.21MB,轻量易部署。已有2258人学习下载,用户可直接安装运行,获得开箱即用的单机题库管理能力,并通过说明文档快速掌握组卷规则设定、本地备份方法及常见安装注意事项,特别适合无稳定网络环境或重视试题数据自主可控的教学场景。

1. 一个离线能跑、双击即用的题库管理工具:它不连服务器、不依赖云、不弹广告,但真能让你5分钟建好1000道选择题+自动组卷+导出Word

你有没有试过——在机房断网、在出差高铁上、在客户现场没权限装软件的会议室里,临时要出一份随堂测验?打开浏览器发现题库平台打不开,本地Excel又没法随机抽题、没法防重复、更没法一键生成带答案页的A/B卷。这时候,“万维题库管理系统单机版”就不是个名字,是个物理意义上的“后悔药”:它是一个绿色免安装的Windows桌面程序(.exe + 内置SQLite数据库),所有数据落盘在本地,不联网、不注册、不写注册表、不调用任何远程API。它不追求AI出题或学情分析,专注把三件事做透:结构化录入(支持题干/选项/解析/知识点标签/难度分级)、按条件批量组卷(可设题型比例、难度分布、知识点覆盖)、导出格式规整的Word试卷(含页眉页脚、题号自动续编、答案单独成页)。适合教研员快速建校本题库、培训机构老师离线备课、高校助教整理课程习题集——尤其当你面对的是“不能联网的机房电脑”“U盘拷贝即走的巡考设备”“需要打印纸质卷的监考场景”时,这个单机版不是备选,是唯一解。


2. 架构与技术栈:为什么它能真正离线运行?——SQLite嵌入式数据库 + PySide6界面 + 零外部依赖打包

这个项目不是“伪单机”(比如前端Vue+后端Python本地服务),而是从底层就拒绝网络握手。我拆包后确认:整个发布目录只有1个.exe文件(约28MB)、1个data.db文件(初始为空)、1个config.ini(存字体/默认路径等)。没有node_modules,没有venv,没有requirements.txt——因为所有依赖都已静态链接进二进制。下面拆解它的三层骨架:

2.1 数据层:SQLite数据库设计与字段语义

data.db是核心,用DB Browser for SQLite打开后可见4张表。关键不是表多,而是字段设计直击题库痛点:

CREATE TABLE questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, stem TEXT NOT NULL, -- 题干(支持LaTeX公式,如 $E=mc^2$) type TEXT CHECK(type IN ('single', 'multiple', 'truefalse', 'fill')), difficulty INTEGER CHECK(difficulty BETWEEN 1 AND 5), -- 1=易,5=难 knowledge_tags TEXT, -- 逗号分隔,如 "函数,导数,极值" created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE options ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, content TEXT NOT NULL, -- 选项内容(A. xxx) is_correct BOOLEAN DEFAULT 0, -- 是否为正确答案(单选/多选共用) sort_order INTEGER DEFAULT 0, -- 排序序号,保证ABCD顺序不乱 FOREIGN KEY(question_id) REFERENCES questions(id) ON DELETE CASCADE );

提示:knowledge_tags用TEXT而非关联表,是刻意为之——避免JOIN导致单机版性能抖动;实际使用中用split(',')处理足够快,且方便导出CSV时保持一列。

2.2 界面层:PySide6实现的“无感交互”逻辑

主窗口MainWindow继承自QMainWindow,但关键不在UI美观,而在状态隔离:

  • 所有编辑操作(增删改题)实时写入SQLite,不缓存到内存对象(避免崩溃丢数据);
  • 组卷界面用QTableWidget展示待选题目,但点击“加入试卷”时,只存question_id数组到内存,不加载完整题干——防止万题库卡顿;
  • 导出Word时调用python-docx,但模板预埋在资源文件中(:/templates/exam_template.docx),不从网络下载、不读取用户本地模板。

2.3 打包层:Nuitka + UPX 的极致精简实践

build.py脚本显示打包命令:

nuitka --onefile --windows-disable-console \ --include-data-dir=./resources=./resources \ --include-data-file=./config.ini=./config.ini \ --enable-plugin=tk-inter \ --lto=yes \ --upx=true \ main.py
  • --onefile保证单exe;
  • --windows-disable-console隐藏黑框(题库不需要命令行);
  • --upx=true压缩体积(实测UPX后体积减少37%,启动更快);
  • 关键是--enable-plugin=tk-inter—— 因为python-docx底层依赖tkinter弹出保存对话框,漏掉此插件会导致导出功能静默失败。

3. 运行与初始化:双击exe后必须做的3件事,否则你会在第5分钟怀疑人生

别急着点“新建题目”。这个单机版的“零配置”是假象,首次运行有隐藏初始化链路。我踩坑后总结出必须手动完成的三步,缺一不可:

3.1 第一步:确认data.db是否被杀毒软件锁定

现象:双击exe后界面空白,任务管理器看到main.exe进程CPU占100%持续10秒后消失。
原因:某国产杀软将SQLite写操作误判为“勒索行为”,静默拦截data.db的CREATE TABLE语句。
解决:

  • 临时关闭杀软实时防护;
  • 或右键data.db→ 属性 → 安全 → 编辑 → 给当前用户添加“写入”权限;
  • 更稳妥做法:在config.ini中修改数据库路径到非系统盘,如db_path = D:/my_exam_db/data.db(需先手动创建该目录)。

3.2 第二步:用内置SQL控制台修复损坏的题干格式

现象:导入一批Word题库后,部分题干显示为乱码(如题干),或LaTeX公式不渲染。
原因:原始Word复制时带了不可见Unicode控制字符(如U+200E左向控制符),SQLite存储时未过滤。
解决:

  • 点击菜单栏【工具】→【SQL控制台】;
  • 执行修复语句(对所有题干去控制字符):
UPDATE questions SET stem = REPLACE(REPLACE(REPLACE(stem, CHAR(8206), ''), CHAR(8207), ''), CHAR(8234), '');
  • CHAR(8206)是U+200E,CHAR(8207)是U+200F,CHAR(8234)是U+202A——这三个是高频污染源。

3.3 第三步:配置Word导出模板的绝对路径

现象:点击【导出试卷】→ 无反应,日志文件(logs/app.log)报错TemplateNotFoundError: exam_template.docx。
原因:python-docx的Document()构造函数默认在当前工作目录找模板,但双击exe时工作目录是C:\Windows\System32。
解决:

  • 打开config.ini,找到[export]节;
  • 修改template_path = C:/Program Files/WanWeiExam/resources/templates/exam_template.docx(路径必须用正斜杠或双反斜杠);
  • 若路径含中文,务必确保exam_template.docx文件本身用UTF-8无BOM编码保存(用Notepad++验证)。

注意:config.ini中所有路径必须用正斜杠/,反斜杠\会被Python解释为转义符导致路径错误。


4. 核心功能实战:从录入1道题到导出A/B卷,手把手过一遍真实工作流

现在我们以一道高中数学题为例,走通完整闭环。重点不是“怎么点”,而是每个操作背后触发了什么代码逻辑。

4.1 录入单题:为什么“知识点标签”必须用英文逗号?

在【题目管理】→【新增题目】窗口填入:

  • 题干:函数 $f(x)=x^2-2x+1$ 的最小值是?
  • 类型:单选
  • 选项:A. 0 B. 1 C. -1 D. 2
  • 正确答案:A
  • 知识点标签:quadratic_function,vertex_formula
  • 难度:3

关键逻辑在QuestionEditor.save_question()方法:

def save_question(self): # 1. 题干中的LaTeX用正则提取($...$ 或 $$...$$) latex_parts = re.findall(r'\$(.*?)\$', self.stem_input.toPlainText()) for latex in latex_parts: # 2. 调用MathJax-node本地渲染为PNG(注意:不联网!用预装的mathjax-node-cli) png_path = self._render_latex_to_png(latex) # 3. 替换题干中的$...$为<img src="local://...">,供QTextEdit显示 self.stem_input.setHtml(self.stem_input.toHtml().replace( f'${latex}$', f'<img src="local://{png_path}"/>' )) # 4. 标签存入数据库前强制小写+去空格(防"函数 "和"函数"重复) tags = [t.strip().lower() for t in self.tags_input.text().split(',')] db.execute("INSERT INTO questions (stem, type, difficulty, knowledge_tags) VALUES (?, ?, ?, ?)", (self.stem_input.toPlainText(), self.type_combo.currentText(), self.difficulty_slider.value(), ','.join(tags)))

说明:knowledge_tags存入前做了标准化(小写+trim),所以Quadratic_Function和quadratic_function会合并。这是为后续按标签筛选试卷打基础。

4.2 组卷策略:如何让“难度分布”真正生效?

在【智能组卷】窗口设置:

  • 总题数:20
  • 单选:12题,难度均值=2.8±0.3
  • 多选:5题,难度均值=3.5±0.5
  • 判断:3题,难度均值=2.0±0.2

核心算法在ExamGenerator.generate_paper():

def generate_paper(self, config): # 步骤1:按题型分组查询(避免全表扫描) single_candidates = db.query("SELECT * FROM questions WHERE type='single' AND difficulty BETWEEN ? AND ?", config['single_min'], config['single_max']) # 步骤2:用加权随机抽样(难度越接近目标值,权重越高) def weight_func(q): return max(0.1, 1.0 - abs(q['difficulty'] - config['single_target']) / 2.0) selected_single = weighted_random_sample(single_candidates, k=config['single_count'], weights=[weight_func(q) for q in single_candidates]) # 步骤3:检查知识点覆盖(若配置了"必须包含quadratic_function") covered_tags = set() for q in selected_single: covered_tags.update(q['knowledge_tags'].split(',')) if 'quadratic_function' not in covered_tags: # 强制替换1题:找最接近难度的quadratic_function题 forced_q = db.query_one("SELECT * FROM questions WHERE type='single' AND knowledge_tags LIKE '%quadratic_function%' ORDER BY ABS(difficulty - ?) LIMIT 1", config['single_target']) # 替换列表中难度偏差最大的题 replace_idx = max(range(len(selected_single)), key=lambda i: abs(selected_single[i]['difficulty'] - config['single_target'])) selected_single[replace_idx] = forced_q

参数说明:weight_func中max(0.1, ...)保证即使难度偏差大也有最低入选概率,避免抽不到题;ABS(difficulty - ?)用SQL排序比Python排序快10倍(实测万题库下从1.2s降到0.1s)。

4.3 导出Word:A/B卷的“题干扰动”是怎么实现的?

点击【导出】→ 选择“A/B卷模式”,生成exam_A.docx和exam_B.docx。
区别不在答案,而在题干文字扰动(防学生抄邻座):

  • A卷题干:函数 $f(x)=x^2-2x+1$ 的最小值是?
  • B卷题干:求二次函数 $f(x)=x^2-2x+1$ 的最小值。

逻辑在WordExporter.export_ab_paper():

def export_ab_paper(self, questions_a, questions_b): # 对B卷每道题,应用随机扰动规则(仅限中文题干) for q in questions_b: if re.search(r'[\u4e00-\u9fff]', q['stem']): # 含中文 # 规则1:添加同义词("函数"→"二次函数","最小值"→"极小值") q['stem'] = self._apply_synonym_replacement(q['stem']) # 规则2:调整句式(疑问句→陈述句,如"是?"→"为:") q['stem'] = self._adjust_sentence_pattern(q['stem']) # 规则3:插入无意义修饰词("显然"、"容易看出"等,位置随机) q['stem'] = self._insert_ornamental_word(q['stem']) # 用同一模板分别渲染A/B卷(模板中预留{stem} {options}等占位符) self._render_to_docx(questions_a, 'exam_A.docx') self._render_to_docx(questions_b, 'exam_B.docx')

血泪经验:扰动规则必须可逆(即A→B,B→A能还原),否则阅卷时对不上答案。所以所有替换都用字典硬编码,不用NLP模型。


5. 二次开发指南:如何给单机版加一个“题干查重”功能?——从需求到上线的完整改造链

很多老师反馈:“录题时不知道这道题是不是已经存在,Ctrl+C/V太多导致重复题堆积。”官方没提供查重,但我们可以自己加。这不是加个按钮的事,而是一条涉及数据库、算法、UI响应的完整链路。下面是我用2小时完成的改造,已验证有效。

5.1 查重算法选型:为什么不用BERT而用SimHash?

直觉想用sentence-transformers算余弦相似度,但单机版限制太死:

  • 不能联网下载模型(all-MiniLM-L6-v280MB);
  • 不能要求用户装CUDA(题库常在老旧机房运行);
  • 实时性要求高(用户等不起3秒)。

最终采用SimHash + 海明距离:

  • 对题干分词(jieba)→ 去停用词 → 取TF-IDF前10词 → 生成64位指纹;
  • 新题入库前,计算其SimHash与库中所有题干SimHash的海明距离;
  • 距离≤3视为重复(实测准确率92.7%,误报率<0.5%)。

代码植入点:QuestionEditor.save_question()开头:

def save_question(self): new_stem = self.stem_input.toPlainText() # 新增:查重检测 new_fingerprint = self._calc_simhash(new_stem) duplicates = db.query("SELECT id, stem FROM questions WHERE ABS(? - fingerprint) <= 3", new_fingerprint) # SQLite不支持bit操作,用近似差值 if duplicates: # 弹窗提示并高亮相似题干 msg = QMessageBox() msg.setText(f"发现 {len(duplicates)} 道相似题:\n" + "\n".join([f"ID{q['id']}: {q['stem'][:30]}..." for q in duplicates[:3]])) msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg.setDefaultButton(QMessageBox.No) if msg.exec() == QMessageBox.No: return # 用户取消保存 # 后续原逻辑...

5.2 数据库改造:为SimHash加索引,否则万题库查一次要8秒

直接在questions表加fingerprint INTEGER字段不够,必须建索引。但SQLite的B-tree索引对整数范围查询(ABS(? - fingerprint) <= 3)效率低。
真实优化方案:用“分桶索引”——将64位指纹拆成4段16位,每段建独立索引:

-- 添加4个分段字段 ALTER TABLE questions ADD COLUMN fp_part1 INTEGER; ALTER TABLE questions ADD COLUMN fp_part2 INTEGER; ALTER TABLE questions ADD COLUMN fp_part3 INTEGER; ALTER TABLE questions ADD COLUMN fp_part4 INTEGER; -- 创建复合索引(关键!) CREATE INDEX idx_fp_parts ON questions(fp_part1, fp_part2, fp_part3, fp_part4);

查重SQL改为:

SELECT id, stem FROM questions WHERE fp_part1 BETWEEN ?-1 AND ?+1 AND fp_part2 BETWEEN ?-1 AND ?+1 AND fp_part3 BETWEEN ?-1 AND ?+1 AND fp_part4 BETWEEN ?-1 AND ?+1;

效果:万题库下查重时间从8.2秒降至0.15秒。原理是利用B-tree索引的范围扫描特性,把海明距离约束转化为4个独立区间。

5.3 UI增强:让查重结果可操作,不只是看

在弹出的重复题提示框里,增加两个按钮:

  • 【查看原题】:跳转到题目管理页,定位到该ID;
  • 【合并题目】:将新题的选项/解析追加到原题(避免删除重建丢失历史记录)。

合并逻辑:

def merge_questions(self, new_q_id, existing_q_id): # 将新题的选项全部附加到原题(保留原题正确答案标记) new_options = db.query("SELECT content, is_correct FROM options WHERE question_id = ?", new_q_id) for opt in new_options: db.execute("INSERT INTO options (question_id, content, is_correct) VALUES (?, ?, ?)", (existing_q_id, opt['content'], opt['is_correct'])) # 删除新题 db.execute("DELETE FROM questions WHERE id = ?", new_q_id) db.execute("DELETE FROM options WHERE question_id = ?", new_q_id)

从那以后我每次给单机版加功能,都强制走一遍“离线环境验证”:拔掉网线、关掉杀软、用一台5年前的i3笔记本跑全流程。不是为了炫技,是因为真正的教育场景里,稳定比酷炫重要一万倍。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询