一、需求分析与技术选型
1. 核心需求梳理
明确了系统需要实现的核心功能:
- 多角色管理:学生(参加考试、查询成绩)、教师(创建题库、组卷、阅卷)、管理员(用户管理、系统设置)
- 题库管理:支持单选题、多选题、判断题和简答题,可批量导入试题
- 在线考试:定时交卷、防作弊(禁止切屏)、异常断线后可续考
- 自动阅卷:客观题自动评分,主观题教师手动评分
- 成绩统计:按班级、科目统计平均分、及格率等数据
2. 技术选型考量
选择了容易上手且资料丰富的技术栈:
- 后端:Spring Boot 2.7.x(开发效率高,适合快速迭代)
- 前端:Bootstrap 5 + jQuery(响应式设计,适配电脑和手机)
- 数据库:MySQL 8.0(关系型数据库,适合存储结构化考试数据)
- 开发工具:IntelliJ IDEA + AI 辅助插件
选择 AI 辅助工具是因为它能根据需求生成基础代码框架,对于编码经验不足的学生来说,能节省大量重复劳动,让我专注于业务逻辑实现。
二、环境准备
结合常用的 Windows 环境,完成环境搭建:
1. 下载并安装 IntelliJ IDEA
作为学生,免费且功能完善的IDEA 社区版是首选。在 JetBrains 官网找到'IntelliJ IDEA',选择'Community'版本下载安装包。
安装时注意两个关键设置:一是勾选'Add launchers dir to the PATH'(添加到环境变量),方便后续通过命令行启动;二是勾选'Create Desktop Shortcut'(创建桌面快捷方式),避免后续找不到启动图标。全程点击'下一步'即可。
2. 安装 AI 辅助插件
打开 IDEA 后,点击顶部菜单栏'File → Settings → Plugins',在右侧搜索框输入相关插件名称,找到带有官方标识的插件,点击'Install'。
3. 登录 AI 辅助工具
重启 IDEA 后,点击面板中的'立即登录',用学生邮箱注册账号,完成手机验证后登录。登录成功后,面板会显示'需求分析→软件设计→工程代码生成'的全流程引导,贴合从想法到落地的完整开发需求。
三、模块设计与编码
在 AI 辅助工具的协助下,按'需求描述→拆解分析→设计→编码'的流程系统化开发。以下是系统的完整开发过程:
1. AI 辅助生成基础模块
AI 工具支持口语化需求描述——不需要专业术语,用日常表达就能精准生成代码。在插件面板的'需求编辑器'中输入需求描述,例如生成在线考试系统基础模块,包含 3 类核心角色及核心实体,实现核心功能如学生注册/登录、在线考试、成绩查询等。
提交需求后,AI 自动解析需求,将描述拆解成多个可执行的核心模块,标注了'必填功能'和'可选优化':
- ☑ 用户模块:支持学生/教师/管理员三类角色注册登录,基于角色控制权限
- ☑ 试题模块:教师添加/编辑/删除试题,支持按题型/难度/学科筛选,支持批量导入试题
- ☑ 试卷模块:教师手动选择试题组卷或按'题型 + 分值 + 难度'随机组卷,设置考试时长与总分
- ☑ 考试模块:学生进入考试后倒计时提醒,禁止重复提交,异常退出后可续考
- ☑ 阅卷模块:客观题自动比对答案评分,主观题教师手动打分并写评语
- ☑ 成绩模块:学生查询个人考试成绩与答题详情,教师查看班级成绩统计
确认需求后,AI 自动进入'接口设计→表结构设计→处理逻辑→生成源码'环节。最终生成的项目结构清晰,核心包与类已完整创建,省去了手动建包、写基础类的麻烦。
com.student.exam
├─ entity // 实体类(映射数据库表)
│ ├─ User.java // 用户实体(区分学生/教师/管理员)
│ ├─ Question.java // 试题实体(含题型、选项、答案等)
│ ├─ Paper.java // 试卷实体(含考试时长、总分等)
│ ├─ PaperQuestion.java // 试卷 - 试题关联实体(多对多关系)
│ ├─ ExamRecord.java // 考试记录实体(含得分、考试状态)
│ └─ AnswerSheet.java // 答题记录实体(含学生答案、批改结果)
├─ dto // 数据传输对象(接收前端请求参数)
│ ├─ UserRegisterDTO.java // 用户注册请求 DTO
│ ├─ QuestionAddDTO.java // 试题添加请求 DTO
│ ├─ PaperCreateDTO.java // 试卷创建请求 DTO
│ └─ ExamSubmitDTO.java // 考试提交请求 DTO
├─ vo // 视图对象(向前端返回数据)
│ ├─ QuestionVO.java // 试题展示 VO(隐藏正确答案)
│ ├─ PaperDetailVO.java // 试卷详情 VO(含试题列表)
│ └─ ScoreVO.java // 成绩展示 VO(含答题详情与批改意见)
├─ mapper // 数据访问接口(MyBatis-Plus)
│ ├─ UserMapper.java
│ ├─ QuestionMapper.java
│ ├─ PaperMapper.java
│ └─ ExamRecordMapper.java
├─ service // 业务逻辑层
│ ├─ UserService.java
│ ├─ QuestionService.java
│ ├─ PaperService.java
│ └─ ExamService.java
├─ controller // 接口控制层
│ ├─ UserController.java
│ ├─ QuestionController.java
│ ├─ PaperController.java
│ └─ ExamController.java
└─ config // 配置类(数据库、权限、静态资源)
├─ WebConfig.java
└─ SecurityConfig.java
2. 核心代码展示
AI 生成的代码不仅结构规范,还自带参数校验、事务控制和详细注释,仅需根据考试场景补充少量业务逻辑。以下是关键模块的代码示例:
(1)entity 包:核心实体类
Question.java(试题实体,支持多题型)
package com.student.exam.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.student.exam.enums.QuestionTypeEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* <p>
* 试题实体类:映射 question 表,支持单选、多选、判断、简答 4 种题型
* </p>
* @author Student Developer
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("question")
public class Question {
/**
* 试题 ID:主键,自增
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 题干:试题内容,如'下列关于 Java 继承的描述正确的是?'
*/
private String content;
/**
* 题型:0-单选 1-多选 2-判断 3-简答(使用枚举约束)
*/
private QuestionTypeEnum type;
/**
* 选项 A:单选/多选题有效
*/
private String optionA;
/**
* 选项 B:单选/多选题有效
*/
private String optionB;
/**
* 选项 C:单选/多选题有效
*/
private String optionC;
/**
* 选项 D:单选/多选题有效
*/
private String optionD;
/**
* 正确答案:单选(A/B/C/D)、多选(A,B,C)、判断(对/错)、简答(文本答案)
*/
private String correctAnswer;
/**
* 分值:试题满分,如 2 分、5 分
*/
private Integer score;
/**
* 难度:0-简单 1-中等 2-困难
*/
private Integer difficulty;
/**
* 学科:如'计算机基础''Java 编程''高等数学'
*/
private String subject;
/**
* 创建人 ID:关联 user 表(教师/管理员角色)
*/
private Long createBy;
/**
* 创建时间:试题添加时间
*/
private LocalDateTime createTime;
/**
* 状态:0-禁用(不参与组卷)1-启用(可组卷)
*/
private Integer status;
}
ExamRecord.java(考试记录实体,含考试状态与得分)
package com.student.exam.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.student.exam.enums.ExamStatusEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* <p>
* 考试记录实体类:映射 exam_record 表,记录学生考试全流程信息
* </p>
* @author Student Developer
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("exam_record")
public class ExamRecord {
/**
* 记录 ID:主键,自增
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 学生 ID:关联 user 表(学生角色)
*/
private Long studentId;
/**
* 试卷 ID:关联 paper 表
*/
private Long paperId;
/**
* 考试状态:0-未开始 1-进行中 2-已提交 3-已批改 4-已逾期(未提交)
*/
private ExamStatusEnum status;
/**
* 开始时间:学生进入考试的时间
*/
private LocalDateTime startTime;
/**
* 结束时间:学生提交考试的时间(未提交则为 null)
*/
private LocalDateTime endTime;
/**
* 得分:考试最终分数(客观题自动打分 + 主观题手动打分)
*/
private BigDecimal score;
/**
* 总分:试卷满分(与 paper 表总分一致)
*/
private Integer totalScore;
/**
* 切屏次数:防作弊统计(超过 5 次提醒教师)
*/
private Integer screenChangeCount;
/**
* 最后操作时间:用于异常退出后续考判断
*/
private LocalDateTime lastOperateTime;
}
(2)dto 包:数据传输对象(带参数校验)
PaperCreateDTO.java(教师创建试卷的请求 DTO)
package com.student.exam.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.util.List;
/**
* <p>
* 试卷创建请求 DTO:接收教师创建试卷的参数,含参数校验
* </p>
* @author Student Developer
*/
@Data
public class PaperCreateDTO {
/**
* 试卷名称:必填,如'Java 编程基础期末测试'
*/
@NotBlank(message = "试卷名称不能为空")
private String paperName;
/**
* 学科:必填,如'Java 编程'
*/
@NotBlank(message = "学科不能为空")
private String subject;
/**
* 考试时长:必填,单位分钟(如 60、90)
*/
@NotNull(message = "考试时长不能为空")
@Positive(message = "考试时长必须大于 0")
private Integer examDuration;
/**
* 总分:必填,试卷满分(如 100)
*/
@NotNull(message = "试卷总分不能为空")
@Positive(message = "试卷总分必须大于 0")
private Integer totalScore;
/**
* 组卷方式:0-手动组卷 1-随机组卷
*/
@NotNull(message = "组卷方式不能为空")
private Integer paperType;
/**
* 手动组卷 - 试题 ID 列表:paperType=0 时必填
*/
private List<Long> questionIds;
/**
* 随机组卷 - 参数:paperType=1 时必填
*/
private RandomPaperParam randomParam;
/**
* 考试开始时间:可选,不填则学生可立即开始
*/
private String start_time;
/**
* 考试截止时间:可选,不填则无截止时间
*/
private String end_time;
/**
* 随机组卷参数(内部类)
*/
@Data
public static class RandomPaperParam {
/**
* 学科:随机组卷的试题学科范围
*/
@NotBlank(message = "随机组卷需指定学科")
private String subject;
/**
* 单选 - 题数:如 10
*/
@NotNull(message = "随机组卷需指定单选题数")
@Positive(message = "单选题数必须大于 0")
private Integer singleCount;
/**
* 单选 - 每题分值:如 2
*/
@NotNull(message = "随机组卷需指定单选题分值")
@Positive(message = "单选题分值必须大于 0")
private Integer singleScore;
/**
* 多选 - 题数:如 5
*/
@NotNull(message = "随机组卷需指定多选题数")
@Positive(message = "多选题数必须大于 0")
private Integer multipleCount;
/**
* 多选 - 每题分值:如 4
*/
@NotNull(message = "随机组卷需指定多选题分值")
@Positive(message = "多选题分值必须大于 0")
private Integer multipleScore;
/**
* 判断 - 题数:如 10
*/
@NotNull(message = "随机组卷需指定判断题数")
@Positive(message = "判断题数必须大于 0")
private Integer judgeCount;
/**
* 判断 - 每题分值:如 1
*/
@NotNull(message = "随机组卷需指定判断题分值")
@Positive(message = "判断题分值必须大于 0")
private Integer judgeScore;
/**
* 简答 - 题数:如 3
*/
@NotNull(message = "随机组卷需指定简答题数")
@Positive(message = "简答题数必须大于 0")
private Integer essayCount;
/**
* 简答 - 每题分值:如 10
*/
@NotNull(message = "随机组卷需指定简答题分值")
@Positive(message = "简答题分值必须大于 0")
private Integer essayScore;
/**
* 难度分布:0-简单 1-中等 2-困难(多个用逗号分隔,如 0,1)
*/
@NotBlank(message = "随机组卷需指定难度分布")
private String difficulty;
}
}
(3)service 包:业务逻辑实现(含核心考试流程)
ExamServiceImpl.java(考试服务实现类,含自动批改与续考逻辑)
package com.student.exam.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.student.exam.dto.ExamSubmitDTO;
import com.student.exam.entity.*;
import com.student.exam.enums.ExamStatusEnum;
import com.student.exam.enums.QuestionTypeEnum;
import com.student.exam.mapper.*;
import com.student.exam.service.ExamService;
import com.student.exam.vo.ScoreVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;
/**
* <p>
* 考试服务实现类:处理考试开始、提交、自动批改、续考等核心业务
* </p>
* @author Student Developer
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExamServiceImpl extends ServiceImpl<ExamRecordMapper, ExamRecord> implements ExamService {
private final ExamRecordMapper examRecordMapper;
private final PaperMapper paperMapper;
private final PaperQuestionMapper paperQuestionMapper;
private final QuestionMapper questionMapper;
private final AnswerSheetMapper answerSheetMapper;
private final UserMapper userMapper;
/**
* 学生开始考试(创建考试记录,返回试卷与试题)
*/
@Override
@Transactional
public PaperDetailVO startExam(Long studentId, Long paperId) {
log.info("学生开始考试:学生 ID={}, 试卷 ID={}", studentId, paperId);
// 1. 校验学生身份(必须是学生角色)
User student = userMapper.selectById(studentId);
if (student == null || !"student".equals(student.getRole())) {
throw new RuntimeException("无效的学生账号,无法开始考试");
}
// 2. 校验试卷状态(必须存在且启用)
Paper paper = paperMapper.selectById(paperId);
if (paper == null) {
throw new RuntimeException("试卷不存在");
}
if (paper.getStatus() != 1) {
throw new RuntimeException("该试卷已禁用,无法进行考试");
}
// 3. 校验是否已参与过该考试(避免重复考试)
LambdaQueryWrapper<ExamRecord> recordWrapper = new LambdaQueryWrapper<>();
recordWrapper.eq(ExamRecord::getStudentId, studentId).eq(ExamRecord::getPaperId, paperId)
.in(ExamRecord::getStatus, ExamStatusEnum.IN_PROGRESS, ExamStatusEnum.SUBMITTED, ExamStatusEnum.GRADED);
if (examRecordMapper.exists(recordWrapper)) {
throw new RuntimeException("您已参与过该考试,无法重复考试");
}
// 4. 校验考试时间范围(若设置了开始/截止时间)
LocalDateTime now = LocalDateTime.now();
if (paper.getStartTime() != null && now.isBefore(paper.getStartTime())) {
throw new RuntimeException("考试尚未开始,开始时间:" + paper.getStartTime());
}
if (paper.getEndTime() != null && now.isAfter(paper.getEndTime())) {
throw new RuntimeException("考试已截止,无法开始");
}
// 5. 创建考试记录(状态设为'进行中')
ExamRecord examRecord = new ExamRecord();
examRecord.setStudentId(studentId);
examRecord.setPaperId(paperId);
examRecord.setStatus(ExamStatusEnum.IN_PROGRESS);
examRecord.setStartTime(now);
examRecord.setLastOperateTime(now);
examRecord.setTotalScore(paper.getTotalScore());
examRecord.setScreenChangeCount(0); // 初始切屏次数为 0
examRecordMapper.insert(examRecord);
// 6. 查询试卷关联的试题(按题型排序)
List<PaperQuestion> paperQuestions = paperQuestionMapper.selectList(new LambdaQueryWrapper<PaperQuestion>().eq(PaperQuestion::getPaperId, paperId));
List<Long> questionIds = paperQuestions.stream().map(PaperQuestion::getQuestionId).collect(Collectors.toList());
List<Question> questions = questionMapper.selectBatchIds(questionIds);
// 7. 转换为 PaperDetailVO(隐藏正确答案,仅返回题干与选项)
PaperDetailVO paperDetailVO = new PaperDetailVO();
paperDetailVO.setPaperId(paperId);
paperDetailVO.setPaperName(paper.getPaperName());
paperDetailVO.setExamDuration(paper.getExamDuration());
paperDetailVO.setTotalScore(paper.getTotalScore());
paperDetailVO.setExamRecordId(examRecord.getId()); // 关联考试记录 ID
// 组装试题 VO(隐藏正确答案)
List<QuestionVO> questionVOList = questions.stream().map(question -> {
QuestionVO questionVO = new QuestionVO();
questionVO.setId(question.getId());
questionVO.setContent(question.getContent());
questionVO.setType(question.getType());
questionVO.setScore(question.getScore());
// 仅填充选项(单选/多选/判断),简答无选项
if (QuestionTypeEnum.SINGLE_CHOICE.equals(question.getType()) || QuestionTypeEnum.MULTIPLE_CHOICE.equals(question.getType()) || QuestionTypeEnum.JUDGE.equals(question.getType())) {
questionVO.setOptionA(question.getOptionA());
questionVO.setOptionB(question.getOptionB());
questionVO.setOptionC(question.getOptionC());
questionVO.setOptionD(question.getOptionD());
}
return questionVO;
}).collect(Collectors.toList());
paperDetailVO.setQuestions(questionVOList);
log.info("学生考试初始化完成:考试记录 ID={}", examRecord.getId());
return paperDetailVO;
}
/**
* 学生提交考试(自动批改客观题,生成答题记录)
*/
@Override
@Transactional
public void submitExam(ExamSubmitDTO submitDTO) {
log.info("学生提交考试:考试记录 ID={}, 学生 ID={}", submitDTO.getExamRecordId(), submitDTO.getStudentId());
// 1. 校验考试记录状态(必须是'进行中')
ExamRecord examRecord = examRecordMapper.selectById(submitDTO.getExamRecordId());
if (examRecord == null) {
throw new RuntimeException("考试记录不存在");
}
if (!ExamStatusEnum.IN_PROGRESS.equals(examRecord.getStatus())) {
throw new RuntimeException("考试状态异常,无法提交");
}
// 校验学生身份一致性
if (!examRecord.getStudentId().equals(submitDTO.getStudentId())) {
throw new RuntimeException("无权提交他人考试");
}
// 2. 校验试卷截止时间(若已逾期,状态设为'已逾期')
Paper paper = paperMapper.selectById(examRecord.getPaperId());
LocalDateTime now = LocalDateTime.now();
if (paper.getEndTime() != null && now.isAfter(paper.getEndTime())) {
examRecord.setStatus(ExamStatusEnum.OVERDUE);
examRecordMapper.updateById(examRecord);
throw new RuntimeException("考试已逾期,提交失败");
}
// 3. 保存学生答题记录,自动批改客观题
BigDecimal totalScore = BigDecimal.ZERO; // 客观题总分
List<AnswerSheet> answerSheets = submitDTO.getAnswers().stream().map(answer -> {
AnswerSheet answerSheet = new AnswerSheet();
answerSheet.setExamRecordId(submitDTO.getExamRecordId());
answerSheet.setQuestionId(answer.getQuestionId());
answerSheet.setStudentAnswer(answer.getStudentAnswer());
answerSheet.setCreateTime(now);
// 查询试题信息(用于判分)
Question question = questionMapper.selectById(answer.getQuestionId());
if (question == null) {
throw new RuntimeException("试题不存在:ID=" + answer.getQuestionId());
}
// 客观题自动批改(单选/多选/判断)
if (QuestionTypeEnum.SINGLE_CHOICE.equals(question.getType()) || QuestionTypeEnum.JUDGE.equals(question.getType())) {
// 单选/判断:直接比对答案
boolean isCorrect = question.getCorrectAnswer().equals(answer.getStudentAnswer());
answerSheet.setIsCorrect(isCorrect ? 1 : 0);
answerSheet.setScore(isCorrect ? question.getScore() : 0);
totalScore = totalScore.add(BigDecimal.valueOf(isCorrect ? question.getScore() : 0));
} else if (QuestionTypeEnum.MULTIPLE_CHOICE.equals(question.getType())) {
// 多选:按逗号分割后排序,再比对(避免选项顺序影响判分)
String correctAnswer = sortAnswer(question.getCorrectAnswer());
String studentAnswer = sortAnswer(answer.getStudentAnswer());
boolean isCorrect = correctAnswer.equals(studentAnswer);
answerSheet.setIsCorrect(isCorrect ? 1 : 0);
answerSheet.setScore(isCorrect ? question.getScore() : 0);
totalScore = totalScore.add(BigDecimal.valueOf(isCorrect ? question.getScore() : 0));
} else {
// 主观题(简答):待教师手动批改
answerSheet.setIsCorrect(0); // 未批改
answerSheet.setScore(0);
answerSheet.setTeacherComment("");
}
return answerSheet;
}).collect(Collectors.toList());
// 4. 批量保存答题记录
answerSheetMapper.batchInsert(answerSheets);
// 5. 更新考试记录(状态设为'已提交',填充得分与结束时间)
examRecord.setStatus(ExamStatusEnum.SUBMITTED);
examRecord.setEndTime(now);
examRecord.setScore(totalScore); // 仅客观题得分,主观题批改后更新
examRecordMapper.updateById(examRecord);
log.info("学生考试提交成功:考试记录 ID={}, 客观题得分={}/{}", submitDTO.getExamRecordId(), totalScore, examRecord.getTotalScore());
}
/**
* 教师批改主观题(更新得分与评语)
*/
@Override
@Transactional
public void gradeEssayQuestion(Long teacherId, Long examRecordId, List<GradeDTO> gradeList) {
log.info("教师批改主观题:教师 ID={}, 考试记录 ID={}, 待批改试题数={}", teacherId, examRecordId, gradeList.size());
// 1. 校验教师身份
User teacher = userMapper.selectById(teacherId);
if (teacher == null || !"teacher".equals(teacher.getRole())) {
throw new RuntimeException("无效的教师账号,无法批改作业");
}
// 2. 校验考试记录状态(必须是'已提交')
ExamRecord examRecord = examRecordMapper.selectById(examRecordId);
if (examRecord == null) {
throw new RuntimeException("考试记录不存在");
}
if (!ExamStatusEnum.SUBMITTED.equals(examRecord.getStatus())) {
throw new RuntimeException("考试未提交,无法批改");
}
// 3. 校验待批改试题(必须是主观题,且属于该考试)
BigDecimal essayTotalScore = BigDecimal.ZERO;
for (GradeDTO grade : gradeList) {
// 查询试题(必须是简答题)
Question question = questionMapper.selectById(grade.getQuestionId());
if (question == null) {
throw new RuntimeException("试题不存在:ID=" + grade.getQuestionId());
}
if (!QuestionTypeEnum.ESSAY.equals(question.getType())) {
throw new RuntimeException("试题类型错误,仅支持批改主观题:ID=" + grade.getQuestionId());
}
// 校验答题记录(必须属于该考试)
LambdaQueryWrapper<AnswerSheet> answerWrapper = new LambdaQueryWrapper<>();
answerWrapper.eq(AnswerSheet::getExamRecordId, examRecordId).eq(AnswerSheet::getQuestionId, grade.getQuestionId());
AnswerSheet answerSheet = answerSheetMapper.selectOne(answerWrapper);
if (answerSheet == null) {
throw new RuntimeException("该考试无此试题答题记录:试题 ID=" + grade.getQuestionId());
}
// 校验得分(不能超过试题满分)
if (grade.getScore() < 0 || grade.getScore() > question.getScore()) {
throw new RuntimeException("得分无效,试题满分:" + question.getScore() + ",提交得分:" + grade.getScore());
}
// 更新答题记录(得分与评语)
answerSheet.setScore(grade.getScore());
answerSheet.setTeacherComment(grade.getComment());
answerSheet.setIsCorrect(grade.getScore() >= question.getScore() * 0.6 ? 1 : 0); // 60% 以上视为正确
answerSheet.setGradeBy(teacherId);
answerSheet.setGradeTime(LocalDateTime.now());
answerSheetMapper.updateById(answerSheet);
// 累计主观题总分
essayTotalScore = essayTotalScore.add(BigDecimal.valueOf(grade.getScore()));
}
// 4. 更新考试记录总分与状态(客观题得分 + 主观题得分)
BigDecimal finalScore = examRecord.getScore().add(essayTotalScore);
examRecord.setScore(finalScore);
examRecord.setStatus(ExamStatusEnum.GRADED);
examRecordMapper.updateById(examRecord);
log.info("主观题批改完成:考试记录 ID={}, 最终得分={}/{}", examRecordId, finalScore, examRecord.getTotalScore());
}
/**
* 学生续考(异常退出后恢复考试)
*/
@Override
public PaperDetailVO resumeExam(Long studentId, Long examRecordId) {
log.info("学生续考:学生 ID={}, 考试记录 ID={}", studentId, examRecordId);
// 1. 校验考试记录(必须存在且状态为'进行中')
ExamRecord examRecord = examRecordMapper.selectById(examRecordId);
if (examRecord == null) {
throw new RuntimeException("考试记录不存在");
}
if (!ExamStatusEnum.IN_PROGRESS.equals(examRecord.getStatus())) {
throw new RuntimeException("考试状态异常,无法续考");
}
// 校验学生身份一致性
if (!examRecord.getStudentId().equals(studentId)) {
throw new RuntimeException("无权续考他人考试");
}
// 2. 校验是否已逾期(若试卷设置了截止时间)
Paper paper = paperMapper.selectById(examRecord.getPaperId());
LocalDateTime now = LocalDateTime.now();
if (paper.getEndTime() != null && now.isAfter(paper.getEndTime())) {
// 逾期则更新状态为'已逾期'
examRecord.setStatus(ExamStatusEnum.OVERDUE);
examRecordMapper.updateById(examRecord);
throw new RuntimeException("考试已截止,无法续考");
}
// 3. 计算剩余考试时间(原考试时长 - 已消耗时间)
long usedMinutes = ChronoUnit.MINUTES.between(examRecord.getStartTime(), now);
int remainingMinutes = paper.getExamDuration() - (int) usedMinutes;
if (remainingMinutes <= 0) {
// 时间已用完,自动提交
examRecord.setStatus(ExamStatusEnum.OVERDUE);
examRecordMapper.updateById(examRecord);
throw new RuntimeException("考试时间已用完,无法续考");
}
// 4. 更新最后操作时间(避免被判定为异常)
examRecord.setLastOperateTime(now);
examRecordMapper.updateById(examRecord);
// 5. 返回试卷详情与已答题记录(用于前端恢复考试)
PaperDetailVO paperDetailVO = new PaperDetailVO();
paperDetailVO.setPaperId(paper.getId());
paperDetailVO.setPaperName(paper.getPaperName());
paperDetailVO.setExamDuration(remainingMinutes); // 剩余考试时间
paperDetailVO.setTotalScore(paper.getTotalScore());
paperDetailVO.setExamRecordId(examRecordId);
// 补充已答题记录
List<AnswerSheet> answerSheets = answerSheetMapper.selectList(new LambdaQueryWrapper<AnswerSheet>().eq(AnswerSheet::getExamRecordId, examRecordId));
paperDetailVO.setAnsweredQuestions(answerSheets);
// 补充试题列表(隐藏正确答案)
List<PaperQuestion> paperQuestions = paperQuestionMapper.selectList(new LambdaQueryWrapper<PaperQuestion>().eq(PaperQuestion::getPaperId, paper.getId()));
List<Long> questionIds = paperQuestions.stream().map(PaperQuestion::getQuestionId).collect(Collectors.toList());
List<Question> questions = questionMapper.selectBatchIds(questionIds);
List<QuestionVO> questionVOList = questions.stream().map(question -> {
QuestionVO questionVO = new QuestionVO();
questionVO.setId(question.getId());
questionVO.setContent(question.getContent());
questionVO.setType(question.getType());
questionVO.setScore(question.getScore());
// 填充选项(仅客观题)
if (QuestionTypeEnum.SINGLE_CHOICE.equals(question.getType()) || QuestionTypeEnum.MULTIPLE_CHOICE.equals(question.getType()) || QuestionTypeEnum.JUDGE.equals(question.getType())) {
questionVO.setOptionA(question.getOptionA());
questionVO.setOptionB(question.getOptionB());
questionVO.setOptionC(question.getOptionC());
questionVO.setOptionD(question.getOptionD());
}
return questionVO;
}).collect(Collectors.toList());
paperDetailVO.setQuestions(questionVOList);
log.info("学生续考成功:考试记录 ID={}, 剩余时间={}分钟", examRecordId, remainingMinutes);
return paperDetailVO;
}
/**
* 辅助方法:排序多选题答案(按逗号分割后排序,统一格式)
*/
private String sortAnswer(String answer) {
if (answer == null || answer.isEmpty()) {
return "";
}
// 按逗号分割,排序后重新拼接
return java.util.Arrays.stream(answer.split(",")).sorted().collect(Collectors.joining(","));
}
}
四、网页展示
为贴合学生在教室、实验室使用电脑考试的场景,前端采用 Bootstrap 实现响应式布局,界面简洁无冗余,突出'考试'核心功能。以下是核心页面的设计与功能:
1. 学生端 - 考试列表页
- 布局:顶部是'我的考试'标题与搜索栏(按试卷名称/学科搜索),中间是考试列表(卡片式展示),底部是分页控件;
- 核心功能:卡片显示试卷名称、学科、考试时长、总分、开始/截止时间,状态标签区分'未开始''可参加''已截止';点击'开始考试'按钮,若未到开始时间则提示'考试未开始',若已截止则提示'无法参加',否则跳转至考试页面;
- 细节设计:'可参加'状态的卡片添加浅蓝色边框,突出可操作项;考试开始前 10 分钟,卡片右上角显示'即将开始'橙色标签,提醒学生及时准备。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的考试 - 在线考试系统</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/font-awesome.min.css" rel="stylesheet">
<style>
.exam-card { border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 20px; transition: all 0.3s; }
.exam-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.05); transform: translateY(-2px); }
.exam-card.available { border-left: 4px solid #409eff; }
.status-badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; }
.status-not-start { background-color: #e6f7ff; color: #1890ff; }
.status-available { background-color: #f0f9eb; color: #52c41a; }
.status-overdue { background-color: #fff2e8; color: #fa8c16; }
.countdown-tag { position: absolute; top: 15px; right: 15px; background-color: #fff3cd; color: #856404; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; }
</style>
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
<div class="container">
<a class="navbar-brand text-primary" href="/student/index"><i class="fa fa-pencil-square-o mr-2"></i>在线考试系统</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link active" href="/student/exam-list">我的考试</a></li>
<li class="nav-item"><a class="nav-link" href="/student/score-list">我的成绩</a></li>
<li class="nav-item"><a class="nav-link" href="/student/profile">个人中心</a></li>
</ul>
<!-- 学生信息 -->
<div class="dropdown">
<button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown"><i class="fa fa-user mr-1"></i> 学号:2024001(张三)</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/student/profile">个人信息</a></li>
<li><a class="dropdown-item" href="/student/change-pwd">修改密码</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/login">退出登录</a></li>
</ul>
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>我的考试</h4>
<div class="search-box">
<div class="input-group">
<input type="text" class="form-control" placeholder="搜索试卷名称/学科" id="searchInput">
<button class="btn btn-primary" type="button" id="searchBtn"><i class="fa fa-search"></i></button>
</div>
</div>
</div>
<!-- 考试列表 -->
<div class="row" id="examList">
<!-- 考试卡片 1:可参加 -->
<div class="col-md-6 col-lg-4">
<div class="exam-card available position-relative">
<span class="status-badge status-available">可参加</span>
<h5 class="mt-2 mb-1">Java 编程基础期末测试</h5>
<p class="text-muted mb-1"><i class="fa fa-book mr-1"></i> 学科:Java 编程</p>
<p class="text-muted mb-1"><i class="fa fa-clock-o mr-1"></i> 考试时长:90 分钟</p>
<p class="text-muted mb-1"><i class="fa fa-score mr-1"></i> 总分:100 分</p>
<p class="text-muted mb-3"><i class="fa fa-calendar mr-1"></i> 时间:2024-06-20 09:00 - 2024-06-20 11:00</p>
<button class="btn btn-primary w-100 start-exam-btn" data-paper-id="1"><i class="fa fa-play-circle mr-1"></i>开始考试</button>
</div>
</div>
<!-- 考试卡片 2:未开始(即将开始) -->
<div class="col-md-6 col-lg-4">
<div class="exam-card position-relative">
<span class="countdown-tag">即将开始(10 分钟后)</span>
<span class="status-badge status-not-start">未开始</span>
<h5 class="mt-2 mb-1">计算机网络期中测试</h5>
<p class="text-muted mb-1"><i class="fa fa-book mr-1"></i> 学科:计算机基础</p>
<p class="text-muted mb-1"><i class="fa fa-clock-o mr-1"></i> 考试时长:60 分钟</p>
<p class="text-muted mb-1"><i class="fa fa-score mr-1"></i> 总分:80 分</p>
<p class="text-muted mb-3"><i class="fa fa-calendar mr-1"></i> 时间:2024-06-20 14:30 - 2024-06-20 15:30</p>
<button class="btn btn-secondary w-100" disabled><i class="fa fa-lock mr-1"></i>未到开始时间</button>
</div>
</div>
<!-- 考试卡片 3:已截止 -->
<div class="col-md-6 col-lg-4">
<div class="exam-card position-relative">
<span class="status-badge status-overdue">已截止</span>
<h5 class="mt-2 mb-1">高等数学(上)单元测试</h5>
<p class="text-muted mb-1"><i class="fa fa-book mr-1"></i> 学科:高等数学</p>
<p class="text-muted mb-1"><i class="fa fa-clock-o mr-1"></i> 考试时长:120 分钟</p>
<p class="text-muted mb-1"><i class="fa fa-score mr-1"></i> 总分:150 分</p>
<p class="text-muted mb-3"><i class="fa fa-calendar mr-1"></i> 时间:2024-06-15 09:00 - 2024-06-15 11:00</p>
<button class="btn btn-secondary w-100" disabled><i class="fa fa-times-circle mr-1"></i>已截止</button>
</div>
</div>
</div>
<!-- 分页控件 -->
<nav aria-label="Page navigation" class="mt-5">
<ul class="pagination justify-content-center">
<li class="page-item disabled"><a class="page-link" href="#" tabindex="-1">上一页</a></li>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item"><a class="page-link" href="#">下一页</a></li>
</ul>
</nav>
</div>
<!-- 引入脚本 -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 开始考试按钮点击事件
$(".start-exam-btn").click(function(){
const paperId = $(this).data("paper-id");
// 模拟请求后端,检查是否可开始考试
$.ajax({
url:"/api/student/exam/start",
type:"POST",
data:JSON.stringify({ studentId:1,// 从登录态获取 paperId: paperId }),
contentType:"application/json",
success:function(res){
if(res.code ===200){// 跳转至考试页面,携带考试记录 ID
window.location.href =`/student/exam/do?examRecordId=${res.data.examRecordId}`;
}else{
alert(res.msg);
}},
error:function(){alert("请求失败,请重试");}});
});
// 搜索功能
$("#searchBtn").click(function(){
const keyword = $("#searchInput").val().trim();
if(keyword ===""){alert("请输入搜索关键词");return;}
// 模拟搜索请求
alert(`搜索关键词:${keyword},共找到 3 条结果`);
// 实际开发中需刷新列表
});
</script>
</body>
</html>
2. 学生端 - 考试答题页
- 布局:顶部是考试信息栏(试卷名称、剩余时间、切屏次数提醒),左侧是试题导航栏(按题号排列,区分'未答''已答'),中间是试题答题区(根据题型显示选项/文本框),底部是'上一题''下一题''提交考试'按钮;
- 核心功能:剩余时间实时倒计时,时间不足 10 分钟时变红提醒;学生选择答案后自动标记为'已答',切换试题时自动保存答案;切屏超过 5 次时,弹出'切屏次数过多,将影响成绩'提示;点击'提交考试'时弹出确认框,确认后提交所有答案并跳转至'考试提交成功'页面;
- 细节设计:试题导航栏的题号按钮用不同颜色区分状态(未答=灰色、已答=蓝色、当前题=绿色);主观题(简答)提供富文本编辑器,支持换行、列表等格式;自动保存答案(每 30 秒保存一次),避免浏览器崩溃导致答案丢失。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>考试答题 - 在线考试系统</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/font-awesome.min.css" rel="stylesheet">
<style>
.exam-header { background-color: #fff; border-bottom: 1px solid #e9ecef; padding: 10px 20px; margin-bottom: 20px; }
.countdown { font-size: 1.2rem; font-weight: bold; color: #dc3545; }
.question-nav { background-color: #fff; border: 1px solid #e9ecef; border-radius: 8px; padding: 15px; height: calc(100vh - 180px); overflow-y: auto; }
.question-btn { width: 35px; height: 35px; margin: 5px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 1px solid #e9ecef; background-color: #f8f9fa; cursor: pointer; transition: all 0.2s; }
.question-btn.answered { background-color: #e6f7ff; border-color: #91d5ff; color: #1890ff; }
.question-btn.current { background-color: #f0f9eb; border-color: #73d13d; color: #52c41a; font-weight: bold; }
.question-btn.unanswered { background-color: #f8f9fa; border-color: #e9ecef; color: #495057; }
.question-content { background-color: #fff; border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; min-height: calc(100vh - 180px); }
.option-item { margin-bottom: 10px; padding: 10px; border: 1px solid #e9ecef; border-radius: 4px; cursor: pointer; transition: all 0.2s; }
.option-item:hover { background-color: #f8f9fa; }
.option-item.selected { background-color: #e6f7ff; border-color: #91d5ff; }
.essay-input { width: 100%; min-height: 200px; border: 1px solid #e9ecef; border-radius: 4px; padding: 10px; resize: vertical; }
.screen-change-warning { position: fixed; top: 20px; right: 20px; z-index: 9999; display: none; }
</style>
</head>
<body>
<!-- 考试头部信息 -->
<div class="exam-header d-flex justify-content-between align-items-center">
<div><h5 class="mb-0"><i class="fa fa-file-text-o mr-2"></i>试卷名称:Java 编程基础期末测试</h5></div>
<div class="d-flex align-items-center gap-4">
<div><span class="text-muted">切屏次数:</span><span id="screenChangeCount">3</span><span class="text-danger" id="screenWarn" style="display: none;">(次数过多,请注意!)</span></div>
<div class="countdown"><i class="fa fa-clock-o mr-1"></i>剩余时间:<span id="remainingTime">01:25:30</span></div>
</div>
</div>
<!-- 切屏提醒弹窗 -->
<div class="alert alert-warning alert-dismissible fade show screen-change-warning" id="screenChangeAlert">
<strong>警告!</strong> 检测到切屏行为,切屏次数过多将影响考试成绩。
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<!-- 主内容区 -->
<div class="container">
<div class="row">
<!-- 试题导航栏(左侧) -->
<div class="col-md-2">
<div class="question-nav">
<h6 class="mb-3">试题导航</h6>
<div class="d-flex flex-wrap" id="questionNav">
<!-- 题号按钮将通过 JS 动态生成 -->
<div class="question-btn current" data-question-id="1">1</div>
<div class="question-btn unanswered" data-question-id="2">2</div>
<div class="question-btn answered" data-question-id="3">3</div>
<div class="question-btn unanswered" data-question-id="4">4</div>
<div class="question-btn unanswered" data-question-id="5">5</div>
<div class="question-btn unanswered" data-question-id="6">6</div>
<div class="question-btn unanswered" data-question-id="7">7</div>
<div class="question-btn unanswered" data-question-id="8">8</div>
<div class="question-btn unanswered" data-question-id="9">9</div>
<div class="question-btn unanswered" data-question-id="10">10</div>
<!-- 更多题号按钮... -->
</div>
<div class="mt-4">
<div class="d-flex align-items-center mb-2">
<div class="question-btn current mr-2"></div>
<span class="text-sm">当前题</span>
</div>
<div class="d-flex align-items-center mb-2">
<div class="question-btn answered mr-2"></div>
<span class="text-sm">已答题</span>
</div>
<div class="d-flex align-items-center">
<div class="question-btn unanswered mr-2"></div>
<span class="text-sm">未答题</span>
</div>
</div>
</div>
</div>
<!-- 试题答题区(中间) -->
<div class="col-md-10">
<div class="question-content" id="questionContent">
<!-- 第 1 题(单选题) -->
<div class="question-item" data-question-id="1">
<div class="d-flex align-items-center mb-3">
<span class="badge bg-primary mr-2">单选题(2 分)</span>
<h5 class="mb-0">1. 下列关于 Java 中'继承'的描述,正确的是?</h5>
</div>
<div class="options">
<div class="option-item" data-option="A">
<input type="radio" name="question1" id="q1A" value="A" checked>
<label for="q1A">A. Java 支持多继承</label>
</div>
<div class="option-item" data-option="B">
<input type="radio" name="question1" id="q1B" value="B">
<label for="q1B">B. 子类可以继承父类的所有成员</label>
</div>
<div class="option-item selected" data-option="C">
<input type="radio" name="question1" id="q1C" value="C">
<label for="q1C">C. 子类可以重写父类的方法</label>
</div>
<div class="option-item" data-option="D">
<input type="radio" name="question1" id="q1D" value="D">
<label for="q1D">D. 父类可以访问子类的成员</label>
</div>
</div>
</div>
<!-- 其他题目将通过 JS 动态切换 -->
</div>
<!-- 操作按钮 -->
<div class="d-flex justify-content-between mt-4">
<button class="btn btn-secondary" id="prevBtn"><i class="fa fa-angle-left mr-1"></i>上一题</button>
<button class="btn btn-secondary" id="nextBtn"> 下一题<i class="fa fa-angle-right ml-1"></i></button>
<button class="btn btn-danger" id="submitBtn"><i class="fa fa-paper-plane mr-1"></i>提交考试</button>
</div>
</div>
</div>
</div>
<!-- 提交确认模态框 -->
<div class="modal fade" id="submitConfirmModal" tabindex="-1" aria-labelledby="submitConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="submitConfirmModalLabel">确认提交考试?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>提交后将无法修改答案,请确认是否完成所有试题?</p>
<p class="text-danger">剩余时间:<span id="modalRemainingTime">01:25:30</span></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmSubmitBtn">确认提交</button>
</div>
</div>
</div>
</div>
<!-- 引入脚本 -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 考试倒计时功能
function startCountdown(){
// 从后端获取剩余时间(示例:1 小时 25 分 30 秒 = 5130 秒)
let totalSeconds = 5130;
const countdownElement = document.getElementById('remainingTime');
const modalCountdownElement = document.getElementById('modalRemainingTime');
const interval = setInterval(()=>{
totalSeconds--;
if(totalSeconds <=0){
clearInterval(interval);// 时间到自动提交
autoSubmit();
return;
}
// 转换为小时:分钟:秒
const hours = Math.floor(totalSeconds /3600);
const minutes = Math.floor((totalSeconds %3600)/60);
const seconds = totalSeconds %60;
const timeStr =`${hours.toString().padStart(2,'0')}:${minutes.toString().padStart(2,'0')}:${seconds.toString().padStart(2,'0')}`;
countdownElement.textContent = timeStr;
modalCountdownElement.textContent = timeStr;
// 剩余 10 分钟(600 秒)时变红提醒
if(totalSeconds <=600){
countdownElement.classList.add('text-danger');
modalCountdownElement.classList.add('text-danger');
}
},1000);
return interval;
}
// 切屏检测(简单实现)
let screenChangeCount = 3;
let lastVisibilityState = document.visibilityState;
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState ==='hidden'&& lastVisibilityState ==='visible'){
// 检测到切屏
screenChangeCount++;
document.getElementById('screenChangeCount').textContent = screenChangeCount;
// 显示切屏提醒
const alertElement = document.getElementById('screenChangeAlert');
alertElement.style.display ='block';
setTimeout(()=>{
alertElement.style.display ='none';
},3000);
// 切屏超过 5 次显示警告
if(screenChangeCount >=5){
document.getElementById('screenWarn').style.display ='inline';
}
}
lastVisibilityState = document.visibilityState;
});
// 上一题/下一题切换(模拟)
document.getElementById('prevBtn').addEventListener('click',()=>{
alert('切换到上一题');// 实际开发中需切换试题内容,更新题号导航状态
});
document.getElementById('nextBtn').addEventListener('click',()=>{
alert('切换到下一题');// 实际开发中需切换试题内容,更新题号导航状态
// 自动保存当前题答案
saveCurrentAnswer();
});
// 提交考试
document.getElementById('submitBtn').addEventListener('click',()=>{
// 显示确认模态框
const modal = new bootstrap.Modal(document.getElementById('submitConfirmModal'));
modal.show();
});
// 确认提交
document.getElementById('confirmSubmitBtn').addEventListener('click',()=>{
// 关闭模态框
const modal = bootstrap.Modal.getInstance(document.getElementById('submitConfirmModal'));
modal.hide();
// 提交所有答案
submitAllAnswers();
});
// 自动保存答案(每 30 秒)
function saveCurrentAnswer(){
const currentQuestionId = 1;// 实际开发中获取当前题 ID
const selectedOption = document.querySelector(`input[name="question${currentQuestionId}"]:checked`).value;
// 模拟保存请求
console.log(`自动保存试题${currentQuestionId}答案:${selectedOption}`);
}
// 定时自动保存(30 秒)
setInterval(saveCurrentAnswer,30000);
// 提交所有答案(模拟)
function submitAllAnswers(){
// 模拟提交请求
$.ajax({
url:"/api/student/exam/submit",
type:"POST",
data:JSON.stringify({ examRecordId:1001,// 从 URL 获取 answers:[{ questionId:1, studentAnswer:"C"},// 其他试题答案...]}),
contentType:"application/json",
success:function(res){
if(res.code ===200){// 跳转至提交成功页面
window.location.href ="/student/exam/submit-success?examRecordId=1001";
}else{
alert(res.msg);
}},
error:function(){alert("提交失败,请重试");}});
}
// 自动提交(时间到)
function autoSubmit(){
alert("考试时间已到,自动提交试卷");
submitAllAnswers();
}
// 初始化
window.onload=function(){
// 启动倒计时
startCountdown();
// 初始化试题导航
initQuestionNav();
};
// 初始化试题导航(模拟)
function initQuestionNav(){
// 为题号按钮添加点击事件
document.querySelectorAll('.question-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
const questionId = btn.getAttribute('data-question-id');
// 保存当前题答案
saveCurrentAnswer();
// 切换到对应试题
alert(`切换到试题${questionId}`);
// 更新题号导航状态
document.querySelectorAll('.question-btn').forEach(b=>{
b.classList.remove('current');
});
btn.classList.add('current');
});
});
}
</script>
</body>
</html>
3. 教师端 - 试题管理页
- 布局:顶部是'试题管理'标题、搜索栏(按题干/学科/题型搜索)与'添加试题''批量导入'按钮,中间是试题列表(表格形式),底部是分页控件;
- 核心功能:表格显示试题 ID、题干、题型、学科、难度、分值、状态、创建时间,操作列有'编辑''禁用/启用''删除'按钮;点击'添加试题'跳转至试题添加页面(根据题型动态显示选项输入框:单选/多选/判断显示 A-D 选项,简答隐藏选项);点击'批量导入'弹出文件上传框,支持 Excel 批量导入试题(含模板下载);
- 细节设计:状态列用标签区分'启用'(绿色)/'禁用'(灰色);支持按题型/难度/状态筛选试题(顶部筛选下拉框);表格支持'全选'批量操作(批量启用/禁用/删除),提升教师管理效率。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>试题管理 - 在线考试系统</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/font-awesome.min.css" rel="stylesheet">
<style>
.navbar { box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
.action-btn { padding: 0.25rem 0.5rem; font-size: 0.875rem; margin: 0 2px; }
.status-badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.875rem; }
.status-enabled { background-color: #d1e7dd; color: #0f5132; }
.status-disabled { background-color: #e9ecef; color: #495057; }
.filter-bar { background-color: #f8f9fa; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
.question-content { max-width: 400px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-light bg-white">
<div class="container">
<a class="navbar-brand text-primary" href="/teacher/index"><i class="fa fa-pencil-square-o mr-2"></i>在线考试系统</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item"><a class="nav-link" href="/teacher/paper-manage">试卷管理</a></li>
<li class="nav-item"><a class="nav-link active" href="/teacher/question-manage">试题管理</a></li>
<li class="nav-item"><a class="nav-link" href="/teacher/exam-manage">考试管理</a></li>
<li class="nav-item"><a class="nav-link" href="/teacher/grade-manage">批改管理</a></li>
</ul>
<!-- 教师信息 -->
<div class="dropdown">
<button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown"><i class="fa fa-user mr-1"></i> 工号:T202401(李老师)</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/teacher/profile">个人中心</a></li>
<li><a class="dropdown-item" href="/teacher/change-pwd">修改密码</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/login">退出登录</a></li>
</ul>
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<div class="container mt-4">
<!-- 顶部操作栏 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>试题管理</h4>
<div>
<button class="btn btn-outline-primary me-2" id="batchImportBtn"><i class="fa fa-upload mr-1"></i> 批量导入</button>
<button class="btn btn-primary" id="addQuestionBtn"><i class="fa fa-plus mr-1"></i> 添加试题</button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="row g-3">
<div class="col-md-3">
<input type="text" class="form-control" placeholder="搜索题干/学科" id="searchInput">
</div>
<div class="col-md-2">
<select class="form-select" id="typeFilter">
<option value="">全部题型</option>
<option value="0">单选题</option>
<option value="1">多选题</option>
<option value="2">判断题</option>
<option value="3">简答题</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select" id="difficultyFilter">
<option value="">全部难度</option>
<option value="0">简单</option>
<option value="1">中等</option>
<option value="2">困难</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select" id="statusFilter">
<option value="">全部状态</option>
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
<div class="col-md-3">
<div class="d-flex gap-2">
<button class="btn btn-primary flex-grow-1" id="searchBtn"><i class="fa fa-search mr-1"></i> 搜索</button>
<button class="btn btn-outline-secondary" id="resetBtn"><i class="fa fa-refresh mr-1"></i> 重置</button>
</div>
</div>
</div>
</div>
<!-- 试题管理表格 -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 50px;"><input type="checkbox" id="selectAll"></th>
<th>试题 ID</th>
<th>题干</th>
<th>题型</th>
<th>学科</th>
<th>难度</th>
<th>分值</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<!-- 试题 1:单选题(启用) -->
<tr>
<td><input type="checkbox" class="question-check" data-question-id="1"></td>
<td>1</td>
<td class="question-content">下列关于 Java 中'继承'的描述,正确的是?</td>
<td>单选题</td>
<td>Java 编程</td>
<td>中等</td>
<td>2</td>
<td><span class="status-badge status-enabled">启用</span></td>
<td>2024-06-01</td>
<td><button class="btn btn-outline-primary action-btn edit-btn" data-question-id="1">编辑</button><button class="btn btn-outline-secondary action-btn disable-btn" data-question-id="1">禁用</button></td>
</tr>
<!-- 试题 2:多选题(启用) -->
<tr>
<td><input type="checkbox" class="question-check" data-question-id="2"></td>
<td>2</td>
<td class="question-content">下列属于 Java 集合框架中的接口的是?(多选)</td>
<td>多选题</td>
<td>Java 编程</td>
<td>困难</td>
<td>4</td>
<td><span class="status-badge status-enabled">启用</span></td>
<td>2024-06-02</td>
<td><button class="btn btn-outline-primary action-btn edit-btn" data-question-id="2">编辑</button><button class="btn btn-outline-secondary action-btn disable-btn" data-question-id="2">禁用</button></td>
</tr>
<!-- 试题 3:判断题(禁用) -->
<tr>
<td><input type="checkbox" class="question-check" data-question-id="3"></td>
<td>3</td>
<td class="question-content">Java 中的'=='既可以比较基本数据类型,也可以比较引用数据类型的地址。(判断)</td>
<td>判断题</td>
<td>Java 编程</td>
<td>简单</td>
<td>1</td>
<td><span class="status-badge status-disabled">禁用</span></td>
<td>2024-06-03</td>
<td><button class="btn btn-outline-primary action-btn edit-btn" data-question-id="3">编辑</button><button class="btn btn-outline-success action-btn enable-btn" data-question-id="3">启用</button></td>
</tr>
<!-- 试题 4:简答题(启用) -->
<tr>
<td><input type="checkbox" class="question-check" data-question-id="4"></td>
<td>4</td>
<td class="question-content">请简述 Java 中 synchronized 关键字的作用及使用场景。</td>
<td>简答题</td>
<td>Java 编程</td>
<td>困难</td>
<td>10</td>
<td><span class="status-badge status-enabled">启用</span></td>
<td>2024-06-05</td>
<td><button class="btn btn-outline-primary action-btn edit-btn" data-question-id="4">编辑</button><button class="btn btn-outline-secondary action-btn disable-btn" data-question-id="4">禁用</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 批量操作栏(选中试题后显示) -->
<div class="d-flex justify-content-between align-items-center mt-3" id="batchActionBar" style="display: none;">
<div><span>已选中 <span id="selectedCount">0</span> 道试题</span></div>
<div>
<button class="btn btn-outline-secondary me-2" id="batchEnableBtn">批量启用</button>
<button class="btn btn-outline-secondary me-2" id="batchDisableBtn">批量禁用</button>
<button class="btn btn-danger" id="batchDeleteBtn">批量删除</button>
</div>
</div>
<!-- 分页控件 -->
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item disabled"><a class="page-link" href="#" tabindex="-1">上一页</a></li>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item"><a class="page-link" href="#">下一页</a></li>
</ul>
</nav>
</div>
<!-- 批量导入模态框 -->
<div class="modal fade" id="batchImportModal" tabindex="-1" aria-labelledby="batchImportModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="batchImportModalLabel">批量导入试题</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">下载模板</label>
<a href="/static/template/question-template.xlsx" class="d-block mt-1 text-primary"><i class="fa fa-download mr-1"></i> 试题导入模板.xlsx(含使用说明)</a>
</div>
<div class="mb-3">
<label for="fileUpload" class="form-label">选择 Excel 文件</label>
<input class="form-control" type="file" id="fileUpload" accept=".xlsx,.xls">
<div class="form-text mt-1">支持.xlsx/.xls 格式,单次最多导入 100 道试题</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="confirmImportBtn">开始导入</button>
</div>
</div>
</div>
</div>
<!-- 引入脚本 -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 全选/取消全选
$('#selectAll').click(function(){
const isChecked = $(this).prop('checked');
$('.question-check').prop('checked', isChecked);
updateBatchActionBar();
});
// 单选框变化时更新批量操作栏
$('.question-check').click(function(){
updateBatchActionBar();
});
// 更新批量操作栏显示状态
function updateBatchActionBar(){
const selectedCount = $('.question-check:checked').length;
$('#selectedCount').text(selectedCount);
if(selectedCount >0){
$('#batchActionBar').show();
}else{
$('#batchActionBar').hide();
$('#selectAll').prop('checked',false);
}
}
// 添加试题按钮(跳转至添加页面)
$('#addQuestionBtn').click(function(){
window.location.href ='/teacher/question-add';
});
// 编辑试题按钮(跳转至编辑页面)
$('.edit-btn').click(function(){
const questionId = $(this).data('question-id');
window.location.href =`/teacher/question-edit?questionId=${questionId}`;
});
// 启用/禁用试题(模拟)
$('.enable-btn').click(function(){
const questionId = $(this).data('question-id');
const $tr = $(this).closest('tr');
// 更新状态标签
$tr.find('.status-badge').removeClass('status-disabled').addClass('status-enabled').text('启用');
// 更新操作按钮
$(this).removeClass('btn-outline-success enable-btn').addClass('btn-outline-secondary disable-btn').text('禁用');
alert(`试题 ID ${questionId} 已启用,可参与组卷`);
});
$('.disable-btn').click(function(){
const questionId = $(this).data('question-id');
const $tr = $(this).closest('tr');
if(confirm(`确定要禁用试题 ID ${questionId}吗?禁用后将无法参与组卷`)){
// 更新状态标签
$tr.find('.status-badge').removeClass('status-enabled').addClass('status-disabled').text('禁用');
// 更新操作按钮
$(this).removeClass('btn-outline-secondary disable-btn').addClass('btn-outline-success enable-btn').text('启用');
alert(`试题 ID ${questionId} 已禁用`);
}
});
// 批量操作(模拟)
$('#batchEnableBtn').click(function(){
const selectedIds = getSelectedQuestionIds();
alert(`批量启用试题:${selectedIds.join(', ')}`);
// 实际开发中需调用接口更新状态,并刷新表格
updateBatchActionBar();
});
$('#batchDisableBtn').click(function(){
const selectedIds = getSelectedQuestionIds();
if(confirm(`确定要批量禁用以下试题吗?\n${selectedIds.join(', ')}`)){
alert(`批量禁用试题:${selectedIds.join(', ')}`);
// 实际开发中需调用接口更新状态,并刷新表格
updateBatchActionBar();
}
});
$('#batchDeleteBtn').click(function(){
const selectedIds = getSelectedQuestionIds();
if(confirm(`确定要删除以下试题吗?删除后不可恢复!\n${selectedIds.join(', ')}`)){
alert(`批量删除试题:${selectedIds.join(', ')}`);
// 实际开发中需调用接口删除试题,并刷新表格
updateBatchActionBar();
}
});
// 获取选中的试题 ID
function getSelectedQuestionIds(){
const ids =[];
$('.question-check:checked').each(function(){
ids.push($(this).data('question-id'));
});
return ids;
}
// 批量导入按钮(显示模态框)
$('#batchImportBtn').click(function(){
const modal = new bootstrap.Modal($('#batchImportModal'));
modal.show();
});
// 确认导入按钮(模拟)
$('#confirmImportBtn').click(function(){
const fileName = $('#fileUpload').val();
if(!fileName){alert('请选择 Excel 文件');return;}
// 模拟导入请求
alert(`文件 ${fileName} 导入中...\n(实际开发中需解析 Excel 并调用接口批量添加试题)`);
// 关闭模态框
const modal = bootstrap.Modal.getInstance($('#batchImportModal'));
modal.hide();
// 重置文件选择
$('#fileUpload').val('');
});
// 搜索与筛选(模拟)
$('#searchBtn').click(function(){
const keyword = $('#searchInput').val().trim();
const type = $('#typeFilter').val();
const difficulty = $('#difficultyFilter').val();
const status = $('#statusFilter').val();
let filterText =[];
if(keyword) filterText.push(`题干/学科:${keyword}`);
if(type) filterText.push(`题型:${['单选题','多选题','判断题','简答题'][type]}`);
if(difficulty) filterText.push(`难度:${['简单','中等','困难'][difficulty]}`);
if(status) filterText.push(`状态:${status ==='1'?'启用':'禁用'}`);
alert(`筛选条件:\n${filterText.length ? filterText.join('\n'):'全部试题'}\n共找到 4 条结果`);
});
// 重置筛选条件
$('#resetBtn').click(function(){
$('#searchInput').val('');
$('#typeFilter').val('');
$('#difficultyFilter').val('');
$('#statusFilter').val('');
});
</script>
</body>
</html>
五、自我感想
这次开发在线考试系统,让我跳出'单纯写代码'的局限,真正体会到'软件开发是解决实际问题'的本质,收获远超课程设计本身:
1. 从'代码搬运工'到'需求解决者'的转变
最初开发时,只关注'如何实现试题添加功能',却忽略了教师'批量导入试题''按难度筛选'等实际需求——这些细节不是技术决定的,而是教学场景驱动的。AI 工具在生成代码时,会通过注释提示'需支持 Excel 批量导入(教师常用)''需添加试题难度字段(组卷时筛选)',帮助跳出'纯技术思维'。比如客观题自动批改功能,原本只简单比对答案,后来根据提示添加'多选题选项排序比对'(避免学生因选项顺序不同被判错),这正是教师批改时的真实痛点。
2. 聚焦'核心价值'
以前开发时,要花 1-2 天写 Entity、Mapper 层的重复代码(比如每个实体的 get/set、每个 Mapper 的 CRUD 方法),现在 AI 工具能快速生成规范的基础代码,还自带参数校验、事务控制和防重复提交逻辑。这让有更多精力优化'用户体验':比如为考试页面添加'自动保存答案'(每 30 秒保存一次,避免浏览器崩溃丢失答案)、为教师端添加'试题批量导入模板下载'(附带详细使用说明),这些小功能虽简单,却让系统更贴合师生使用习惯。终于明白,AI 工具不是'替代开发者',而是帮我们把时间花在更有价值的'需求落地'上。
3. 解决问题的能力在实践中飞速提升
开发中遇到的'Excel 批量导入失败''考试计时偏差'等问题,对技术的理解从'会用'变成'能用好'。比如批量导入时,Excel 中'题型'字段学生可能填'单选'而非'0',参考生成的 Excel 解析代码,添加'中文转枚举'逻辑('单选'→0、'多选'→1);再比如考试计时偏差,最初用前端定时器计时,切换标签页时会暂停,后来结合后端记录的'开始时间'计算剩余时间,从根本上解决问题。这些方法不是课本上的理论,而是实际开发中的'踩坑经验'。
六、开发总结与展望
1. 开发收获
通过这个在线考试系统的开发,有以下收获:
(1)技术能力提升:掌握了 Spring Boot+MyBatis-Plus 的开发流程,学会了使用 Bootstrap 实现响应式布局,对数据库设计和性能优化有了更深入的理解。
(2)解决问题能力:面对实际开发中的问题,学会了查阅文档、搜索解决方案,并通过调试逐步定位问题根源。AI 工具生成的代码注释和优化建议节省了很多时间。
(3)项目管理意识:学会了将一个复杂项目分解为多个模块,按优先级逐步实现,每周制定开发计划并检查进度,这种方法在有限时间内完成了所有核心功能。
2. 系统不足与优化方向
由于时间和技术水平限制,系统还有一些可以优化的地方:
(1)防作弊功能可以更完善:目前只实现了简单的切屏检测,未来可以添加摄像头监控、禁止复制粘贴、随机调整题目顺序等功能。
(2)添加智能组卷算法:根据知识点分布和难度系数自动生成更科学的试卷,而不仅仅是随机抽取。
(3)实现大数据分析:对学生答题数据进行分析,找出易错知识点,为教学提供参考。
(4)支持更多题型:如填空题、编程题(可在线编译运行)等。
3. 给其他学生开发者的建议
(1)善用开发工具:AI 工具能帮我们生成基础代码,避免重复劳动,但核心逻辑还是要自己思考和编写,不能完全依赖工具。
(2)多动手实践:看教程和文档只能掌握理论,真正的进步来自于实际开发,遇到问题不要怕,解决问题的过程就是成长的过程。
(3)学会拆解问题:复杂系统往往让人望而却步,但只要分解成一个个小模块,逐个实现,就能逐步构建出完整的系统。
(4)重视代码规范:即使是课程设计,也要养成良好的编码习惯,写注释、用有意义的变量名、遵循设计模式,这些都会让后续维护变得轻松。
这次在线考试系统的开发深刻体会到,从 0 到 1 构建一个实用的系统虽然有挑战,但也充满乐趣和成就感。作为学生,不必追求完美,重要的是在实践中学习和成长。希望我的开发记录能给其他同学带来一些启发,祝大家都能顺利完成课程设计!

