
简介万维题库管理系统单机版是一款面向教育机构及个人教师的离线试题管理工具主要用于试题的分类维护、批量导入导出、随机组卷与答案解析等场景适合无稳定网络或对试题数据隐私性要求较高的用户使用。压缩包共11个文件、约14.21MB以SETUP.EXE主安装程序为核心搭配DLL、LIB等安装支持库以及INI、INS等配置文件另附Readme-说明.htm帮助文档覆盖从安装引导到参数配置的完整流程。目前已有2255人学习下载。通过安装部署这套系统用户可在本地独立完成题库建设与试卷生成按学科或难度分类管理试题并利用随机组卷功能快速整编测试卷。结合Readme中的说明掌握导入导出格式与组卷规则可进一步减少人工整理题目的重复劳动适合需要离线维护题库的教师、培训人员与考试组织者。1. 万维题库管理系统单机版为什么本地优先是教研管理的刚需当一份试卷背后躺着一千多道题目散落在十几个 Word 文件里整理靠复制粘贴、组卷靠手工挑题时你就知道题库管理缺在哪了。万维题库管理系统单机版解决的正是这个场景把题目按科目、知识点、题型和难度结构化存入本地库支持批量导入、快速检索、按规则组卷全程不需要服务器和网络。它的核心资产是一份本地数据库文件复制一份就是备份这是单机版最本质的竞争力。对个人教师、培训机构教研组、备赛团队来说这是投入产出比最高的起步方式。下面这套方案从数据模型写到组卷逻辑坑我也一并标出来照着做可以少走小半年的弯路。2. 数据模型与选型三张表怎么把题库收纳干净2.1 题目为什么必须拆成三张表category、question、option 的关系我第一次做题库时试图把一道题塞到一行里题干、选项A、选项B、选项C、正确答案、解析。结果导出试卷时还能应付但一旦想改某个选择题的选项B、或者想统计某道题的选项分布就只能在 Excel 里横向对齐选项字段一多表结构动不动要改后来想扩展多选和判断题时更麻烦。最常见的可靠做法是把题目相关的信息拆成 category 表和 question 表和 option 表category分类树。用 parent_id 支持两级或三级目录比如“高等数学”是父类“极限与连续”是子类。表结构只需要 id、name、parent_id 三列。question题目主表。存储与“题目本身”绑定的信息属于哪个分类、什么题型、题干文本、参考答案、解析、难度、创建时间。表里不存选项因为题型不同选项的数量和形式完全不同。option选项表。每道题一行存题号、选项键A/B/C/D、选项文本、是否正确。判断题可以把“正确”“错误”当作两个选项塞进去也可以用 qtype 单独分支处理。为什么选项单独拆表而不是用 JSON 拼在一个字段里第一个原因是查询你经常会按选项文本搜索某道题第二个原因是更新修改某个选项不需要重写整个题干字符串第三个原因是扩展多选、判断题和填空的答案结构差异很大拆开之后题型再多也只改 qtype 枚举不动表结构。三张表的 DDL 大概是这样的CREATE TABLE category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, parent_id INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, qtype INTEGER NOT NULL, -- 1单选 2多选 3判断 4填空 5问答 content TEXT NOT NULL, -- 题干纯文本或富文本 answer TEXT DEFAULT , -- 填空/问答的参考答案选择题留空 analysis TEXT DEFAULT , -- 答案解析 difficulty INTEGER NOT NULL DEFAULT 3, -- 1~5默认中等 created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)), FOREIGN KEY (category_id) REFERENCES category(id) ); CREATE TABLE option_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, opt_key TEXT NOT NULL, -- A、B、C、D content TEXT NOT NULL, is_correct INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (question_id) REFERENCES question(id) ON DELETE CASCADE );这里选了最简单的 INTEGER PRIMARY KEY 写法自增行为由行号保证单机版不需要纠结主键回补的问题。三个地方要留意分类表 parent_id 不要设外键约束根分类的父节点是 0option_item 表的正确答案字段不要建唯一索引因为多选的正确答案不止一个难度字段要定死取值 1~5否则导入脏数据后组卷条件会失效。索引是单机版容易被忽视的部分。分类一般几十条没必要加索引但 question 表超过两千行后按分类、题型、难度做的组合查询会明显变慢。在建表脚本里顺手给 category_id、qtype、difficulty 各建一个索引多数组卷查询都能走索引。这类索引有利无弊因为单机版写入量很小索引带来的额外写入成本几乎可以忽略。2.2 选型对比SQLite 与 JSON 文件、本地数据库服务的取舍很多人的第一版题库是直接拿 JSON 文件存的。几百条题时没问题但超过两三千条之后每次修改都要把整个文件读进来改完再写回去。先不说性能程序的崩溃点正好落在写回那一步数据损坏概率会直线上升。JSON 文件没有事务、没有行级锁也无法真正支持多人同时打开编辑。Excel 同理当编辑工具用可以当“数据库”用一定会遇到并发覆盖的问题。SQLite 是单机版最稳妥的存储层。它是嵌入式数据库不需要单独安装服务程序连接就是打开一个本地文件支持 ACID 事务和行级锁还支持标准 SQL配合内置的 sqlite3 驱动连第三方依赖都不用装。题库系统读写量都不大单文件四处拷贝也方便SQLite 几乎是为单机题库量身定做的。相比在本地装 MySQL 或 PostgreSQLSQLite 省掉的东西非常多不用起服务、不用配账号和端口、不用处理连接池、不用考虑防火墙。对“一台电脑、一个人用”的单机场景本地数据库服务是典型的大炮打蚊子只会增加部署成本和排障成本。我一般建议的选型顺序是纯文件JSON只用来做导入导出绝不作为存储主库单机场景直接用 SQLite真正出现多人同时协作写题库的需求再部署数据库服务。2.3 单机版的边界什么规模下必须换架构SQLite 不是没有天花板。它适合“单写多读”单机版通常是一个人维护题库、最多几个老师同时查题目读写冲突几乎不存在。但出现下面三种信号时就该考虑换客户端服务器架构了第一个是同时写入人数超过三五个SQLite 的写锁会让界面操作频繁卡顿第二个是题库需要跨校区、跨机构实时同步单机文件复制做不到第三个是数据量突破百万级虽然 SQLite 在一台机器上也能撑但备份、迁移、权限控制会开始别扭。换架构的迁移路径也不复杂因为数据模型不变只是把 sqlite3 连接换成数据库服务连接查询语句基本不用动。这种“先用 SQLite、后换库”的路径前提是业务逻辑层不要写死 SQLite 特有的函数。我在组卷模块里只用了 WHERE、ORDER BY、LIMIT 这类标准语法后来迁移时几乎没有改过业务代码。3. 核心功能落地初始化脚本、增删改查和 Excel 批量导入3.1 数据库初始化首次运行要做的事初始化逻辑只有一条规则检查数据库文件是否存在不存在就建表、建索引、预置默认分类存在就跳过。这里容易翻车的是用__file__定位数据库路径——当程序被打包成 exe 后__file__指向临时解包目录数据会写到临时目录里重开程序就找不到了。我一般把数据库放在和代码同级的 data 目录打包后用配置文件或环境变量指定实际数据目录。import sqlite3 import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) DATA_DIR os.path.join(BASE_DIR, data) DB_PATH os.path.join(DATA_DIR, question_bank.db) SCHEMA CREATE TABLE IF NOT EXISTS category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, parent_id INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS question ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, qtype INTEGER NOT NULL, content TEXT NOT NULL, answer TEXT DEFAULT , analysis TEXT DEFAULT , difficulty INTEGER NOT NULL DEFAULT 3, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); CREATE TABLE IF NOT EXISTS option_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, opt_key TEXT NOT NULL, content TEXT NOT NULL, is_correct INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_question_category ON question(category_id); CREATE INDEX IF NOT EXISTS idx_question_qtype ON question(qtype); CREATE INDEX IF NOT EXISTS idx_question_difficulty ON question(difficulty); def init_db(): os.makedirs(DATA_DIR, exist_okTrue) conn sqlite3.connect(DB_PATH) conn.executescript(SCHEMA) conn.commit() conn.close()逻辑说明SCHEMA 字符串里建了三张表和三个索引IF NOT EXISTS保证重复执行安全。DATA_DIR用exist_okTrue自动创建避免首次运行因目录不存在报错。这段代码在启动函数里先执行一次之后连接数据库就不需要重复检查表结构了。参数说明如果想把数据库放到用户目录把DATA_DIR改成os.path.expanduser(~/.local/share/qbank)或%APPDATA%下的对应路径。Windows 上尤其建议这样避免安装目录有写权限问题。另外初始化完顺手打印一个日志方便判断是不是走了初始化分支。3.2 题目增删改查四个函数覆盖日常操作有了基础连接之后增删改查的封装是题库管理最常用的部分。这里我用了“无状态连接”的写法每个函数内部自己开关连接。单机版每次操作只有毫秒级开销不需要连接池代码反而干净。def connect(): conn sqlite3.connect(DB_PATH) conn.execute(PRAGMA foreign_keys ON) return conn def add_question(cat_id, qtype, content, optionsNone, answer, analysis, difficulty3): conn connect() cur conn.cursor() cur.execute( INSERT INTO question(category_id, qtype, content, answer, analysis, difficulty) VALUES(?,?,?,?,?,?), (cat_id, qtype, content, answer, analysis, difficulty), ) qid cur.lastrowid if options: for key, text, correct in options: cur.execute( INSERT INTO option_item(question_id, opt_key, content, is_correct) VALUES(?,?,?,?), (qid, key, text, 1 if correct else 0), ) conn.commit() conn.close() return qid def update_question(qid, contentNone, analysisNone, difficultyNone): fields, args [], [] if content is not None: fields.append(content ?) args.append(content) if analysis is not None: fields.append(analysis ?) args.append(analysis) if difficulty is not None: fields.append(difficulty ?) args.append(difficulty) if not fields: return args.append(qid) conn connect() conn.execute( fUPDATE question SET {, .join(fields)} WHERE id ?, args ) conn.commit() conn.close() def delete_question(qid): conn connect() conn.execute(DELETE FROM question WHERE id ?, (qid,)) conn.commit() conn.close() def fetch_questions(category_idNone, qtypeNone, difficultyNone, keyword): conn connect() conn.row_factory sqlite3.Row sql SELECT * FROM question WHERE 11 args [] if category_id: sql AND category_id ? args.append(category_id) if qtype: sql AND qtype ? args.append(qtype) if difficulty: sql AND difficulty ? args.append(difficulty) if keyword: sql AND content LIKE ? args.append(f%{keyword}%) rows conn.execute(sql, args).fetchall() conn.close() return [dict(row) for row in rows]逻辑说明connect()里加PRAGMA foreign_keys ON很关键。SQLite 默认不启用外键约束不加这一行ON DELETE CASCADE就是摆设删除题目时选项表会留下孤儿数据。add_question里先插主表拿到lastrowid再循环插入选项两个操作在同一个事务里提交保证选项不会只插一半。update_question用动态拼接字段的方式只更新传入的列避免误清空其它字段。参数说明options参数约定为三元组列表格式是(A, 单选文本, True)其中第三个值表示是否正确。fetch_questions的difficulty传 0 时不会参与过滤因为默认值取不到如果按难度精确筛选要保证前端传来的值是 1~5 区间内的整数。3.3 Excel 批量导入格式约定与解析代码批量导入是题库系统能真正用起来的跳板。手工录入几百道题录入错误率高还慢一般首次建库都是用现成的 Word 或 Excel 题目表批量导入。我在项目里约定的 Excel 列格式如下分类、题型、题干、选项A、选项B、选项C、选项D、答案、解析、难度。题型用中文写“单选”“多选”“判断”“填空”“问答”答案列里单选写“A”多选写“ABD”判断写“正确/错误”填空和问答写参考答案。import pandas as pd QTYPE_MAP {单选: 1, 多选: 2, 判断: 3, 填空: 4, 问答: 5} def get_or_create_category(conn, name): cur conn.cursor() cur.execute(SELECT id FROM category WHERE name ?, (name,)) row cur.fetchone() if row: return row[0] cur.execute(INSERT INTO category(name, parent_id) VALUES(?, 0), (name,)) return cur.lastrowid def import_excel(filepath): df pd.read_excel(filepath, dtypestr).fillna() required [分类, 题型, 题干, 选项A, 选项B, 选项C, 选项D, 答案, 解析, 难度] for col in required: if col not in df.columns: raise ValueError(f缺少列{col}模板必须包含{required}) conn connect() imported 0 try: for idx, row in df.iterrows(): qtype QTYPE_MAP.get(str(row[题型]).strip(), 0) if qtype 0: raise ValueError(f第{idx 2}行题型无法识别{row[题型]}) cat_id get_or_create_category(conn, str(row[分类]).strip()) difficulty int(row[难度]) if str(row[难度]).strip() else 3 if difficulty not in range(1, 6): difficulty 3 options None answer if qtype in (1, 2): options [] valid_answer str(row[答案]).upper().strip() for key in [A, B, C, D]: text str(row[f选项{key}]).strip() if text: options.append((key, text, key in valid_answer)) elif qtype 3: options [] answer_val str(row[答案]).strip() options.append((A, 正确, answer_val 正确)) options.append((B, 错误, answer_val 错误)) else: answer str(row[答案]).strip() add_question(cat_id, qtype, str(row[题干]).strip(), optionsoptions, answeranswer, analysisstr(row[解析]).strip(), difficultydifficulty) imported 1 conn.commit() except Exception as e: conn.rollback() raise ValueError(f导入失败已回滚{e}) finally: conn.close() return imported逻辑说明导入函数先把表头校验放在最前面列名不对直接抛错防止用户拿错模板硬跑。get_or_create_category按分类名查找不存在就插入这样几万道题里重复出现的分类名只会建一条记录。单选和多选走同一个解析分支先确定“答案”字符串里包含哪个选项键决定is_correct。判断题把“正确”“错误”做成固定选项省掉单独维护判断题选项表的逻辑。参数说明pandas.read_excel必须加dtypestr否则 Excel 会把“题干”里的数字串改成科学计数法。fillna()把合并单元格和空行产生的 NaN 变成空字符串避免后续类型报错。事务提交放在循环外面所以一次导入几十万条也不会卡在频繁提交上任一行出错就全体回滚出现重复导入时清理比较省心。4. 抽题组卷与单机部署规则怎么设、参数怎么配4.1 按规则抽题组卷题型、难度和分类的组合过滤组卷是题库系统的核心输出。常见需求是“高等数学下单选 10 道难度 3问答题 5 道难度 2~3”。这里有一个容易踩的误区直接在数据库里分多次随机取题整卷没有统一随机种子导出同一份卷子两次题目顺序都不同。我一般把组卷规则做成一组结构化参数并用一个显式的随机种子来控制整个抽题过程。import random def generate_paper(rules, seed20240601): # rules 示例 # [{category_id: 3, qtype: 1, difficulty: 3, count: 10}, # {category_id: 3, qtype: 5, difficulty: 2, count: 5}] rng random.Random(seed) conn connect() picked [] for rule in rules: sql SELECT id, content, answer, difficulty FROM question WHERE 11 args [] if rule.get(category_id): sql AND category_id ? args.append(rule[category_id]) if rule.get(qtype): sql AND qtype ? args.append(rule[qtype]) if rule.get(difficulty): sql AND difficulty ? args.append(rule[difficulty]) rows conn.execute(sql, args).fetchall() count max(0, min(rule.get(count, 0), len(rows))) picked.extend(rng.sample(rows, count)) conn.close() # 全卷去重防止不同规则之间抽中同一道题 seen set() unique [] for q in picked: if q[0] not in seen: seen.add(q[0]) unique.append(q) return unique逻辑说明每条 rule 是一次独立的条件查询category_id、qtype、difficulty只要传了值就参与过滤。rng.sample(rows, count)从满足条件的候选中不放回地随机抽保证同一规则内不会重复。全卷去重是容易被忽略的第二道防线比如两个规则都只筛“难度 3”而未指定分类同一道题很可能被抽到两遍。参数说明difficulty只支持精确匹配如果需要“2 到 3 之间”应把 rule 改为传difficulty_min和difficulty_max在 SQL 里用BETWEEN ? AND ?。另外rng.sample返回的顺序是随机的如果试卷需要按题型分区块排列在返回前要再做一次“按 qtype 分类再拼接”的处理。4.2 单机版运行参数数据目录、日志与启动配置单机版部署看似简单最常见的翻车点都在路径上。程序装在 C 盘数据写在安装目录里用户手动清理或系统升级后数据就丢了。我一般把单机版的目录分成两层程序目录只放可执行文件和默认配置数据目录单独指定首次启动时做一次检查。import configparser import logging import os CONFIG_PATH os.path.join(os.path.expanduser(~), .qbank_config.ini) def load_config(): config configparser.ConfigParser() if os.path.exists(CONFIG_PATH): config.read(CONFIG_PATH, encodingutf-8) else: default_data_dir os.path.join(os.path.expanduser(~), qbank_data) config[DEFAULT] {data_dir: default_data_dir, json_export_dir: default_data_dir} with open(CONFIG_PATH, w, encodingutf-8) as f: config.write(f) return config def setup_logging(data_dir): os.makedirs(data_dir, exist_okTrue) logging.basicConfig( filenameos.path.join(data_dir, app.log), levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, )逻辑说明配置文件放在用户主目录下不跟随安装目录移动因此重装程序后配置还在。首次运行会生成默认配置文件并在用户目录创建qbank_data文件夹数据库和导出文件都落在这里避免写入权限问题。参数说明如果应用打包成单文件 exe__file__指向的是临时解包目录这时加载配置和数据目录必须走os.path.expanduser或环境变量不能用相对路径。日志级别建议先用 INFO等出现查不到的问题再调成 DEBUG因为单机版没有控制台输出错误信息如果只打内存用户无从反馈。5. 单机版避坑指南五条真实踩坑记录5.1 题目图片和公式数据库膨胀到失控现象导入一批带截图的题目后数据库文件从几 MB 变成了几百 MB打开和备份都越来越慢。原因把图片直接转成 BLOB 存进了 SQLite。SQLite 单文件能撑住几十 MB但几百 MB 的数据库每次提交都要落盘性能急剧下降而且备份文件也变得巨大。解决图片走“外置文件 相对路径”方案。把图片复制到数据目录下的 media 子文件夹数据库里只存相对路径显示时再拼绝对路径。迁移时只要把整个数据目录打包拷走路径用相对方式就不会断。import shutil import uuid def save_media(src_path, dest_dir): os.makedirs(dest_dir, exist_okTrue) ext os.path.splitext(src_path)[1] new_name f{uuid.uuid4().hex}{ext} dest os.path.join(dest_dir, new_name) shutil.copy(src_path, dest) return dest5.2 重题检测的玄学同一道题换个换行就判不出现象题库里有两道内容几乎一样的题A 题题干是“以下哪个选项正确”B 题多了一个换行或全角空格人工一眼能看出来是重复程序却当作两道题都收了。原因直接拿原始字符串做等值比较。题干里的换行、全角/半角符号、多余空格都会导致字符串不相等。解决在导入前做规范化比较。把字符串里的空白字符全部压缩成单个空格把全角标点转半角再去掉首尾空白。做这一步后大量重复题目能被直接拦住。真正语义层面的重题检测单机版里不要追求完美能做到“明显重复识别 人工确认”就够用了。import re def normalize_question_text(text): text text.replace(\u3000, ).replace(\n, ).replace(\r, ) text re.sub(r\s, , text) text text.strip() return text5.3 组卷翻车随机抽题为什么同一道题出两次现象组卷导出后发现同一道题出现在两个不同的题型区块里被当成两道题计分。原因规则是逐条执行的每条规则独立随机抽取没有跨规则去重。比如“单选 10 道、难度 3”和“问答 5 道、难度 3”里都有可能命中同一道题。解决在generate_paper函数返回前加全卷去重步骤用题目 id 做唯一键。遇到重复时保留第一次抽到的位置后续重复题目丢弃。如果去重后发现某一区间的题目数量不足要在 UI 上明确提示“规则命中不足”而不是直接出卷。5.4 备份后悔药WAL 模式下直接复制数据库文件丢数据现象把question_bank.db复制到 U 盘作为备份换电脑打开后提示数据库损坏或缺少最后几分钟的修改。原因SQLite 默认在 WAL 模式下工作时最新数据可能还在question_bank.db-wal文件里直接复制主库文件等于复制了一个旧版本。解决不要用文件系统拷贝做热备份用 SQLite 的备份接口它在事务层能拿到一致快照。def backup_db(target_path): src sqlite3.connect(DB_PATH) dst sqlite3.connect(target_path) with dst: src.backup(dst) dst.close() src.close()5.5 Excel 导入翻车编码、合并单元格和空行现象导入一个老师发的题目表程序报“第 3 行题型无法识别”打开 Excel 看第 3 行明明写的“单选”。原因多半是 Excel 的合并单元格导致后续行的“分类”或“题型”列是空值另一个常见原因是某个单元格的值被 Excel 格式化成数字或科学计数法读出来变成1.3E15这种字符串。解决读取时用dtypestr强制字符串类型并在解析前对分类列做前向填充因为合并单元格通常只在首行有值df pd.read_excel(filepath, dtypestr).fillna() df[分类] df[分类].ffill()空行问题同样常见Excel 表格末尾有一行空白iterrows()会读到一个全空行把题型解析成 0 直接报错。解决方案是导入前先过滤全部为空的行。做完这三步能消掉 90% 的导入报错。6. 让单机版进入正循环错题本、导出与验证6.1 错题本给题库加一个有状态的答题记录题库系统只有“存题”是不够的真正有价值的是把“做题-错题-薄弱点”这条闭环补上。我常用的做法是新增一张answer_record表记录每次答题的题目 id、是否正确、答题时间、所属学生或练习批次。有了这张表就能统计每道题的答错率按“错题率 30% 以上的题”抽取复习卷。CREATE TABLE answer_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, is_correct INTEGER NOT NULL, student_tag TEXT DEFAULT , answered_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) );6.2 导出试卷组卷结果落到 Word 的思路组卷结果最终要给老师印成纸质卷或导成 Word。我一般用 python-docx 生成试卷标题区写“学校、科目、考试时间”接着按题型分区渲染每组题目。自定义题号前缀、选项字体和答题留白这些用 Word 模板占位符会更省事。导出后一定先做一次“答案比对”从导出的题目 id 反查库里数据确认每道题的题号顺序没乱答案标记位置正确。6.3 验证题库是否健康的三个信号单机版没有网络服务可监控维护健康只能靠定期自查。第一个信号是“导入数量对得上”用count(*)检查每个分类下的题目数和导入文件的行数对比误差应该为零。第二个信号是“组卷结果无重复、无缺题”跑一个覆盖全规则的生成任务检查返回数量是否等于各 rule 请求数量之和。第三个信号是“备份文件能恢复”每个月的例行维护日把备份库恢复到临时目录执行一次全量查询确认表结构完好、行数一致。这三个信号全部通过基本上可以判定当前题库没出大问题。做单机版项目这几年我最深刻的教训是把“备份”和“数据一致性”当成功能来写而不是事后补救。题库的价值全在数据里界面难看一点可以忍数据丢一次就全完了。希望这篇笔记帮你把这套系统的骨架搭稳少踩几个我已经踩过的坑。本文还有配套的精品资源点击获取