简介:这是一套基于Servlet+JSP+MySQL实现的JavaWeb学生成绩管理系统升级版,面向Java初学者与Web开发入门学习者,聚焦教学管理场景下的成绩录入、查询、统计与可视化需求。资源包共344个文件,包含86个Java源码(如ScoreDaoImpl、StudentDaoImpl等核心DAO与Service类)、86个编译后class文件、62个JavaScript脚本(含ECharts图表初始化与数据渲染逻辑)、37个JSP页面(涵盖登录、课程管理、成绩录入等完整功能视图)以及28个CSS样式文件,整体压缩包仅3.59MB,轻量易部署。已有6724人学习下载,体现了其在实践教学中的广泛认可。读者可直接运行项目,获得含前后端交互、数据库操作、权限控制及ECharts动态统计图(如各科成绩分布、班级平均分趋势)在内的完整可运行系统,同时通过清晰的分层代码结构(DAO/Service/Servlet/JSP)深入理解JavaWeb经典MVC开发模式。
1. 这不是又一个“学生信息管理系统”:Servlet+JSP+MySQL+echarts 四件套落地,真能跑通成绩分析闭环?
你肯定见过太多标着“JavaWeb学生管理系统”的课程设计压缩包——点开一看,登录页能进,添加学生能存,但一到“按班级查平均分”就报空指针,echarts 图表永远显示“Loading…”;数据库建了五张表却只用了一张,JSP 里混着 Java 脚本和 HTML,连个基本的 SQL 注入防护都没有。这不是教学 Demo 的问题,是资源没把「真实业务流」跑通:从成绩录入 → 数据持久化 → 多维查询 → 可视化呈现,四个环节必须环环相扣、参数对得上、路径不越界、前后端数据类型不打架。这个资源不一样:它用标准 Servlet 生命周期管理请求,JSP 仅作视图渲染(无 scriptlet),MySQL 表结构含 student、course、score、teacher 四张主表并设外键约束,echarts 图表全部通过 Ajax 异步加载 JSON 数据,且所有图表都带「按学期筛选」「按课程筛选」下拉联动逻辑。适合正在做课程设计、需要交完整可运行源码的 Java 初学者,也适合想快速复现一个轻量级教务后台的实训导师——它不炫技,但每一步都能在 Tomcat 9 + JDK 8 环境下实测启动、增删改查、图表刷新。
2. 项目结构与技术栈选型:为什么是 Servlet 而不是 Spring Boot?为什么 JSP 没被抛弃?
2.1 目录结构即开发逻辑:src 下三层,WebContent 下三类
这个项目的目录组织不是随意堆砌,而是严格对应 JavaWeb 标准部署规范。src/下只有三个包:
com.example.servlet:所有控制器类,如ScoreAddServlet.java、ChartQueryServlet.java,每个类只处理单一 HTTP 请求,不掺杂业务逻辑;com.example.dao:数据访问层,含ScoreDAO.java、CourseDAO.java,封装 JDBC 操作,使用PreparedStatement防注入,所有 SQL 语句外部化为常量字符串;com.example.entity:纯 POJO,字段名与 MySQL 表列名完全一致(如student_id对应Student类中private int studentId),避免 MyBatis 映射歧义。
WebContent/下则划清职责边界:
/jsp/:所有.jsp文件,仅含<%= %>输出表达式和<c:if>等 JSTL 标签,零行 Java 代码(<% %>全禁);/js/:chart.js封装 echarts 初始化逻辑,含initBarChart()、initPieChart()两个函数,接收后端返回的 JSON 并自动渲染;/css/:style.css仅控制布局与响应式断点,不干预图表容器尺寸——echarts 容器宽高由div#chart-container的style="width:100%;height:400px;"硬编码,这是避坑关键。
提示:不要试图把
web.xml删除改用注解配置。本项目依赖web.xml中的<servlet-mapping>显式绑定 URL 路径(如/score/add→ScoreAddServlet),Tomcat 7+ 默认不扫描@WebServlet注解,强行改会直接 404。
2.2 Servlet 是骨架,JSP 是皮肤,MySQL 是血肉,echarts 是神经末梢
很多人问:“都 2024 年了,还学 Servlet+JSP?”——答案是:当你要讲清楚 HTTP 请求如何从浏览器抵达数据库再返回图表,就必须拆开看。Spring Boot 自动装配掩盖了 request.getParameter() 如何取值、response.getWriter() 如何写 JSON、session 如何跨跳转保持、filter 怎么拦截未登录请求这些底层事实。而本项目每一处都暴露给你:
- 登录校验走
LoginFilter.java,检查session.getAttribute("user") != null,否则重定向到/login.jsp; - 成绩录入用
ScoreAddServlet,request.getParameter("studentId")取值后强转Integer.parseInt(),失败则跳回表单页并带错误提示; - echarts 数据来源是
ChartQueryServlet,它不返回 HTML,而是response.setContentType("application/json"); response.getWriter().write(jsonString),前端$.ajax()拿到后直接喂给echarts.init(dom).setOption(option)。
MySQL 方面,建表脚本db_init.sql包含明确约束:
CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2) CHECK(score BETWEEN 0 AND 100), semester VARCHAR(20) NOT NULL, FOREIGN KEY (student_id) REFERENCES student(id), FOREIGN KEY (course_id) REFERENCES course(id) );注意CHECK(score BETWEEN 0 AND 100)和双外键,这比“随便建个 score 表插数据”更能训练数据完整性意识。
2.3 echarts 不是贴图,是动态数据管道:JSON 结构决定图表形态
本项目 echarts 图表不写死数据,所有 option 配置项中的series.data均为空数组[],靠 Ajax 动态填充。关键在于后端返回的 JSON 结构必须与前端 JS 解析逻辑严格匹配。例如柱状图要求:
{ "categories": ["数学", "英语", "物理"], "values": [85.5, 78.0, 92.3] }而饼图要求:
{ "data": [ {"name": "优秀", "value": 12}, {"name": "良好", "value": 25}, {"name": "及格", "value": 8} ] }ChartQueryServlet根据请求参数type=bar或type=pie分支处理,调用不同 DAO 方法组装对应 JSON。这种“请求驱动数据结构”的设计,让初学者一眼看懂:不是 echarts 决定数据,是业务需求决定 JSON 格式,JSON 格式决定图表类型。
3. 核心功能实现:从成绩录入到多维统计,四步闭环全代码实录
3.1 成绩录入:Servlet 接参 → DAO 插库 → 页面跳转带提示
成绩录入页面add_score.jsp是一个标准 HTML 表单,method="post",action="/score/add":
<form action="/score/add" method="post"> <select name="studentId"> <c:forEach items="${studentList}" var="s"> <option value="${s.id}">${s.name} (${s.studentNo})</option> </c:forEach> </select> <select name="courseId"> <c:forEach items="${courseList}" var="c"> <option value="${c.id}">${c.courseName}</option> </c:forEach> </select> <input type="number" name="score" step="0.1" min="0" max="100" required> <input type="text" name="semester" placeholder="例如:2023-2024-1" required> <button type="submit">提交成绩</button> </form>对应ScoreAddServlet.java的核心逻辑:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { try { // 1. 严格取参并校验非空 String studentIdStr = request.getParameter("studentId"); String courseIdStr = request.getParameter("courseId"); String scoreStr = request.getParameter("score"); String semester = request.getParameter("semester"); if (studentIdStr == null || courseIdStr == null || scoreStr == null || semester == null) { request.setAttribute("msg", "请填写完整信息"); request.getRequestDispatcher("/jsp/add_score.jsp").forward(request, response); return; } // 2. 类型转换,捕获 NumberFormatException int studentId = Integer.parseInt(studentIdStr); int courseId = Integer.parseInt(courseIdStr); double score = Double.parseDouble(scoreStr); // 3. 调用 DAO 插入 Score scoreObj = new Score(); scoreObj.setStudentId(studentId); scoreObj.setCourseId(courseId); scoreObj.setScore(score); scoreObj.setSemester(semester); ScoreDAO dao = new ScoreDAO(); boolean success = dao.insert(scoreObj); // 4. 根据结果跳转,带成功/失败提示 if (success) { request.setAttribute("msg", "成绩录入成功!"); request.getRequestDispatcher("/jsp/score_list.jsp").forward(request, response); } else { request.setAttribute("msg", "录入失败,请重试"); request.getRequestDispatcher("/jsp/add_score.jsp").forward(request, response); } } catch (NumberFormatException e) { request.setAttribute("msg", "分数或ID格式错误,请输入数字"); request.getRequestDispatcher("/jsp/add_score.jsp").forward(request, response); } }参数说明:
request.getParameter()是 Servlet 获取表单数据的唯一正道,不能用request.getQueryString()解析 POST;Integer.parseInt()和Double.parseDouble()必须包裹在 try-catch 中,否则 400 错误会直接暴露给用户;request.getRequestDispatcher().forward()是服务端跳转,URL 不变,可携带request.setAttribute()数据;response.sendRedirect()是客户端重定向,URL 改变,无法传 request 属性。
3.2 成绩列表:JSP 使用 JSTL 循环展示,DAO 分页查询防 OOM
score_list.jsp不再用<% while(rs.next()) { %>脚本,而是标准 JSTL:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <c:if test="${empty scoreList}"> <p>暂无成绩记录</p> </c:if> <c:forEach items="${scoreList}" var="s"> <tr> <td>${s.studentName}</td> <td>${s.courseName}</td> <td>${s.score}</td> <td>${s.semester}</td> <td> <a href="/score/delete?id=${s.id}">删除</a> | <a href="/score/edit?id=${s.id}">编辑</a> </td> </tr> </c:forEach>对应ScoreDAO.java的分页查询方法:
public List<Score> listByPage(int pageNum, int pageSize) { List<Score> list = new ArrayList<>(); String sql = "SELECT s.id, s.score, s.semester, " + "st.name AS studentName, st.studentNo, " + "c.courseName " + "FROM score s " + "JOIN student st ON s.student_id = st.id " + "JOIN course c ON s.course_id = c.id " + "ORDER BY s.id DESC " + "LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, (pageNum - 1) * pageSize); // 起始位置 ps.setInt(2, pageSize); // 每页条数 ResultSet rs = ps.executeQuery(); while (rs.next()) { Score score = new Score(); score.setId(rs.getInt("id")); score.setScore(rs.getDouble("score")); score.setSemester(rs.getString("semester")); score.setStudentName(rs.getString("studentName")); score.setStudentNo(rs.getString("studentNo")); score.setCourseName(rs.getString("courseName")); list.add(score); } } catch (SQLException e) { e.printStackTrace(); } return list; }关键点:
- JOIN 查询一次性获取学生姓名、课程名,避免 N+1 查询(即查出 score 后再循环查 student 表);
LIMIT ?, ?参数化分页,防止 SQL 注入,(pageNum-1)*pageSize是 MySQL 偏移量计算公式;DBUtil.getConnection()是封装好的工具类,内部使用DriverManager.getConnection(),连接字符串含useSSL=false&serverTimezone=GMT%2B8,适配 MySQL 8+ 时区问题。
3.3 统计图表:Servlet 返回 JSON,JS 初始化 echarts,联动筛选实时刷新
chart.jsp页面结构极简:
<div class="filter-bar"> <select id="semesterSelect"> <option value="">全部学期</option> <c:forEach items="${semesterList}" var="sem"> <option value="${sem}">${sem}</option> </c:forEach> </select> <select id="courseSelect"> <option value="">全部课程</option> <c:forEach items="${courseList}" var="c"> <option value="${c.id}">${c.courseName}</option> </c:forEach> </select> <button onclick="loadCharts()">刷新图表</button> </div> <div id="barChart" style="width:100%;height:400px;"></div> <div id="pieChart" style="width:100%;height:400px;"></div> <script src="/js/echarts.min.js"></script> <script src="/js/chart.js"></script> <script> // 页面加载时初始化 window.onload = function() { loadCharts(); }; </script>chart.js的核心是loadCharts()函数:
function loadCharts() { const semester = document.getElementById('semesterSelect').value; const courseId = document.getElementById('courseSelect').value; // 1. 加载柱状图(各班平均分) $.ajax({ url: '/chart/query?type=bar', data: { semester: semester, courseId: courseId }, dataType: 'json', success: function(data) { const barDom = document.getElementById('barChart'); const barChart = echarts.init(barDom); const option = { title: { text: '班级平均分统计' }, tooltip: {}, xAxis: { data: data.categories }, yAxis: {}, series: [{ name: '平均分', type: 'bar', data: data.values }] }; barChart.setOption(option); } }); // 2. 加载饼图(成绩等级分布) $.ajax({ url: '/chart/query?type=pie', data: { semester: semester, courseId: courseId }, dataType: 'json', success: function(data) { const pieDom = document.getElementById('pieChart'); const pieChart = echarts.init(pieDom); const option = { title: { text: '成绩等级分布' }, tooltip: { trigger: 'item' }, series: [{ name: '等级', type: 'pie', radius: '50%', data: data.data }] }; pieChart.setOption(option); } }); }后端ChartQueryServlet.java的doGet方法:
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String type = request.getParameter("type"); String semester = request.getParameter("semester"); String courseIdStr = request.getParameter("courseId"); JSONObject result = new JSONObject(); if ("bar".equals(type)) { // 柱状图:按班级统计平均分 List<Object[]> dataList = chartDAO.getAvgScoreByClass(semester, courseIdStr); JSONArray categories = new JSONArray(); JSONArray values = new JSONArray(); for (Object[] row : dataList) { categories.add(row[0].toString()); // class_name values.add(Double.parseDouble(row[1].toString())); // avg_score } result.put("categories", categories); result.put("values", values); } else if ("pie".equals(type)) { // 饼图:按等级统计人数 List<Object[]> dataList = chartDAO.getCountByGrade(semester, courseIdStr); JSONArray dataArr = new JSONArray(); for (Object[] row : dataList) { JSONObject item = new JSONObject(); item.put("name", row[0].toString()); // grade_level item.put("value", Integer.parseInt(row[1].toString())); // count dataArr.add(item); } result.put("data", dataArr); } response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(result.toJSONString()); }参数说明:
dataType: 'json'告诉 jQuery 自动解析 JSON,不用JSON.parse();response.setContentType("application/json;charset=UTF-8")必须设置,否则中文乱码;result.toJSONString()使用 alibaba fastjson,比Gson.toJson()更轻量,项目 lib 下已包含fastjson-1.2.83.jar。
4. 避坑指南:五个真实翻车现场与血泪修复方案
4.1 现象:echarts 图表区域一片空白,控制台无报错,Network 查看返回 JSON 正常
原因:div#barChart容器没有设置宽高,echarts 初始化时getBoundingClientRect()返回{width:0,height:0},导致渲染失败。常见于开发者删掉了style="width:100%;height:400px;",试图用 CSS 类控制,但类中height:400px被父容器overflow:hidden截断。
解决:强制在 HTML 中写内联样式,<div id="barChart" style="width:100%;height:400px;"></div>,并在chart.js初始化前加检测:
const barDom = document.getElementById('barChart'); if (barDom.offsetWidth === 0 || barDom.offsetHeight === 0) { console.error('Chart container has zero size!'); barDom.style.height = '400px'; }4.2 现象:添加成绩时提示“java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver”
原因:MySQL 8+ 驱动类名从com.mysql.jdbc.Driver变更为com.mysql.cj.jdbc.Driver,而DBUtil.java中仍写旧类名;或mysql-connector-java-8.0.33.jar未放入WebContent/WEB-INF/lib/目录(Tomcat 运行时只加载此目录下的 jar)。
解决:打开DBUtil.java,将Class.forName("com.mysql.jdbc.Driver")改为Class.forName("com.mysql.cj.jdbc.Driver");确认 jar 包在WEB-INF/lib/下,且文件名不含-sources或-javadoc后缀。
4.3 现象:JSP 页面中文显示为???,数据库存入也是乱码,但 Navicat 查看表结构显示utf8mb4
原因:MySQL 连接字符串缺少characterEncoding=utf8参数,或 Tomcatserver.xml中Connector未设URIEncoding="UTF-8"。
解决:修改DBUtil.getConnection()中的 URL:
String url = "jdbc:mysql://localhost:3306/school_db?useSSL=false&serverTimezone=GMT%2B8&characterEncoding=utf8";同时检查conf/server.xml,确保<Connector port="8080" ... URIEncoding="UTF-8" />存在。
4.4 现象:点击“删除”链接后,成绩确实没了,但页面跳转到http://localhost:8080/score/delete?id=123显示 404
原因:web.xml中<servlet-mapping>的url-pattern写成了/score/delete/*(带星号),而链接是/score/delete?id=123(查询参数),Servlet 无法匹配。正确应为/score/delete。
解决:检查web.xml:
<servlet-mapping> <servlet-name>DeleteScoreServlet</servlet-name> <url-pattern>/score/delete</url-pattern> <!-- 不能有 * --> </servlet-mapping>4.5 现象:echarts 饼图点击某扇形后,页面跳转到undefined
原因:chart.js中未给饼图 series 添加click事件监听,或监听函数中params.name取值后未拼接正确 URL。本项目默认不启用点击跳转,若自行添加,需确保:
pieChart.on('click', function(params) { const grade = params.name; // 如 "优秀" window.location.href = '/score/list?grade=' + encodeURIComponent(grade); });且ScoreListServlet中要能解析request.getParameter("grade")并查库。
5. 进阶技巧:让统计图表支持导出 PNG,且兼容 IE11 的降级方案
5.1 一键导出 PNG:echarts 内置方法 + 后端兜底
echarts 本身提供chart.getDataURL()方法生成 base64 图片,但直接window.open(dataUrl)在 Chrome 有效,在 IE11 会失败。更稳妥的做法是前端生成 base64 后 POST 给 Servlet,由后端转成 PNG 文件流返回:
chart.js中添加导出按钮事件:
function exportBarChart() { const barChart = echarts.getInstanceByDom(document.getElementById('barChart')); if (!barChart) return; const dataUrl = barChart.getDataURL({ type: 'png', pixelRatio: 2, // 高清 backgroundColor: '#fff' }); // 构造表单提交 base64 const form = document.createElement('form'); form.method = 'post'; form.action = '/chart/export'; form.style.display = 'none'; const input = document.createElement('input'); input.type = 'hidden'; input.name = 'imageData'; input.value = dataUrl; form.appendChild(input); document.body.appendChild(form); form.submit(); }后端ExportServlet.java接收并写文件:
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String imageData = request.getParameter("imageData"); if (imageData == null || !imageData.startsWith("data:image/png;base64,")) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid image data"); return; } // 解码 base64 String base64Image = imageData.substring("data:image/png;base64,".length()); byte[] imageBytes = Base64.getDecoder().decode(base64Image); // 设置响应头,触发下载 response.setContentType("image/png"); response.setHeader("Content-Disposition", "attachment; filename=score_chart.png"); response.getOutputStream().write(imageBytes); }注意:Base64.getDecoder()是 JDK 8+ 内置类,无需额外依赖;Content-Disposition中attachment强制下载,inline则在浏览器中打开。
5.2 IE11 兼容性补丁:polyfill + CSS 降级
IE11 不支持Promise、Array.from()、Object.assign(),而 echarts 4.0+ 依赖这些。必须引入 polyfill:
在chart.jsp<head>中加入:
<!--[if IE]> <script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/array-from-polyfill@1.0.0/index.min.js"></script> <![endif]-->同时,echarts 容器 CSS 需避免 Flex 布局(IE11 Flex bug 多),改用传统浮动:
.chart-container { zoom: 1; } .chart-container:before, .chart-container:after { content: ""; display: table; } .chart-container:after { clear: both; } #barChart, #pieChart { float: left; width: 100%; margin-bottom: 20px; }5.3 成绩预警功能:在图表中高亮低于 60 分的班级
业务常需“一眼看出问题班级”。可在柱状图中为低于 60 的柱子染红色:
修改ChartQueryServlet返回的 JSON,增加colors字段:
// 在返回柱状图数据时 JSONArray colors = new JSONArray(); for (Object[] row : dataList) { double avg = Double.parseDouble(row[1].toString()); colors.add(avg < 60 ? "#e74c3c" : "#3498db"); // 红色预警,蓝色正常 } result.put("colors", colors);前端chart.js中:
series: [{ name: '平均分', type: 'bar', data: data.values, itemStyle: { color: function(params) { return data.colors[params.dataIndex]; } } }]这样,当某班平均分跌破 60,柱子立刻变红,无需额外开发告警模块。
从那以后我每次交付 JavaWeb 项目,都会在web.xml里手敲一遍<servlet-mapping>,在DBUtil里核对三遍驱动类名,把echarts.init(dom)前的宽高检查写成函数模板。不是信不过 IDE,是信不过自己哪次手抖漏掉的分号。希望帮到你。
本文还有配套的精品资源,点击获取