简介:本资源是一套完整的Java EE课程设计级图书管理系统实现方案,面向高校计算机相关专业学生及Java Web初学者,用于完成期末作业或课程设计实践。系统采用B/S架构,支持借阅者(学生、教师等)与管理员两类角色,涵盖图书检索、借阅归还、用户管理、库存统计等核心业务功能,具备工程可部署性与教学示范价值。压缩包共350个文件,含51个Java后端逻辑类、14个JSP页面、56个JS交互脚本、25个HTML模板、19个CSS/LESS/SCSS样式文件、22个Jar依赖库及1个SQL数据库脚本,辅以开发文档与Layui+Font Awesome等前端UI资源,整体大小为30.12MB。已有5126人学习下载,资源提供开箱即用的完整源码结构、清晰分层的MVC实现、配套数据库设计说明及典型Web应用调试要点,便于理解Java EE技术栈整合实践。
1. 项目概述:一个Java EE图书管理系统的诞生记
又到了期末,看着课程设计的要求,是不是感觉头大?Java EE、数据库、MVC架构、前后端交互……一堆名词砸过来,却不知道从哪里开始。如果你手上正有一个名为“图书管理系统”的课程设计任务,或者想通过一个完整的项目来巩固Java EE知识体系,那么这篇分享或许能给你一些实实在在的参考。我当年做这个项目时,从需求分析到最终部署上线,踩过的坑、绕过的弯路,今天都整理成这篇超过五千字的实战复盘。这不是一个冷冰冰的“源码+文档”压缩包说明,而是一个过来人关于如何从零构建一个健壮、可扩展的Java EE应用的经验谈。我们将围绕一个典型的图书管理系统,拆解其核心模块、技术选型背后的逻辑、数据库设计的艺术,以及那些开发文档里不会写的“坑点”和调试技巧。
这个系统麻雀虽小,五脏俱全,涵盖了用户管理、图书信息管理、借阅归还、查询统计等核心业务。通过它,你不仅能完成课程设计,更能深入理解如何将Servlet、JSP、JDBC、MVC这些分散的知识点串联成一个有机的整体,理解一个Web应用从数据层到展示层的完整生命周期。更重要的是,我会分享如何让你的项目代码结构更清晰、更容易获得高分,以及如何撰写一份让老师眼前一亮的开发文档。
2. 技术栈选型与项目架构设计:为什么是这些组合?
面对一个课程设计项目,技术选型往往不是追求最新最炫,而是在满足功能需求、教学要求和个人技术栈之间找到最佳平衡点。对于经典的Java EE图书管理系统,一套经过时间检验、资料丰富、易于上手的组合是成功的关键。
2.1 核心框架:Servlet + JSP + JavaBean (MVC模式)
这是最经典、也是最符合课程设计要求的Java Web开发组合。很多学校课程仍以此为核心,因为它能最直观地体现Java EE Web开发的基础原理。
- Servlet (Controller层):作为控制器,它接收所有HTTP请求(如
/borrowBook,/queryBook),进行业务逻辑处理,并决定将哪个视图(JSP页面)呈现给用户。选择Servlet而不是更现代的Spring MVC,是为了夯实基础。你需要亲手处理HttpServletRequest和HttpServletResponse,理解会话(Session)和上下文(Application)的生命周期,这对理解Web工作原理至关重要。 - JSP (View层):用于生成动态HTML页面。虽然现在前后端分离是主流,但在课程设计中,JSP能让你快速将Java数据(如图书列表)渲染到页面上。我们会使用JSTL标签库和EL表达式来替代JSP中的Java脚本片段,使页面更清晰,这是体现代码规范性的加分项。
- JavaBean (Model层):每个实体类(如
Book,User)就是一个JavaBean,它封装数据和行为。它们是与数据库表直接映射的对象,也是在各层之间传递数据的载体。
注意:虽然Spring Boot能极大提升开发效率,但在课程设计中,如果要求体现“Java EE”核心内容,使用原生Servlet/JSP组合更能展示你对底层技术的掌握。如果允许,可以在文档中对比说明两种方式的优劣,体现你的思考深度。
2.2 数据持久层:JDBC与连接池
直接使用JDBC(Java Database Connectivity)进行数据库操作,而不是MyBatis或Hibernate,原因有三:一是教学要求通常如此;二是能让你彻底理解SQL和数据库交互的每一个步骤;三是在简单项目中,它的性能开销最小。
- 核心操作:加载驱动、获取连接(
Connection)、创建语句(Statement/PreparedStatement)、执行SQL、处理结果集(ResultSet)、关闭资源。这里最大的坑就是资源泄露,务必在finally块中或使用try-with-resources语法确保Connection,Statement,ResultSet被关闭。 - 连接池的必要性:每次请求都新建和关闭数据库连接是极其低效且消耗资源的。因此,引入一个数据库连接池(如DBCP, C3P0, 或HikariCP)是生产级思维的体现。它会预先创建一些连接放在池中,用时取出,用毕归还,大幅提升性能。在课程设计中集成连接池,会让你的项目脱颖而出。
2.3 辅助工具与依赖管理
- 数据库:MySQL是首选。它免费、流行、资料多。对于图书管理系统,5.7或8.0版本均可。要特别注意字符集设置为
utf8mb4,以支持存储Emoji表情(虽然图书信息可能用不到,但这是好习惯)。 - Web服务器:Apache Tomcat。版本选择9.x或10.x(注意Servlet API版本对应关系)。它是Servlet/JSP规范的实现者,轻量且易于配置。
- 依赖管理与构建:使用Maven来管理项目依赖(如数据库驱动、连接池、JSTL标签库)。它通过
pom.xml文件声明依赖,自动下载jar包,解决令人头疼的“ClassNotFound”问题,并规范项目结构。即使课程不要求,也强烈建议使用,这是现代Java开发的标配。 - IDE:IntelliJ IDEA或Eclipse。IDEA在智能提示、Maven集成和调试方面更胜一筹。熟练使用其“导出数据库脚本”功能(连接数据库后,右键表 -> SQL Scripts -> Generate DDL to Clipboard),可以方便地将你的表结构分享给他人或写入文档。
3. 数据库设计:不仅仅是建表
数据库设计是系统的基石,设计的好坏直接决定了后期开发的复杂度和数据的一致性。图书管理系统的核心实体通常包括:用户(管理员/读者)、图书、借阅记录。
3.1 表结构设计详解
下面是一个经过精简和优化的核心表结构设计,每一处设计都有其考量:
1. 用户表 (sys_user)这张表存储所有系统用户,包括管理员和普通读者。通过user_type字段区分角色。
CREATE TABLE `sys_user` ( `user_id` int(11) NOT NULL AUTO_INCREMENT COMMENT ‘用户ID‘, `username` varchar(50) NOT NULL UNIQUE COMMENT ‘用户名(登录用)‘, `password` varchar(255) NOT NULL COMMENT ‘密码(需加密存储)‘, `real_name` varchar(50) DEFAULT NULL COMMENT ‘真实姓名‘, `user_type` tinyint(1) NOT NULL DEFAULT ‘1‘ COMMENT ‘用户类型:0-管理员,1-普通读者‘, `status` tinyint(1) NOT NULL DEFAULT ‘1‘ COMMENT ‘账户状态:0-禁用,1-正常‘, `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT ‘创建时间‘, PRIMARY KEY (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=‘系统用户表‘;- 设计思考:
password字段使用varchar(255)并为将来可能的加密算法(如BCrypt)留足空间。user_type和status使用tinyint而非varchar,节省空间且查询效率高。create_time默认值为当前时间,避免业务代码处理。
2. 图书信息表 (book_info)这是系统的核心数据表。
CREATE TABLE `book_info` ( `book_id` int(11) NOT NULL AUTO_INCREMENT COMMENT ‘图书ID‘, `isbn` varchar(20) DEFAULT NULL UNIQUE COMMENT ‘国际标准书号‘, `book_name` varchar(200) NOT NULL COMMENT ‘图书名称‘, `author` varchar(100) NOT NULL COMMENT ‘作者‘, `publisher` varchar(100) DEFAULT NULL COMMENT ‘出版社‘, `publish_date` date DEFAULT NULL COMMENT ‘出版日期‘, `price` decimal(10,2) DEFAULT NULL COMMENT ‘价格‘, `category` varchar(50) DEFAULT NULL COMMENT ‘分类‘, `total_count` int(11) NOT NULL DEFAULT ‘0‘ COMMENT ‘馆藏总量‘, `available_count` int(11) NOT NULL DEFAULT ‘0‘ COMMENT ‘可借数量‘, `location` varchar(100) DEFAULT NULL COMMENT ‘馆藏位置‘, `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT ‘入库时间‘, PRIMARY KEY (`book_id`), KEY `idx_book_name` (`book_name`), KEY `idx_author` (`author`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=‘图书信息表‘;- 设计思考:将
total_count(总量)和available_count(可借量)分开存储是关键。每次借阅时,只需原子地减少available_count,而不是每次都去计算(总量 - 已借出)。这能有效避免并发借阅时的数据不一致问题。为book_name和author建立普通索引,能大幅提升根据书名和作者查询的效率。
3. 借阅记录表 (borrow_record)记录每一次借阅行为,是业务流水表。
CREATE TABLE `borrow_record` ( `record_id` int(11) NOT NULL AUTO_INCREMENT COMMENT ‘记录ID‘, `user_id` int(11) NOT NULL COMMENT ‘借阅用户ID‘, `book_id` int(11) NOT NULL COMMENT ‘借阅图书ID‘, `borrow_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT ‘借出时间‘, `due_time` datetime NOT NULL COMMENT ‘应还时间‘, `return_time` datetime DEFAULT NULL COMMENT ‘实际归还时间‘, `status` tinyint(1) NOT NULL DEFAULT ‘0‘ COMMENT ‘状态:0-借出中,1-已归还,2-超期未还‘, PRIMARY KEY (`record_id`), KEY `idx_user_id` (`user_id`), KEY `idx_book_id` (`book_id`), KEY `idx_due_time` (`due_time`), CONSTRAINT `fk_borrow_user` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`user_id`) ON DELETE CASCADE, CONSTRAINT `fk_borrow_book` FOREIGN KEY (`book_id`) REFERENCES `book_info` (`book_id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=‘借阅记录表‘;- 设计思考:
- 状态字段:
status字段非常重要。通过它,可以轻松查询“当前借出中的图书”、“所有超期未还的记录”,而无需在每次查询时计算return_time是否为NULL以及due_time是否小于当前时间。这是一个典型的“用空间换时间”和“使查询语义更清晰”的设计。 - 外键约束:定义了
user_id和book_id的外键约束,并设置ON DELETE CASCADE。这意味着当用户或图书被删除时,其相关的借阅记录也会自动删除,保证了数据的参照完整性。但请注意,在高并发或大型系统中,外键约束可能会影响性能,有时会在应用层通过逻辑来保证一致性。课程设计中,使用外键能清晰体现你的数据库知识。 - 索引:为
user_id,book_id,due_time建立了索引。查询某个用户的借阅记录、查询某本书的借阅历史、以及定时扫描超期记录(where due_time < NOW() and status=0)都会因此受益。
- 状态字段:
3.2 业务逻辑与SQL实战
设计好表只是第一步,如何让它们在业务中高效协作才是难点。我们以最核心的“借书”业务为例,看看如何编写一个健壮的事务性操作。
借书不是简单的插入一条借阅记录,它需要:
- 检查图书是否可借(
available_count > 0)。 - 减少图书的可借数量(
available_count - 1)。 - 新增一条借阅记录。
这三步必须在一个数据库事务中完成,否则可能出现“超借”(两个用户同时借最后一本书,都成功)的情况。
Servlet中的借书方法核心代码片段:
// BorrowServlet.java 中的 doPost 方法片段 Connection conn = null; PreparedStatement pstmtCheck = null; PreparedStatement pstmtUpdateBook = null; PreparedStatement pstmtInsertRecord = null; try { conn = dataSource.getConnection(); // 从连接池获取连接 conn.setAutoCommit(false); // 开启事务 // 1. 检查图书可借数量 String sqlCheck = “SELECT available_count FROM book_info WHERE book_id = ? FOR UPDATE“; pstmtCheck = conn.prepareStatement(sqlCheck); pstmtCheck.setInt(1, bookId); ResultSet rs = pstmtCheck.executeQuery(); if (rs.next()) { int available = rs.getInt(“available_count“); if (available <= 0) { throw new SQLException(“图书已借完“); } } else { throw new SQLException(“图书不存在“); } // 2. 更新图书可借数量 (原子操作) String sqlUpdateBook = “UPDATE book_info SET available_count = available_count - 1 WHERE book_id = ? AND available_count > 0“; pstmtUpdateBook = conn.prepareStatement(sqlUpdateBook); pstmtUpdateBook.setInt(1, bookId); int rowsAffected = pstmtUpdateBook.executeUpdate(); if (rowsAffected == 0) { // 如果更新行数为0,说明在检查之后、更新之前,可借数量被其他事务修改为0了 throw new SQLException(“借书失败,图书可能已被借出“); } // 3. 插入借阅记录 String sqlInsertRecord = “INSERT INTO borrow_record (user_id, book_id, due_time, status) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY), 0)“; pstmtInsertRecord = conn.prepareStatement(sqlInsertRecord); pstmtInsertRecord.setInt(1, userId); pstmtInsertRecord.setInt(2, bookId); pstmtInsertRecord.executeUpdate(); conn.commit(); // 提交事务 // 返回成功信息给前端 } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { /* 记录日志 */ } } // 返回失败信息给前端 } finally { // 按顺序关闭所有Statement和Connection(归还连接池) }- 关键点解析:
FOR UPDATE:在检查时对记录加行锁,防止其他事务同时修改这条图书记录,这是解决“丢失更新”问题的关键。UPDATE ... WHERE available_count > 0:在更新语句的WHERE条件中再次判断可用数量,这是一个“乐观锁”的变体,即使FOR UPDATE锁在某些隔离级别下可能不生效,这个条件也能作为最后一道防线。- 事务管理:通过
setAutoCommit(false),commit(),rollback()手动控制事务,确保三步操作要么全部成功,要么全部回滚。 - 资源关闭:在
finally块中确保所有数据库资源被正确关闭,防止连接泄漏。使用try-with-resources语法可以更优雅地实现。
4. 核心功能模块实现与避坑指南
有了扎实的数据库和事务基础,我们来看前端页面如何与后端Servlet协作,完成一个个功能模块。这里以“图书查询”和“借阅记录分页”两个典型场景为例,分享实现细节和常见问题。
4.1 图书查询:模糊搜索与多条件组合
查询功能是使用最频繁的。前端通常有一个表单,包含书名、作者、分类等输入框。后端需要动态拼接SQL。
常见错误做法:字符串拼接SQL
String sql = “SELECT * FROM book_info WHERE 1=1 “; if (bookName != null && !bookName.trim().isEmpty()) { sql += “ AND book_name LIKE ‘%“ + bookName + “%‘“; // SQL注入漏洞! }这种方式极易导致SQL注入攻击,且性能不佳。
正确做法:使用PreparedStatement动态拼接
// 在Servlet中 List<String> conditions = new ArrayList<>(); List<Object> parameters = new ArrayList<>(); if (bookName != null && !bookName.trim().isEmpty()) { conditions.add(“book_name LIKE ?“); parameters.add(“%“ + bookName + “%“); } if (author != null && !author.trim().isEmpty()) { conditions.add(“author LIKE ?“); parameters.add(“%“ + author + “%“); } // ... 其他条件 StringBuilder sqlBuilder = new StringBuilder(“SELECT * FROM book_info“); if (!conditions.isEmpty()) { sqlBuilder.append(“ WHERE “).append(String.join(“ AND “, conditions)); } // 可以加上排序和分页 sqlBuilder.append(“ ORDER BY create_time DESC LIMIT ?, ?“); parameters.add((pageNum - 1) * pageSize); parameters.add(pageSize); try (PreparedStatement pstmt = conn.prepareStatement(sqlBuilder.toString())) { for (int i = 0; i < parameters.size(); i++) { pstmt.setObject(i + 1, parameters.get(i)); } ResultSet rs = pstmt.executeQuery(); // ... 处理结果集,封装成Book对象列表 }- 优势:完全避免了SQL注入,数据库会对预编译的SQL进行缓存,提升重复查询的性能。
前端与后端的交互:查询表单通常使用GET请求,参数附在URL后(如/queryBook?bookName=Java&author=Martin)。Servlet通过request.getParameter()获取参数。查询结果(List<Book>)通过request.setAttribute(“bookList“, bookList)放入请求域,然后转发(request.getRequestDispatcher(“/bookList.jsp“).forward(request, response))到JSP页面展示。
4.2 借阅记录分页查询:性能与用户体验的平衡
当借阅记录很多时,一次性查询所有数据并展示是不可取的。分页查询是必备技能。它涉及两个关键SQL操作:查询当前页的数据和查询总记录数。
1. 查询总记录数
SELECT COUNT(*) FROM borrow_record WHERE user_id = ? AND status = ?;2. 查询当前页数据
SELECT * FROM borrow_record WHERE user_id = ? AND status = ? ORDER BY borrow_time DESC LIMIT ?, ?;LIMIT的两个参数分别是偏移量((pageNum-1) * pageSize)和每页条数(pageSize)。
后端分页逻辑封装: 可以创建一个通用的PageInfo<T>类,包含当前页码、每页大小、总记录数、总页数、当前页数据列表等属性。在Servlet中计算好这些值,传递给JSP。
JSP页面中的分页条实现:
<%-- bookList.jsp --%> <div class=“pagination“> <c:if test=“${pageInfo.hasPreviousPage}“> <a href=“queryBook?pageNum=${pageInfo.pageNum-1}&bookName=${param.bookName}“>上一页</a> </c:if> <c:forEach begin=“1“ end=“${pageInfo.pages}“ var=“i“> <c:choose> <c:when test=“${i == pageInfo.pageNum}“> <span class=“current“>${i}</span> </c:when> <c:otherwise> <a href=“queryBook?pageNum=${i}&bookName=${param.bookName}“>${i}</a> </c:otherwise> </c:choose> </c:forEach> <c:if test=“${pageInfo.hasNextPage}“> <a href=“queryBook?pageNum=${pageInfo.pageNum+1}&bookName=${param.bookName}“>下一页</a> </c:if> </div>- 注意:分页链接中需要带上所有查询条件(如
bookName=${param.bookName}),否则翻页后条件会丢失。${param.xxx}是EL表达式获取请求参数的方式。
分页的潜在性能问题:当数据量极大(百万级)时,使用LIMIT offset, size在偏移量很大时(如LIMIT 1000000, 20)会非常慢,因为MySQL需要先扫描并丢弃前100万条记录。对于课程设计,数据量不大,此方法足够。如果作为拓展知识,可以了解“游标分页”或“基于主键ID的范围查询”等优化方案。
4.3 用户登录与会话管理
这是Web开发的基础。用户登录成功后,需要将其身份信息(如userId,username,userType)存入Session,以便在后续请求中识别用户。
// LoginServlet User user = userService.login(username, password); if (user != null) { HttpSession session = request.getSession(); session.setAttribute(“loginUser“, user); // 将用户对象存入Session // 重定向到首页 response.sendRedirect(“index.jsp“); } else { // 返回登录失败信息 }在每个需要权限控制的Servlet或JSP中,都需要检查Session中是否存在loginUser。
<%-- 在JSP页面顶部进行权限检查 --%> <%@ page import=“com.yourproject.model.User“ %> <% User loginUser = (User) session.getAttribute(“loginUser“); if (loginUser == null || loginUser.getUserType() != 0) { // 假设0是管理员 response.sendRedirect(“login.jsp“); return; } %>实操心得:不要在多个地方散落着权限检查代码。可以编写一个
Filter(过滤器),对/admin/*这样的路径进行统一拦截和权限验证,这是更优雅和专业的做法。在课程设计中实现一个Filter,会是很大的亮点。
5. 项目部署、文档编写与高分技巧
代码写完只是成功了一半,如何打包、部署,以及如何呈现你的工作,同样重要。
5.1 项目打包与部署到Tomcat
- 使用Maven打包:在项目根目录下运行
mvn clean package。Maven会在target目录下生成一个项目名.war文件(如BookManager.war)。 - 部署到Tomcat:将
.war文件复制到Tomcat的webapps目录下。启动Tomcat(执行bin/startup.bat或startup.sh),Tomcat会自动解压war包并部署应用。 - 访问应用:在浏览器中输入
http://localhost:8080/BookManager(假设你的应用上下文路径是BookManager)。
常见部署问题:
- 端口冲突:如果8080端口被占用,修改Tomcat
conf/server.xml中的<Connector port=“8080“ ...>。 - 数据库连接失败:确保Tomcat所在机器能访问数据库服务器,检查
JDBC URL、用户名和密码。连接池的配置通常放在src/main/resources下的属性文件或XML中,需要确保打包后这些配置文件在类路径下。 - 中文乱码:确保数据库、连接字符串、JSP页面、Servlet请求/响应编码全部统一为UTF-8。一个常见的陷阱是
GET请求参数乱码,需要在Tomcat的server.xml中配置<Connector ... URIEncoding=“UTF-8“>。
5.2 开发文档撰写要点
一份好的开发文档不是代码的重复,而是对项目思路、设计和决策的阐述。它应该包含以下几个部分:
- 项目简介:用一两句话说明这是什么系统,谁用的,解决什么问题。
- 需求分析:列出核心功能模块(如用户管理、图书管理、借阅管理、查询统计),最好能用用例图或简单的文字描述。
- 系统设计:
- 技术架构图:手绘或使用工具画出MVC各层的关系,标明使用的技术。
- 数据库设计:给出完整的ER图(实体关系图),并附上所有表的建表SQL语句(可以用IDEA导出)。
- 核心类设计:说明主要的JavaBean(实体类)、Servlet(控制器)、Service(业务逻辑层)和Dao(数据访问层)的职责和关键方法。
- 功能模块详解:挑选2-3个核心功能(如借书、复杂查询),详细描述其前后端交互流程、关键代码逻辑(可以贴核心代码片段,并加以说明)、涉及到的SQL。
- 系统测试:描述测试环境,并提供一些测试用例。例如,“测试借书功能:当可借数量为0时,尝试借书,系统应提示‘图书已借完’”。附上测试结果的截图(登录成功、查询结果、操作成功/失败提示等)。
- 总结与心得体会:这部分是加分项。不要写“我学会了Java EE”,而是写你遇到的具体问题和解决方案。例如:
- “在实现借书事务时,最初没有使用
FOR UPDATE导致并发测试时出现了超借。通过查阅资料,理解了数据库锁机制,并优化了代码。” - “在解决分页查询条件丢失问题时,学会了如何使用JSTL的
${param}来在翻页时保留查询参数。” - “通过配置Tomcat的
URIEncoding解决了GET请求中文乱码问题。”
- “在实现借书事务时,最初没有使用
5.3 获取高分的额外建议
- 代码规范:使用有意义的变量名、方法名,添加必要的注释。遵循一定的包结构,如
com.xxx.controller,com.xxx.service,com.xxx.dao,com.xxx.model,com.xxx.util。 - 前端美化:即使不擅长前端,也可以使用Bootstrap等CSS框架快速搭建一个简洁、美观的界面。一个好看的UI会给老师留下很好的第一印象。
- 扩展功能:在完成基础功能(增删改查、借还)上,尝试实现一个扩展功能,例如:
- 图书借阅排行榜(统计借阅次数最多的图书)。
- 借阅超期自动计算罚金(通过后台定时任务或查询时计算)。
- 简单的数据可视化(使用JFreeChart等库生成图书分类占比饼图)。
- Excel导入导出(使用Apache POI实现图书数据的批量导入和导出)。
- 准备答辩:理清你的项目脉络,能说清楚技术选型理由、数据库设计亮点、遇到的最大挑战和如何解决的。老师问的最多的往往是:“这里你是怎么考虑的?”“如果数据量很大,这里会有问题吗?”
最后,把项目源码、数据库SQL脚本、可运行的War包、以及你精心编写的开发文档打包成一个清晰的压缩包。在根目录放一个README.txt,简要说明如何配置数据库、如何部署项目。这一套完整的、可运行、有文档、有思考的项目,无疑会是一份优秀的期末作业。
本文还有配套的精品资源,点击获取