
简介面向Java课程设计与期末大作业学生提供基于Java Swing与MySQL的学生成绩管理系统完整源码及PDF报告解决从系统设计、编码实现到项目文档撰写的全过程需求。系统包含学生、教师、课程、成绩等核心数据表源码按登录、学生菜单、教师菜单、数据库查询等模块清晰组织采用GridBagConstraints布局实现界面封装JDBC查询返回结果配置MySQL驱动即可在本地编译运行。资源共18个文件含7个Java源文件、4个SQL脚本、1份PDF报告以及数据库连接jar包、界面效果图、使用说明等整体压缩包仅3.32MB目录结构紧凑便于按需查阅。目前已有83人学习/下载该源码评审分为98分代码经本地编译验证可靠既适合快速掌握Swing界面与MySQL交互的实战技巧也能直接作为期末大作业或课程设计的完整参考方案。1. 为什么这个项目是Java基础阶段的检验标准见过不少人把学生成绩管理系统当成「老掉牙的练习」真到课设答辩或者面试现场聊 Swing MySQL 的时候反而说不清。这个题目之所以一直没被淘汰是因为它把 Java 基础最核心的三件事压在一个桌面应用里JDBC 连接 MySQL、Swing 做可视化界面、以及围绕成绩数据的增删改查和统计报表。做完它等于把 java 学习路线里从环境配置到数据库交互的断点全部连上了。这个项目的价值不在界面多炫而在边界问题多登录鉴权、成绩唯一性、空成绩代表缺考、导出 PDF 报告、Excel 批量登分。任何一个点都够面试官追问三五轮。适合三类人正在做课设的在校生想把 java 基础补扎实的转行者以及想用一个完整源码项目撑起简历作品的求职者。下面按我自己的实现顺序来讲先建库再搭界面然后写业务最后处理导出和打包。2. MySQL成绩库怎么建才不返工表结构、索引与JDBC连接2.1 四张表设计为什么学号用CHAR而不是VARCHAR很多初学版本把学生、课程、成绩塞进一张表后面统计及格率必然写出一堆重复 CASE WHEN。成绩管理系统至少要拆成四张表sys_user 存登录账号student 存学生档案course 存课程score 存成绩。核心关系是 score 通过 stu_no 和 course_id 关联前两张表。用 MySQL Workbench 连上服务端后先跑一遍建库脚本后面所有代码都依赖这个结构。CREATE DATABASE student_grade_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE student_grade_db; CREATE TABLE sys_user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role TINYINT NOT NULL DEFAULT 1 COMMENT 1管理员 2教师, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE student ( stu_no CHAR(10) PRIMARY KEY, stu_name VARCHAR(30) NOT NULL, gender CHAR(1) NOT NULL, major VARCHAR(50), class_name VARCHAR(30), enroll_year YEAR ) ENGINEInnoDB; CREATE TABLE course ( course_id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(50) NOT NULL, credit DECIMAL(2,1) NOT NULL ) ENGINEInnoDB; CREATE TABLE score ( score_id INT PRIMARY KEY AUTO_INCREMENT, stu_no CHAR(10) NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), exam_date DATE, UNIQUE KEY uk_stu_course (stu_no, course_id), CONSTRAINT fk_score_student FOREIGN KEY (stu_no) REFERENCES student(stu_no), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course(course_id) ) ENGINEInnoDB;几个经常被课设评委追问的设计点。学号是固定位数的数字用 CHAR(10) 比 VARCHAR(10) 少了长度判断的开销这是 mysql 创建索引和查询都更稳的类型选择。成绩用 DECIMAL(5,2) 而不是 FLOAT因为浮点数存 59.9 这类分数会出现二进制误差打印报表时对不齐。score 字段允许 NULL含义是缺考而不是 0 分这个语义后面统计 AVG 时会用到。uk_stu_course 唯一索引保证同一学生同一课程只有一条成绩这是业务规则在数据库层面的兜底比只靠代码判断可靠得多。字段选型汇总表字段类型选择理由studentstu_noCHAR(10)定长学号主键检索快避免 VARCHAR 额外长度字节coursecreditDECIMAL(2,1)学分只到 0.5避免浮点误差scorescoreDECIMAL(5,2)覆盖百分制和 0.5 分制避免浮点误差scoreexam_dateDATE支持按学年/学期过滤sys_userroleTINYINT1 管理员 2 教师权限分支简单自增主键配合 ENGINEInnoDB 使用。MySQL 8 默认就是 InnoDB但课设文档里手写 CREATE TABLE 时建议显式声明。如果成绩查询常按课程维度统计还可以在 score(course_id) 上再建一个普通索引CREATE INDEX idx_course ON score(course_id);这条语句也能在 mysql 创建索引的考题里见到。2.2 JDBC连接串里的三个经典坑驱动类、时区和SSL界面写得再好连不上库就是零分。用原生 JDBC 而不是直接上 MyBatis是为了让 java 基础阶段的开发者看清每一步。数据库配置放到 src 根目录的 db.propertiesdrivercom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/student_grade_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 userroot password你的密码连接工具类import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Properties; public class DbUtil { private static String url; private static String user; private static String password; // 静态块只执行一次加载驱动并读取配置 static { try (InputStream in DbUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(in); Class.forName(props.getProperty(driver)); url props.getProperty(url); user props.getProperty(user); password props.getProperty(password); } catch (Exception e) { // 配置或驱动缺失时类加载直接失败尽早暴露问题 throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } }静态块里做三件事读取配置、Class.forName 加载驱动、保存连接参数。MySQL 8 之后驱动类必须写成 com.mysql.cj.jdbc.Driver写旧的 com.mysql.jdbc.Driver 会在启动时报 ClassNotFoundException。url 里 serverTimezoneAsia/Shanghai 解决 MySQL 8 返回的时区类型与 JVM 默认时区不一致导致的报错characterEncodingutf8 解决中文乱码useSSLfalse 是在本地开发时不触发证书校验。mysql安装配置教程里最容易漏的一步是 connector 版本要和 MySQL 服务端匹配MySQL 8.x 配 mysql-connector-j 8.0.x用 5.1.x 驱动去连 8.x 服务端握手阶段会提示 Public Key Retrieval is not allowed需要在 url 追加 allowPublicKeyRetrievaltrue。这句参数在很多 java 面试八股文里也出现值得记住原因而不是背答案caching_sha2_password 认证要求先安全获取公钥。提示连接串里的字符集参数要和数据库实际字符集一致库是 utf8mb4url 里就不要写 latin1否则中文界面会直接乱码。2.3 PreparedStatement和Statement课设答辩必被问的差别有旧代码习惯的同学会用 Statement 拼 SQLStatement stmt conn.createStatement(); stmt.executeUpdate(INSERT INTO score (stu_no, course_id, score) VALUES ( stuNo , courseId , score ));这种写法的问题首先是 SQL 注入stuNo 传1 OR 11这类输入时拼接语句会被当成逻辑条件执行。其次是性能每次拼接都是新 SQLMySQL 无法复用执行计划。改用 PreparedStatement 之后SQL 骨架固定参数通过占位符传输JDBC 驱动和数据库都能缓存执行计划。String sql SELECT * FROM student WHERE stu_no ?; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, stuNo); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 每一行映射成一个 Student 对象放进 List 返回 } } }这里用 try-with-resources 关闭了 Connection、PreparedStatement、ResultSet 三层资源防止连接泄漏。getConnection 每次创建新连接在小规模课设里可以接受但要清楚这是「能用」而不是「生产级」生产环境会换成 Druid 或 C3P0 连接池这是优化方向不是现在的重点。下一章把这些连接语句接进 Swing 界面。3. Swing界面三层搭JFrame、DefaultTableModel与事件监听3.1 主窗体布局BorderLayout加CardLayout管理页面切换Swing 桌面程序的骨架是 JFrame内部按 BorderLayout 分区域NORTH 放标题或工具栏CENTER 放可切换的内容面板SOUTH 放状态栏。多个业务面板学生管理、成绩录入、统计报表用 CardLayout 叠放通过卡片名切换避免重复创建 JFrame 导致内存泄漏。三种布局在这个系统里的分工如下布局管理器用途本系统中的位置BorderLayout上下左右中五区布局JFrame 根容器NORTH 工具栏、CENTER 卡片面板CardLayout同一区域切换多张卡片主界面切换学生/成绩/统计三个业务面板BoxLayout水平或垂直排列组件表单弹窗里的标签 输入框逐行排列import javax.swing.*; import java.awt.*; public class MainFrame extends JFrame { private CardLayout cardLayout new CardLayout(); private JPanel cardPanel new JPanel(cardLayout); public MainFrame() { setTitle(学生成绩管理系统); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(1024, 700); setLocationRelativeTo(null); buildMenu(); cardPanel.add(new StudentPanel(), student); cardPanel.add(new ScorePanel(), score); cardPanel.add(new StatPanel(), stat); add(cardPanel, BorderLayout.CENTER); } private void buildMenu() { JMenuBar menuBar new JMenuBar(); JMenu menu new JMenu(功能); JMenuItem itemStudent new JMenuItem(学生管理); itemStudent.addActionListener(e - cardLayout.show(cardPanel, student)); JMenuItem itemScore new JMenuItem(成绩管理); itemScore.addActionListener(e - cardLayout.show(cardPanel, score)); menu.add(itemStudent); menu.add(itemScore); menuBar.add(menu); setJMenuBar(menuBar); } }关键点在于三个面板只在构造时 new 一次之后都是 show 来回切数据加载放在每个面板的 reloadData 里而不是重新创建面板。菜单按钮靠 ActionListener 回调里跳转卡片界面只负责展示业务逻辑放到 Service 层。setLocationRelativeTo(null) 让窗口居中这是 Swing 里最容易被忽略的体验细节。3.2 用DefaultTableModel承载JTable别每次查询都重建表格很多 Swing 小项目一刷新数据就把旧 JTable 从容器里 remove再 new 一个加回来。这种写法会闪烁而且之前注册的监听器全部失效。正确做法是先创建一个 DefaultTableModel把数据变更收敛到 model 上JTable 只做视图展示。public class StudentPanel extends JPanel { private final DefaultTableModel model; private final JTable table; public StudentPanel() { setLayout(new BorderLayout()); model new DefaultTableModel( new String[]{学号, 姓名, 性别, 专业, 班级, 入学年份}, 0) { // 禁止双击直接编辑单元格所有修改走表单弹窗 Override public boolean isCellEditable(int row, int column) { return false; } }; table new JTable(model); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.getTableHeader().setReorderingAllowed(false); add(new JScrollPane(table), BorderLayout.CENTER); reloadData(); } public void reloadData() { model.setRowCount(0); // 清空旧数据复用同一个 model String sql SELECT stu_no, stu_name, gender, major, class_name, enroll_year FROM student ORDER BY stu_no; try (Connection conn DbUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { model.addRow(new Object[]{ rs.getString(stu_no), rs.getString(stu_name), rs.getString(gender), rs.getString(major), rs.getString(class_name), rs.getString(enroll_year) }); } } catch (SQLException e) { JOptionPane.showMessageDialog(this, 加载学生数据失败 e.getMessage(), 错误, JOptionPane.ERROR_MESSAGE); } } }匿名子类重写 isCellEditable 返回 false让表格在界面上不可直接编辑保证数据校验逻辑只有一个入口。model.setRowCount(0) 是清空旧数据的标准做法比 removeAll 干净。JTable 放进 JScrollPane 而不是直接 add否则表头和横向滚动条都不会出现。表格里拿当前选中行的写法也值得记住int row table.getSelectedRow(); if (row 0) { JOptionPane.showMessageDialog(this, 请先选择一行); return; } String stuNo (String) model.getValueAt(row, 0);getSelectedRow 返回 -1 表示没有选中需要先判空再取值。如果表格设置了 RowSorter 排序器视图行号和模型行号会不一致要用table.convertRowIndexToModel(row)转换。课设里一般不加排序器但知道这个坑能预防答不上来。3.3 登录窗口JDialog阻塞主窗体与回车触发登录登录用 JDialog 而不是 JFrame原因在于 setModal(true) 可以阻塞父窗口用户不登录就没法操作主界面。常见做法是在 main 方法里先弹出登录框验证成功后才 new MainFrame。public class LoginFrame extends JDialog { private JTextField usernameField new JTextField(15); private JPasswordField passwordField new JPasswordField(15); public LoginFrame(JFrame owner) { super(owner, 登录, true); // 第三个参数 true 表示模态 setSize(320, 180); setLocationRelativeTo(owner); JButton loginBtn new JButton(登录); loginBtn.addActionListener(e - doLogin()); // 把 Enter 键绑定到按钮动作焦点在输入框时也能触发登录 loginBtn.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) .put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), doLogin); loginBtn.getActionMap().put(doLogin, new AbstractAction() { Override public void actionPerformed(ActionEvent e) { doLogin(); } }); // 布局略JLabel JTextField JPasswordField 按行排列 } private void doLogin() { String username usernameField.getText().trim(); String password new String(passwordField.getPassword()); // 查询 sys_user 表比对密码建议散列后比对 if (checkUser(username, password)) { dispose(); // 成功后关闭登录框主窗口继续显示 } else { JOptionPane.showMessageDialog(this, 用户名或密码错误); } } }回车触发登录这里有个细节把 Enter 键绑定到按钮的 InputMap 上绑定条件用 WHEN_IN_FOCUSED_WINDOW这样焦点在输入框里时回车也能触发登录。JPasswordField 用 getPassword 取 char 数组而不是 getText这是 Swing 的安全设计获取后立即转成 String 使用。密码存储不要在数据库里放明文至少用 MD5 加盐或 SHA-256。答辩时主动提「密码散列后入库」是加分项也是 java 面试题里加密考点的活学活用。4. 成绩的增删改查与统计聚合SQL和事务批量登分4.1 成绩录入的完整方法先查重再插入或更新成绩界面是系统的核心操作对象是 score 表。单个录入时要处理三种情况该学生该课程没有成绩则插入已有成绩则更新成绩为空表示缺考。代码里用「先 COUNT 判重再走 INSERT 或 UPDATE」的方式业务上清晰也方便给界面提示「已存在将覆盖原成绩」。public class ScoreService { public boolean saveScore(String stuNo, int courseId, Double score, String examDate) { String checkSql SELECT COUNT(*) FROM score WHERE stu_no ? AND course_id ?; try (Connection conn DbUtil.getConnection(); PreparedStatement checkPs conn.prepareStatement(checkSql)) { checkPs.setString(1, stuNo); checkPs.setInt(2, courseId); boolean exists; try (ResultSet rs checkPs.executeQuery()) { rs.next(); exists rs.getInt(1) 0; } if (exists) { return updateScore(conn, stuNo, courseId, score, examDate); } return insertScore(conn, stuNo, courseId, score, examDate); } catch (SQLException e) { e.printStackTrace(); return false; } } private boolean insertScore(Connection conn, String stuNo, int courseId, Double score, String examDate) throws SQLException { String sql INSERT INTO score (stu_no, course_id, score, exam_date) VALUES (?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, stuNo); ps.setInt(2, courseId); ps.setObject(3, score); // score 为 null 时自动写 SQL NULL表示缺考 ps.setObject(4, examDate null ? null : java.sql.Date.valueOf(examDate)); return ps.executeUpdate() 0; } } private boolean updateScore(Connection conn, String stuNo, int courseId, Double score, String examDate) throws SQLException { String sql UPDATE score SET score ?, exam_date ? WHERE stu_no ? AND course_id ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setObject(1, score); ps.setObject(2, examDate null ? null : java.sql.Date.valueOf(examDate)); ps.setString(3, stuNo); ps.setInt(4, courseId); return ps.executeUpdate() 0; } } }ps.setObject(3, score) 传入 null 时JDBC 会把参数设为 SQL NULL正好表达「缺考」。日期字段用 java.sql.Date.valueOf 从字符串转换格式必须是 yyyy-MM-dd。这里有个容易踩的坑如果把 java.util.Date 直接 setObject 给 DATE 字段某些驱动版本会报不支持的类型。界面层在调用前还要做分数范围校验0 到 100 之外直接拦截别等到 SQL 报错才处理。前面建表时的 uk_stu_course 唯一索引在这里当第二道防线万一两个窗口同时插入同一对 stu_no course_id数据库会抛 DuplicateKeyException不会出现两条重复成绩。4.2 批量导入成绩为什么必须用事务和批量执行教师端常见场景是 Excel 里几百条成绩一次性录入。如果逐条提交前 100 条成功、第 101 条失败数据就处于半成品状态。所以批量导入必须开事务全部成功才 commit中间任何一条失败都 rollback。public int batchSave(ListScoreItem items) throws SQLException { String sql INSERT INTO score (stu_no, course_id, score, exam_date) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE score VALUES(score), exam_date VALUES(exam_date); try (Connection conn DbUtil.getConnection()) { conn.setAutoCommit(false); // 开启事务 try (PreparedStatement ps conn.prepareStatement(sql)) { for (ScoreItem item : items) { ps.setString(1, item.getStuNo()); ps.setInt(2, item.getCourseId()); ps.setObject(3, item.getScore()); ps.setString(4, item.getExamDate()); ps.addBatch(); } int[] counts ps.executeBatch(); conn.commit(); // 全部成功才提交 return counts.length; } catch (SQLException e) { conn.rollback(); // 任何一条失败整体回滚 throw e; } finally { conn.setAutoCommit(true); // 恢复自动提交避免连接池复用出错 } } }addBatch 只是把参数装进驱动缓冲真正发到 MySQL 的是 executeBatch。MySQL JDBC 驱动默认一条条发要达到真正的批量效果连接串要追加 rewriteBatchedStatementstrue驱动才会把多条 INSERT 重写成一条多 VALUES 的语句性能差距可以达到数倍。ON DUPLICATE KEY UPDATE 把「已存在就更新」的判断交给 MySQL 唯一索引去完成省掉了先查一遍再决定走 INSERT 还是 UPDATE 的来回和 4.1 的显式判重是两种思路单条录入用可读性更好的判重分支批量导入用数据库原子语义。事务的三行关键代码不要说反了先 setAutoCommit(false)成功 commit异常 rollbackfinally 里恢复 true。finally 恢复自动提交很重要因为连接可能回到连接池被复用不恢复会把脏状态带给下一个使用者。提示executeBatch 返回的 int[] 是每批影响的行数不要拿它和 items.size() 严格比对因为 ON DUPLICATE KEY UPDATE 对更新行的返回值会因驱动版本而不同。4.3 统计报表的SQL聚合函数和CASE WHEN的一次成型成绩统计一般包含每门课的选考人数、平均分、最高分、最低分和及格率。这些全部能用一条聚合 SQL 算出来不要在 Java 里 for 循环算平均分那样既慢又容易数错行数。SELECT c.course_id, c.course_name, COUNT(s.score_id) AS exam_count, ROUND(AVG(s.score), 1) AS avg_score, MAX(s.score) AS max_score, MIN(s.score) AS min_score, ROUND(SUM(CASE WHEN s.score 60 THEN 1 ELSE 0 END) / COUNT(s.score_id) * 100, 1) AS pass_rate FROM course c LEFT JOIN score s ON c.course_id s.course_id GROUP BY c.course_id, c.course_name ORDER BY c.course_id;LEFT JOIN 的目的是把还没人参加考试的课程也列出来COUNT(s.score_id) 只统计非空的成绩行数如果课程没人选这一列是 0 而不是 1。AVG、MAX、MIN 会自动忽略 NULL所以缺考不会拉低平均分这也呼应了表设计里「NULL 表示缺考」的语义决策。及格率里用 COUNT(s.score_id) 做分母确保和参考人数一致如果用 COUNT(*) 会把 NULL 成绩也算进去及格率就错了。聚合函数的取舍可以整理成一张表答辩时照着说聚合函数行为成绩报表里的含义COUNT(s.score_id)统计非 NULL 行数参考人数缺考不计AVG(s.score)忽略 NULL 求平均课程平均分MAX / MIN忽略 NULL 返回极值最高分 / 最低分SUM(CASE WHEN …)条件计数及格人数、优秀人数班级维度的统计会用到多条件 CASE WHENSELECT st.class_name, COUNT(DISTINCT st.stu_no) AS stu_count, SUM(CASE WHEN sc.score 85 THEN 1 ELSE 0 END) AS excellent_cnt, SUM(CASE WHEN sc.score 60 THEN 1 ELSE 0 END) AS fail_cnt FROM student st LEFT JOIN score sc ON st.stu_no sc.stu_no GROUP BY st.class_name;统计结果在 Swing 里照样用 DefaultTableModel 填充但建议把聚合 SQL 封装在 StatService界面层只拿 ListMapString, Object 直接回显职责更干净。查询出来的列名是 exam_count 这种下划线命名用 rs.getObject(exam_count) 取值时注意别拼错。5. OpenPDF导出成绩单与打包运行后的自查清单5.1 导出PDF中文字体用CID而不是本地字体PDF 导出的关键不在排版在中文字体。iText 2.x 之后改名 iText 5/7AGPL 协议对商用有要求课设和内部工具建议用 OpenPDF包名是 com.lowagie.textAPI 风格和 iText 早期版本几乎一致License 是 LGPL相对省心。import com.lowagie.text.Document; import com.lowagie.text.Font; import com.lowagie.text.PageSize; import com.lowagie.text.Paragraph; import com.lowagie.text.Phrase; import com.lowagie.text.pdf.BaseFont; import com.lowagie.text.pdf.PdfPCell; import com.lowagie.text.pdf.PdfPTable; import com.lowagie.text.pdf.PdfWriter; import java.io.FileOutputStream; import java.util.List; public class ScorePdfExporter { public static void export(ListScoreItem items, String savePath) throws Exception { Document doc new Document(PageSize.A4); PdfWriter.getInstance(doc, new FileOutputStream(savePath)); doc.open(); // CID 字体不依赖操作系统安装的中文字体文件 BaseFont bf BaseFont.createFont(STSong-Light, UniGB-UCS2-H, BaseFont.NOT_EMBEDDED); Font headerFont new Font(bf, 16, Font.BOLD); Font cellFont new Font(bf, 10, Font.NORMAL); doc.add(new Paragraph(学生成绩单, headerFont)); doc.add(new Paragraph(导出时间 new java.text.SimpleDateFormat(yyyy-MM-dd HH:mm) .format(new java.util.Date()), cellFont)); PdfPTable table new PdfPTable(5); table.setWidthPercentage(100); table.setWidths(new float[]{2, 2, 3, 2, 2}); String[] headers {学号, 姓名, 课程, 成绩, 考试日期}; for (String h : headers) { table.addCell(new PdfPCell(new Phrase(h, cellFont))); } for (ScoreItem item : items) { table.addCell(new PdfPCell(new Phrase(item.getStuNo(), cellFont))); table.addCell(new PdfPCell(new Phrase(item.getStuName(), cellFont))); table.addCell(new PdfPCell(new Phrase(item.getCourseName(), cellFont))); table.addCell(new PdfPCell(new Phrase( item.getScore() null ? 缺考 : String.valueOf(item.getScore()), cellFont))); table.addCell(new PdfPCell(new Phrase(item.getExamDate(), cellFont))); } doc.add(table); doc.close(); } }STSong-Light UniGB-UCS2-H 是 Adobe 的 CID 字体不需要在服务器上安装中文字体文件跨平台一致。不要用 new Font 传系统字体路径Windows 上能跑到了 Linux 环境导出的 PDF 全是方框。setWidths 控制各列宽度比例按「学号小、课程名宽」的实际需求调。界面里用 JFileChooser 让用户选保存路径记得给文件名补 .pdf 后缀判断。5.2 打包成jarassembly插件和编码参数Swing 程序交付的是 jar不应该是 class 目录。用 maven-assembly-plugin 打「全家桶 jar」把 mysql-connector-j、OpenPDF 依赖一起打进去双击就能运行。build finalNamestudent-grade-system/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-assembly-plugin/artifactId version3.6.0/version configuration archive manifest mainClasscom.grade.ui.LoginFrame/mainClass /manifest /archive descriptorRefs descriptorRefjar-with-dependencies/descriptorRef /descriptorRefs /configuration executions execution phasepackage/phase goals goalsingle/goal /goals /execution /executions /plugin /plugins /build构建命令mvn clean package -DskipTests产物在 target 目录下。Windows 上乱码高发建议加-Dfile.encodingUTF-8IDEA 里 Project Encoding、VM options 里的 file.encoding、maven 的编码三处保持一致否则界面文字偶尔正常、导出 PDF 却乱码问题往往出在编译期编码而不是运行期。5.3 交项目前的最终验证清单检查项命令/操作预期结果数据库能连mysql -uroot -p后执行SELECT 1;返回 1数据备份mysqldump -uroot -p student_grade_db backup.sql备份文件生成中文不乱码录入一条含中文姓名和课程名的成绩界面与导出 PDF 均显示正常成绩边界输入 101、-1、空值被界面拦截或按缺考处理重复录入同一学号课程序号录两次第二次变为更新覆盖导出PDF统计页导出后打开文件中文字体、表格线完整mysqldump 这行是答辩前最该做的一次演练机房环境变了执行mysql -uroot -p student_grade_db backup.sql能在 5 分钟内恢复整套系统。最后一条交付前把 jar 和 db.properties 里的 root 密码改成演示库的弱密码别把本机真实密码带进交付物这也是历年课设里最常见的低级扣分点。本文还有配套的精品资源点击获取