基于Spring Boot和Vue的课程作业管理系统设计与实现
2026/9/16 16:59:43 网站建设 项目流程

简介:这是一套基于Spring Boot与Vue的课程作业管理系统设计源码案例,面向教育机构教务人员、高校学生及Java全栈开发者,用于解决作业发布、学生在线提交、教师批改评分等环节效率低下的问题。压缩包共438个文件,整体约39.69MB,核心包含126个Java源文件、44个Vue组件、161个SVG图标、30张JPG图片、14张PNG图片、17个XML配置文件、14个JS脚本,以及SQL数据库脚本、bat安装与启动脚本、mp4演示视频、doc/docx说明文档等十余种类型,资源构成完整。目前已有359人浏览学习。资源目录结构清晰,前后端分离,后端提供完整业务接口,前端包含侧边栏、头部、面包屑等后台组件,并附带开题报告等文档,便于快速了解项目全貌和模块划分。配套的一键安装与运行脚本可帮助不熟悉部署流程的读者搭建本地环境,整体适合作为毕业设计、课程设计参考,也可用于学习Spring Boot与Vue整合开发实践。

1. 用 Spring Boot + Vue 写课程作业管理系统,先拆需求再动代码

用 Spring Boot 和 Vue 做课程作业管理系统,难点往往不在 CRUD,而在提交、截止时间、补交和批改这条主链路上的状态边界,这也是“基于 Spring Boot 和 Vue 的课程作业管理系统设计源码案例”真正值得读的地方。一个学生迟交了两分钟,系统是拒绝提交还是标记为补交;教师批改完成之后,学生还能不能重交——这些规则一旦散落在 Controller 的 if 里,后期维护会非常被动。

我会按实际开发顺序展开:先把后端拆成课程、作业、提交记录三张核心表,再写 Vue 3 前端的路由和页面,然后聚焦“提交-批改”这个业务核心,最后落到打包部署。读源码时不必逐行看,先对应数据库脚本和前端路由表,把一条主链路走通,比代码行数有用得多。如果你打算自己动手写一版,准备 JDK、Maven、Node.js 和一台 MySQL 就够了。

2. 用 Spring Boot 搭后端:课程、作业、提交记录三张核心表与分层接口

课程作业管理系统的后端,我一般会先建四张表:user 放账号和角色,course 关联教师,assignment 放作业要求,submission 放学生提交记录。用 IDEA 新建 Spring Boot 项目时,版本在 Spring Initializr 里选当前默认稳定版就行,不要看到新版本就往上点;很多 Mapper 注入和依赖冲突问题,其实都来自 Spring Boot 版本与 starter 组合不一致。

2.1 三张核心表的字段怎么定

先看核心表的关系:course 1:N assignment 1:N submission,user 分别关联 teacher 和 student。表结构最重要的是 submission 不要设计成“每次上传插一行”,而应该让“作业+学生”唯一,做法是加联合唯一索引。

表名作用关键字段
course课程id, name, teacher_id
assignment作业id, course_id, title, deadline, allow_late, late_penalty
submission提交记录id, assignment_id, student_id, status, submit_count, score

allow_latelate_penalty分开设计:前者决定是否接受补交,后者记录补交后扣多少比例。有人只用 deadline 判断,不做单独开关,结果教师想临时放宽时间只能改表,很不方便。

2.2 DDL:deadline 类型和唯一索引

下面这段 SQL 可以直接建库:

CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, teacher_id BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE assignment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, title VARCHAR(128) NOT NULL, deadline DATETIME NOT NULL, allow_late TINYINT NOT NULL DEFAULT 1, late_penalty DECIMAL(5,2) NOT NULL DEFAULT 0.00, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_course (course_id) ); CREATE TABLE submission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, assignment_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0未提交 1已提交 2已批改', submit_count INT NOT NULL DEFAULT 0, file_path VARCHAR(255) DEFAULT NULL, score DECIMAL(5,2) DEFAULT NULL, teacher_comment VARCHAR(500) DEFAULT NULL, submit_time DATETIME DEFAULT NULL, grade_time DATETIME DEFAULT NULL, UNIQUE KEY uk_assign_student (assignment_id, student_id) );

