简介:面向Java Web课程设计与期末作业的学生信息管理系统项目包,涵盖登录验证、学生信息增删改查、课程关联等典型功能,适合正在学习Servlet/JSP、MVC分层架构与数据库编程的初学者。压缩包共209个文件,大小仅4.57MB,包含11个java源码文件、4个sql建表脚本、37个class编译产物以及150张png界面操作截图,可据此快速还原系统运行效果,也便于对照源码理解前后端交互。已有414人学习下载。项目按MVC思路组织源码,涉及登录管理、数据库连接等核心模块,配合数据库表结构脚本与依赖jar包,能在Tomcat环境下直接部署实践。通过完整走查登录模块、学生信息管理面板及数据库连接实现,读者可掌握JDBC操作、页面请求响应流程、配置管理及项目部署等关键技能,是期末设计或Java Web入门练手的实用参考。
1. 学生信息管理系统:一份能直接交的 Java Web 期末作业
期末作业里出现频率最高的题目,学生信息管理系统绝对排前三。Java Web 方向更是重灾区——老师要求用 JSP + Servlet 做一套完整的 CRUD,还要带分页、模糊查询、登录拦截。听起来不难,但真动手写的时候,大部分人卡在环境配置、JDBC 连接、Tomcat 部署这些基础环节上,代码反而写得不多。这套资源就是把「从零到能答辩」的全套 Java Web 学生信息管理系统给你拆好:数据库脚本、Dao 层、Servlet 控制层、JSP 页面、配置文件全都在,你拿到之后改改数据库密码、换换学校名就能跑起来。适合三类人:赶期末作业的学生、刚学完 Java 基础想找个完整 web 项目练手的人、以及需要一套代码做课程设计底子的人。下面我从技术选型开始讲,把每一层代码怎么组织的逻辑说清楚,再给你踩过的坑。
2. 技术选型:为什么期末作业首选 JSP + Servlet + JDBC 而不是 SSM
2.1 Tomcat + MySQL 8 的环境版本匹配
这套资源的技术栈是最经典的 Java Web 三件套:JSP + Servlet + JDBC,服务器用 Tomcat 9.x,数据库用 MySQL 8.x,开发工具用 IDEA 2024 社区版或旗舰版都行。先解释为什么不用 Spring Boot —— 期末作业的本质是让老师看到你理解 HTTP 请求怎么走、Servlet 生命周期怎么回事、JSP 怎么渲染页面。Spring Boot 把这一切都封装了,你写完了老师一问三不知,反而扣分。
环境版本上有个很关键的匹配点:JDK 版本和 Tomcat 版本必须兼容。JDK 8 对应 Tomcat 9 完全没问题,但如果你电脑装的是 JDK 17,直接用 Tomcat 9 也能跑,只是要把 web.xml 的头部声明改成 Servlet 4.0 的版本。如果用的是 Tomcat 10,那问题就大了——Tomcat 10 把 javax.servlet 包名改成了 jakarta.servlet,网上找的很多旧代码直接编译报错。我建议你图省心就用 JDK 8 + Tomcat 9,这个是兼容性最稳的组合。
注意:确认 java -version 和 catalina.bat version 的输出结果,两个版本号差太大会出现 ClassNotFoundException 或 NoClassDefFoundError,这两个错在期末作业现场最容易让脑子一片空白。
2.2 JDBC 连接参数与 DBUtil 工具类封装
数据库连接这块,我见太多人把 DriverManager.getConnection 写在每个 Dao 方法里,一个项目里重复了二十几遍。这种写法能跑,但一旦数据库密码改了,你要全局替换。这套资源里我做了一层 DBUtil 封装,用静态代码块加载驱动,连接参数集中在常量里。
public class DBUtil { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/student_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps != null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }URL 里那串参数一个一个说:useUnicode=true 和 characterEncoding=utf8 是保证中文能正常读写,少了这两个参数,插入的中文到数据库里全变成问号;serverTimezone=Asia/Shanghai 是 MySQL 8 必加的,不加会报时区错误;useSSL=false 是为了省掉 SSL 握手的时间,本机调试没必要开。close 方法里我重载了三个参数版本,有的查询没有 ResultSet 就传 null,这样做的好处是调用方不用自己写 try-catch-finally 那一大堆。
2.3 三层架构在期末作业里的合理边界
这套代码用的是标准的 JSP + Servlet + Dao 三层:JSP 只负责展示,Servlet 只做请求接收和转发,Dao 层只写 SQL。很多同学会在 Servlet 里直接拼 SQL,虽然也能跑通,但答辩的时候老师一定会问「如果我要把 MySQL 换成 Oracle 怎么办」——你用三层架构,只需要改 Dao 层。这套资源里的分包结构是 com.example.entity(Student 实体类)、com.example.dao(StudentDao 接口 + 实现类)、com.example.servlet(StudentServlet)、com.example.util(DBUtil),webapp 下面放 JSP。这个分包不是随便分的,实体类对应数据库表的字段,Dao 层对应数据访问逻辑,Servlet 对应控制逻辑,老师看到这个结构就知道你理解了分层,答辩第一印象就稳了。
3. 把学生信息 CRUD 跑通:从建表到核心代码
3.1 数据库表结构设计与约束
数据库脚本是这套资源里最先要看的东西。学生表的设计要满足两个最基本的需求:一个是信息完整展示,一个是学号唯一不能重复。
CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARACTER SET utf8mb4; USE student_db; CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号,唯一约束', name VARCHAR(50) NOT NULL COMMENT '姓名', gender CHAR(1) DEFAULT '男' COMMENT '性别', age INT COMMENT '年龄', class_name VARCHAR(50) COMMENT '班级', phone VARCHAR(20) COMMENT '联系电话', email VARCHAR(50) COMMENT '邮箱', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4; INSERT INTO t_student (student_no, name, gender, age, class_name, phone, email) VALUES ('2024001', '张伟', '男', 20, '计科2401班', '13800001111', 'zhangwei@example.com'), ('2024002', '李娜', '女', 19, '计科2401班', '13800002222', 'lina@example.com'), ('2024003', '王强', '男', 21, '软工2402班', '13800003333', 'wangqiang@example.com');注意三个设计上的细节。第一,id 用自增主键,但业务上唯一标识是 student_no,所以给它加了 UNIQUE 约束——这样即使两个学生同名,学号也不会冲突。第二,gender 用 CHAR(1) 存「男」「女」,省空间,查询结果也直观,不用像存数字那样还要做映射。第三,create_time 设置了 DEFAULT CURRENT_TIMESTAMP,插入数据的时候不用手动传时间,数据库自动填,这个细节答辩时老师可能会问,你要答得上来。
3.2 StudentDao 实现类的 SQL 与参数绑定
Dao 层是整套代码里最容易出 bug 的地方。这套资源里的 StudentDao 实现类用了 PreparedStatement 做参数绑定,这个选择很重要。我见过有人图省事用 Statement 直接拼 SQL 字符串,结果输入的名字里带个单引号,SQL 直接报错。PreparedStatement 有两个核心优势:一是预编译,SQL 结构先发给数据库解析,执行的时候只传参数,性能好;二是参数用占位符 ? 绑定,从根上杜绝 SQL 注入。期末作业里写 PreparedStatement 也是加分项,老师看了会认为你有安全意识。
public class StudentDaoImpl implements StudentDao { @Override public List<Student> findByPage(int pageNo, int pageSize, String keyword) { List<Student> list = new ArrayList<>(); String sql = "SELECT id, student_no, name, gender, age, class_name, phone, email, create_time " + "FROM t_student WHERE 1=1"; List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sql += " AND (name LIKE ? OR student_no LIKE ? OR class_name LIKE ?)"; String likePattern = "%" + keyword.trim() + "%"; params.add(likePattern); params.add(likePattern); params.add(likePattern); } sql += " ORDER BY id DESC LIMIT ?, ?"; Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql); for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } ps.setInt(params.size() + 1, (pageNo - 1) * pageSize); ps.setInt(params.size() + 2, pageSize); rs = ps.executeQuery(); while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setStudentNo(rs.getString("student_no")); s.setName(rs.getString("name")); s.setGender(rs.getString("gender")); s.setAge(rs.getInt("age")); s.setClassName(rs.getString("class_name")); s.setPhone(rs.getString("phone")); s.setEmail(rs.getString("email")); s.setCreateTime(rs.getTimestamp("create_time")); list.add(s); } } catch (SQLException e) { e.printStackTrace(); } finally { DBUtil.close(conn, ps, rs); } return list; } }这段代码有个非常值得抄的写法:SQL 先拼固定部分,再用 StringBuilder 或 += 按条件追加 where 片段,参数统一放到 List 里,最后再按顺序绑定。这样做的核心原因是参数个数动态变化,先把所有 ? 对应的值收拢到一个容器里,最后统一 set 进去。LIMIT 的参数要注意,第一个参数是起始行序号,从 0 开始,所以 (pageNo - 1) * pageSize,比如当前第 2 页,pageSize 是 10,那起始行就是 10,正好是第 11 条记录——这里要是写成 pageNo * pageSize,第二页就把第一页的最后一条数据重复查出来了。
3.3 Servlet 路由设计:一个 Servlet 处理所有请求
Web 项目里 Servlet 的数量和路由设计直接影响代码可读性。这套资源里我只用一个 StudentServlet,通过 action 参数分发到不同的业务方法。这种设计在期末作业里比每个功能写一个 Servlet 更合适,理由是减少 web.xml 或注解的配置量,而且代码结构一眼能看懂。如果你的项目用了 Servlet 3.0+ 的注解方式,直接在类上写 @WebServlet("/student") 就行,不用动 web.xml。
@WebServlet("/student") public class StudentServlet extends HttpServlet { private StudentDao studentDao = new StudentDaoImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("list".equals(action)) { list(req, resp); } else if ("delete".equals(action)) { delete(req, resp); } else if ("toEdit".equals(action)) { toEdit(req, resp); } else if ("toAdd".equals(action)) { req.getRequestDispatcher("/add.jsp").forward(req, resp); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("add".equals(action)) { add(req, resp); } else if ("update".equals(action)) { update(req, resp); } else if ("search".equals(action)) { list(req, resp); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String keyword = req.getParameter("keyword"); int pageNo = 1; int pageSize = 10; String pageNoStr = req.getParameter("pageNo"); if (pageNoStr != null && !pageNoStr.isEmpty()) { pageNo = Integer.parseInt(pageNoStr); } List<Student> students = studentDao.findByPage(pageNo, pageSize, keyword); int totalCount = studentDao.count(keyword); int totalPages = (int) Math.ceil(totalCount * 1.0 / pageSize); req.setAttribute("students", students); req.setAttribute("currentPage", pageNo); req.setAttribute("totalPages", totalPages); req.setAttribute("totalCount", totalCount); req.setAttribute("keyword", keyword); req.getRequestDispatcher("/list.jsp").forward(req, resp); } }doGet 处理查询、删除、跳转编辑页这些幂等操作,doPost 处理新增和更新的写操作——这个分工理论上符合 HTTP 语义,答辩老师问「为什么不用 doGet 做新增」你能答上来。pageNo 从 request 参数里取,取不到就用默认值 1,这个兜底逻辑必须有,否则第一次访问没有 pageNo 参数直接空指针。totalPages 计算用的 Math.ceil 向上取整,totalCount 是 21 的时候除以 10 得到 2.1,Math.ceil 后是 3,正好 3 页。这里有个隐藏问题要注意:Integer.parseInt 如果收到非数字字符串会抛 NumberFormatException,更稳的写法是捕获异常后回退到默认值,你可以在自己的代码里加上。
3.4 JSP 列表页:EL 表达式 + JSTL 渲染
列表页是整套系统里用户直接看的界面,用 EL 表达式和 JSTL 标签替换掉 Java 脚本片段。很多教材还在教 <% %> 这种写法,但实际项目中早就不用了——脚本片段让 HTML 和 Java 代码混在一起,浏览器渲染慢,代码也丑。这套资源的 list.jsp 核心部分比较简洁。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>学生信息列表</title> <link rel="stylesheet" href="css/style.css"> </head> <body> <h2>学生信息列表</h2> <form action="student" method="get"> <input type="hidden" name="action" value="list"> <input type="text" name="keyword" value="${empty keyword ? '' : keyword}" placeholder="输入姓名/学号/班级"> <button type="submit">搜索</button> </form> <table border="1" cellpadding="8" cellspacing="0"> <tr> <th>ID</th><th>学号</th><th>姓名</th><th>性别</th> <th>年龄</th><th>班级</th><th>电话</th><th>邮箱</th><th>操作</th> </tr> <c:forEach items="${students}" var="s"> <tr> <td>${s.id}</td> <td>${s.studentNo}</td> <td>${s.name}</td> <td>${s.gender}</td> <td>${s.age}</td> <td>${s.className}</td> <td>${s.phone}</td> <td>${s.email}</td> <td> <a href="student?action=toEdit&id=${s.id}">编辑</a> | <a href="student?action=delete&id=${s.id}" onclick="return confirm('确定删除该学生吗?')">删除</a> </td> </tr> </c:forEach> </table> <div> <c:if test="${currentPage > 1}"> <a href="student?action=list&pageNo=${currentPage - 1}&keyword=${empty keyword ? '' : keyword}">上一页</a> </c:if> <span>第 ${currentPage} / ${totalPages} 页,共 ${totalCount} 条</span> <c:if test="${currentPage < totalPages}"> <a href="student?action=list&pageNo=${currentPage + 1}&keyword=${empty keyword ? '' : keyword}">下一页</a> </c:if> </div> </body> </html>注意搜索表单里那个隐藏的 action=list,因为表单 method 是 get,提交后 URL 会变成 student?action=list&keyword=xxx,这样 Servlet 才能正确分发。keyword 回显用了 EL 的空值判断,防止第一次加载时输入框里显示 null 字面量。分页链接里也拼了 keyword 参数,这个细节非常关键——如果你在搜索状态下点了下一页,keyword 没传过去,下一页就变成不带条件查询了,这是分页搜索最常见的 bug 之一。这套代码里每个关键位置都处理了这个状态保留,你可以逐个检查确认。
4. 分页查询与模糊搜索:答辩时最容易被追问的两个点
4.1 分页参数的传递链路:从前端到 SQL 的完整流转
分页是期末作业答辩时的必问题目,你要把参数怎么走的链条背熟。前端 JSP 页面点击页码链接,URL 里带的参数是 pageNo,比如 student?action=list&pageNo=2。Tomcat 收到请求后,Servlet 容器把参数封装进 HttpServletRequest,你的 Servlet 代码通过 req.getParameter("pageNo") 拿到字符串 "2",再 Integer.parseInt 转成 int。接着传给 Dao 层的 findByPage(pageNo, pageSize, keyword),最终在 SQL 里变成 LIMIT 10, 10。
这个链路里最容易出问题的是类型转换。request.getParameter 永远返回 String,如果前端传了 pageNo=abc,Integer.parseInt 直接抛 NumberFormatException,页面 500。我一般会在解析参数的地方做个保护,捕获 NumberFormatException 后回退到默认值 1。
int pageNo = 1; try { String pageNoStr = req.getParameter("pageNo"); if (pageNoStr != null && !pageNoStr.isEmpty()) { pageNo = Integer.parseInt(pageNoStr); } } catch (NumberFormatException e) { pageNo = 1; }补充一个隐藏场景:如果用户手贱把 URL 改成 pageNo=999,总页码只有 5,查出来结果是空列表。你可以在 Servlet 里对 pageNo 做边界校正,如果大于 totalPages 就让它等于 totalPages。这套资源里加了这个处理,建议你答辩的时候主动提这个边界条件,老师会觉得你想得周到。
4.2 模糊搜索与分页组合时的 SQL 拼接坑
模糊搜索和分页分开写都简单,组合起来就出现经典的「搜索后翻页失效」问题。现象是这样的:你在搜索框里输入「张」,点击搜索,第一页结果正常;点下一页,结果变成了全表数据——因为下一页的链接里没带 keyword 参数。还有另一个翻车现场是 SQL 里 LIKE 的占位符顺序搞错,导致 LIMIT 的起始行被当成 LIKE 参数绑定。
正确思路是三个要素同时考虑:SQL 层面,where 条件追加到 LIMIT 之前;参数绑定层面,likePattern 参数排在 limit 参数前面;前端链接层面,keyword 必须显式拼在翻页 URL 里。三个地方只要有一个漏了,查询就错。
提示:给你一个自检方法,在 Dao 层代码里临时加一行 System.out.println(sql),把最终 SQL 打到控制台看一遍。如果输出的 SQL 是 SELECT ... WHERE name LIKE '%张%' ORDER BY id DESC LIMIT 10, 10,说明拼接顺序是对的;如果 WHERE 语句消失或者 LIMIT 值不对,按顺序检查 keyword 参数传递。
5. 避坑排查:期末作业最常见的 6 个翻车现场
5.1 Tomcat 启动后访问 404
现象:IDEA 里 Tomcat 启动日志显示 Server startup in XXXX ms,但浏览器访问 http://localhost:8080/ 显示 404。
原因:八成是部署描述符里没有将项目根路径映射到默认页面,或者 web.xml 里没有配置 welcome-file,访问根路径时 Tomcat 找不到要显示的页面。
解决:在 web.xml 里加 welcome-file 配置:
<welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list>然后直接访问 http://localhost:8080/项目名/ 验证。
5.2 启动时报 ClassNotFoundException: com.mysql.cj.jdbc.Driver
现象:Tomcat 启动后访问某个页面,控制台报错 ClassNotFoundException,指向 DBUtil 的静态代码块。
原因:MySQL 驱动 jar 包没有放到 WEB-INF/lib 目录下,或者放到了 Tomcat 的 lib 目录但版本不匹配。IDEA 里常见的操作是把 jar 添加到项目依赖,但没勾选 Deploy to Server,运行的时候 Tomcat 没把 jar 带上。
解决:把 mysql-connector-java-8.0.x.jar 物理拷贝到项目的 web/WEB-INF/lib 目录下,然后右键 Add as Library。启动前在 IDEA 的 Artifacts 设置里确认 WEB-INF/lib 下面能看到这个 jar。
5.3 插入数据后中文全部变成问号
现象:从前端表单提交中文姓名,页面上显示正常,但 Navicat 打开数据库表看到的是 „???"。
原因:三层字符集至少有一层出了问题。最直接的原因是 JDBC URL 没带 characterEncoding=utf8 参数;其次是页面本身 content type 没设置 UTF-8;再一个是数据库表默认字符集不是 utf8mb4。
解决:先改 JDBC URL 确保 useUnicode=true&characterEncoding=utf8,再检查 JSP 第一行的 page 指令里 contentType="text/html;charset=UTF-8" 有没有加,最后执行 ALTER TABLE t_student CONVERT TO CHARACTER SET utf8mb4;。三个位置都检查一遍,再插入一条测试数据,基本能解决。
5.4 端口占用:Tomcat 启动直接崩
现象:IDEA 控制台报错 Port 8080 was already in use,Tomcat 起不来。
原因:之前某次运行没有正常关闭 Tomcat 进程,或者有其他程序占了 8080 端口(比如某个服务、Nginx、另一个 IDE 的调试端口)。
解决:优先把残留的 java 进程按 PID kill 掉,Windows 下命令是 netstat -ano | findstr 8080 然后 taskkill /PID 进程号 /F。如果担心影响其他项目,也可以改 Tomcat 端口:打开 conf/server.xml,把 Connector port 改成 8081、8082 这种不冲突的端口。
5.5 查询结果列表页样式全丢
现象:列表页的 CSS 样式出不来,页面纯 HTML 裸奔。
原因:JSP 里引用 CSS 用的是相对路径 css/style.css,但当前路径是 project/student?action=list,相对路径解析出来会变成 project/student/css/style.css,自然找不到文件。
解决:统一用${pageContext.request.contextPath}/css/style.css绝对路径引用。
5.6 update 操作后数据库数据没变
现象:编辑表单提交后提示成功,但刷新列表数据还是旧值。
原因:检查 SQL 是否真的执行了 update 语句。常见的情况是 getParameter 拿到的 id 为空,导致 where 条件不成立,executeUpdate 影响行数是 0。
解决:在 update 方法里加一个影响行数的日志输出,int rows = ps.executeUpdate(); System.out.println("影响行数=" + rows); 如果 rows 为 0,断点查看 id 参数有没有传对。
6. 答辩前必做的最后验证:四步走完再交作业
整套代码跑通之后,别急着交。我自己的习惯是每次期末作业输出前强制走一遍「从零部署验证」,你要照着做一遍,能拦住 90% 的现场翻车。
第一步,把数据库删掉重来:DROP DATABASE student_db; 然后重新执行提供的 sql 脚本,这一步确认脚本文件本身没问题,而不是你电脑里残留的旧数据在撑着。第二步,把项目在 IDEA 里 Clean 一次再 Rebuild,确认没有编译错误;然后把 target 目录里旧的 classes 清掉。第三步,用「干净环境」模拟答辩现场——关掉你开发时一直开着的 MySQL 进程和 Tomcat,重新启动 MySQL,启动 Tomcat,打开无痕浏览器访问 http://localhost:8080/项目名/,因为无痕浏览器不会带旧缓存和 Cookie,最接近老师机器上的真实情况。第四步,过一遍核心功能用例,我给你列个表格参考:
| 操作步骤 | 预期结果 | 常见失败点 |
|---|---|---|
| 输入正确账号密码登录 | 跳转到学生列表页 | 数据库密码没改成 root 的本地密码 |
| 新增一个学生,只填必填字段 | 列表页出现新数据 | StudentServlet 的 doPost 没配置 action=add |
| 搜索「张」点击搜索 | 列表只显示姓张的记录 | keyword 参数在翻页链接中丢失 |
| 翻到第 2 页再翻回来 | 搜索条件保留,数据不重不漏 | LIMIT 起始值算错导致重复 |
| 删除第 2 页的一条数据 | 删除后页面停留在第 2 页 | 接受到 record ID 用错导致删除失败 |
做完这四步,你心里基本有底了。篇幅有限只写了核心逻辑和最容易踩的坑,剩下的完整代码包括登录模块、编辑弹窗、CSS 样式都在资源包里。说实话,做这套作业的时候最折磨我的不是写代码,而是反复调试那些明明配置对了却仍然报错的环境问题——有一次因为 MySQL 8 的 caching_sha2_password 认证插件和旧版 JDBC 驱动不兼容,我整整折腾了一晚上。从那以后我每次搭 Java Web 环境都强制走一遍「版本匹配清单」:JDK 版本、Tomcat 版本、MySQL 版本、JDBC 驱动版本,四项逐一确认无误再写代码。这套资源里的环境和代码都是按这个清单验证过的,希望帮到你。
本文还有配套的精品资源,点击获取