2026/10/7 10:15:51

SpringBoot+Layui+Thymeleaf学生成绩管理系统开发实战

SpringBoot+Layui+Thymeleaf学生成绩管理系统开发实战 简介基于Spring Boot Layui Thymeleaf开发的学生成绩管理系统定位为Java后端学习、毕业设计与课程作业可参考的完整Web项目。系统涵盖学生信息维护、成绩录入与查询、班级统计等常见教务场景涉及Spring Boot整合Thymeleaf渲染、Layui前端组件、JSON配置及SQL数据脚本适合有一定Java基础、希望从零跑通一个前后端项目的学习者。资源共332个文件、压缩包约6.67MB主要包含33个Java源码、26个HTML页面、86个JS脚本、18个CSS样式、38个JSON配置以及SQL脚本、mvnw构建命令和相关备份文件便于对照理解后端分层、前端交互与数据表结构。项目源码已在本地编译通过下载后按文档配置环境即可运行难度适中内容经助教审定既能满足课程设计验收也可作为毕业设计二次开发蓝本。压缩包内包含模块化目录、数据库初始化脚本及环境配置备份文件能从登录权限到成绩统计全链路查看实现方式。目前已有85人学习/下载如有问题可私信作者获取支持。1. 为什么 2024 年还有人在用 springbootlayuithymeleaf 做学生成绩管理系统“基于springbootlayuithymeleaf的学生成绩管理系统”看起来不像新潮选题但对大多数Java方向的在校项目来说它恰恰踩在“能演示、好答辩、可扩展”的平衡点上。前后端分离不是灵丹妙药Vue项目要装Node、要处理跨域、要想办法把打包产物塞回Spring Boot每一步都是没有业务收益的成本。而这个组合里Thymeleaf负责服务端渲染Layui负责把UI组件撑起来Spring Boot统一管接口和业务三者在同一个Java工程里就能跑通整条成绩管理链。适合正在做Java毕设、课程实训或者想快速搭一个管理系统骨架的初学者。2. 技术选型与项目结构springboot、layui、thymeleaf 各自把住哪一层拿到这种标题最常见的疑问是“为什么不用 VueElementUI”。我做过几个成绩管理方向的项目负责任地说这个场景页面复杂度不高核心是几张表格加几个表单Layui 的 table、form、layer 组件足够用。强行拆前后端反而要处理跨域、拦截器放行、静态资源打包一堆破事。Spring Boot 提供接口与事务能力Thymeleaf 在服务端把数据填进 HTMLLayui 在浏览器端补齐交互这层分工才是正确理解这套技术栈的前提。2.1 layui 和 thymeleaf 同时出现会不会打架分工边界两个东西名字都带“模板/组件”属性容易让人误以为功能重叠。实际区别非常清楚Thymeleaf 运行在 Java 服务端它的作用是把后端模型里的数据渲染成 HTML 输出给浏览器Layui 运行在浏览器端提供表格、弹层、日期选择、表单美化这些组件它不关心数据从哪来你只要给它一个 JSON 数组它就能把表格画出来。真正容易打架的点是“列表渲染方式”。用 th:each 在服务端循环输出表格行还是把查询结果交给 Layui table 从接口加载我的建议是混合使用页面骨架、菜单、搜索表单回显、编辑页数据回填这些场景用 Thymeleaf理由是不会出现异步加载的白屏闪动而列表数据、分页、排序这些交给 Layui table因为它的分页栏和排序功能是现成的你不用手写一整套前端分页逻辑。答辩的时候你还能同时讲清楚“服务端渲染”和“前端组件渲染”两个概念比单一写法更站得住。需要注意的边界是一旦页面里既有 th:each 循环又有 Layui table 的分页很容易出现“数据在页面里重复”的错觉。比如 Thymeleaf 渲染了 10 行Layui 又异步插入了 10 行。遇到这种场景就坚持一个表只有一个数据来源。2.2 springboot 项目结构从 controller 到 templates 的目录设计Spring Boot 的约定优于配置在这里体现得很直接。我一般按模块分包controller 放接口入口service 放业务处理mapper 放数据库操作entity 放实体映射。前端文件按类型分开放Thymeleaf 模板统一进 templatesLayui 等静态资源统一进 static。目录结构如下src/main/java/com/example/score/ controller/ # ScoreController、StudentController、CourseController service/ # ScoreService 接口 impl/ScoreServiceImpl mapper/ # ScoreMapper、StudentMapper、CourseMapper entity/ # Score、Student、Course 实体类 config/ # MybatisPlusInterceptor 等配置 src/main/resources/ mapper/ # 自定义 SQL 映射文件按需放 templates/ # score/list.html、score/form.html、score/stat.html static/ # layui/ 目录存放 layui.css、layui.js 等 pom.xml application.yml对应关系如下表目录/文件职责controller接收请求参数、调用 service、返回视图名或 JSONservice业务规则事务边界统计逻辑mapper数据库访问MyBatis-Plus 可直接继承 BaseMappertemplatesThymeleaf 模板页面负责最终 HTML 输出static静态资源CSS/JS/图片Layui 相关文件放这里这个结构的好处是新人也能一眼定位改页面去 templates改接口逻辑去 service改 SQL 去 mapper。不要为了省事把业务写在 controller 里成绩统计和排名的逻辑将来会被多个页面复用写在 controller 里就废了。2.3 依赖与配置pom.xml 和 application.yml 怎么落pom.xml 里关键依赖并不复杂核心是 web、thymeleaf、mybatis-plus、mysql、lombok、validation 这几样。我一般基于 Spring Boot 2.7.x配 JDK 8 最稳如果本机是 JDK 17再考虑 3.x但要注意 3.x 里 javax 包名换成了 jakarta网上老教程直接复制容易报编译错这也是热词里“springboot版本太高”最常见的翻车场景。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesSpring Boot 的 starter 依赖会把 Thymeleaf、MyBatis-Plus 的自动配置都拉起来你只需要写配置不用自己 new 数据源。application.yml 里的关键参数如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/student_score?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 thymeleaf: cache: false encoding: UTF-8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl解释一下几个容易错的地方。连接串里 useUnicodetruecharacterEncodingutf8 少了任何一个中文乱码就可能出现serverTimezone 不写MySQL 8.x 驱动会在启动时报时区错误。thymeleaf.cachefalse是开发阶段必须开的否则改一个页面要重启服务才能看到效果。map-underscore-to-camel-case会自动把数据库的 student_no 映射成实体的 studentNo省去大量手写 ResultMap。StdOutImpl会在控制台打印每条 SQL排查成绩查询结果不对时非常有用。3. 数据库设计与项目搭建先让成绩管理的骨架跑起来这一章的目标是用最小代价把系统跑起来。数据库设计是“学生成绩管理系统用例图”里所有功能的基础学生管理、课程管理、成绩录入、成绩查询、统计报表最后都会落在几张表上。表设计错了后面每个功能都在填坑。3.1 成绩管理系统的核心表设计学生、课程、成绩三张表怎么建模最少需要三张表student、course、score。为什么不把成绩直接塞进学生表因为“一门课一个成绩”是典型的多值属性一个学生会有多门课程成绩必须拆表。成绩表是连接学生和课程的关联表同时携带分数、考试时间等业务字段。CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, gender VARCHAR(10) DEFAULT NULL COMMENT 性别, class_name VARCHAR(50) DEFAULT NULL COMMENT 班级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生表; CREATE TABLE course ( id BIGINT AUTO_INCREMENT PRIMARY KEY, course_name VARCHAR(50) NOT NULL COMMENT 课程名, credit DECIMAL(3,1) DEFAULT 0 COMMENT 学分, teacher VARCHAR(50) DEFAULT NULL COMMENT 任课教师, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_course_name (course_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; CREATE TABLE score ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL COMMENT 学生ID, course_id BIGINT NOT NULL COMMENT 课程ID, score DECIMAL(5,2) DEFAULT NULL COMMENT 成绩, exam_time DATETIME DEFAULT NULL COMMENT 考试时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT成绩表;成绩表设计上有两个关键决策。第一student_id 和 course_id 我没有建物理外键只建了普通索引。原因是毕设和实训阶段经常要批量造数据、清数据物理外键在删除和插入时约束太多容易卡住调试流程。关联完整性由 service 层保证删除学生时同时删除其成绩记录。第二UNIQUE KEY uk_student_course是防重复录入的命门没有它同一学生同一课程能录进两条分数统计平均分和及格率时数据直接失真。成绩字段用 DECIMAL(5,2) 而不是 INT因为很多学校有补考、重修成绩可能带小数0 到 100 之间五位精度足够。3.2 启动一个 springboot 成绩模块的最小代码实体、Mapper、Service、Controller建好表之后先跑通一条最小链路打开页面看到成绩列表。用 MyBatis-Plus 可以省掉几乎所有单表 CRUD 的 SQL。实体类用 Lombok 简化 getter/setterMapper 接口直接继承 BaseMapperData TableName(score) public class Score { TableId(type IdType.AUTO) private Long id; private Long studentId; private Long courseId; private BigDecimal score; private LocalDateTime examTime; private LocalDateTime createTime; }Mapper public interface ScoreMapper extends BaseMapperScore { // 单表 CRUD 由 MyBatis-Plus 提供这里只写自定义统计 SQL }Service 里注入 MapperController 里调用 Service这是标准的 springboot 项目结构。列表接口要返回 Layui table 能直接识别的 JSON 格式code0、msg、count、data四要素。RestController RequestMapping(/score) public class ScoreController { Autowired private ScoreService scoreService; GetMapping(/list) public MapString, Object list(RequestParam(defaultValue 1) long page, RequestParam(defaultValue 10) long limit) { PageScore result scoreService.pageList(page, limit, null); MapString, Object map new HashMap(); map.put(code, 0); map.put(msg, ); map.put(count, result.getTotal()); map.put(data, result.getRecords()); return map; } }这里要配分页插件才能让 Page 生效。MyBatis-Plus 的分页插件是一个拦截器在 config 包里注册Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }参数说明page 默认从 1 开始limit 是每页条数这两个参数刚好和 Layui table 页面的 page 和 limit 对齐可以直接对接。PaginationInnerInterceptor会自动拼接 limit 语句并执行 count 查询不用手写分页 SQL。分页插件要注册成 Bean 才生效漏了这一步page()方法会返回全部数据这是新手最容易误判的问题。3.3 页面怎么接到数据thymeleaf 渲染学生列表的第一版页面列表页我用 Thymeleaf 搭页面骨架用 Layui table 渲染数据表格。页面头部和菜单用 th:replace 抽取公共模板主题区域放一个表格容器然后在前端初始化 Layui tabletable idscoreTable lay-filterscoreTable/table script layui.use([table, form], function () { var table layui.table; table.render({ elem: #scoreTable, url: /score/list, page: true, cols: [[ {field: studentId, title: 学生ID, width: 90}, {field: courseId, title: 课程ID, width: 90}, {field: score, title: 成绩, width: 100}, {field: examTime, title: 考试时间, width: 160} ]] }); }); /script这里需要注意Controller 返回的一定要是 JSON绝不能返回视图名否则 Layui 拿到的是 HTML 字符串表格会渲染失败。路径写{}表达式是 Thymeleaf 的 URL 写法它可以自动处理项目部署在根路径还是子路径的情况。如果你把这个接口路径硬写成/score/list将来项目部署到http://ip:8080/school/这种带前缀的地址时请求会全部 404。4. 核心功能实现成绩录入、查询、统计排名这一套怎么闭环列表调通后系统真正有价值的功能是录入、查询、统计这一套闭环。学生成绩管理系统的用例图里这几个功能都是核心用例。下面按实际开发顺序逐一实现。4.1 成绩录入的页面表单与后端校验layui 表单 spring validation成绩录入页面需要选择学生、选择课程、填入分数。学生和课程用下拉框数据来自服务端渲染的 option分数用数字输入框限定范围 0 到 100。Layui 表单的作用是统一美化样式和收集提交数据。form classlayui-form idscoreForm div classlayui-form-item label classlayui-form-label学生/label div classlayui-input-block select namestudentId lay-search option value请选择学生/option option th:eachs : ${students} th:value${s.id} th:text${s.name}/option /select /div /div div classlayui-form-item label classlayui-form-label课程/label div classlayui-input-block select namecourseId lay-search option value请选择课程/option option th:eachc : ${courses} th:value${c.id} th:text${c.courseName}/option /select /div /div div classlayui-form-item label classlayui-form-label分数/label div classlayui-input-block input typenumber namescore lay-verifyrequired placeholder0-100 min0 max100 classlayui-input/ /div /div button classlayui-btn lay-submit lay-filtersubmitScore保存/button /form后端不能只靠前端限制。Layui 的 lay-verify 只是浏览器端校验接口直接被人用 Postman 调的情况下分数范围、空值必须由 Spring Validation 兜底。我习惯给接口单独建一个 DTO不直接暴露实体类这样前端的字段边界是明确的Data public class ScoreDTO { NotNull(message 学生不能为空) private Long studentId; NotNull(message 课程不能为空) private Long courseId; NotNull(message 成绩不能为空) DecimalMin(value 0, message 成绩不能小于0) DecimalMax(value 100, message 成绩不能大于100) private BigDecimal score; }Controller 里接收 DTO 并加 Valid 触发校验校验失败会抛出 MethodArgumentNotValidException由全局异常处理器转成统一 JSON 返回给前端。Layui 提交时有两个易错点一是表单里必须写 lay-submit否则 Layui 不会拦截提交二是提交回调里必须 return false 阻止表单默认跳转否则页面会刷新数据虽然入库了但体验像失败。4.2 多条件成绩查询MyBatis 动态 SQL 与分页参数成绩查询最常见的场景是按学生姓名模糊查、按课程精确查、按分数区间段查。MyBatis-Plus 的 LambdaQueryWrapper 可以组合条件不需要手写动态 XMLpublic PageScoreVO queryScores(long page, long limit, String studentName, Long courseId, BigDecimal minScore, BigDecimal maxScore) { LambdaQueryWrapperScore wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(studentName), Score::getStudentId, studentName) .eq(courseId ! null, Score::getCourseId, courseId) .ge(minScore ! null, Score::getScore, minScore) .le(maxScore ! null, Score::getScore, maxScore) .orderByDesc(Score::getScore); PageScore pageResult scoreMapper.selectPage(new Page(page, limit), wrapper); // 这里还需要把 studentId 翻译成学生姓名常见做法是批量查出学生表做映射 return convert(pageResult); }条件构造器里每个条件前面的布尔值很关键StringUtils.hasText(studentName)为 false 时like 条件自动跳过courseId 为 null 时 eq 自动跳过。这比在 XML 里写 标签要简洁也避免了空字符串造成的意外过滤。分页参数的意义要明确page 从 1 开始limit 是每页条数Layui table 默认发送的参数名就是这两个所以接口参数名最好直接对齐省去前端二次映射。这里还有一个隐藏坑如果一张成绩表里只有 studentId 和 courseId页面上显示出来就是两个数字 ID演示效果非常差。常见做法是查询完成后收集所有 studentId 去 student 表批量查出姓名再拼成 VO。不要在循环里单条查学生表成绩记录一多会变成 N1 查询答辩时评委大概率会追问性能问题。4.3 统计与排名平均分、及格率、排名怎么用 SQL 和 Java 配合做统计页是这套系统答辩时的高频提问点。课程平均分、最高分、及格率直接用 SQL 聚合即可。这里采用 LEFT JOIN保证没人选课的课程也能出现在统计表里SELECT c.id, c.course_name, COUNT(s.id) AS exam_count, COALESCE(AVG(sc.score), 0) AS avg_score, COALESCE(MAX(sc.score), 0) AS max_score, COALESCE(SUM(sc.score 60) / COUNT(s.id) * 100, 0) AS pass_rate FROM course c LEFT JOIN score sc ON sc.course_id c.id LEFT JOIN student s ON s.id sc.student_id GROUP BY c.id, c.course_name;说明几个细节。SUM(sc.score 60) 在 MySQL 中会把布尔表达式转成 0 或 1用这个方式统计及格人数比 CASE WHEN 更简洁。COALESCE 用来处理没有成绩数据时 AVG 返回 NULL 的情况前端展示 0 比展示 null 好看。考试评分标准不一定是 60 分及格这里可以做成参数查询时由前端传入 passScore 字段但 SQL 里写死 60 是最常见的实现方式。排名逻辑要分数据库版本处理。MySQL 8.0 可以直接用窗口函数SELECT s.name, c.course_name, sc.score, DENSE_RANK() OVER (ORDER BY sc.score DESC) AS rank_no FROM score sc JOIN student s ON s.id sc.student_id JOIN course c ON c.id sc.course_id WHERE sc.course_id 1;如果 MySQL 是 5.7没有窗口函数常见做法是把数据查出来后用 Java 按分数排序相同分给相同名次。几千条成绩在内存里排一遍性能完全不是瓶颈没必要在 5.7 上硬写用户变量模拟排名。这里我建议优先用 Java 排序因为逻辑更好解释、也更方便加“只看本班排名”这类筛选条件。5. 避坑与排查这 5 个坑我把毕设演示现场都翻过车标题里的“设计与实现”写进论文很容易真正跑演示时翻车的往往是几个不起眼的细节。以下每一条都是现象、原因、解决三段式照排查能省一晚上。5.1 后台首页样式全乱layui 的 css/js 加载 404现象页面能打开但是光秃秃的纯 HTMLLayui 的按钮、表格样式全没加载浏览器控制台一堆 404。原因模板页面里引用静态资源的路径写成了相对路径。比如页面在 templates/score/list.html 下用layui/css/layui.css这种相对路径浏览器会把它解析成/score/layui/css/layui.css自然找不到。解决统一用 Thymeleaf 的 URL 表达式th:href{/layui/css/layui.css}它会把项目上下文前缀自动拼上。不要把路径写死成/static/layui/...Spring Boot 默认把 static 映射到根路径写 /static 反而多一层。5.2 模板里内联 JavaScript 的“”触发模板解析异常现象页面打不开控制台报 org.thymeleaf.exceptions.TemplateProcessingException定位到某一行 JS 代码。原因Thymeleaf 解析模板时JS 代码里的比较运算符会被当作标签开始。例如if (score 60)里的模板引擎可能误判为标签。解决三个方案任选。最省事是把这种转义成lt;更优雅的是用 Thymeleaf 的内联 JavaScript 语法th:inlinejavascript包裹代码块如果这段 JS 完全不需要服务端数据直接放到单独的 .js 文件里引用从根上避开模板解析。5.3 Layui select 下拉框数据加载了但默认值选不中现象编辑成绩时打开页面数据库里的分数正常回显但学生、课程两个下拉框没有选中任何选项默认空着。原因Thymeleaf 渲染 option 时确实生成了 selected 属性但 Layui 的 form 组件在渲染完成后并没有收到这个状态。Layui 对 select 做了自定义渲染必须显式调用form.render(select)刷新组件树。解决把表单渲染代码放在模板底部等 HTML 生成后执行layui.form.render(select)同时确保 Thymeleaf 的 selected 逻辑写在 option 上用th:selected${score.studentId s.id}而不是靠 CSS 选择器去猜。5.4 service 内部方法自调用导致事务失效成绩删除只删了一半现象删除某个学生时先删成绩再删学生但删除后刷新发现成绩表少了几条学生却还在或者反过来。整个操作没有回滚。原因Spring Boot 默认使用 cglib 代理实现 Transactional。代理只对从外部注入进来的对象生效同一个类里方法直接调用this.deleteScoreByStudentId()是绕过代理的内部调用事务注解形同虚设。解决不强依赖事务自调用。把“删学生并清理成绩”拆到两个 service 类里或者把需要事务的方法独立成一个方法由 Controller 调用 service 的公开方法。新版 Spring Boot 3.x 默认仍然走 cglib 代理这个坑不分版本照踩所以约定就是事务方法永远从外部调用。5.5 分页查询翻页时数据重复或总数对不上现象第一页和第二页出现同一条数据或者 layui table 显示的 count 总数和实际记录数不一致。原因分页 SQL 没有稳定的排序。MySQL InnoDB 的默认返回顺序在无 ORDER BY 时是不确定的特别是数据量小、走全表扫描时两次查询返回顺序可能不同。PageHelper 或 MyBatis-Plus 的分页只是拼接 limit不会自动帮你补排序。解决列表查询强制加orderByDesc(Score::getCreateTime)或按 id 排序。如果还要按分数排名就按 score 降序再按 id 升序保证同分数据也有稳定顺序。6. 进阶批量导入导出与一轮简单压测让答辩演示更站得住成绩录入逐个手工填在演示时很浪费时间。给系统加一个 Excel 导出接口用 EasyExcel 几行代码就能生成可下载的成绩单。导出时把学生的姓名、学号和课程名拼进 VO而不是导出两个数字 ID效果完全不同GetMapping(/export) public void export(HttpServletResponse response) throws IOException { ListScoreExcelVO list scoreService.listScoresForExport(); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); response.setHeader(Content-Disposition, attachment;filenamescores.xlsx); EasyExcel.write(response.getOutputStream(), ScoreExcelVO.class) .sheet(成绩单) .doWrite(list); }批量导入也是答辩高频演示点。常见做法是前端用 Layui upload 组件选 Excel后端在监听器里逐行解析并校验格式收集全部合法记录后一次性批量插入。导入前务必确认数据库里那张成绩表的唯一索引建了否则同一学生同一课程的记录可能被插入两次统计结果直接出错。最后在演示前给接口做一轮最基础的压测。用 IDEA 自带的 HTTP Client 或 Postman 对 /score/list 循环请求 200 次看平均耗时和有没有 500再模拟 100 条成绩导入观察事务提交时间。学生管理系统对性能的要求不高但这一步能提前暴露连接池耗尽、死锁之类的问题。我带过的项目里最狼狈的一次是现场演示批量导入30 行真实成绩因为去重索引缺失插出重复记录被评委当场翻出来。从那以后我养成了习惯凡是和成绩写入沾边的接口先确认唯一索引和事务边界再谈功能。希望帮到你。本文还有配套的精品资源点击获取