deadline用 DATETIME,后端对应 Java 的 LocalDateTime,前端传2026-01-15 23:59:59这类格式最直观。status只保留 0/1/2 三个值,业务逻辑统一放到 Service 判断。uk_assign_student唯一索引是提交幂等的第一道保障,有了它你才能放心地做“重复提交就是 update 同一条记录”。

2.3 实体、Mapper 和 Service 分层

实体直接用 Lombok 配合 MyBatis-Plus:

@Data @TableName("assignment") public class Assignment { @TableId(type = IdType.AUTO) private Long id; private Long courseId; private String title; private LocalDateTime deadline; private Boolean allowLate; private BigDecimal latePenalty; }

Mapper 继承 BaseMapper 就够用,复杂查询再追加自定义方法:

public interface AssignmentMapper extends BaseMapper<Assignment> { @Select(""" SELECT a.*, s.status AS submit_status, s.score FROM assignment a LEFT JOIN submission s ON s.assignment_id = a.id AND s.student_id = #{studentId} WHERE a.id = #{assignmentId} """) AssignmentDetailVO selectDetailForStudent(Long assignmentId, Long studentId); }

left join 的关联条件里带着s.student_id,这样即使学生还没提交过,也能查出作业信息,只是 submit_status 为 null。很多新手把 join 条件写在 where 里,导致没提交记录时作业也查不出来,这是第一个容易踩的坑。

2.4 Controller 只做参数接收

Controller 层保持薄,业务规则全部放到 Service:

@RestController @RequestMapping("/api/course/{courseId}/assignment") public class AssignmentController { @GetMapping("/list") public Result list(@PathVariable Long courseId, @RequestParam(required = false) Integer status) { return Result.ok(assignmentService.listByCourse(courseId, status)); } }

status是可选筛选条件,教师端可以用它只看“待批改”。courseId 从路径取,学生调用时 Service 里还要校验该学生确实选了这门课,不能只靠前端路由挡人。接口返回统一用 Result 包装,避免每个方法各自拼 Map。

3. Vue 3 + Element Plus 搭前端:路由拆分、请求封装和页面职责

后端接口稳了之后,前端最需要注意的是页面职责怎么切。教师端要列表和批改操作,学生端要详情和提交上传,两边共用一套数据结构但操作粒度完全不同。我一般会把路由按teacherstudent拆成两个业务块,后端返回的 role 只用来做页面级校验,按钮级权限仍然由组件自己控制。

3.1 初始化项目并补齐依赖

先用 Vite 创建 Vue 3 项目,再手动补 router、pinia、element-plus:

npm create vite@latest course-frontend -- --template vue cd course-frontend npm install npm install vue-router@4 pinia element-plus axios

vue-router@4对应 Vue 3,如果用 Vue 2 的旧项目,直接升级依赖容易撞出问题。Element Plus 和 Ant Design Vue 在这类系统里都可以,我习惯用 Element Plus,是因为它的表格和表单对教师端场景覆盖比较完整。

3.2 按角色拆路由表

路由表先按业务角色分组:

const routes = [ { path: '/login', component: () => import('@/views/LoginView.vue') }, { path: '/teacher', component: () => import('@/layouts/TeacherLayout.vue'), meta: { requiresAuth: true, role: 'teacher' }, children: [ { path: 'courses/:courseId/assignments', component: () => import('@/views/teacher/AssignmentListView.vue') } ] }, { path: '/student/assignments/:assignmentId', component: () => import('@/views/student/AssignmentSubmitView.vue'), meta: { requiresAuth: true, role: 'student' } } ]

meta.role是路由级的声明,不是权限的最终依据。真正的拦截在守卫里写,后端接口仍要校验,前端只是让用户少看到不相关页面。

守卫代码:

router.beforeEach((to) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { return '/login' } return true })

如果还要判断 role,可以从 JWT payload 解析,也可以再调/api/user/info。只在客户端做角色判断不安全,只能作为体验优化。

3.3 axios 封装和 vite 代理

统一请求实例:

