ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Spring Boot和Vue的课程作业管理系统设计与实现

基于Spring Boot和Vue的课程作业管理系统设计与实现 简介这是一套基于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 submissionuser 分别关联 teacher 和 student。表结构最重要的是 submission 不要设计成“每次上传插一行”而应该让“作业学生”唯一做法是加联合唯一索引。表名作用关键字段course课程id, name, teacher_idassignment作业id, course_id, title, deadline, allow_late, late_penaltysubmission提交记录id, assignment_id, student_id, status, submit_count, scoreallow_late和late_penalty分开设计前者决定是否接受补交后者记录补交后扣多少比例。有人只用 deadline 判断不做单独开关结果教师想临时放宽时间只能改表很不方便。2.2 DDLdeadline 类型和唯一索引下面这段 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-PlusData 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 BaseMapperAssignment { 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 层保持薄业务规则全部放到 ServiceRestController 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 搭前端路由拆分、请求封装和页面职责后端接口稳了之后前端最需要注意的是页面职责怎么切。教师端要列表和批改操作学生端要详情和提交上传两边共用一套数据结构但操作粒度完全不同。我一般会把路由按teacher和student拆成两个业务块后端返回的 role 只用来做页面级校验按钮级权限仍然由组件自己控制。3.1 初始化项目并补齐依赖先用 Vite 创建 Vue 3 项目再手动补 router、pinia、element-plusnpm create vitelatest course-frontend -- --template vue cd course-frontend npm install npm install vue-router4 pinia element-plus axiosvue-router4对应 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 requestbaseURL 写/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取作业 IDscript 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-uploadfalse在自定义按钮里调用这个 submitFile比依赖组件的 action 属性更容易统一处理 token 和错误提示。路由片段页面核心职责/teacher/courses/:courseId/assignments作业列表新建作业、查看提交、进入批改/student/assignments/:assignmentId学生详情看要求、上传文件、查看成绩两条主路径成立后可以抽一个useAssignmentState.jscomposable统一把 deadline 和 status 组合成前端显示状态。教师端要看“已迟交未批改”学生端要看“已截止未提交”同一个状态在不同角色嘴里名字完全不同集中维护比到处写模板判断清楚得多。4. 课程作业管理系统最核心的提交与批改文件上传、补交限制和成绩回流前两章搭起来的是框架核心是提交和批改。前端可能同时点两次提交后端也可能同时处理“学生提交”和“教师批改”这两件事并发发生时状态就乱了。最直接的做法是要求提交操作具备幂等性并在 Service 里集中管理状态。4.1 状态流转表操作原状态新状态关键条件学生提交0 或 11未过截止时间或 allow_late1教师批改12提交记录存在且未被批改教师退回21学生需要重新上传记录保留这里没有把“已退回”单列成一个整数字段而是通过 status1 加 teacher_comment 非空来识别。想更明确也可以加 status3但改字段时注意兼容历史数据。4.2 submit 方法的校验顺序一个完整的提交方法应该按固定顺序做校验Transactional public void submit(Long assignmentId, Long studentId, MultipartFile file) { Assignment assignment assignmentMapper.selectById(assignmentId); Submission submission submissionMapper.selectOne( new LambdaQueryWrapperSubmission() .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(ListGradeItem items) { ListSubmission 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 buildmaven 构建出target/*.jarnpm 构建出dist/。-DskipTests只跳过测试执行如果不想编译测试类用-Dmaven.test.skiptrue。前端打包后如果出现样式错乱优先检查 vite 的base配置根路径部署不需要配子路径部署才需要改成相对路径。5.2 合体方式和 history 模式两种常见部署方案方案做法适合场景单 jar 托管把 dist 复制到src/main/resources/static后重新打 jar演示、课程设计、单机部署Nginx 分开部署dist 放 Nginx/api 反代到后端前后端独立维护、后端多实例单 jar 方案需要在 Spring 配置里把未命中的路径 forward 到 index.htmlNginx 方案则交给 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 确认代理。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进