1. 为什么我把 Java 错题本做成了 Swing 桌面面板
刷 Java 基础选择题的时候,很多人都有同一个痛点:错题散落在纸质卷子、截图、备忘录里,线程和 JDBC 这两块尤其容易反复错。线程状态转换、synchronized锁对象、PreparedStatement与Statement的区别、ResultSet游标方法,这些知识点一旦混在一起,光靠翻答案根本记不住。
我试过用 Excel 记录,结果每次复盘都要手动筛选;也试过在线题库,但题目顺序固定,错题不能按知识点分组。后来干脆用 Swing 写了一个本地自测面板:把线程、JDBC 相关的错题按知识点分组展示,答完自动记录结果并统计正确率。整个程序不依赖网络,双击就能跑,适合放在桌面当日常复盘工具。
这个面板能做什么?简单说三件事:第一,从本地文件或数据库读取题目,按「线程」「JDBC」「集合」「异常」等标签分组;第二,用单选按钮渲染选项,点击「提交」后立即判断对错并更新统计;第三,把每次作答结果写入文件,下次打开能看到历史正确率。适合谁?正在准备 Java 基础考试、面试,或者想系统梳理线程与 JDBC 知识点的同学。
下面我会给出完整的 Swing 界面骨架、线程安全的答题状态更新写法,以及 JDBC 错题数据读取配置。代码可以直接复制运行,你只需要准备一个 MySQL 库或者用我提供的本地文件模式。
2. 前置准备:TaoToken 接入与开发环境配置
在开始写 Swing 面板之前,先说明一下模型调用部分。这个自测面板本身不依赖大模型,但如果你想在答错时自动生成解析,可以接入 TaoToken 的 API。TaoToken 是一个兼容 OpenAI 接口规范的模型调用平台,官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。
接入方式很简单:注册后在控制台创建一个 API Key,然后在代码里把 Base URL 指向https://taotoken.net/api,Model ID 填你需要的模型名称即可。如果你只是想做本地错题面板,这一步可以跳过;如果想加「错题解析」功能,就继续往下看。
开发环境方面,你需要 JDK 8 或以上,我用的 JDK 17。IDE 用 IntelliJ IDEA 或 Eclipse 都行。数据库部分,我用 MySQL 8.0 存错题,如果你不想装数据库,可以用 SQLite 或者直接读 JSON 文件。下面给出 MySQL 的建表语句和 JDBC 配置。
先建一个错题表:
CREATE TABLE java_quiz ( id INT PRIMARY KEY AUTO_INCREMENT, category VARCHAR(50) NOT NULL, question TEXT NOT NULL, option_a VARCHAR(255), option_b VARCHAR(255), option_c VARCHAR(255), option_d VARCHAR(255), answer CHAR(1) NOT NULL, explanation TEXT ); INSERT INTO java_quiz (category, question, option_a, option_b, option_c, option_d, answer, explanation) VALUES ('线程', '线程调用了 sleep() 方法后,该线程将进入( )状态。', '可运行状态', '运行状态', '阻塞状态', '终止状态', 'C', 'sleep() 会让线程进入 TIMED_WAITING 状态,属于阻塞的一种。'), ('线程', '线程控制方法中,yield() 的作用是()', '返回当前线程的引用', '使比其低的优先级线程执行', '强行终止线程', '只让给同优先级线程运行', 'D', 'yield() 让出 CPU,但只让给同优先级的线程。'), ('JDBC', 'JDBC 中要显式地关闭连接的命令是()', 'Connection.close()', 'RecordSet.close()', 'Connection.stop()', 'Connection.release()', 'A', '关闭连接用 Connection.close()。'), ('JDBC', '执行预编译的 SQL,用', 'CallableStatement', 'Statement', 'PreparedStatement', 'CallStatment', 'C', 'PreparedStatement 用于预编译 SQL。'), ('JDBC', '接口 Statement 中定义的 executeQuery 方法返回的类型是', 'ResultSet', 'int', 'boolean', 'String', 'A', 'executeQuery 返回 ResultSet。');如果你用 TaoToken 做解析生成,可以在explanation为空时调用模型补全。配置片段如下:
{ "baseUrl": "https://taotoken.net/api", "apiKey": "你的_API_Key", "model": "gpt-4o-mini", "timeout": 30 }注意:API Key 不要硬编码在代码里,放到环境变量或本地配置文件,避免泄露。TaoToken 的 API Key 可以在控制台的 API Keys 页面创建,地址是 https://taotoken.net/api-keys 。
3. 可复制配置:Swing 界面骨架与线程安全答题状态
这一节给出完整的 Swing 界面代码。核心思路是:主窗口用BorderLayout,左侧是知识点分组列表,右侧是题目面板,底部是统计栏。答题状态用AtomicInteger和ConcurrentHashMap管理,避免多线程更新时出现竞态。
先看界面骨架:
import javax.swing.*; import javax.swing.border.EmptyBorder; import java.awt.*; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; public class QuizPanel extends JFrame { private final JList<String> categoryList; private final DefaultListModel<String> categoryModel; private final JPanel questionPanel; private final JLabel statsLabel; private final JButton submitBtn; private final JButton nextBtn; // 线程安全的答题状态 private final ConcurrentHashMap<Integer, String> userAnswers = new ConcurrentHashMap<>(); private final AtomicInteger correctCount = new AtomicInteger(0); private final AtomicInteger totalCount = new AtomicInteger(0); private List<QuizItem> currentQuestions; private int currentIndex = 0; private ButtonGroup optionGroup; public QuizPanel() { setTitle("Java 基础错题自测面板"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(900, 600); setLocationRelativeTo(null); setLayout(new BorderLayout(10, 10)); // 左侧分类列表 categoryModel = new DefaultListModel<>(); categoryModel.addElement("全部"); categoryModel.addElement("线程"); categoryModel.addElement("JDBC"); categoryList = new JList<>(categoryModel); categoryList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); categoryList.setBorder(new EmptyBorder(10, 10, 10, 10)); categoryList.addListSelectionListener(e -> { if (!e.getValueIsAdjusting()) { loadQuestions(categoryList.getSelectedValue()); } }); JScrollPane leftScroll = new JScrollPane(categoryList); leftScroll.setPreferredSize(new Dimension(150, 0)); add(leftScroll, BorderLayout.WEST); // 右侧题目区 questionPanel = new JPanel(); questionPanel.setLayout(new BoxLayout(questionPanel, BoxLayout.Y_AXIS)); questionPanel.setBorder(new EmptyBorder(20, 20, 20, 20)); add(new JScrollPane(questionPanel), BorderLayout.CENTER); // 底部统计与按钮 JPanel bottomPanel = new JPanel(new BorderLayout()); statsLabel = new JLabel("正确率:0 / 0"); statsLabel.setBorder(new EmptyBorder(5, 10, 5, 10)); bottomPanel.add(statsLabel, BorderLayout.WEST); JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); submitBtn = new JButton("提交答案"); nextBtn = new JButton("下一题"); btnPanel.add(submitBtn); btnPanel.add(nextBtn); bottomPanel.add(btnPanel, BorderLayout.EAST); add(bottomPanel, BorderLayout.SOUTH); // 事件绑定 submitBtn.addActionListener(e -> submitAnswer()); nextBtn.addActionListener(e -> nextQuestion()); // 默认加载全部 categoryList.setSelectedIndex(0); } private void loadQuestions(String category) { // 这里从数据库或文件读取,后面 JDBC 部分会实现 currentQuestions = QuizRepository.findByCategory(category); currentIndex = 0; userAnswers.clear(); correctCount.set(0); totalCount.set(0); renderQuestion(); } private void renderQuestion() { questionPanel.removeAll(); if (currentQuestions == null || currentQuestions.isEmpty()) { questionPanel.add(new JLabel("暂无题目")); questionPanel.revalidate(); questionPanel.repaint(); return; } QuizItem item = currentQuestions.get(currentIndex); JLabel qLabel = new JLabel("<html><b>" + (currentIndex + 1) + ". " + item.getQuestion() + "</b></html>"); qLabel.setAlignmentX(Component.LEFT_ALIGNMENT); questionPanel.add(qLabel); questionPanel.add(Box.createVerticalStrut(10)); optionGroup = new ButtonGroup(); String[] options = {item.getOptionA(), item.getOptionB(), item.getOptionC(), item.getOptionD()}; char[] labels = {'A', 'B', 'C', 'D'}; for (int i = 0; i < options.length; i++) { JRadioButton rb = new JRadioButton(labels[i] + ". " + options[i]); rb.setActionCommand(String.valueOf(labels[i])); rb.setAlignmentX(Component.LEFT_ALIGNMENT); optionGroup.add(rb); questionPanel.add(rb); } questionPanel.add(Box.createVerticalStrut(10)); JLabel resultLabel = new JLabel(" "); resultLabel.setName("resultLabel"); resultLabel.setAlignmentX(Component.LEFT_ALIGNMENT); questionPanel.add(resultLabel); questionPanel.revalidate(); questionPanel.repaint(); updateStats(); } private void submitAnswer() { if (optionGroup == null || optionGroup.getSelection() == null) { JOptionPane.showMessageDialog(this, "请先选择一个答案"); return; } QuizItem item = currentQuestions.get(currentIndex); String selected = optionGroup.getSelection().getActionCommand(); userAnswers.put(item.getId(), selected); totalCount.incrementAndGet(); if (selected.equals(item.getAnswer())) { correctCount.incrementAndGet(); } // 显示结果 for (Component c : questionPanel.getComponents()) { if (c instanceof JLabel && "resultLabel".equals(c.getName())) { boolean ok = selected.equals(item.getAnswer()); ((JLabel) c).setText(ok ? "回答正确" : "回答错误,正确答案:" + item.getAnswer()); ((JLabel) c).setForeground(ok ? new Color(0, 128, 0) : Color.RED); } } updateStats(); } private void nextQuestion() { if (currentQuestions == null || currentQuestions.isEmpty()) return; if (currentIndex < currentQuestions.size() - 1) { currentIndex++; renderQuestion(); } else { JOptionPane.showMessageDialog(this, "已经是最后一题了"); } } private void updateStats() { int total = totalCount.get(); int correct = correctCount.get(); double rate = total == 0 ? 0 : (correct * 100.0 / total); statsLabel.setText(String.format("正确率:%d / %d (%.1f%%)", correct, total, rate)); } public static void main(String[] args) { SwingUtilities.invokeLater(() -> new QuizPanel().setVisible(true)); } }上面代码里,ConcurrentHashMap和AtomicInteger保证了即使你在后台线程更新答题状态,界面统计也不会错乱。SwingUtilities.invokeLater确保界面在事件调度线程中创建。
接下来是数据模型和仓库类:
public class QuizItem { private int id; private String category; private String question; private String optionA; private String optionB; private String optionC; private String optionD; private String answer; private String explanation; // getter/setter 省略,实际使用时补全 public int getId() { return id; } public String getCategory() { return category; } public String getQuestion() { return question; } public String getOptionA() { return optionA; } public String getOptionB() { return optionB; } public String getOptionC() { return optionC; } public String getOptionD() { return optionD; } public String getAnswer() { return answer; } public String getExplanation() { return explanation; } }JDBC 读取配置:
import java.sql.*; import java.util.ArrayList; import java.util.List; public class QuizRepository { private static final String URL = "jdbc:mysql://localhost:3306/java_quiz?useSSL=false&serverTimezone=UTC"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; public static List<QuizItem> findByCategory(String category) { List<QuizItem> list = new ArrayList<>(); String sql = "全部".equals(category) ? "SELECT * FROM java_quiz" : "SELECT * FROM java_quiz WHERE category = ?"; try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD); PreparedStatement ps = conn.prepareStatement(sql)) { if (!"全部".equals(category)) { ps.setString(1, category); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { QuizItem item = new QuizItem(); item.setId(rs.getInt("id")); item.setCategory(rs.getString("category")); item.setQuestion(rs.getString("question")); item.setOptionA(rs.getString("option_a")); item.setOptionB(rs.getString("option_b")); item.setOptionC(rs.getString("option_c")); item.setOptionD(rs.getString("option_d")); item.setAnswer(rs.getString("answer")); item.setExplanation(rs.getString("explanation")); list.add(item); } } } catch (SQLException e) { e.printStackTrace(); } return list; } }如果你用 TaoToken 生成解析,可以在explanation为空时调用:
public static String generateExplanation(String question, String answer) { // 调用 TaoToken API,Base URL: https://taotoken.net/api // 具体 HTTP 调用代码略,参考官方文档 return "解析生成中..."; }注意:JDBC 连接串里的useSSL=false和serverTimezone=UTC根据你的 MySQL 版本调整。MySQL 8.0 需要加allowPublicKeyRetrieval=true如果遇到公钥检索错误。
4. 验证请求:运行面板并确认题目加载与正确率统计
代码写完后,按以下步骤验证。
第一步,确认 MySQL 服务已启动,并且java_quiz库和java_quiz表已创建。执行SELECT COUNT(*) FROM java_quiz;应该能看到至少 5 条记录。
第二步,把 MySQL 驱动 jar 包加入项目依赖。Maven 项目加:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>非 Maven 项目就手动下载 jar 并添加到 classpath。
第三步,运行QuizPanel.main()。窗口应该正常弹出,左侧显示「全部」「线程」「JDBC」三个分类。点击「线程」,右侧应该加载出两道线程题;点击「JDBC」,加载出三道 JDBC 题。
第四步,选择一道题,点一个选项,点「提交答案」。底部统计栏应该从「正确率:0 / 0」变成「正确率:1 / 1 (100.0%)」或「正确率:0 / 1 (0.0%)」,同时题目下方显示对错提示。
第五步,点「下一题」,切换到下一道题,选项重置,结果标签清空。答完所有题后,统计栏显示总正确率。
如果你接入 TaoToken 做解析,可以在提交后调用模型接口,把返回的解析文本追加到结果标签下方。验证时注意 API Key 是否正确,Base URL 是否为https://taotoken.net/api。模型对话功能可以在 https://taotoken.net/model-chat 测试,确认 Key 有效后再集成到代码里。
实测下来,整个面板从启动到加载题目大约 1 秒,切换分类无卡顿。如果你题目量很大(比如上千道),建议在loadQuestions里加一个后台线程,用SwingWorker异步加载,避免界面冻结。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节整理我在开发和接入过程中遇到的真实报错,以及排查方法。
报错一:401 Unauthorized
如果你调用 TaoToken API 时返回 401,说明 API Key 无效或未正确传递。检查请求头:
connection.setRequestProperty("Authorization", "Bearer " + apiKey);确认apiKey没有多余空格,并且是在 https://taotoken.net/api-keys 创建的。如果 Key 刚创建,等几秒再试。
报错二:local proxy failed
这个报错通常出现在你本地设置了代理,但代理不可用。检查系统环境变量HTTP_PROXY和HTTPS_PROXY,如果不需要代理就清空。Java 代码里可以用:
System.setProperty("http.proxyHost", ""); System.setProperty("http.proxyPort", "");或者直接在 JVM 启动参数加-Dhttp.proxyHost= -Dhttp.proxyPort=。
报错三:reading choices
这个报错一般出现在解析模型返回的 JSON 时,字段名不匹配。比如你期望choices[0].message.content,但实际返回结构不同。建议先打印完整响应体:
System.out.println(responseBody);确认字段路径后再解析。TaoToken 兼容 OpenAI 格式,正常返回应该包含choices数组。
报错四:OAuth 相关错误
如果你用 OAuth 方式接入,报错可能是 token 过期或 scope 不足。检查 token 有效期,重新生成。如果只是本地自测面板,建议直接用 API Key,不用 OAuth。
报错五:JDBC 连接失败
Communications link failure说明 MySQL 没启动或端口不对。Access denied for user说明用户名密码错误。Unknown database说明库没建。逐个排查即可。
报错六:Swing 界面乱码
如果中文显示为方块,在 JVM 启动参数加-Dfile.encoding=UTF-8,或者用new JLabel(new String("中文".getBytes("UTF-8"), "UTF-8"))。推荐统一用 UTF-8 编码。
报错七:线程安全统计错误
如果你在后台线程直接更新JLabel,可能抛NullPointerException或界面不刷新。所有界面更新必须放在SwingUtilities.invokeLater里:
SwingUtilities.invokeLater(() -> statsLabel.setText("..."));记住:Swing 组件不是线程安全的,只有事件调度线程能操作它们。
6. 下一步:把错题面板接入 Coding Plan 做长期复盘
这个 Swing 面板已经能完成基本的错题分组、作答和统计。如果你想进一步自动化,比如每天自动拉取新题、生成解析、记录历史趋势,可以接入 TaoToken 的 Coding Plan。Coding Plan 适合长期编码和 Agent 场景,地址是 https://taotoken.net/coding-plan 。
具体做法:写一个定时任务,每天从数据库读取未掌握的错题,调用模型生成解析,然后把解析写回explanation字段。下次打开面板时,错题旁边就会显示详细解析。模型调用部分参考接入文档 https://taotoken.net/doc ,里面有完整的请求示例和参数说明。
如果你更想先验证模型效果,可以到模型对话页面 https://taotoken.net/model-chat 手动测试几道线程和 JDBC 题目,确认解析质量后再集成。控制台 https://taotoken.net/console 可以查看调用量和余额。
最后提醒一点:错题面板的核心价值在于「反复练」,而不是「一次做完」。建议把答错的题目标记为「待复习」,下次打开时优先加载这些题。你可以在java_quiz表加一个wrong_count字段,每次答错就加一,加载时按wrong_count DESC排序。这样坚持一周,线程和 JDBC 的高频错点自然就攻克了。