import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) export default request

baseURL 写/api而不是完整地址,开发环境用 vite 代理,生产环境用 nginx 反代,这样一份代码不用来回改。对应配置:

export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': 'http://localhost:8080' } } })

代理里的后端地址可以带端口,也可以带前缀,它只负责把/api开头的内容转发过去。响应拦截器里可以统一处理 401,例如 token 过期时跳转登录页,避免每个页面重复写错误判断。

3.4 学生提交页面:路由参数与上传

学生在作业列表点击一条记录后进入/student/assignments/1,页面从route.params.assignmentId取作业 ID:

<script setup> import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' import { ElMessage } from 'element-plus' import request from '@/api/request' const route = useRoute() const assignmentId = route.params.assignmentId const detail = ref(null) onMounted(async () => { const res = await request.get(`/assignment/${assignmentId}/detail`) detail.value = res.data }) async function submitFile(file) { const form = new FormData() form.append('file', file) await request.post(`/assignment/${assignmentId}/submit`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) ElMessage.success('提交成功') } </script>

使用route.params而不是把 assignmentId 存在 localStorage 里,好处是刷新不丢、链接可以复制给其他人。Element Plus 的el-upload可以设置:auto-upload="false",在自定义按钮里调用这个 submitFile,比依赖组件的 action 属性更容易统一处理 token 和错误提示。

路由片段页面核心职责
/teacher/courses/:courseId/assignments作业列表新建作业、查看提交、进入批改
/student/assignments/:assignmentId学生详情看要求、上传文件、查看成绩

两条主路径成立后,可以抽一个useAssignmentState.jscomposable,统一把 deadline 和 status 组合成前端显示状态。教师端要看“已迟交未批改”,学生端要看“已截止未提交”,同一个状态在不同角色嘴里名字完全不同,集中维护比到处写模板判断清楚得多。

4. 课程作业管理系统最核心的提交与批改:文件上传、补交限制和成绩回流

前两章搭起来的是框架,核心是提交和批改。前端可能同时点两次提交,后端也可能同时处理“学生提交”和“教师批改”,这两件事并发发生时,状态就乱了。最直接的做法是要求提交操作具备幂等性,并在 Service 里集中管理状态。

4.1 状态流转表

操作原状态新状态关键条件
学生提交0 或 11未过截止时间,或 allow_late=1
教师批改12提交记录存在且未被批改
教师退回21学生需要重新上传,记录保留

这里没有把“已退回”单列成一个整数字段,而是通过 status=1 加 teacher_comment 非空来识别。想更明确也可以加 status=3,但改字段时注意兼容历史数据。

4.2 submit 方法的校验顺序

一个完整的提交方法应该按固定顺序做校验:

@Transactional public void submit(Long assignmentId, Long studentId, MultipartFile file) { Assignment assignment = assignmentMapper.selectById(assignmentId); Submission submission = submissionMapper.selectOne( new LambdaQueryWrapper<Submission>() .eq(Submission::getAssignmentId, assignmentId) .eq(Submission::getStudentId, studentId)); boolean late = LocalDateTime.now().isAfter(assignment.getDeadline()); if (late && Boolean.FALSE.equals(assignment.getAllowLate())) { throw new BusinessException("已超过截止时间,且不允许补交"); } if (submission != null && Integer.valueOf(2).equals(submission.getStatus())) { throw new BusinessException("该作业已批改,如需重交请联系教师退回"); } String filePath = fileStore.store(file); if (submission == null) { submission = new Submission(); submission.setAssignmentId(assignmentId); submission.setStudentId(studentId); submission.setSubmitCount(1); submission.setStatus(1); submissionMapper.insert(submission); } else { submission.setFilePath(filePath); submission.setStatus(1); submission.setSubmitTime(LocalDateTime.now()); submission.setSubmitCount(submission.getSubmitCount() + 1); submissionMapper.updateById(submission); } }

校验顺序是:先看作业是否存在,再看截止时间,再看当前提交是否已被批改。最后一个条件很关键,如果教师已经打了分,学生就不能再用旧文件覆盖,必须由教师退回后才能重交。submit_count在这里既记录补交次数,也能作为教师侧“学生改了几版”的参考。

