简介:一套基于Java Swing和MySQL的学校教务管理系统完整项目,面向Java SE学习者、高校课程设计与毕业设计学生,以及需要借鉴桌面端管理类系统架构的开发者。系统涵盖用户登录与权限控制、课程管理、学生与教师信息维护、选课排课、考勤记录、成绩查询及报表生成等核心业务,能够有效提升教务管理效率。资源共包含245个文件,压缩包2.74MB,其中54个java源码和168个class编译文件为主体,另附1个sql数据库脚本、1个jar依赖包及若干jpg/png界面预览图片,导入IDE并执行数据库脚本即可运行观察。已有3349人学习下载。借助Swing组件构建的多角色界面和MySQL中规范化的数据表结构,读者可直接学习登录认证、排课选课、成绩统计等典型模块的编码实现,项目代码结构清晰,并基于现有代码灵活扩展为毕业设计或实际教学辅助工具。
1. 为什么还在用 Swing 写教务管理系统
教务管理系统这类桌面应用,放在今天依然有大量学校在跑,不是因为它新,而是因为它稳。Java Swing 做内网部署的 GUI 足够轻,不依赖浏览器版本,装上 JDK 就能启动;MySQL 存教务数据又是最成熟的组合。你打开 Login.java、AdminFrame.java、StudentFrame.java 这一批类名,就能看出这套系统把登录、管理端、学生端拆得很清楚。
一个容易被忽略的点是:Swing 程序的维护成本其实比 Web 项目低。没有前端构建、没有跨域、没有 session 过期问题,窗口之间传对象直接用构造方法或静态上下文就行。后面我会从架构分层、数据库设计、选课排课的业务冲突处理讲到数据导出,把这条链路完整过一遍,适合正在做课程设计、毕业设计,或者想接手老旧桌面教务项目的开发者。新手可以照步骤复现,熟手可以重点看第 4 章的并发选课和排课冲突检测部分。
2. 分层与权限:从 Login.java 到 AdminFrame、StudentFrame
2.1 MVC 在 Swing 里的落地方式
摘要里提到系统遵循 MVC 模式。在 Swing 项目里,MVC 的边界比 Spring 项目更需要在代码层面主动约束。最常见的问题是把 SQL 直接写进按钮的 ActionListener,导致界面类和数据库耦合在一起。这套系统的类名其实已经给出了分层的线索:Login、AdminFrame、StudentFrame 属于视图层;ManagerStu、ManagerTeach、ManagerCourse、ManagerClass、ManagerScore 属于控制层;而数据访问通常在控制层里通过 JDBC 完成。
我一般会在控制类里维护一个Connection对象,通过构造方法注入给视图层使用。这样 AdminFrame 只需要调用managerScore.queryByStudentId(id),不需要知道 SQL 长什么样。视图层只干两件事:收集用户输入、刷新表格模型。这样后续把数据源从 MySQL 换成其他数据库,或者把桌面端改成 Web 端,边界都是清晰的。
2.2 登录校验与角色路由
登录是权限控制的入口。ManagerStu 控制学生,ManagerTeach 控制教师,AdminFrame 是管理端主窗口,StudentFrame 是学生端主窗口。登录成功后要做的不是简单弹出一个窗口,而是根据角色路由到不同的 JFrame 并关闭登录窗口。下面是一段典型的登录校验逻辑:
public void login(String username, String password) { String sql = "SELECT role FROM t_user WHERE username=? AND password=?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery(); if (rs.next()) { String role = rs.getString("role"); EventQueue.invokeLater(() -> { if ("admin".equals(role)) { new AdminFrame(username, conn).setVisible(true); } else if ("student".equals(role)) { new StudentFrame(username, conn).setVisible(true); } else { JOptionPane.showMessageDialog(null, "未知角色"); } }); // 关闭登录窗口,避免界面残留 closeLoginWindow(); } else { JOptionPane.showMessageDialog(null, "用户名或密码错误"); } } catch (SQLException e) { e.printStackTrace(); } }这段代码用PreparedStatement而不是字符串拼接,是为了防 SQL 注入;密码字段在真实部署中应该存加盐后的哈希值,而不是明文。EventQueue.invokeLater保证 Swing 组件创建和显示发生在事件调度线程上。登录窗口关闭后,JVM 不会退出,因为新窗口持有非守护线程的引用。
2.3 功能权限:不只是隐藏按钮
很多教务系统把权限控制做成“看不到按钮,但地址照样能访问”。桌面程序没有 URL,但仍然可以通过反射或直接 new 出内部窗口绕过界面限制。所以控制层的管理类里要传当前登录用户的角色参数,并在关键操作里做二次校验:
public boolean hasRight(String role, String action) { Map<String, List<String>> permissionMap = new HashMap<>(); permissionMap.put("admin", Arrays.asList("add_course", "delete_course", "arrange_course")); permissionMap.put("student", Arrays.asList("select_course", "view_score")); List<String> actions = permissionMap.get(role); return actions != null && actions.contains(action); }参数action是业务动作标识,与菜单名称无关。这样做的好处是权限配置不随界面文案变化,后续如果需要做成动态权限,可以直接把permissionMap换成数据库表。管理员拥有的排课、教师管理、班级管理权限,与学生端的选课权限被隔离在两个界面,但从控制层拦截才是安全底线。
3. Mysql 表结构设计:五张核心表与统计 SQL
3.1 表结构:从类名反推业务实体
从 ManagerStu、ManagerTeach、ManagerClass、ManagerCourse 这几个控制类反推开,数据库至少要覆盖学生、教师、班级、课程、选课关系五类实体。选课关系表同时承担成绩记录的功能,这样避免再单独建一张成绩表导致关联查询过度复杂。下面是核心 DDL 设计:
CREATE TABLE t_student ( student_id VARCHAR(20) PRIMARY KEY, student_name VARCHAR(50) NOT NULL, class_id INT NOT NULL, enroll_year INT, FOREIGN KEY (class_id) REFERENCES t_class(class_id) ); CREATE TABLE t_teacher ( teacher_id VARCHAR(20) PRIMARY KEY, teacher_name VARCHAR(50) NOT NULL, title VARCHAR(30) ); CREATE TABLE t_course ( course_id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id VARCHAR(20) NOT NULL, credit DECIMAL(3,1), max_students INT DEFAULT 50, selected_count INT DEFAULT 0, FOREIGN KEY (teacher_id) REFERENCES t_teacher(teacher_id) ); CREATE TABLE t_choose ( student_id VARCHAR(20) NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), choose_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES t_student(student_id), FOREIGN KEY (course_id) REFERENCES t_course(course_id) );t_course表里的selected_count字段是冗余设计,避免每次选课都要COUNT(*)统计。max_students用于课程容量控制。t_choose用联合主键保证同一学生不能重复选同一门课。成绩字段score放在选课关系表里,这样一张表既维护了学生与课程的关联,也存储了成绩,成绩录入和选课记录共用这一条数据。
3.2 多表关联查询:一个学生完整视图
教务系统里最频繁的查询是“查某个学生的课表”,以及“查某门课的选课名单”。这两个查询都需要三表 JOIN。查询学生课表时,需要从选课表出发,关联课程表和教师表:
SELECT c.course_name, t.teacher_name, c.credit, ch.score FROM t_choose ch JOIN t_course c ON ch.course_id = c.course_id JOIN t_teacher t ON c.teacher_id = t.teacher_id WHERE ch.student_id = '2024001' ORDER BY c.course_id;这里用JOIN而不是WHERE逗号连接,是为了让关联关系明确。t_choose表是事实表,t_course和t_teacher是维度表。实际开发中如果发现这条 SQL 执行慢,检查t_choose上的student_id是否建了索引。联合主键(student_id, course_id)已经覆盖了student_id的最左前缀,所以不需要额外加索引。
查询班级各科平均分时,要聚合两个维度:
SELECT s.class_id, c.course_id, AVG(ch.score) AS avg_score, COUNT(*) AS stu_cnt FROM t_choose ch JOIN t_student s ON ch.student_id = s.student_id JOIN t_course c ON ch.course_id = c.course_id GROUP BY s.class_id, c.course_id HAVING stu_cnt >= 5;HAVING筛选的是分组后条件,WHERE筛选的是分组前条件,这是最容易写错的地方。AVG函数会忽略 NULL 值,如果库里有未录入的成绩,平均分统计的是非空记录,这点在展示报表时要向使用者说明。
3.3 JDBC 连接池与中文乱码
桌面应用虽然没有 Web 容器那种高并发,但频繁创建数据库连接也会拖慢界面响应。一个务实的做法是使用 HikariCP 做连接池,配置文件里指定时区和编码:
jdbcUrl=jdbc:mysql://localhost:3306/school username=root password=your_password driverClassName=com.mysql.cj.jdbc.Driver maximumPoolSize=10 connectionInitSql=SET NAMES utf8mb4参数connectionInitSql=SET NAMES utf8mb4是连接建立后立即执行的初始化 SQL,确保插入的中文数据不乱码。maximumPoolSize=10对教务管理这种几十个终端的内网应用已经足够。如果界面层显示中文时出现乱码,优先检查这个配置。
4. 选课与排课:时间冲突检测、容量锁与成绩报表
4.1 排课冲突检测的三个维度
ArrangeCourse 类是排课管理模块。排课的本质是给“班级、教师、教室”三个资源分配时间片。冲突检测通常分为三步:同一教师在同一时间只能上一门课,同一班级在同一时间只能上一门课,同一教室在同一时间只能上一门课。具体到代码实现,最稳妥的方式是在t_course表里增加week_day和start_section字段,然后在插入前做校验:
public boolean checkTeacherConflict(int teacherId, String weekDay, int startSection) { String sql = "SELECT COUNT(*) FROM t_course " + "WHERE teacher_id=? AND week_day=? AND start_section=?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, teacherId); ps.setString(2, weekDay); ps.setInt(3, startSection); ResultSet rs = ps.executeQuery(); rs.next(); return rs.getInt(1) > 0; } catch (SQLException e) { e.printStackTrace(); return true; // 查询异常时保守拒绝 } }注意这里只判断了节次起点相同的情况。如果课程跨两节课,比如第 3、4 节连上,那么start_section为 3 的课和start_section为 4 的课也会冲突。解决方法是把排课的时间片存成“起始节次+持续节次”,冲突检测时用区间重叠判断。简单做法是检查两侧:新课程的起始节次落在已有课程区间内,或已有课程的起始节次落在新课程区间内,二者任一成立即冲突。
4.2 选课并发控制:防止超选
选课系统的核心风险是多人同时抢最后一门课,导致selected_count超过max_students。如果只做界面上的“选课前先查容量”检查,在高并发下会出现多条连接同时读到selected_count=49,然后同时插入,最终超卖。解决思路是事务加行锁:
public boolean selectCourse(String studentId, int courseId) { String lockSql = "SELECT max_students, selected_count FROM t_course WHERE course_id=? FOR UPDATE"; String insertSql = "INSERT INTO t_choose(student_id, course_id) VALUES(?,?)"; String updateSql = "UPDATE t_course SET selected_count=selected_count+1 WHERE course_id=?"; Connection conn = dataSource.getConnection(); try { conn.setAutoCommit(false); PreparedStatement ps = conn.prepareStatement(lockSql); ps.setInt(1, courseId); ResultSet rs = ps.executeQuery(); if (!rs.next()) throw new RuntimeException("课程不存在"); int max = rs.getInt("max_students"); int current = rs.getInt("selected_count"); if (current >= max) { conn.rollback(); return false; } PreparedStatement psInsert = conn.prepareStatement(insertSql); psInsert.setString(1, studentId); psInsert.setInt(2, courseId); psInsert.executeUpdate(); PreparedStatement psUpdate = conn.prepareStatement(updateSql); psUpdate.setInt(1, courseId); psUpdate.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { try { conn.rollback(); } catch (SQLException ignored) {} e.printStackTrace(); return false; } finally { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } }FOR UPDATE是行级排他锁,在事务提交前,其他事务的SELECT ... FOR UPDATE会等待这条记录释放锁。这保证了容量检查和插入更新在逻辑上是原子的。如果应用场景是 Web 化后的高并发选课,还可以用 Redis 做预扣减,但当前 Swing 桌面应用场景下,数据库锁已经足够可靠。注意这里没有捕获唯一键冲突异常,实际项目里应该对重复选课的SQLIntegrityConstraintViolationException单独做提示。
4.3 成绩统计分析:ManagerScore 的核心 SQL
ManagerScore 模块负责成绩录入和报表。成绩分布统计比简单的平均分更有参考价值,教务处需要知道各分数段的分布来评估考试难度。这时可以用分段统计:
SELECT SUM(CASE WHEN score >= 90 THEN 1 ELSE 0 END) AS excellent, SUM(CASE WHEN score >= 80 AND score < 90 THEN 1 ELSE 0 END) AS good, SUM(CASE WHEN score >= 60 AND score < 80 THEN 1 ELSE 0 END) AS pass, SUM(CASE WHEN score < 60 THEN 1 ELSE 0 END) AS fail, AVG(score) AS avg_score FROM t_choose WHERE course_id = 101;SUM(CASE WHEN ...)这种写法比多个COUNT子查询效率高得多,因为它只扫描一次选课表。在 Swing 里展示结果时,可以把查询结果封装成一个ScoreStat对象,然后填充到DefaultTableModel。注意成绩为 NULL 的记录(表示已选课但未考试)会被所有比较条件排除,统计时如果要计入缺考人数,需要单独过滤score IS NULL。
5. 数据落地:从 JTable 导出 CSV 与 SwingWorker 刷新统计
5.1 带 BOM 的 CSV 导出,解决 Excel 中文乱码
教务系统最终要把报表交给教务处,导出 CSV 是最通用的做法。Swing 里可以直接从 JTable 的TableModel读取数据,不必重新查询数据库。这里最容易踩的坑是:直接输出 UTF-8 编码的 CSV 用 Excel 打开会乱码。解决办法是在文件开头写入 UTF-8 BOM:
public void exportTableToCsv(JTable table, File file) throws IOException { try (FileOutputStream fos = new FileOutputStream(file); OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8)) { fos.write(0xEF); fos.write(0xBB); fos.write(0xBF); TableModel model = table.getModel(); for (int i = 0; i < model.getColumnCount(); i++) { osw.write(model.getColumnName(i)); if (i < model.getColumnCount() - 1) osw.write(","); } osw.write("\r\n"); for (int r = 0; r < model.getRowCount(); r++) { for (int c = 0; c < model.getColumnCount(); c++) { Object val = model.getValueAt(r, c); String cell = val == null ? "" : val.toString().replace(",", ","); osw.write(cell); if (c < model.getColumnCount() - 1) osw.write(","); } osw.write("\r\n"); } } }这里把英文逗号替换成中文逗号,是一种简单粗暴但有效的方案。如果单元格内容包含逗号、引号、换行符的任意组合,标准的做法是用双引号包裹单元格,并把内部双引号转成两个双引号。上面代码处理了最常见的“课程名称中含逗号”的情况。换行用\r\n而不是\n,是为了兼容 Windows 记事本。
5.2 SwingWorker 避免报表查询卡死界面
成绩报表查询如果涉及大班级联表统计,网络延迟和数据库计算时间可能导致窗口假死。Swing 的单线程规则要求界面更新必须在 EDT,但耗时操作阻塞 EDT 会让按钮点击无响应。标准解法是 SwingWorker,在后台线程执行查询,在done()方法里更新表格:
class LoadScoreStatWorker extends SwingWorker<ScoreStat, Void> { private final int courseId; private final JLabel avgLabel; LoadScoreStatWorker(int courseId, JLabel avgLabel) { this.courseId = courseId; this.avgLabel = avgLabel; } @Override protected ScoreStat doInBackground() { return scoreDao.queryScoreStat(courseId); } @Override protected void done() { try { ScoreStat stat = get(); avgLabel.setText("平均分: " + stat.getAvgScore()); } catch (InterruptedException | ExecutionException e) { avgLabel.setText("统计失败,请查看日志"); } } }参数courseId用于指定统计哪门课程,avgLabel是界面上的显示组件。doInBackground里的scoreDao.queryScoreStat是耗时操作,会在后台线程执行;done()里通过get()获取结果并更新标签,此时已经回到 EDT。注意get()抛出的两种异常要分开处理:InterruptedException说明线程被中断,ExecutionException说明数据库查询本身出错。启动任务时,保存一个引用便于在窗口关闭时调用cancel(true)取消未完成的任务。
本文还有配套的精品资源,点击获取