简介:万维题库管理系统单机版是一套面向教育机构、教师及个人自测用户的本地化题库管理软件,重点解决大量试题的创建、编辑、分类存储与快速检索问题。系统支持题目分类管理、试题导入导出、随机组卷、答案解析与难度设置等功能,且无需联网即可独立运行,数据保存在本机,适合网络条件有限或重视隐私的使用场景,也便于按章节或知识点组织内容。压缩包共11个文件,约14.21MB,以exe安装主程序、dll与lib功能模块、ini/ins配置文件及htm说明文档为主,安装包结构完整,可完成软件安装、参数配置与操作查阅。目前已有2257人学习,读者可据此在本地搭建题库,借助说明文档熟悉组卷规则和导入导出流程,并通过分类管理提升出题、备考与自我测试效率。
1. 单机题库管理系统:断网机房里也能把一套卷子出完
去年帮一个教学单位整理期末试卷,机房整层断网,在线题库在另一栋楼的服务器上,页面转了半天打不开,最后是拿 U 盘从一位老师电脑里拷出来一个压缩包救的场——那包里就是这套万维题库管理系统单机版。它把题目、选项、知识点、试卷模板、导出记录全塞在本地一个数据库文件里,双击就能进主界面,不用装服务端、不用配连接串、不用等人给你开端口。适合谁用其实很清楚:一线教师自己攒题库、教研组几个人共用一台机器轮流出卷、以及拿它当课程设计底座或二次开发起点的学生和初级开发。它不解决多人在线协作,解决的是“打开就能录题、点两下就能出卷”这件很具体的事。
2. 技术栈与目录结构:Swing + SQLite 的桌面端骨架怎么搭
这套工程的路子很典型:Java 桌面端 + 嵌入式数据库,界面用 Swing 手写布局,数据层用原生 JDBC 直接怼 SQL,没有 Spring、没有 MyBatis,连 ORM 都省了。第一次打开源码的人容易觉得“怎么这么原始”,但单机题库管理这个场景恰恰吃这套——启动快、依赖少、拷走就能跑,用户电脑上不需要额外装任何数据库服务。
2.1 技术选型:为什么单机场景更适合 SQLite 而不是 MySQL
单机版最怕的就是“装不上”。你让一个不写代码的老师先装 MySQL、再建库、再导 SQL、再改配置文件里的账号密码,这套流程能劝退九成使用者。SQLite 把这些问题一次性抹平:整个库就是一个文件,第一次启动时自动建表,备份就是把文件复制一份,换电脑就是把文件拷过去。代价也很明确——写并发弱、没有用户权限体系、不适合几十个人同时改同一批题。但对“一台机器、一个人出卷”的定位来说,这些代价可以接受。
这里有个血泪经验:选 SQLite 不是因为它是新技术,而是因为它把“分发成本”降到了零。判断标准很简单,如果你的用户需要看一份《安装部署文档》才能用起来,那这个东西在单机场景就已经输了。
依赖清单大致是这么几张牌:
| 依赖 | 作用 | 备注 |
|---|---|---|
| sqlite-jdbc | 提供 JDBC 驱动,连接本地 db 文件 | 版本要和 JDK 对齐,太新在 JDK 8 上会报 unsupported class version |
| poi / poi-ooxml | 导出 Word 试卷与 Excel 题库 | 两个包版本必须一致,混版是导出失败的头号原因 |
| flatlaf(可选) | 给 Swing 换一套现代一点的皮肤 | 不加也能跑,只是界面像十年前的软件 |
| maven-assembly-plugin | 打 fat jar,双击即用 | 不打这个包,用户拿到 jar 是跑不起来的 |
pom 里最关键的是这一段,缺了它后面所有步骤都是白忙:
<build> <plugins> <plugin> <artifactId>maven-assembly-plugin</artifactId> <configuration> <descriptorRefs> <!-- 把所有依赖一起打进 jar,否则用户双击必然报 ClassNotFound --> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <!-- 主类写错一个字母,就是 "no main manifest attribute" --> <mainClass>com.qbank.App</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build>2.2 目录结构:从入口类到 dao、service、ui 的分层
这套代码的分层是朴素的 MVC 变体,没有框架约束,全靠包名自律:
questionbank/ ├── pom.xml ├── data/ # 运行期生成,存 db 文件和题目配图 ├── src/main/java/com/qbank/ │ ├── App.java # 唯一入口,只做三件事:定路径、初始化、开窗口 │ ├── ui/ # 所有 JFrame / JPanel,不含 SQL │ │ ├── MainFrame.java │ │ ├── QuestionEditDialog.java │ │ ├── PaperGeneratePanel.java │ │ └── component/ # 分页条、题型下拉、知识点树 │ ├── service/ # 业务规则:组卷、导入去重、导出排版 │ ├── dao/ # 一张表一个类,只写 SQL 和映射 │ ├── model/ # 纯 POJO,Question / Paper / Knowledge │ └── util/ # Db 连接、字符串处理、文件路径解析 └── src/main/resources/ ├── db/schema.sql # 建表语句,首次启动执行 └── images/ # 界面图标判断一个桌面项目结构好不好,我一般看一条:ui包里有没有出现SELECT。这套工程基本守住了这条线,界面只调 service,service 只调 dao。你在二次开发时如果图省事,直接在按钮的点击事件里写 SQL,短时间没问题,等功能一多,改个字段名要全局搜索二十个文件,那时候就麻烦了。
2.3 建表脚本与首次启动初始化
单机版的初始化必须做到“用户零操作”:删掉 data 目录再启动,等于恢复出厂设置。入口类就干这件事:
public class App { public static void main(String[] args) { // 数据目录放在用户主目录下,而不是当前工作目录 // 因为双击 jar 时 user.dir 可能是桌面,也可能是 jar 所在目录,行为不稳定 Path dataDir = Paths.get(System.getProperty("user.home"), ".qbank"); try { Files.createDirectories(dataDir); Db.init(dataDir.resolve("questionbank.db")); // 建库 + 建表 + 灌默认知识点 } catch (Exception e) { // 初始化失败必须立刻停,继续往下走只会得到一堆空指针 JOptionPane.showMessageDialog(null, "初始化失败:" + e.getMessage()); return; } // Swing 组件创建统一回到事件分发线程,避免偶发的界面卡死 SwingUtilities.invokeLater(() -> new MainFrame().setVisible(true)); } }Db.init负责把schema.sql读出来按语句执行。这里有个小设计:所有建表都写CREATE TABLE IF NOT EXISTS,这样第二次启动不会报错,也就不需要额外维护“是否已初始化”的标记文件。
public final class Db { private static String url; public static void init(Path dbFile) throws Exception { url = "jdbc:sqlite:" + dbFile.toAbsolutePath(); try (Connection conn = getConn(); Statement st = conn.createStatement()) { conn.setAutoCommit(false); // 整批建表要么全成要么全废 for (String sql : loadSchema()) { // 从 resources/db/schema.sql 读取 if (!sql.isBlank()) st.execute(sql); } conn.commit(); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(url); } }参数上要注意两点:toAbsolutePath()不能省,否则相对路径会跟着启动方式漂移;setAutoCommit(false)也不能省,建表脚本里如果有十几条语句,中途失败留下半张表的结构,后面排查会非常难受。
提示:
loadSchema()里如果按分号切语句,别忘了schema.sql里别写注释块带分号,也别写触发器。真被切坏过一次,报的错是“near ... syntax error”,跟建表本身没关系,查半天。
3. 数据模型与题型设计:一道题从录入到被抽中的字段链路
题库管理系统看着简单,真正的难点全在“题型怎么存”。单选、多选、判断、填空、简答,选项数量不一样、答案格式不一样、判分方式不一样,如果按题型各建一张表,后面加一个“不定项选择”就要改五处代码。这套工程用的是单表 + 类型字段的方案,扩展性明显更好。
3.1 题目表、选项表与知识点表的关系
主表只放“所有题型都有的东西”,选项拆到子表,知识点做成树:
-- 题目主表:所有题型共用,type 决定怎么解释 answer 字段 CREATE TABLE IF NOT EXISTS question ( id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL, -- 1单选 2多选 3判断 4填空 5简答 stem TEXT NOT NULL, -- 题干,允许存少量 HTML 片段 answer TEXT NOT NULL, -- 多选用 A|C 这种分隔符,填空用 | 分隔多空 analysis TEXT, -- 解析,可以为空 difficulty INTEGER NOT NULL DEFAULT 3, -- 1~5,组卷按这个区间筛 score REAL NOT NULL DEFAULT 2.0, -- 单题默认分值,组卷时可覆盖 knowledge_id INTEGER, -- 归属知识点,允许为空(未分类) image_path TEXT, -- 相对 data/ 的路径,绝不存绝对路径 created_at TEXT DEFAULT (datetime('now','localtime')) ); -- 选项表:只有单选和多选会往里写数据,判断题固定两个选项也可以不写 CREATE TABLE IF NOT EXISTS option_item ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL, label TEXT NOT NULL, -- A/B/C/D content TEXT NOT NULL, sort_no INTEGER DEFAULT 0 ); -- 知识点树:parent_id 为 0 表示章,非 0 表示节 CREATE TABLE IF NOT EXISTS knowledge ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, parent_id INTEGER NOT NULL DEFAULT 0, sort_no INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_q_knowledge ON question(knowledge_id); CREATE INDEX IF NOT EXISTS idx_q_type ON question(type);image_path这一列的注释不是废话。见过太多项目在这里存了D:\题库\图片\1.png,换台机器打开全是红叉,最后只能一张一张重新挂图。存相对路径、运行时用数据目录拼绝对路径,是唯一能撑过换机的做法。
3.2 题型抽象:五种题型怎么共用一套读写逻辑
统一的代价是每个环节都要按 type 分叉。这套代码里分叉点集中在一个枚举上,而不是散落在各个if里:
| type | 题型 | answer 存储格式 | 是否有选项 | 判分方式 |
|---|---|---|---|---|
| 1 | 单选 | B | 是 | 全等 |
| 2 | 多选 | `A | C | D` |
| 3 | 判断 | T/F | 否 | 全等 |
| 4 | 填空 | `北京 | 上海` 按空顺序 | 否 |
| 5 | 简答 | 参考答案长文本 | 否 | 人工阅卷 |
对应到 Java 侧就是一个枚举加两个方法:hasOptions()决定录入界面要不要显示选项编辑区,splitAnswer()决定怎么把答案拆成集合。这样新增题型时,主要改动就集中在这一个文件里。
public enum QuestionType { SINGLE(1, "单选题", true), MULTI(2, "多选题", true), JUDGE(3, "判断题", false), BLANK(4, "填空题", false), ESSAY(5, "简答题", false); private final int code; private final String label; private final boolean hasOptions; // 注意分隔符用了 | ,正则里必须转义,否则 split 会按字符切 public List<String> splitAnswer(String answer) { if (answer == null || answer.isBlank()) return List.of(); return Arrays.stream(answer.split("\\|")) .map(String::trim) .filter(s -> !s.isEmpty()) .collect(Collectors.toList()); } public static QuestionType of(int code) { for (QuestionType t : values()) { if (t.code == code) return t; } throw new IllegalArgumentException("未知题型:" + code); } }splitAnswer里那个\\|是新手上手第一天最容易踩的坑,写成split("|")得到的是一个空字符数组,然后你在界面上看到答案是空的,去数据库里查又明明有值,来回折腾半天。
3.3 组卷规则模型:分值、难度、知识点配比怎么表达
组卷不是随机抽题,是按约束抽题。约束大致是三层:题型层面要几道,知识点层面占多少比例,难度层面落在什么区间。这套工程把规则存成一张表,用户可以在界面上存多套模板(比如“期末 A 卷”“随堂测 20 题”),下次直接调用。
CREATE TABLE IF NOT EXISTS paper_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- 模板名,如“期末卷-基础版” total_score REAL NOT NULL, config_json TEXT NOT NULL, -- 各题型数量、分值、难度区间、知识点配比 created_at TEXT DEFAULT (datetime('now','localtime')) );config_json里长这样,读出来反序列化就能用:
{ "quotas": [ {"type": 1, "count": 10, "score": 2, "difficulty": [1, 3]}, {"type": 2, "count": 5, "score": 4, "difficulty": [2, 4]}, {"type": 5, "count": 2, "score": 15, "difficulty": [3, 5]} ], "knowledgeRatios": [ {"knowledgeId": 3, "name": "第一章", "ratio": 0.4}, {"knowledgeId": 7, "name": "第二章", "ratio": 0.6} ] }把它做成 JSON 而不是拆成多张关系表,理由很实在:规则是整体读、整体写的,从来不会单独查“所有模板里难度区间为 2 到 4 的配额”。这种场景塞进 JSON 列反而更省事,改结构时也不用动表。代价是不能用 SQL 直接统计,但对单机版来说这根本不是需求。
4. 核心代码拆解:录入、检索、组卷、导出四条主链路
界面长得再好看,题库管理系统的价值也就体现在这四条链路上:题能不能顺利录进去、能不能快速找出来、能不能按规则抽成卷、能不能导出一份能直接打印的文档。下面按实际执行顺序拆。
4.1 题目录入与校验:把脏数据拦在入库之前
录入是数据质量的源头。这套代码的保存逻辑是一个事务:主表插一条,选项表插若干条,任何一步失败全部回滚。
public void save(Question q) throws SQLException { String sql = "INSERT INTO question(type,stem,answer,analysis,difficulty,score,knowledge_id,image_path) " + "VALUES(?,?,?,?,?,?,?,?)"; Connection conn = Db.getConn(); try { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setInt(1, q.getType()); ps.setString(2, q.getStem()); ps.setString(3, q.getAnswer()); ps.setString(4, q.getAnalysis()); ps.setInt(5, q.getDifficulty()); ps.setDouble(6, q.getScore()); ps.setObject(7, q.getKnowledgeId()); ps.setString(8, q.getImagePath()); ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { keys.next(); q.setId(keys.getInt(1)); // 拿到自增主键,后面插选项要用 } } // 只有带选项的题型才写子表,判断题不写,避免出现一堆垃圾行 if (QuestionType.of(q.getType()).hasOptions()) { insertOptions(conn, q); } conn.commit(); } catch (SQLException e) { conn.rollback(); // 主表插成功、选项插失败时,这条脏题不能留在库里 throw e; } finally { conn.close(); } }参数上有一处必须注意:ps.setObject(7, q.getKnowledgeId())而不是setInt。知识点允许为空,用setInt传 null 会抛 NPE,用setObject传 null 才会正确写进 NULL。
校验逻辑放在界面的“保存”按钮里,一共四条,缺一条都会让后面的组卷出错:题干非空、带选项的题型至少两个选项、答案不能为空、分值必须大于 0。校验失败时把焦点定位到出问题的控件上,比弹一句“输入有误”有用得多。
4.2 多条件检索:动态拼 SQL 与知识点递归筛选
题库上万道之后,检索就是高频操作。查询条件是组合的:题型、关键词、难度区间、知识点,用户可能只填一个,也可能全填。做法是拿WHERE 1=1打底往上拼。
public List<Question> search(QuestionQuery q, int page, int size) throws SQLException { StringBuilder sql = new StringBuilder("SELECT * FROM question WHERE 1=1 "); List<Object> args = new ArrayList<>(); if (q.getType() != null) { sql.append("AND type = ? "); args.add(q.getType()); } if (q.getKeyword() != null && !q.getKeyword().isBlank()) { // 题干和解析都搜,老师经常只记得解析里的某句话 sql.append("AND (stem LIKE ? OR analysis LIKE ?) "); String like = "%" + q.getKeyword().trim() + "%"; args.add(like); args.add(like); } if (q.getDifficultyMin() != null) { sql.append("AND difficulty >= ? "); args.add(q.getDifficultyMin()); } if (q.getKnowledgeId() != null) { // 选“章”时要把下面的“节”一起带上,否则按章节筛出来永远是空的 sql.append("AND knowledge_id IN (SELECT id FROM knowledge WHERE id = ? OR parent_id = ?) "); args.add(q.getKnowledgeId()); args.add(q.getKnowledgeId()); } sql.append("ORDER BY id DESC LIMIT ? OFFSET ?"); args.add(size); args.add((page - 1) * size); try (Connection conn = Db.getConn(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < args.size(); i++) { ps.setObject(i + 1, args.get(i)); // 顺序必须和拼 SQL 的顺序完全一致 } return mapRows(ps.executeQuery()); } }这段代码最脆的地方是args的顺序必须跟?严格对应。加条件时随手往中间插一句,参数就全错位了,症状是“搜出来的结果莫名其妙”,而且不报错。稳妥的写法是把每个条件封装成一个小对象,统一收集sql片段和args,最后再拼。分页那块LIMIT ? OFFSET ?的 offset 记得算(page-1)*size,前端传 1 表示第一页,别直接乘。
4.3 自动组卷:按配比抽题与抽不满的处理
组卷是这套系统最核心的一段逻辑,也是唯一会抛业务异常的地方。策略是双层循环:外层走题型配额,内层走知识点配比。
public Paper generate(PaperRule rule) throws SQLException { List<Question> picked = new ArrayList<>(); Set<Integer> usedIds = new HashSet<>(); // 抽过的题必须排除,不然一道题可能进两份配额 for (Quota quota : rule.getQuotas()) { for (KnowledgeRatio kr : rule.getKnowledgeRatios()) { // 该题型在该知识点下应该分到几道,四舍五入后至少 1 道 int need = Math.max(1, Math.round(quota.getCount() * kr.getRatio())); List<Question> part = questionDao.randomPick( kr.getKnowledgeId(), quota.getType(), quota.getDifficultyMin(), quota.getDifficultyMax(), need, usedIds); if (part.size() < need) { // 不要在内部悄悄补别的知识点,宁可失败让用户去补题 throw new BizException(String.format( "知识点【%s】下%s库存不足:需要 %d 道,实际只有 %d 道", kr.getName(), typeName(quota.getType()), need, part.size())); } part.forEach(x -> usedIds.add(x.getId())); picked.addAll(part); } } return Paper.assemble(rule, picked); // 组装成卷,按题型分组并重排序号 }DAO 里的抽题语句很直白:
SELECT * FROM question WHERE type = ? AND knowledge_id IN (SELECT id FROM knowledge WHERE id = ? OR parent_id = ?) AND difficulty BETWEEN ? AND ? AND id NOT IN (/* 动态占位,传 usedIds */) ORDER BY RANDOM() LIMIT ?;关于“抽不满”的处理,这里的取舍值得说一句:不要自动从其他知识点补题。看起来是提升了成功率,实际会让老师拿到一份知识点分布不对的卷子,而且他很可能不看提示直接打印。宁可失败,把缺口数字报清楚,让他去补题或者调配比。
注意:
ORDER BY RANDOM()在几万行的量级上完全够用,没必要上采样算法。但如果一次组卷要连续查十几次,最好先把候选集一次性捞进内存再洗牌分配,能省掉不少重复扫描。
4.4 导出 Word:按题型分组、序号重排与分页控制
导出用的是 Apache POI 的 XWPF。核心是把题目按题型分组,每一大题内部重新从 1 编号,行距和字号要保证打印出来一页放得下。
public void exportDocx(Paper paper, Path out) throws IOException { try (XWPFDocument doc = new XWPFDocument()) { // 卷头 XWPFParagraph title = doc.createParagraph(); title.setAlignment(ParagraphAlignment.CENTER); XWPFRun run = title.createRun(); run.setText(paper.getTitle()); run.setBold(true); run.setFontSize(18); run.setFontFamily("宋体"); int sectionNo = 1; for (Map.Entry<Integer, List<Question>> e : paper.groupByType().entrySet()) { // 大题标题:第X部分 单选题(共10题,每题2分) XWPFParagraph head = doc.createParagraph(); List<Question> list = e.getValue(); head.createRun().setText(String.format("第%s部分 %s(共%d题,每题%s分)", num(sectionNo++), typeName(e.getKey()), list.size(), list.get(0).getScore())); int i = 1; for (Question q : list) { XWPFParagraph p = doc.createParagraph(); p.createRun().setText(i++ + ". " + q.getStem()); if (QuestionType.of(q.getType()).hasOptions()) { for (OptionItem o : q.getOptions()) { // 选项单独一段,靠缩进对齐,比用制表符稳 XWPFParagraph op = doc.createParagraph(); op.setIndentationLeft(240); op.createRun().setText(o.getLabel() + ". " + o.getContent()); } } } } try (OutputStream os = Files.newOutputStream(out)) { doc.write(os); } } }setFontFamily("宋体")这句在中文字体上很关键,不设的话在部分机器上会用默认西文字体渲染,中文变成方块。另外groupByType()返回的 Map 要保证顺序(用LinkedHashMap),否则每次导出的题型顺序都不一样,用户会以为导出出了问题。
5. 运行与排查:从源码到双击可运行的五个坑
拿到压缩包到真正跑起来,中间隔着编码、依赖、路径三道关。这一章按实际操作顺序写,跟着走一遍基本能落地。
5.1 环境准备与启动步骤
先把 JDK 对齐。这类桌面项目通常锁在 JDK 8 或 11,用 17 以上也能编译,但 Swing 的默认外观会有差异,字体渲染也可能对不上:
# 1. 确认版本,1.8 或 11 最稳 java -version mvn -version # 2. 打包,跳过测试(老项目单测常年红着,别浪费这个时间) mvn -DskipTests clean package # 3. 运行 fat jar,缓存目录用系统默认的即可 java -Dfile.encoding=UTF-8 -jar target/questionbank-1.0.0-jar-with-dependencies.jar第一次启动会在用户主目录下生成.qbank/questionbank.db。想恢复出厂设置就删掉这个目录,重新启动会自动建库并灌入默认知识点。用 IntelliJ IDEA 打开的话,直接运行App.java的 main 方法即可,运行配置里的工作目录保持默认就行——因为代码里用的是user.home,不是user.dir。
打包成可分发的形态有两种路子:一是直接发 fat jar,附一句“需要装 Java”的说明;二是用jpackage打成带运行时的独立程序,用户双击就开,代价是包体大几十兆。给老师用我一般选后者,少一轮技术支持。
5.2 五条踩坑记录:现象、原因与解决
坑一:双击 jar 完全没反应,命令行跑却正常
现象是鼠标双击后闪一下就没动静,命令行java -jar报no main manifest attribute。原因基本只有一个:打的不是 fat jar,或者mainClass写错。解决是把 assembly 插件那段补齐,重新package,确认产物名字里有jar-with-dependencies。另一个隐蔽原因是 JDK 版本高于编译目标版本,双击走的是系统关联的 javaw,版本不匹配就直接静默退出。
坑二:题干里的中文变成问号或方块
现象是界面上题目正常,导出 Word 或者换台机器打开就全是乱码。原因有三层:源文件编码不是 UTF-8、javac编译时没带-encoding UTF-8、以及 Swing 默认字体不含中文。解决顺序是先在 pom 的project.build.sourceEncoding里锁死 UTF-8,运行加-Dfile.encoding=UTF-8,最后在MainFrame初始化时统一UIManager.put一个中文字体(比如微软雅黑)。三层缺一层都会复发。
坑三:每次启动都是空库,录的题第二天不见了
现象是用 IDE 跑得好好的,打成 jar 之后数据就“丢”了。原因是数据库路径用了相对路径,user.dir会跟着启动方式变——IDE 里是项目根目录,双击 jar 时是桌面。解决是把路径解析统一改成user.home或者 jar 所在目录,并且打印一次实际路径到日志里。这个坑的排查成本很高,因为看起来像数据被删了,实际是连到了另一个新库。
坑四:题目里的配图换台电脑就显示红叉
现象是本地好好的,拷给别人就全挂。原因就是image_path存了绝对路径。解决是入库时只存相对data/的路径,渲染时用Paths.get(dataDir, imagePath)拼出来。迁移时把data整个目录连同 db 文件一起拷,才不会出现断图。
坑五:导出的 Word 打开提示“文件已损坏”
现象是导出没报错,Word 就是打不开。原因八成是 POI 的版本冲突——poi和poi-ooxml版本不一致,或者项目里同时引入了两个版本的xmlbeans。解决是把这三个包的版本统一锁死在一个数字上,然后mvn dependency:tree确认没有第二份。这个坑的玄学之处在于代码完全正确,错在依赖树,第一次遇到很容易怀疑人生。
6. 二次开发:把单机版改成自己顺手的出卷工具
真要动这套代码,最有价值的改造点是“加题型”,因为它会强迫你走一遍完整链路,走通了整个工程就摸清了。新增一种“不定项选择”,需要同步改四个地方:QuestionType枚举里加一项、录入界面的选项区显示条件、组卷配比表里开放这个类型、导出时决定它算不算“有选项”。这四处漏掉任何一处,症状都很诡异——比如录得进去但抽不出来,或者抽得出来但导出后选项消失。我一般改完会造 30 道测试题,走一遍“录入 → 检索 → 组卷 → 导出 → 打开 Word 检查”的闭环,五步全过才算改完。
第二个值得做的是批量导入。老师们手里的题大多在 Excel 里,让他们一道一道录是不现实的。做法是定一个模板(题干、题型、选项 A-D、答案、难度、知识点),用 POI 读进来,逐行校验后在一个事务里批量插,同时按“题干 + 答案”算一个 MD5 存进新加的fingerprint列做去重,导入时跳过重复项并给出一份“成功 N 条、跳过 M 条”的报告。这比什么都导进去然后让用户自己去重靠谱得多。
第三个是加分发能力。直接用jpackage打包,指定--type exe(Windows)或--type app-image,把 JRE 一起打进去,用户拿到文件夹双击就开,从此不用解释“什么是 JDK”。命令不复杂,麻烦的是图标和版本的细节,建议先打一个最简版本验证能启动,再往里加参数。
有一回我图快,改完枚举直接打包发给了使用方,结果判断题的答案在导出时被判成了“无选项题型”走另一条分支,整份卷子的判断题答案全丢了。从那以后我每次改完题型相关代码,都强制走一遍那五步闭环,导出后必须真的用 Word 打开翻到最后一页确认。希望帮到你。
本文还有配套的精品资源,点击获取