提示:@Transactional只管数据库,管不了本地磁盘里的文件。生产方案应以数据库为最终记录,文件写入失败时补一条清理任务,或者把文件路径写入 DB 失败后手动删除文件。

4.3 文件名处理与目录穿越

文件存储时不要直接使用用户上传的原始文件名:

public String store(MultipartFile file) { String original = file.getOriginalFilename(); String ext = StringUtils.getFilenameExtension(original); String filename = UUID.randomUUID() + "." + ext; Path target = Paths.get(uploadDir).toAbsolutePath().normalize().resolve(filename); file.transferTo(target); return "/files/" + filename; }

原始文件名里可能包含路径分隔符,直接拼路径会带来目录穿越问题;即便没有恶意,重名文件也会互相覆盖。随机文件名加白名单扩展名是课程作业系统里最稳妥的写法。StringUtils.getFilenameExtension是 Spring 自带的工具,不需要引入额外依赖。

4.4 批改成绩的批量更新

教师端一次勾选多份作业时,接口传一个列表。批量更新要带事务,同时一次更新多行:

@Transactional public void grade(List<GradeItem> items) { List<Submission> list = items.stream().map(item -> { Submission s = new Submission(); s.setId(item.getSubmissionId()); s.setScore(item.getScore()); s.setTeacherComment(item.getComment()); s.setStatus(2); s.setGradeTime(LocalDateTime.now()); return s; }).collect(Collectors.toList()); submissionService.updateBatchById(list); }

这段代码虽然叫 batch,但 MyBatis-Plus 底层仍然会生成多条 UPDATE 语句,依靠 JDBC 的批量执行减少往返。对几百条的课程作业量级足够;数据量大时可以改成一条UPDATE ... CASE WHEN,但课程设计场景不需要提前优化。

grade 方法开头还要校验每个 submission 的 assignment 都属于当前教师,否则可能发生“A 课程的老师批改了 B 课程的作业”。这个校验在 Service 里做一次 join 查询即可,不要在循环里逐个查。

5. Spring Boot 打包与 Vue history 模式部署:404 排查和上线验证

先构建再考虑部署,前后端构建命令都比较固定。

5.1 构建命令

mvn clean package -DskipTests npm run build

maven 构建出target/*.jar,npm 构建出dist/-DskipTests只跳过测试执行,如果不想编译测试类,用-Dmaven.test.skip=true。前端打包后如果出现样式错乱,优先检查 vite 的base配置:根路径部署不需要配,子路径部署才需要改成相对路径。

5.2 合体方式和 history 模式

两种常见部署方案:

方案做法适合场景
单 jar 托管把 dist 复制到src/main/resources/static后重新打 jar演示、课程设计、单机部署
Nginx 分开部署dist 放 Nginx,/api 反代到后端前后端独立维护、后端多实例

单 jar 方案需要在 Spring 配置里把未命中的路径 forward 到 index.html;Nginx 方案则交给 try_files。用 Nginx 时配置如下:

server { listen 80; server_name course.example.com; root /opt/course-frontend/dist; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; } location / { try_files $uri $uri/ /index.html; } }

Vue 使用 history 模式时,浏览器访问/teacher/courses/1/assignments在服务器上没有真实目录,Nginx 默认会 404。try_files的最后一个参数/index.html就是让未命中路径回到前端入口,由 Vue 路由接管。

5.3 上线后先用 curl 验证

curl -i http://localhost:8080/api/course/1/assignment/list

接口通了再检查页面。直接在浏览器刷新一个详情地址,如果 URL 不变但返回 404,就是 history 路由回退没配好。配好 Nginx 后再刷新一次,状态码会变 200,响应内容是index.html

如果接口单独访问正常,页面加载后接口全部返回 401,优先检查 Authorization 头是否带上了 token,以及 nginx 是否把/api打到了正确的后端端口。排错顺序就按这三步来:先看 jar 或 dist 里有没有 index.html,再看 try_files 是否生效,最后用 curl 确认代理。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询