first commit

This commit is contained in:
Yakumo Hokori
2026-03-04 21:27:21 +08:00
parent 03304a1714
commit f56733b54b
21 changed files with 4078 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
package com.baobaot.exam.Controller;
import com.baobaot.exam.Service.adminService;
import com.baobaot.exam.Service.makeExamService;
import com.baobaot.exam.dao.examPaper;
import com.baobaot.exam.dao.question;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Controller
public class AdminController {
@Autowired
private adminService adminService;
@Autowired
private makeExamService makeExamService;
@GetMapping("/admin")
public String adminRedirect() {
return "redirect:/admin/exam";
}
@GetMapping("/admin/exam")
public String adminPage(Model model) {
List<question> questions = adminService.list();
model.addAttribute("questions", questions);
return "admin";
}
@PostMapping("/admin/add/choice")
public String addChoiceQuestion(@RequestParam String title,
@RequestParam Integer type,
@RequestParam(required = false) String answer_a,
@RequestParam(required = false) String answer_b,
@RequestParam(required = false) String answer_c,
@RequestParam(required = false) String answer_d,
@RequestParam String r_answer,
@RequestParam Integer score) {
adminService.addChoiceQuestion(title, type, answer_a, answer_b, answer_c, answer_d, r_answer, score);
return "redirect:/admin/exam";
}
@PostMapping("/admin/add/essay")
public String addEssayQuestion(@RequestParam String title,
@RequestParam Integer type,
@RequestParam(required = false) String answer,
@RequestParam(required = false) String r_answer,
@RequestParam Integer score) {
adminService.addEssayQuestion(title, type, answer, r_answer, score);
return "redirect:/admin/exam";
}
@GetMapping("/admin/query/type")
public String queryByType(@RequestParam Integer type, Model model) {
List<question> questions = adminService.getQuestionsByType(type);
model.addAttribute("questions", questions);
model.addAttribute("queryType", "类型: " + type);
return "admin";
}
@GetMapping("/admin/query/score")
public String queryByScore(@RequestParam Integer score, Model model) {
List<question> questions = adminService.getQuestionsByScore(score);
model.addAttribute("questions", questions);
model.addAttribute("queryScore", "分数: " + score);
return "admin";
}
@PostMapping("/admin/delete")
public String deleteByTitle(@RequestParam String title) {
adminService.deleteQuestionByTitle(title);
return "redirect:/admin/exam";
}
@GetMapping("/admin/mkexam")
public String makeExamPage(Model model) {
return "mkexam";
}
@PostMapping("/admin/mkexam/create")
@ResponseBody
public examPaper createExam(@RequestParam(required = false) String title) {
return makeExamService.createExamPaper(title);
}
@GetMapping("/admin/mkexam/detail/{uid}")
@ResponseBody
public Map<String, Object> getExamDetail(@PathVariable String uid) {
return makeExamService.getExamPaperDetail(uid);
}
@GetMapping("/admin/listexam")
public String listExamPage(@RequestParam(required = false) String title, Model model) {
List<examPaper> examPapers = makeExamService.getExamList(title);
model.addAttribute("examPapers", examPapers);
model.addAttribute("searchTitle", title);
return "listexam";
}
}

View File

@@ -0,0 +1,147 @@
package com.baobaot.exam.Controller;
import com.baobaot.exam.Service.CorrectService;
import com.baobaot.exam.Service.ExamAnswerService;
import com.baobaot.exam.Service.makeExamService;
import com.baobaot.exam.dao.examBack;
import com.baobaot.exam.dto.AnswerItem;
import com.baobaot.exam.dto.CorrectResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 批改控制器
*/
@Controller
@RequestMapping("/admin/correct")
public class CorrectController {
@Autowired
private CorrectService correctService;
@Autowired
private ExamAnswerService examAnswerService;
@Autowired
private makeExamService makeExamService;
/**
* 批改首页 - 搜索界面
*/
@GetMapping("")
public String correctPage() {
return "correct";
}
/**
* 获取试卷标题列表(下拉菜单用)
*/
@GetMapping("/titles")
@ResponseBody
public Map<String, Object> getExamTitles() {
Map<String, Object> result = new HashMap<>();
try {
List<String> titles = correctService.getExamTitleList();
result.put("success", true);
result.put("data", titles);
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return result;
}
/**
* 搜索答题记录
* @param name 做题人姓名(可选)
* @param title 试卷标题(可选)
*/
@GetMapping("/search")
@ResponseBody
public Map<String, Object> searchAnswers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String title) {
Map<String, Object> result = new HashMap<>();
try {
List<examBack> answers = correctService.searchAnswers(name, title);
result.put("success", true);
result.put("data", answers);
result.put("count", answers.size());
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return result;
}
/**
* 获取答题详情(用于批改)
* @param answerUid 答题记录UID
*/
@GetMapping("/detail/{answerUid}")
@ResponseBody
public Map<String, Object> getAnswerDetail(@PathVariable String answerUid) {
Map<String, Object> result = new HashMap<>();
try {
CorrectResult detail = correctService.getAnswerDetailForCorrect(answerUid);
result.put("success", true);
result.put("data", detail);
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return result;
}
/**
* 提交批改结果
*/
@PostMapping("/submit/{answerUid}")
@ResponseBody
public Map<String, Object> submitCorrect(
@PathVariable String answerUid,
@RequestBody Map<String, Integer> essayScores) {
Map<String, Object> result = new HashMap<>();
try {
// essayScores: key = "q_" + qid, value = 得分
int totalScore = correctService.submitCorrect(answerUid, essayScores);
result.put("success", true);
result.put("message", "批改完成");
result.put("totalScore", totalScore);
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return result;
}
/**
* 打印页面 - 纯文本试卷答案和分数
* @param answerUid 答题记录UID
*/
@GetMapping("/print/{answerUid}")
public String printPage(@PathVariable String answerUid, Model model) {
try {
CorrectResult detail = correctService.getAnswerDetailForCorrect(answerUid);
model.addAttribute("data", detail);
return "print";
} catch (Exception e) {
model.addAttribute("error", e.getMessage());
return "print";
}
}
}

View File

@@ -0,0 +1,183 @@
package com.baobaot.exam.Controller;
import com.baobaot.exam.Service.ExamAnswerService;
import com.baobaot.exam.Service.makeExamService;
import com.baobaot.exam.dao.examBack;
import com.baobaot.exam.dao.examPaper;
import com.baobaot.exam.dto.AnswerItem;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
public class ExamAnswerController {
@Autowired
private makeExamService makeExamService;
@Autowired
private ExamAnswerService examAnswerService;
/**
* 答题页面 - 根据试卷UID显示答题界面
*/
@GetMapping("/{examUid}")
public String answerPage(@PathVariable String examUid, Model model) {
// 获取试卷详情
Map<String, Object> examDetail = makeExamService.getExamPaperDetail(examUid);
if (examDetail == null) {
model.addAttribute("error", "试卷不存在");
return "error";
}
model.addAttribute("examUid", examUid);
model.addAttribute("title", examDetail.get("title") != null ? examDetail.get("title") : "未命名试卷");
model.addAttribute("choiceQuestions", examDetail.get("choiceQuestions"));
model.addAttribute("essayQuestions", examDetail.get("essayQuestions"));
model.addAttribute("choiceCount", examDetail.get("choiceCount"));
model.addAttribute("essayCount", examDetail.get("essayCount"));
model.addAttribute("totalScore", examDetail.get("totalScore"));
return "answer";
}
/**
* 用于接收前端传来的答卷数据的 DTO 内部类
*/
public static class SubmitRequest {
private String name;
private Map<String, String> answers;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Map<String, String> getAnswers() {
return answers;
}
public void setAnswers(Map<String, String> answers) {
this.answers = answers;
}
}
/**
* 提交答案
*/
@PostMapping("/{examUid}/submit")
@ResponseBody
public Map<String, Object> submitAnswer(@PathVariable String examUid,
@RequestBody SubmitRequest request) {
Map<String, Object> result = new HashMap<>();
try {
String name = request.getName();
Map<String, String> answers = request.getAnswers();
// 验证试卷是否存在
examPaper paper = makeExamService.getExamPaperByUid(examUid);
if (paper == null) {
result.put("success", false);
result.put("message", "试卷不存在");
return result;
}
// 验证姓名
if (name == null || name.trim().isEmpty()) {
result.put("success", false);
result.put("message", "请输入做题人姓名");
return result;
}
// 调用服务提交答案
String answerUid = examAnswerService.submitAnswer(examUid, name, answers);
result.put("success", true);
result.put("message", "提交成功");
result.put("answerUid", answerUid);
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return result;
}
/**
* 查询答题记录详情根据答题记录UID
*/
@GetMapping("/answer/{answerUid}")
@ResponseBody
public Map<String, Object> getAnswerDetail(@PathVariable String answerUid) {
Map<String, Object> result = new HashMap<>();
try {
examBack back = examAnswerService.getAnswerByUid(answerUid);
if (back == null) {
result.put("success", false);
result.put("message", "答题记录不存在");
return result;
}
// 解析答案内容
List<AnswerItem> answers = examAnswerService.parseAnswerContent(back.getContent());
result.put("success", true);
result.put("uid", back.getUid());
result.put("examId", back.getExamId());
result.put("name", back.getName());
result.put("answers", answers);
result.put("submitTime", back.getId()); // 如果有时间字段可以替换
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return result;
}
/**
* 查询某试卷的所有答题记录
*/
@GetMapping("/{examUid}/answers")
@ResponseBody
public Map<String, Object> getAnswersByExam(@PathVariable String examUid) {
Map<String, Object> result = new HashMap<>();
try {
// 验证试卷是否存在
examPaper paper = makeExamService.getExamPaperByUid(examUid);
if (paper == null) {
result.put("success", false);
result.put("message", "试卷不存在");
return result;
}
List<examBack> answers = examAnswerService.getAnswersByExamId(examUid);
result.put("success", true);
result.put("examUid", examUid);
result.put("examTitle", paper.getTitle());
result.put("count", answers.size());
result.put("answers", answers);
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return result;
}
}

View File

@@ -0,0 +1,60 @@
package com.baobaot.exam.Controller;
import com.baobaot.exam.Service.makeExamService;
import com.baobaot.exam.dao.examPaper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/exam")
public class ExamController {
@Autowired
private makeExamService makeExamService;
/**
* 创建试卷
*/
@PostMapping("/create")
public ResponseEntity<Map<String, Object>> createExam(@RequestParam(required = false) String title) {
Map<String, Object> result = new HashMap<>();
try {
examPaper paper = makeExamService.createExamPaper(title);
result.put("success", true);
result.put("uid", paper.getUid());
result.put("title", paper.getTitle());
result.put("select", paper.getSelect());
result.put("content", paper.getContent());
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
return ResponseEntity.badRequest().body(result);
}
}
/**
* 根据UID查询试卷
*/
@GetMapping("/get/{uid}")
public ResponseEntity<Map<String, Object>> getExam(@PathVariable String uid) {
Map<String, Object> result = new HashMap<>();
examPaper paper = makeExamService.getExamPaperByUid(uid);
if (paper != null) {
result.put("success", true);
result.put("uid", paper.getUid());
result.put("title", paper.getTitle());
result.put("select", paper.getSelect());
result.put("content", paper.getContent());
return ResponseEntity.ok(result);
} else {
result.put("success", false);
result.put("message", "试卷不存在");
return ResponseEntity.notFound().build();
}
}
}

View File

@@ -0,0 +1,42 @@
package com.baobaot.exam.Service;
import com.baobaot.exam.dao.examBack;
import com.baobaot.exam.dto.CorrectResult;
import java.util.List;
import java.util.Map;
/**
* 批改服务接口
*/
public interface CorrectService {
/**
* 搜索答题记录
* @param name 做题人姓名(可选)
* @param title 试卷标题(可选)
* @return 答题记录列表
*/
List<examBack> searchAnswers(String name, String title);
/**
* 获取所有试卷标题列表(去重)
* @return 试卷标题列表
*/
List<String> getExamTitleList();
/**
* 获取答题详情(用于批改)
* @param answerUid 答题记录UID
* @return 批改详情(包含题目、答案、选择题自动批改结果)
*/
CorrectResult getAnswerDetailForCorrect(String answerUid);
/**
* 提交批改结果
* @param answerUid 答题记录UID
* @param essayScores 简答题得分 Map<qid, score>
* @return 总得分
*/
int submitCorrect(String answerUid, Map<String, Integer> essayScores);
}

View File

@@ -0,0 +1,43 @@
package com.baobaot.exam.Service;
import com.baobaot.exam.dao.examBack;
import com.baobaot.exam.dto.AnswerItem;
import java.util.List;
import java.util.Map;
public interface ExamAnswerService {
/**
* 提交答案
* @param examUid 试卷UID
* @param name 做题人姓名
* @param answers 答案Map (key: q_题目ID, value: 答案)
* @return 答题记录UID
*/
String submitAnswer(String examUid, String name, Map<String, String> answers);
/**
* 根据答题记录UID查询答案
*/
examBack getAnswerByUid(String uid);
/**
* 根据试卷UID查询所有答题记录
*/
List<examBack> getAnswersByExamId(String examId);
/**
* 解析答题记录的content JSON
* @param content examBack.content 字段的JSON字符串
* @return 答案项列表
*/
List<AnswerItem> parseAnswerContent(String content);
/**
* 根据答题记录UID获取解析后的答案列表
* @param uid 答题记录UID
* @return 答案项列表
*/
List<AnswerItem> getParsedAnswersByUid(String uid);
}

View File

@@ -0,0 +1,249 @@
package com.baobaot.exam.Service.impl;
import com.baobaot.exam.Service.CorrectService;
import com.baobaot.exam.Service.ExamAnswerService;
import com.baobaot.exam.Service.makeExamService;
import com.baobaot.exam.dao.examBack;
import com.baobaot.exam.dao.question;
import com.baobaot.exam.dto.AnswerItem;
import com.baobaot.exam.dto.CorrectDetailItem;
import com.baobaot.exam.dto.CorrectResult;
import com.baobaot.exam.mapper.examBackMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.*;
@Service
public class CorrectServiceImpl implements CorrectService {
@Autowired
private examBackMapper examBackMapper;
@Autowired
private ExamAnswerService examAnswerService;
@Autowired
private makeExamService makeExamService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public List<examBack> searchAnswers(String name, String title) {
LambdaQueryWrapper<examBack> wrapper = new LambdaQueryWrapper<>();
// 根据姓名搜索(模糊匹配)
if (StringUtils.hasText(name)) {
wrapper.like(examBack::getName, name);
}
// 根据试卷标题搜索(精确匹配)
if (StringUtils.hasText(title)) {
wrapper.eq(examBack::getTitle, title);
}
// 按ID降序最新的在前面
wrapper.orderByDesc(examBack::getId);
return examBackMapper.selectList(wrapper);
}
@Override
public List<String> getExamTitleList() {
// 获取所有不重复的试卷标题使用Java去重更可靠
LambdaQueryWrapper<examBack> wrapper = new LambdaQueryWrapper<>();
wrapper.isNotNull(examBack::getTitle);
wrapper.ne(examBack::getTitle, ""); // 排除空字符串
List<examBack> list = examBackMapper.selectList(wrapper);
// 使用Set去重然后排序
Set<String> titleSet = new HashSet<>();
for (examBack back : list) {
if (StringUtils.hasText(back.getTitle())) {
titleSet.add(back.getTitle());
}
}
List<String> titles = new ArrayList<>(titleSet);
Collections.sort(titles); // 按字母顺序排序
return titles;
}
@Override
public CorrectResult getAnswerDetailForCorrect(String answerUid) {
// 1. 获取答题记录
examBack back = examAnswerService.getAnswerByUid(answerUid);
if (back == null) {
throw new RuntimeException("答题记录不存在");
}
// 2. 获取试卷详情
Map<String, Object> examDetail = makeExamService.getExamPaperDetail(back.getExamId());
if (examDetail == null) {
throw new RuntimeException("试卷不存在");
}
// 3. 解析答案(包含得分)
List<AnswerItem> answerItems = examAnswerService.parseAnswerContent(back.getContent());
Map<Integer, AnswerItem> answerItemMap = new HashMap<>();
for (AnswerItem item : answerItems) {
answerItemMap.put(item.getQid(), item);
}
// 4. 获取题目列表
@SuppressWarnings("unchecked")
List<question> choiceQuestions = (List<question>) examDetail.get("choiceQuestions");
@SuppressWarnings("unchecked")
List<question> essayQuestions = (List<question>) examDetail.get("essayQuestions");
// 5. 构建批改详情
List<CorrectDetailItem> detailItems = new ArrayList<>();
int choiceScore = 0;
int essayTotalScore = 0;
int exmaid = 1;
// 处理选择题(自动批改)
for (question q : choiceQuestions) {
AnswerItem answerItem = answerItemMap.get(q.getId());
String userAnswer = answerItem != null ? answerItem.getAnswer() : "";
String correctAnswer = q.getRAnswer();
boolean isCorrect = userAnswer.equalsIgnoreCase(correctAnswer);
int score = isCorrect ? q.getScore() : 0;
choiceScore += score;
CorrectDetailItem item = CorrectDetailItem.builder()
.exmaid(exmaid++)
.qid(q.getId())
.type(1)
.title(q.getTitle())
.score(q.getScore())
.userAnswer(userAnswer)
.correctAnswer(correctAnswer)
.autoCorrect(true)
.autoScore(score)
.isCorrect(isCorrect)
.build();
detailItems.add(item);
}
// 处理简答题(需要人工批改)
int essayScore = 0;
for (question q : essayQuestions) {
AnswerItem answerItem = answerItemMap.get(q.getId());
String userAnswer = answerItem != null ? answerItem.getAnswer() : "";
// 从 answerItem 中获取得分(如果已批改)
Integer itemScore = answerItem != null ? answerItem.getScore() : null;
essayTotalScore += q.getScore();
if (itemScore != null) {
essayScore += itemScore;
}
CorrectDetailItem item = CorrectDetailItem.builder()
.exmaid(exmaid++)
.qid(q.getId())
.type(2)
.title(q.getTitle())
.score(q.getScore())
.userAnswer(userAnswer)
.correctAnswer(q.getRAnswer())
.autoCorrect(false)
.autoScore(itemScore != null ? itemScore : 0)
.isCorrect(null)
.build();
detailItems.add(item);
}
// 6. 判断是否已批改exam_back.score字段不为空表示已批改
boolean isCorrected = back.getScore() != null && !back.getScore().isEmpty();
int finalScore = isCorrected ? (choiceScore + essayScore) : choiceScore;
return CorrectResult.builder()
.answerUid(answerUid)
.studentName(back.getName())
.examUid(back.getExamId())
.examTitle((String) examDetail.get("title"))
.choiceScore(choiceScore)
.essayTotalScore(essayTotalScore)
.totalScore(choiceScore + essayTotalScore)
.isCorrected(isCorrected)
.finalScore(finalScore)
.questions(detailItems)
.build();
}
@Override
public int submitCorrect(String answerUid, Map<String, Integer> essayScores) {
// 1. 获取答题记录
examBack back = examAnswerService.getAnswerByUid(answerUid);
if (back == null) {
throw new RuntimeException("答题记录不存在");
}
// 2. 获取试卷详情以计算选择题得分
Map<String, Object> examDetail = makeExamService.getExamPaperDetail(back.getExamId());
if (examDetail == null) {
throw new RuntimeException("试卷不存在");
}
// 3. 解析答案
List<AnswerItem> answerItems = examAnswerService.parseAnswerContent(back.getContent());
Map<Integer, AnswerItem> answerItemMap = new HashMap<>();
for (AnswerItem item : answerItems) {
answerItemMap.put(item.getQid(), item);
}
// 4. 计算选择题得分并设置简答题得分
@SuppressWarnings("unchecked")
List<question> choiceQuestions = (List<question>) examDetail.get("choiceQuestions");
@SuppressWarnings("unchecked")
List<question> essayQuestions = (List<question>) examDetail.get("essayQuestions");
int choiceScore = 0;
int essayScore = 0;
// 设置选择题得分
for (question q : choiceQuestions) {
AnswerItem item = answerItemMap.get(q.getId());
if (item == null) continue;
String userAnswer = item.getAnswer();
if (userAnswer != null && userAnswer.equalsIgnoreCase(q.getRAnswer())) {
item.setScore(q.getScore());
choiceScore += q.getScore();
} else {
item.setScore(0);
}
}
// 设置简答题得分
for (question q : essayQuestions) {
String key = "q_" + q.getId();
Integer score = essayScores.get(key);
if (score != null) {
AnswerItem item = answerItemMap.get(q.getId());
if (item != null) {
item.setScore(score);
essayScore += score;
}
}
}
// 5. 计算总分
int totalScore = choiceScore + essayScore;
// 6. 更新答题记录更新content和score
try {
back.setContent(objectMapper.writeValueAsString(answerItems));
} catch (Exception e) {
throw new RuntimeException("保存答案得分失败: " + e.getMessage());
}
back.setScore(String.valueOf(totalScore));
examBackMapper.updateById(back);
return totalScore;
}
}

View File

@@ -0,0 +1,138 @@
package com.baobaot.exam.Service.impl;
import com.baobaot.exam.Service.ExamAnswerService;
import com.baobaot.exam.Service.makeExamService;
import com.baobaot.exam.dao.examBack;
import com.baobaot.exam.dao.examPaper;
import com.baobaot.exam.dao.question;
import com.baobaot.exam.dto.AnswerItem;
import com.baobaot.exam.mapper.examBackMapper;
import com.baobaot.exam.util.UidGenerator;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class ExamAnswerServiceImpl implements ExamAnswerService {
@Autowired
private makeExamService makeExamService;
@Autowired
private examBackMapper examBackMapper;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public String submitAnswer(String examUid, String name, Map<String, String> answers) {
try {
// 获取试卷题目
Map<String, Object> examDetail = makeExamService.getExamPaperDetail(examUid);
if (examDetail == null) {
throw new RuntimeException("试卷不存在");
}
@SuppressWarnings("unchecked")
List<question> choiceQuestions = (List<question>) examDetail.get("choiceQuestions");
@SuppressWarnings("unchecked")
List<question> essayQuestions = (List<question>) examDetail.get("essayQuestions");
// 构建答案JSON格式: [{"exmaid": 1, "qid": 3, "answer": "A"}]
List<AnswerItem> answerList = new ArrayList<>();
// 处理选择题答案
int exmaid = 1; // 试卷中的题目序号从1开始
for (question q : choiceQuestions) {
AnswerItem item = AnswerItem.builder()
.exmaid(exmaid++)
.qid(q.getId())
.answer(answers.getOrDefault("q_" + q.getId(), ""))
.build();
answerList.add(item);
}
// 处理简答题答案
for (question q : essayQuestions) {
AnswerItem item = AnswerItem.builder()
.exmaid(exmaid++)
.qid(q.getId())
.answer(answers.getOrDefault("q_" + q.getId(), ""))
.build();
answerList.add(item);
}
// 生成唯一UID
String uid = UidGenerator.generateUniqueUid(this::isAnswerUidExists);
// 获取试卷标题
examPaper paper = makeExamService.getExamPaperByUid(examUid);
String examTitle = paper != null ? paper.getTitle() : "";
// 保存到数据库
examBack back = examBack.builder()
.uid(uid)
.examId(examUid)
.title(examTitle)
.name(name.trim())
.content(objectMapper.writeValueAsString(answerList))
.build();
examBackMapper.insert(back);
return uid;
} catch (Exception e) {
throw new RuntimeException("提交失败: " + e.getMessage(), e);
}
}
@Override
public examBack getAnswerByUid(String uid) {
LambdaQueryWrapper<examBack> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(examBack::getUid, uid);
return examBackMapper.selectOne(wrapper);
}
@Override
public List<examBack> getAnswersByExamId(String examId) {
LambdaQueryWrapper<examBack> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(examBack::getExamId, examId);
return examBackMapper.selectList(wrapper);
}
/**
* 检查答题记录UID是否已存在
*/
private boolean isAnswerUidExists(String uid) {
LambdaQueryWrapper<examBack> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(examBack::getUid, uid);
return examBackMapper.selectCount(wrapper) > 0;
}
@Override
public List<AnswerItem> parseAnswerContent(String content) {
try {
if (content == null || content.trim().isEmpty()) {
return new ArrayList<>();
}
return objectMapper.readValue(content, new TypeReference<List<AnswerItem>>() {});
} catch (Exception e) {
throw new RuntimeException("解析答案内容失败: " + e.getMessage(), e);
}
}
@Override
public List<AnswerItem> getParsedAnswersByUid(String uid) {
examBack back = getAnswerByUid(uid);
if (back == null) {
return new ArrayList<>();
}
return parseAnswerContent(back.getContent());
}
}

View File

@@ -0,0 +1,37 @@
package com.baobaot.exam.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 答题记录中的单个答案项
* 对应 exam_back.content JSON 数组中的每个元素
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnswerItem {
/**
* 试卷中的题目序号(第几题)
*/
private Integer exmaid;
/**
* 题目在题库中的ID
*/
private Integer qid;
/**
* 用户提交的答案
*/
private String answer;
/**
* 该题得分(批改后填充)
*/
private Integer score;
}

View File

@@ -0,0 +1,66 @@
package com.baobaot.exam.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 批改详情中的单个题目项
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CorrectDetailItem {
/**
* 试卷中的题目序号
*/
private Integer exmaid;
/**
* 题目在题库中的ID
*/
private Integer qid;
/**
* 题目类型: 1-选择题, 2-简答题
*/
private Integer type;
/**
* 题目内容
*/
private String title;
/**
* 题目分值
*/
private Integer score;
/**
* 用户提交的答案
*/
private String userAnswer;
/**
* 正确答案(选择题)/参考答案(简答题)
*/
private String correctAnswer;
/**
* 是否自动批改选择题为true
*/
private Boolean autoCorrect;
/**
* 自动批改得分(选择题)
*/
private Integer autoScore;
/**
* 是否正确(选择题)
*/
private Boolean isCorrect;
}

View File

@@ -0,0 +1,69 @@
package com.baobaot.exam.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 批改结果DTO
* 用于返回批改页面的完整数据
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CorrectResult {
/**
* 答题记录UID
*/
private String answerUid;
/**
* 做题人姓名
*/
private String studentName;
/**
* 试卷UID
*/
private String examUid;
/**
* 试卷标题
*/
private String examTitle;
/**
* 选择题自动批改得分
*/
private Integer choiceScore;
/**
* 简答题总分值
*/
private Integer essayTotalScore;
/**
* 试卷总分
*/
private Integer totalScore;
/**
* 是否已批改
*/
private Boolean isCorrected;
/**
* 最终得分(已批改时显示)
*/
private Integer finalScore;
/**
* 题目详情列表
*/
private List<CorrectDetailItem> questions;
}

View File

@@ -0,0 +1,37 @@
package com.baobaot.exam.util;
import java.security.SecureRandom;
/**
* 生成16位随机UID工具类
* 包含A-Z, a-z, 0-9字符
*/
public class UidGenerator {
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final int UID_LENGTH = 16;
private static final SecureRandom random = new SecureRandom();
/**
* 生成16位随机UID
*/
public static String generateUid() {
StringBuilder sb = new StringBuilder(UID_LENGTH);
for (int i = 0; i < UID_LENGTH; i++) {
int index = random.nextInt(CHARACTERS.length());
sb.append(CHARACTERS.charAt(index));
}
return sb.toString();
}
/**
* 生成唯一的UID需传入检查唯一性的接口
*/
public static String generateUniqueUid(java.util.function.Function<String, Boolean> existsChecker) {
String uid;
do {
uid = generateUid();
} while (existsChecker.apply(uid));
return uid;
}
}

View File

@@ -0,0 +1,9 @@
spring:
application:
name: exam
datasource:
url: jdbc:mysql://localhost:3306/exam
# url: jdbc:mysql://192.168.1.56:3306/exam?useSSL=true&requireSSL=true&serverTimezone=UTC
username: root
password: 521707
driver-class-name: com.mysql.cj.jdbc.Driver

View File

@@ -0,0 +1,388 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>题库管理系统</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafafa;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 2.5rem;
}
.card {
background: white;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border: 1px solid #f0f0f0;
}
.card h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid #f59e0b;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 6px;
color: #555;
font-weight: 500;
}
label .required {
color: #e74c3c;
margin-left: 4px;
}
input, select, textarea {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #f59e0b;
}
textarea {
resize: vertical;
min-height: 80px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-primary {
background: #f59e0b;
color: white;
}
.btn-primary:hover {
background: #d97706;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(245, 158, 11, 0.3);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-success {
background: #fbbf24;
color: #333;
}
.btn-success:hover {
background: #f59e0b;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.query-section {
display: flex;
gap: 12px;
align-items: flex-end;
}
.query-section .form-group {
flex: 1;
margin-bottom: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 14px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.tag {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.tag-choice {
background: #fef3c7;
color: #d97706;
}
.tag-essay {
background: #ffedd5;
color: #ea580c;
}
.tag-answer {
background: #fef9c3;
color: #a16207;
}
.score-badge {
background: #f59e0b;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 16px;
opacity: 0.5;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.tab-btn {
padding: 10px 20px;
border: 2px solid #e0e0e0;
background: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.tab-btn.active {
background: #f59e0b;
color: white;
border-color: #f59e0b;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.alert-info {
background: #fef3c7;
color: #d97706;
}
</style>
</head>
<body>
<div class="container">
<h1>题库管理系统</h1>
<!-- 添加题目 -->
<div class="card">
<h2>添加题目</h2>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('choice')">选择题 (Type 1)</button>
<button class="tab-btn" onclick="switchTab('essay')">简答题 (Type 2)</button>
</div>
<!-- 选择题表单 -->
<div id="choice-tab" class="tab-content active">
<form th:action="@{/admin/add/choice}" method="post">
<input type="hidden" name="type" value="1">
<div class="form-group">
<label>题目</label>
<textarea name="title" placeholder="请输入题目内容" required></textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label>选项 A</label>
<input type="text" name="answer_a" placeholder="选填">
</div>
<div class="form-group">
<label>选项 B</label>
<input type="text" name="answer_b" placeholder="选填">
</div>
<div class="form-group">
<label>选项 C</label>
<input type="text" name="answer_c" placeholder="选填">
</div>
<div class="form-group">
<label>选项 D</label>
<input type="text" name="answer_d" placeholder="选填">
</div>
</div>
<div class="form-group">
<label>正确答案<span class="required">*</span></label>
<select name="r_answer" required>
<option value="">请选择正确答案</option>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
<option value="D">D</option>
</select>
</div>
<div class="form-group">
<label>分值</label>
<input type="number" name="score" placeholder="请输入分值" required min="0">
</div>
<button type="submit" class="btn btn-primary">添加选择题</button>
</form>
</div>
<!-- 简答题表单 -->
<div id="essay-tab" class="tab-content">
<form th:action="@{/admin/add/essay}" method="post">
<input type="hidden" name="type" value="2">
<div class="form-group">
<label>题目</label>
<textarea name="title" placeholder="请输入题目内容" required></textarea>
</div>
<div class="form-group">
<label>学生答案参考</label>
<textarea name="answer" placeholder="选填"></textarea>
</div>
<div class="form-group">
<label>参考答案</label>
<textarea name="r_answer" placeholder="选填"></textarea>
</div>
<div class="form-group">
<label>分值</label>
<input type="number" name="score" placeholder="请输入分值" required min="0">
</div>
<button type="submit" class="btn btn-primary">添加简答题</button>
</form>
</div>
</div>
<!-- 查询 -->
<div class="card">
<h2>查询题目</h2>
<div class="grid-2">
<div>
<form th:action="@{/admin/query/type}" method="get" class="query-section">
<div class="form-group">
<label>按类型查询</label>
<select name="type" required>
<option value="1">选择题</option>
<option value="2">简答题</option>
</select>
</div>
<button type="submit" class="btn btn-success">查询</button>
</form>
</div>
<div>
<form th:action="@{/admin/query/score}" method="get" class="query-section">
<div class="form-group">
<label>按分值查询</label>
<input type="number" name="score" placeholder="请输入分值" required min="0">
</div>
<button type="submit" class="btn btn-success">查询</button>
</form>
</div>
</div>
<div style="margin-top: 16px; display: flex; gap: 12px;">
<a th:href="@{/admin/exam}" class="btn btn-primary">显示全部</a>
<a th:href="@{/admin/mkexam}" class="btn btn-success">创建试卷</a>
<a th:href="@{/admin/listexam}" class="btn btn-success">查看试卷</a>
<a th:href="@{/admin/correct}" class="btn btn-success" style="background: #f59e0b;">批改试卷</a>
</div>
</div>
<!-- 题目列表 -->
<div class="card">
<h2>
题目列表
<span th:if="${queryType != null}" class="tag tag-choice" th:text="${queryType}" style="margin-left: 10px;"></span>
<span th:if="${queryScore != null}" class="tag tag-essay" th:text="${queryScore}" style="margin-left: 10px;"></span>
</h2>
<div th:if="${#lists.isEmpty(questions)}" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>暂无题目数据</p>
</div>
<table th:if="${!#lists.isEmpty(questions)}">
<thead>
<tr>
<th>ID</th>
<th>题目</th>
<th>类型</th>
<th>选项/学生答案</th>
<th>正确答案</th>
<th>分值</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr th:each="q : ${questions}">
<td th:text="${q.id}"></td>
<td th:text="${q.title}" style="max-width: 300px; word-wrap: break-word;"></td>
<td>
<span th:if="${q.type == 1}" class="tag tag-choice">选择题</span>
<span th:if="${q.type == 2}" class="tag tag-essay">简答题</span>
</td>
<td>
<div th:if="${q.type == 1}">
<span th:if="${q.answerA != null and q.answerA != ''}" th:text="'A: ' + ${q.answerA}"></span><br th:if="${q.answerA != null and q.answerA != ''}">
<span th:if="${q.answerB != null and q.answerB != ''}" th:text="'B: ' + ${q.answerB}"></span><br th:if="${q.answerB != null and q.answerB != ''}">
<span th:if="${q.answerC != null and q.answerC != ''}" th:text="'C: ' + ${q.answerC}"></span><br th:if="${q.answerC != null and q.answerC != ''}">
<span th:if="${q.answerD != null and q.answerD != ''}" th:text="'D: ' + ${q.answerD}"></span>
</div>
<div th:if="${q.type == 2}" th:text="${q.answer != null ? q.answer : '无答案'}"></div>
</td>
<td>
<span th:if="${q.rAnswer != null and q.rAnswer != ''}" class="tag tag-answer" th:text="${q.rAnswer}"></span>
<span th:if="${q.rAnswer == null or q.rAnswer == ''}" style="color: #999;">-</span>
</td>
<td><span class="score-badge" th:text="${q.score}"></span></td>
<td>
<form th:action="@{/admin/delete}" method="post" style="display: inline;">
<input type="hidden" name="title" th:value="${q.title}">
<button type="submit" class="btn btn-danger" onclick="return confirm('确定要删除这道题吗?')">删除</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
function switchTab(type) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(type + '-tab').classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,417 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${title} + ' - 在线答题'">在线答题</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafafa;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
.exam-header {
background: white;
border-radius: 16px;
padding: 30px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border: 1px solid #f0f0f0;
text-align: center;
}
.exam-title {
font-size: 28px;
color: #333;
margin-bottom: 20px;
}
.exam-stats {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 20px;
}
.stat-item {
background: #f8f9fa;
padding: 10px 20px;
border-radius: 8px;
}
.stat-label {
color: #666;
font-size: 12px;
}
.stat-value {
color: #f59e0b;
font-size: 18px;
font-weight: bold;
}
.student-info {
max-width: 400px;
margin: 0 auto;
text-align: left;
}
.student-info label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
}
.student-info input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
.student-info input:focus {
outline: none;
border-color: #f59e0b;
}
.question-card {
background: white;
border-radius: 16px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border: 1px solid #f0f0f0;
}
.question-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.question-number {
background: #f59e0b;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.question-type {
background: #fef3c7;
color: #d97706;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
margin-left: auto;
}
.question-title {
font-size: 16px;
color: #333;
line-height: 1.6;
flex: 1;
}
.question-score {
background: #f59e0b;
color: white;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
margin-left: 8px;
}
.options {
margin-left: 44px;
}
.option {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.option:hover {
border-color: #f59e0b;
background: #fffbeb;
}
.option input[type="radio"] {
margin-right: 12px;
width: 18px;
height: 18px;
cursor: pointer;
}
.option-label {
font-size: 15px;
color: #333;
cursor: pointer;
}
.essay-input {
margin-left: 44px;
}
.essay-input textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
resize: vertical;
transition: border-color 0.3s;
}
.essay-input textarea:focus {
outline: none;
border-color: #f59e0b;
}
.submit-section {
text-align: center;
padding: 30px;
}
.btn-submit {
background: #f59e0b;
color: white;
border: none;
padding: 16px 60px;
border-radius: 8px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-submit:hover {
background: #d97706;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(245, 158, 11, 0.3);
}
.btn-submit:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 40px;
border-radius: 16px;
text-align: center;
max-width: 400px;
}
.modal-content h2 {
color: #f59e0b;
margin-bottom: 16px;
}
.modal-content p {
color: #666;
margin-bottom: 24px;
}
.modal-content .answer-uid {
background: #fef3c7;
padding: 12px;
border-radius: 8px;
font-family: monospace;
margin-bottom: 24px;
word-break: break-all;
}
.btn-close {
background: #f59e0b;
color: white;
border: none;
padding: 12px 32px;
border-radius: 8px;
cursor: pointer;
}
.error-message {
color: #e74c3c;
text-align: center;
padding: 40px;
font-size: 18px;
}
</style>
</head>
<body>
<div class="container">
<!-- 错误提示 -->
<div th:if="${error}" class="error-message" th:text="${error}"></div>
<!-- 答题界面 -->
<div th:unless="${error}">
<!-- 试卷头部 -->
<div class="exam-header">
<h1 class="exam-title" th:text="${title}">试卷标题</h1>
<div class="exam-stats">
<div class="stat-item">
<div class="stat-label">选择题</div>
<div class="stat-value" th:text="${choiceCount} + '道'">0道</div>
</div>
<div class="stat-item">
<div class="stat-label">简答题</div>
<div class="stat-value" th:text="${essayCount} + '道'">0道</div>
</div>
<div class="stat-item">
<div class="stat-label">总分</div>
<div class="stat-value" th:text="${totalScore} + '分'">100分</div>
</div>
</div>
<div class="student-info">
<label for="studentName">做题人姓名 *</label>
<input type="text" id="studentName" placeholder="请输入您的姓名" required>
</div>
</div>
<!-- 答题表单 -->
<form id="answerForm">
<input type="hidden" id="examUid" th:value="${examUid}">
<!-- 选择题 -->
<div th:each="q, iterStat : ${choiceQuestions}" class="question-card">
<div class="question-header">
<span class="question-number" th:text="${iterStat.index + 1}">1</span>
<span class="question-title" th:text="${q.title}">题目内容</span>
<span class="question-score" th:text="${q.score} + '分'">3分</span>
<span class="question-type">选择题</span>
</div>
<div class="options">
<label class="option" th:if="${q.answerA != null and q.answerA != ''}">
<input type="radio" th:name="'q_' + ${q.id}" value="A" required>
<span class="option-label" th:text="'A. ' + ${q.answerA}">A. 选项A</span>
</label>
<label class="option" th:if="${q.answerB != null and q.answerB != ''}">
<input type="radio" th:name="'q_' + ${q.id}" value="B">
<span class="option-label" th:text="'B. ' + ${q.answerB}">B. 选项B</span>
</label>
<label class="option" th:if="${q.answerC != null and q.answerC != ''}">
<input type="radio" th:name="'q_' + ${q.id}" value="C">
<span class="option-label" th:text="'C. ' + ${q.answerC}">C. 选项C</span>
</label>
<label class="option" th:if="${q.answerD != null and q.answerD != ''}">
<input type="radio" th:name="'q_' + ${q.id}" value="D">
<span class="option-label" th:text="'D. ' + ${q.answerD}">D. 选项D</span>
</label>
</div>
</div>
<!-- 简答题 -->
<div th:each="q, iterStat : ${essayQuestions}" class="question-card">
<div class="question-header">
<span class="question-number" th:text="${choiceCount + iterStat.index + 1}">1</span>
<span class="question-title" th:text="${q.title}">题目内容</span>
<span class="question-score" th:text="${q.score} + '分'">5分</span>
<span class="question-type">简答题</span>
</div>
<div class="essay-input">
<textarea th:name="'q_' + ${q.id}" placeholder="请输入您的答案..."></textarea>
</div>
</div>
<!-- 提交按钮 -->
<div class="submit-section">
<button type="submit" class="btn-submit">提交答案</button>
</div>
</form>
</div>
</div>
<!-- 成功弹窗 -->
<div class="modal" id="successModal">
<div class="modal-content">
<h2>✓ 提交成功</h2>
<p>您的答案已保存答题记录UID</p>
<div class="answer-uid" id="answerUid"></div>
<button class="btn-close" onclick="closeModal()">确定</button>
</div>
</div>
<script th:inline="javascript">
document.getElementById('answerForm').addEventListener('submit', function(e) {
e.preventDefault();
const name = document.getElementById('studentName').value.trim();
if (!name) {
alert('请输入做题人姓名');
document.getElementById('studentName').focus();
return;
}
const formData = new FormData(this);
const answers = {};
// 遍历 FormData 收集答案,排名前面的系统字段
for (let [key, value] of formData.entries()) {
if (key !== 'name' && key !== 'examUid') {
answers[key] = value;
}
}
const examUid = document.getElementById('examUid').value;
const submitBtn = document.querySelector('.btn-submit');
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
const payload = {
name: name,
answers: answers
};
fetch('/' + examUid + '/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success) {
// 填入返回的答案 UID 并显示弹窗
const answerUidElement = document.getElementById('answerUid');
if (answerUidElement) {
answerUidElement.textContent = data.answerUid;
}
const modalElement = document.getElementById('successModal');
if (modalElement) {
modalElement.style.display = 'flex';
}
} else {
alert(data.message || '提交失败');
submitBtn.disabled = false;
submitBtn.textContent = '提交答案';
}
})
.catch(error => {
alert('提交失败: ' + error.message);
console.error('Error:', error);
submitBtn.disabled = false;
submitBtn.textContent = '提交答案';
});
});
function closeModal() {
document.getElementById('successModal').style.display = 'none';
// 重置表单
document.getElementById('answerForm').reset();
document.getElementById('studentName').value = '';
document.querySelector('.btn-submit').disabled = false;
document.querySelector('.btn-submit').textContent = '提交答案';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,705 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>批改试卷</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafafa;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 2.5rem;
}
.card {
background: white;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border: 1px solid #f0f0f0;
}
.card h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid #f59e0b;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
margin-bottom: 6px;
color: #555;
font-weight: 500;
}
input, select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus, select:focus {
outline: none;
border-color: #f59e0b;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.btn-primary {
background: #f59e0b;
color: white;
}
.btn-primary:hover {
background: #d97706;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(245, 158, 11, 0.3);
}
.btn-success {
background: #fbbf24;
color: #333;
}
.btn-success:hover {
background: #f59e0b;
}
.btn-secondary {
background: #9ca3af;
color: white;
}
.btn-secondary:hover {
background: #6b7280;
}
.search-section {
display: flex;
gap: 12px;
align-items: flex-end;
}
.search-section .form-group {
flex: 1;
margin-bottom: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 14px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.tag {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.tag-corrected {
background: #fef3c7;
color: #d97706;
}
.tag-pending {
background: #fffbeb;
color: #b45309;
}
.score-badge {
background: #f59e0b;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
/* 批改详情样式 */
.exam-header {
background: #f8f9fa;
padding: 20px;
border-radius: 12px;
margin-bottom: 24px;
}
.exam-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
color: #666;
font-size: 12px;
margin-bottom: 4px;
}
.info-value {
color: #333;
font-size: 16px;
font-weight: 600;
}
.score-summary {
display: flex;
gap: 24px;
padding: 16px;
background: #f59e0b;
border-radius: 12px;
color: white;
margin-bottom: 24px;
}
.summary-item {
text-align: center;
}
.summary-label {
font-size: 12px;
opacity: 0.9;
}
.summary-value {
font-size: 28px;
font-weight: bold;
}
.question-card {
background: white;
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
transition: border-color 0.3s;
}
.question-card:hover {
border-color: #f59e0b;
}
.question-card.correct {
border-color: #fbbf24;
background: #fffbeb;
}
.question-card.wrong {
border-color: #ef4444;
background: #fef2f2;
}
.question-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.question-number {
background: #f59e0b;
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.question-type {
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.type-choice {
background: #e3f2fd;
color: #d97706;
}
.type-essay {
background: #ffedd5;
color: #ea580c;
}
.question-score {
margin-left: auto;
background: #f0f0f0;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.question-title {
font-size: 16px;
color: #333;
line-height: 1.6;
margin-bottom: 12px;
}
.answer-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.answer-box {
padding: 12px;
border-radius: 8px;
background: #f8f9fa;
}
.answer-box label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.answer-box .answer-text {
color: #333;
font-weight: 500;
}
.score-input {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.score-input input {
width: 100px;
text-align: center;
font-size: 18px;
font-weight: bold;
}
.score-input span {
color: #666;
}
.auto-correct-result {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 12px;
border-radius: 8px;
}
.auto-correct-result.correct {
background: #fef3c7;
color: #d97706;
}
.auto-correct-result.wrong {
background: #ffebee;
color: #c62828;
}
.submit-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 20px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.1);
display: flex;
justify-content: center;
gap: 16px;
z-index: 100;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 40px;
border-radius: 16px;
text-align: center;
max-width: 400px;
}
.modal-content h2 {
color: #f59e0b;
margin-bottom: 16px;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: white;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<a href="/admin" class="back-link">← 返回管理后台</a>
<h1>批改试卷</h1>
<!-- 搜索区域 -->
<div class="card" id="searchCard">
<h2>搜索答题记录</h2>
<div class="search-section">
<div class="form-group">
<label>做题人姓名</label>
<input type="text" id="searchName" placeholder="输入姓名(可选)">
</div>
<div class="form-group">
<label>试卷标题</label>
<select id="searchTitle">
<option value="">全部试卷</option>
</select>
</div>
<button class="btn btn-primary" onclick="searchAnswers()">搜索</button>
</div>
<!-- 搜索结果 -->
<div id="searchResults" style="margin-top: 24px; display: none;">
<table>
<thead>
<tr>
<th>答题记录UID</th>
<th>做题人姓名</th>
<th>试卷标题</th>
<th>状态</th>
<th>得分</th>
<th>操作</th>
</tr>
</thead>
<tbody id="resultsBody"></tbody>
</table>
</div>
<div id="emptyResult" class="empty-state" style="display: none;">
未找到匹配的答题记录
</div>
</div>
<!-- 批改区域 -->
<div class="card" id="correctCard" style="display: none;">
<h2>批改详情</h2>
<!-- 试卷信息 -->
<div class="exam-header">
<div class="exam-info">
<div class="info-item">
<span class="info-label">答题记录UID</span>
<span class="info-value" id="answerUid"></span>
</div>
<div class="info-item">
<span class="info-label">做题人姓名</span>
<span class="info-value" id="studentName"></span>
</div>
<div class="info-item">
<span class="info-label">试卷标题</span>
<span class="info-value" id="examTitle"></span>
</div>
</div>
</div>
<!-- 得分汇总 -->
<div class="score-summary">
<div class="summary-item">
<div class="summary-label">选择题得分</div>
<div class="summary-value" id="choiceScore">0</div>
</div>
<div class="summary-item">
<div class="summary-label">简答题满分</div>
<div class="summary-value" id="essayTotal">0</div>
</div>
<div class="summary-item">
<div class="summary-label">总分</div>
<div class="summary-value" id="totalScore">0</div>
</div>
</div>
<!-- 打印按钮 -->
<div style="text-align: center; margin-bottom: 24px;">
<button class="btn btn-primary" onclick="printAnswer()">打印试卷答案</button>
</div>
<!-- 题目列表 -->
<div id="questionsList"></div>
<div style="height: 100px;"></div>
</div>
<!-- 提交按钮 -->
<div class="submit-section" id="submitSection" style="display: none;">
<button class="btn btn-secondary" onclick="backToSearch()">返回搜索</button>
<button class="btn btn-success" onclick="submitCorrect()">提交批改</button>
</div>
</div>
<!-- 成功弹窗 -->
<div class="modal" id="successModal">
<div class="modal-content">
<h2>✓ 批改完成</h2>
<p>试卷批改成功!</p>
<p>最终得分:<strong id="finalScore" style="font-size: 24px; color: #f59e0b;"></strong></p>
<button class="btn btn-primary" onclick="closeModal()" style="margin-top: 20px;">确定</button>
</div>
</div>
<script>
let currentAnswerUid = null;
let essayQuestions = [];
// 页面加载时获取试卷标题列表
function loadExamTitles() {
fetch('/admin/correct/titles')
.then(res => res.json())
.then(data => {
if (data.success) {
const select = document.getElementById('searchTitle');
// 保留"全部试卷"选项
select.innerHTML = '<option value="">全部试卷</option>';
data.data.forEach(title => {
const option = document.createElement('option');
option.value = title;
option.textContent = title;
select.appendChild(option);
});
}
})
.catch(err => {
console.error('加载试卷标题失败:', err);
});
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', loadExamTitles);
// 搜索答题记录
function searchAnswers() {
const name = document.getElementById('searchName').value.trim();
const title = document.getElementById('searchTitle').value;
fetch(`/admin/correct/search?name=${encodeURIComponent(name)}&title=${encodeURIComponent(title)}`)
.then(res => res.json())
.then(data => {
if (data.success) {
displayResults(data.data);
} else {
alert(data.message);
}
})
.catch(err => {
console.error(err);
alert('搜索失败');
});
}
// 显示搜索结果
function displayResults(answers) {
const resultsDiv = document.getElementById('searchResults');
const emptyDiv = document.getElementById('emptyResult');
const tbody = document.getElementById('resultsBody');
if (answers.length === 0) {
resultsDiv.style.display = 'none';
emptyDiv.style.display = 'block';
return;
}
resultsDiv.style.display = 'block';
emptyDiv.style.display = 'none';
tbody.innerHTML = answers.map(a => {
const isCorrected = a.score != null && a.score !== '';
return `
<tr>
<td>${a.uid}</td>
<td>${a.name || '-'}</td>
<td>${a.title || '-'}</td>
<td>
<span class="tag ${isCorrected ? 'tag-corrected' : 'tag-pending'}">
${isCorrected ? '已批改' : '待批改'}
</span>
</td>
<td>
${isCorrected ? `<span class="score-badge">${a.score}分</span>` : '-'}
</td>
<td>
<button class="btn btn-primary" onclick="loadAnswerDetail('${a.uid}')">
${isCorrected ? '查看' : '批改'}
</button>
</td>
</tr>
`;
}).join('');
}
// 加载答题详情
function loadAnswerDetail(answerUid) {
currentAnswerUid = answerUid;
fetch(`/admin/correct/detail/${answerUid}`)
.then(res => res.json())
.then(data => {
if (data.success) {
displayCorrectDetail(data.data);
} else {
alert(data.message);
}
})
.catch(err => {
console.error(err);
alert('加载详情失败');
});
}
// 显示批改详情
function displayCorrectDetail(data) {
document.getElementById('searchCard').style.display = 'none';
document.getElementById('correctCard').style.display = 'block';
document.getElementById('submitSection').style.display = 'flex';
document.getElementById('answerUid').textContent = data.answerUid;
document.getElementById('studentName').textContent = data.studentName;
document.getElementById('examTitle').textContent = data.examTitle;
document.getElementById('choiceScore').textContent = data.choiceScore;
document.getElementById('essayTotal').textContent = data.essayTotalScore;
document.getElementById('totalScore').textContent = data.totalScore;
const listDiv = document.getElementById('questionsList');
essayQuestions = [];
listDiv.innerHTML = data.questions.map((q, index) => {
const isChoice = q.type === 1;
if (!isChoice) {
essayQuestions.push(q);
}
return `
<div class="question-card ${isChoice ? (q.isCorrect ? 'correct' : 'wrong') : ''}">
<div class="question-header">
<span class="question-number">${q.exmaid}</span>
<span class="question-type ${isChoice ? 'type-choice' : 'type-essay'}">
${isChoice ? '选择题' : '简答题'}
</span>
<span class="question-score">${q.score}分</span>
</div>
<div class="question-title">${q.title}</div>
<div class="answer-section">
<div class="answer-box">
<label>学生答案</label>
<div class="answer-text">${q.userAnswer || '未作答'}</div>
</div>
<div class="answer-box">
<label>${isChoice ? '正确答案' : '参考答案'}</label>
<div class="answer-text">${q.correctAnswer || '-'}</div>
</div>
</div>
${isChoice ? `
<div class="auto-correct-result ${q.isCorrect ? 'correct' : 'wrong'}">
<span>${q.isCorrect ? '✓ 回答正确' : '✗ 回答错误'}</span>
<span style="margin-left: auto; font-weight: bold;">得分: ${q.autoScore}/${q.score}</span>
</div>
` : `
<div class="score-input">
<label>评分:</label>
<input type="number"
id="score_${q.qid}"
min="0"
max="${q.score}"
value="${data.isCorrected ? q.autoScore : ''}"
${data.isCorrected ? 'disabled' : ''}>
<span>/ ${q.score} 分</span>
</div>
`}
</div>
`;
}).join('');
// 如果已批改,禁用提交按钮
if (data.isCorrected) {
document.querySelector('#submitSection .btn-success').style.display = 'none';
}
}
// 返回搜索
function backToSearch() {
document.getElementById('searchCard').style.display = 'block';
document.getElementById('correctCard').style.display = 'none';
document.getElementById('submitSection').style.display = 'none';
currentAnswerUid = null;
}
// 提交批改
function submitCorrect() {
if (!currentAnswerUid) return;
const essayScores = {};
for (let q of essayQuestions) {
const input = document.getElementById(`score_${q.qid}`);
const score = parseInt(input.value) || 0;
if (score < 0 || score > q.score) {
alert(`${q.exmaid}题得分必须在0-${q.score}之间`);
return;
}
essayScores[`q_${q.qid}`] = score;
}
fetch(`/admin/correct/submit/${currentAnswerUid}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(essayScores)
})
.then(res => res.json())
.then(data => {
if (data.success) {
document.getElementById('finalScore').textContent = data.totalScore;
document.getElementById('successModal').style.display = 'flex';
} else {
alert(data.message);
}
})
.catch(err => {
console.error(err);
alert('提交失败');
});
}
// 关闭弹窗
function closeModal() {
document.getElementById('successModal').style.display = 'none';
backToSearch();
searchAnswers(); // 刷新列表
}
// 打印试卷答案
function printAnswer() {
if (!currentAnswerUid) return;
window.open(`/admin/correct/print/${currentAnswerUid}`, '_blank');
}
// 回车键搜索(仅姓名输入框)
document.getElementById('searchName')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') searchAnswers();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,389 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>查看试卷列表</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafafa;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 2.5rem;
}
.card {
background: white;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border: 1px solid #f0f0f0;
}
.card h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid #f59e0b;
}
.search-section {
display: flex;
gap: 12px;
align-items: flex-end;
margin-bottom: 20px;
}
.search-section .form-group {
flex: 1;
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 6px;
color: #555;
font-weight: 500;
font-size: 14px;
}
input[type="text"] {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input[type="text"]:focus {
outline: none;
border-color: #f59e0b;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #f59e0b;
color: white;
}
.btn-primary:hover {
background: #d97706;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(245, 158, 11, 0.3);
}
.btn-secondary {
background: #9ca3af;
color: white;
}
.btn-secondary:hover {
background: #6b7280;
}
.btn-success {
background: #fbbf24;
color: #333;
}
.btn-success:hover {
background: #f59e0b;
}
.btn-sm {
padding: 8px 16px;
font-size: 13px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 14px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #555;
}
tr:hover {
background: #f8f9fa;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
}
.nav-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
/* 弹窗样式 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
overflow-y: auto;
padding: 40px 20px;
}
.modal-content {
background: white;
border-radius: 16px;
max-width: 800px;
margin: 0 auto;
padding: 30px;
position: relative;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.close-btn {
position: absolute;
right: 20px;
top: 20px;
font-size: 28px;
cursor: pointer;
color: #999;
transition: 0.3s;
}
.close-btn:hover {
color: #f59e0b;
}
.modal-header {
margin-bottom: 24px;
border-bottom: 3px solid #f59e0b;
padding-bottom: 12px;
}
.modal-header h2 {
color: #333;
margin-top: 0;
margin-bottom: 8px;
border: none;
padding: 0;
}
.exam-meta {
background: #fffbeb;
border: 1px solid #fef3c7;
padding: 15px;
border-radius: 8px;
margin-bottom: 24px;
color: #d97706;
}
.question-item {
border: 1px solid #e0e0e0;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.question-type {
background: #fef3c7;
color: #d97706;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.question-score {
background: #f59e0b;
color: white;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
margin-left: 8px;
}
.answer-content {
margin-top: 10px;
color: #666;
margin-left: 20px;
}
.correct-answer {
margin-top: 10px;
color: #d97706;
font-weight: bold;
background: #fef3c7;
padding: 8px 12px;
border-radius: 6px;
}
.action-btns {
display: flex;
gap: 8px;
}
</style>
</head>
<body>
<div class="container">
<h1>试卷管理与浏览</h1>
<div class="nav-bar">
<a href="/admin" class="btn btn-secondary">← 返回题库管理</a>
<a href="/admin/mkexam" class="btn btn-success">+ 创建试卷</a>
</div>
<div class="card">
<h2>试卷列表</h2>
<!-- 搜索框 -->
<form class="search-section" action="/admin/listexam" method="get">
<div class="form-group">
<label>试卷标题</label>
<input type="text" name="title" th:value="${searchTitle}" placeholder="输入试卷标题进行模糊搜索...">
</div>
<button type="submit" class="btn btn-primary">搜索</button>
<a href="/admin/listexam" class="btn btn-secondary">重置</a>
</form>
<div th:if="${#lists.isEmpty(examPapers)}" class="empty-state">
暂无试卷记录,点击上方"创建试卷"生成一份吧
</div>
<table th:unless="${#lists.isEmpty(examPapers)}">
<thead>
<tr>
<th width="60">ID</th>
<th width="280">UID</th>
<th>试卷标题</th>
<th width="220">操作</th>
</tr>
</thead>
<tbody>
<tr th:each="paper : ${examPapers}">
<td th:text="${paper.id}"></td>
<td style="font-family: monospace; color: #666; font-size: 13px;" th:text="${paper.uid}"></td>
<td th:text="${paper.title != null and paper.title != '' ? paper.title : '未命名试卷'}"></td>
<td>
<div class="action-btns">
<button class="btn btn-primary btn-sm" th:attr="onclick='viewExamDetail(\'' + ${paper.uid} + '\')'">查看内容</button>
<a th:href="'/' + ${paper.uid}" target="_blank" class="btn btn-success btn-sm">前往答题</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 试卷内容详情弹窗 -->
<div id="detailModal" class="modal">
<div class="modal-content">
<span class="close-btn" onclick="closeModal()">&times;</span>
<div class="modal-header">
<h2 id="modalTitle">试卷加载中...</h2>
</div>
<div class="exam-meta">
<p><strong>试卷UID</strong> <span id="modalUid" style="font-family: monospace;"></span></p>
<p style="margin-top: 8px;">
<strong>总分:</strong> <span id="modalTotalScore" style="font-size: 18px; font-weight: bold;">0</span>
<span style="margin: 0 10px;">|</span>
<strong>选择题:</strong> <span id="modalChoiceCount">0</span>
<span style="margin: 0 10px;">|</span>
<strong>简答题:</strong> <span id="modalEssayCount">0</span>
</p>
</div>
<h3 style="color: #333; border-left: 4px solid #f59e0b; padding-left: 10px; margin-bottom: 16px;">一、选择题</h3>
<div id="choiceContainer" style="margin-bottom: 30px;"></div>
<h3 style="color: #333; border-left: 4px solid #f59e0b; padding-left: 10px; margin-bottom: 16px;">二、简答题</h3>
<div id="essayContainer"></div>
</div>
</div>
<script>
function viewExamDetail(uid) {
// 打开弹窗
document.getElementById('detailModal').style.display = 'block';
document.getElementById('modalTitle').textContent = '试卷加载中...';
fetch('/admin/mkexam/detail/' + uid)
.then(res => res.json())
.then(data => {
document.getElementById('modalTitle').textContent = data.title || '未命名试卷';
document.getElementById('modalUid').textContent = data.uid;
document.getElementById('modalTotalScore').textContent = data.totalScore;
document.getElementById('modalChoiceCount').textContent = data.choiceCount;
document.getElementById('modalEssayCount').textContent = data.essayCount;
// 渲染选择题
let choiceHtml = '';
if (data.choiceQuestions && data.choiceQuestions.length > 0) {
data.choiceQuestions.forEach((q, index) => {
choiceHtml += `
<div class="question-item">
<div style="margin-bottom: 12px;">
<strong>${index + 1}. ${q.title}</strong>
<span class="question-type">选择题</span>
<span class="question-score">${q.score}分</span>
</div>
<div class="answer-content">
${q.answerA ? `<div>A. ${q.answerA}</div>` : ''}
${q.answerB ? `<div>B. ${q.answerB}</div>` : ''}
${q.answerC ? `<div>C. ${q.answerC}</div>` : ''}
${q.answerD ? `<div>D. ${q.answerD}</div>` : ''}
</div>
<div class="correct-answer">正确答案:${q.ranswer || q.rAnswer || '-'}</div>
</div>
`;
});
} else {
choiceHtml = '<div class="empty-state" style="padding: 20px;">无选择题</div>';
}
document.getElementById('choiceContainer').innerHTML = choiceHtml;
// 渲染简答题
let essayHtml = '';
if (data.essayQuestions && data.essayQuestions.length > 0) {
data.essayQuestions.forEach((q, index) => {
essayHtml += `
<div class="question-item">
<div style="margin-bottom: 12px;">
<strong>${index + 1}. ${q.title}</strong>
<span class="question-type">简答题</span>
<span class="question-score">${q.score}分</span>
</div>
<div class="correct-answer">参考答案:${q.ranswer || q.rAnswer || q.answer || '无'}</div>
</div>
`;
});
} else {
essayHtml = '<div class="empty-state" style="padding: 20px;">无简答题</div>';
}
document.getElementById('essayContainer').innerHTML = essayHtml;
})
.catch(err => {
console.error('获取试卷详情失败', err);
alert('获取试卷详情失败,请检查网络或后端接口!');
});
}
function closeModal() {
document.getElementById('detailModal').style.display = 'none';
}
// 点击遮罩层关闭弹窗
window.onclick = function(event) {
let modal = document.getElementById('detailModal');
if (event.target == modal) {
modal.style.display = "none";
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,536 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>创建试卷</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafafa;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 2.5rem;
}
.card {
background: white;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
border: 1px solid #f0f0f0;
}
.card h2 {
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 3px solid #f59e0b;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #f59e0b;
color: white;
}
.btn-primary:hover {
background: #d97706;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(245, 158, 11, 0.3);
}
.btn-secondary {
background: #9ca3af;
color: white;
}
.btn-secondary:hover {
background: #6b7280;
}
.btn-success {
background: #fbbf24;
color: #333;
}
.btn-success:hover {
background: #f59e0b;
}
.nav-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.exam-info {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.exam-info h3 {
color: #f59e0b;
margin-bottom: 12px;
font-size: 14px;
}
.uid-display {
font-family: 'Courier New', monospace;
font-size: 16px;
background: #fef3c7;
padding: 12px 16px;
border-radius: 8px;
word-break: break-all;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.copy-btn {
background: #f59e0b;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
}
.copy-btn:hover {
background: #d97706;
}
.question-section {
margin-top: 20px;
}
.question-section h4 {
color: #333;
margin-bottom: 12px;
padding: 10px 16px;
background: #f59e0b;
color: white;
border-radius: 8px;
}
.question-list {
list-style: none;
}
.question-item {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: flex-start;
gap: 12px;
}
.question-item:last-child {
border-bottom: none;
}
.question-number {
background: #f59e0b;
color: white;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
margin-top: 2px;
}
.question-content {
flex: 1;
}
.question-title {
font-size: 15px;
color: #333;
line-height: 1.5;
margin-bottom: 6px;
}
.question-meta {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.question-id {
background: #e0e0e0;
color: #666;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
.question-score {
background: #f59e0b;
color: white;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.question-type {
background: #fef3c7;
color: #d97706;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
}
.loading {
display: none;
text-align: center;
padding: 40px;
}
.loading-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #f59e0b;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.alert-success {
background: #d4edda;
color: #155724;
}
.alert-error {
background: #f8d7da;
color: #721c24;
}
.create-section {
padding: 20px 0;
}
.create-section > p {
color: #666;
margin-bottom: 24px;
font-size: 14px;
text-align: center;
}
.create-form {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #555;
font-weight: 500;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input:focus {
outline: none;
border-color: #f59e0b;
}
.form-actions {
display: flex;
justify-content: center;
margin-top: 24px;
}
.create-btn {
padding: 14px 48px;
font-size: 16px;
}
.stats {
display: flex;
gap: 16px;
margin-top: 16px;
flex-wrap: wrap;
}
.stat-item {
background: white;
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
flex: 1;
min-width: 120px;
text-align: center;
}
.stat-label {
color: #666;
font-size: 12px;
margin-bottom: 4px;
}
.stat-value {
color: #f59e0b;
font-size: 28px;
font-weight: bold;
}
.exam-title {
font-size: 18px;
color: #333;
padding: 12px 16px;
background: #fef3c7;
border-radius: 8px;
border-left: 4px solid #f59e0b;
}
.result-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e0e0e0;
}
</style>
</head>
<body>
<div class="container">
<h1>创建试卷</h1>
<div class="nav-bar">
<a th:href="@{/admin/exam}" class="btn btn-secondary">← 返回题库管理</a>
</div>
<!-- 创建按钮区域 -->
<div class="card" id="createSection">
<h2>生成新试卷</h2>
<div class="create-section">
<p>系统将自动从题库中随机选择题目的7:3比例组合选择题:简答题总分100分</p>
<div class="create-form">
<div class="form-group">
<label for="examTitle">试卷标题</label>
<input type="text" id="examTitle" placeholder="请输入试卷标题(可选)" maxlength="100">
</div>
<div class="form-actions">
<button class="btn btn-primary create-btn" onclick="createExam()">创建试卷</button>
</div>
</div>
</div>
</div>
<!-- 加载中 -->
<div class="loading card" id="loadingSection">
<div class="loading-spinner"></div>
<p>正在生成试卷...</p>
</div>
<!-- 试卷信息展示 -->
<div class="card" id="resultSection" style="display: none;">
<h2>试卷生成成功</h2>
<div class="exam-info">
<h3>试卷标题</h3>
<div class="exam-title" id="examTitleDisplay">-</div>
<h3 style="margin-top: 16px;">试卷UID</h3>
<div class="uid-display">
<span id="uidDisplay"></span>
<button class="copy-btn" onclick="copyUid()">复制</button>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-label">选择题数量</div>
<div class="stat-value" id="choiceCount">0</div>
</div>
<div class="stat-item">
<div class="stat-label">简答题数量</div>
<div class="stat-value" id="essayCount">0</div>
</div>
<div class="stat-item">
<div class="stat-label">总题数</div>
<div class="stat-value" id="totalCount">0</div>
</div>
</div>
</div>
<div class="question-section">
<h4>选择题列表</h4>
<ul class="question-list" id="choiceList"></ul>
</div>
<div class="question-section">
<h4>简答题列表</h4>
<ul class="question-list" id="essayList"></ul>
</div>
<div class="result-actions">
<button class="btn btn-success" onclick="createAnother()">再创建一份</button>
</div>
</div>
<!-- 错误提示 -->
<div class="alert alert-error" id="errorSection" style="display: none;"></div>
</div>
<script>
function createExam() {
// 获取试卷标题
const title = document.getElementById('examTitle').value.trim();
// 显示加载,隐藏创建按钮
document.getElementById('createSection').style.display = 'none';
document.getElementById('loadingSection').style.display = 'block';
document.getElementById('resultSection').style.display = 'none';
document.getElementById('errorSection').style.display = 'none';
// 构建表单数据
const formData = new FormData();
if (title) {
formData.append('title', title);
}
fetch('/admin/mkexam/create', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('创建试卷失败');
}
return response.json();
})
.then(data => {
// 获取试卷详情包含题目title
return fetch('/admin/mkexam/detail/' + data.uid);
})
.then(response => response.json())
.then(detail => {
displayExamDetail(detail);
})
.catch(error => {
showError(error.message);
});
}
function displayExamDetail(data) {
document.getElementById('loadingSection').style.display = 'none';
document.getElementById('resultSection').style.display = 'block';
// 显示试卷标题
const titleDisplay = document.getElementById('examTitleDisplay');
titleDisplay.textContent = data.title || '未命名试卷';
// 显示UID
document.getElementById('uidDisplay').textContent = data.uid;
// 更新统计
const choiceQuestions = data.choiceQuestions || [];
const essayQuestions = data.essayQuestions || [];
document.getElementById('choiceCount').textContent = choiceQuestions.length;
document.getElementById('essayCount').textContent = essayQuestions.length;
document.getElementById('totalCount').textContent = choiceQuestions.length + essayQuestions.length;
// 显示选择题列表
const choiceList = document.getElementById('choiceList');
choiceList.innerHTML = '';
if (choiceQuestions.length === 0) {
choiceList.innerHTML = '<li class="question-item"><div class="question-content">暂无选择题</div></li>';
} else {
choiceQuestions.forEach((q, index) => {
const li = document.createElement('li');
li.className = 'question-item';
li.innerHTML = `
<span class="question-number">${index + 1}</span>
<div class="question-content">
<div class="question-title">${escapeHtml(q.title)}</div>
<div class="question-meta">
<span class="question-id">ID: ${q.id}</span>
<span class="question-type">选择题</span>
<span class="question-score">${q.score || 0}分</span>
</div>
</div>
`;
choiceList.appendChild(li);
});
}
// 显示简答题列表
const essayList = document.getElementById('essayList');
essayList.innerHTML = '';
if (essayQuestions.length === 0) {
essayList.innerHTML = '<li class="question-item"><div class="question-content">暂无简答题</div></li>';
} else {
essayQuestions.forEach((q, index) => {
const li = document.createElement('li');
li.className = 'question-item';
li.innerHTML = `
<span class="question-number">${index + 1}</span>
<div class="question-content">
<div class="question-title">${escapeHtml(q.title)}</div>
<div class="question-meta">
<span class="question-id">ID: ${q.id}</span>
<span class="question-type">简答题</span>
<span class="question-score">${q.score || 0}分</span>
</div>
</div>
`;
essayList.appendChild(li);
});
}
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showError(message) {
document.getElementById('loadingSection').style.display = 'none';
document.getElementById('createSection').style.display = 'block';
const errorSection = document.getElementById('errorSection');
errorSection.textContent = message;
errorSection.style.display = 'block';
}
function copyUid() {
const uid = document.getElementById('uidDisplay').textContent;
navigator.clipboard.writeText(uid).then(() => {
const btn = document.querySelector('.copy-btn');
btn.textContent = '已复制';
setTimeout(() => {
btn.textContent = '复制';
}, 2000);
});
}
function createAnother() {
document.getElementById('resultSection').style.display = 'none';
document.getElementById('createSection').style.display = 'block';
document.getElementById('errorSection').style.display = 'none';
// 清空标题输入框
document.getElementById('examTitle').value = '';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${data != null ? data.examTitle + ' - 试卷答案' : '打印试卷'}">试卷答案</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "SimSun", "宋体", serif;
font-size: 14px;
line-height: 1.8;
color: #333;
background: white;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
/* 打印样式 */
@media print {
body {
padding: 0;
font-size: 12pt;
}
.no-print {
display: none !important;
}
.page-break {
page-break-before: always;
}
}
/* 按钮样式(仅屏幕显示) */
.print-actions {
text-align: center;
padding: 20px;
background: #f5f5f5;
margin-bottom: 30px;
border-radius: 8px;
}
.btn {
padding: 10px 24px;
margin: 0 8px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-print {
background: #fbbf24;
color: white;
}
.btn-back {
background: #f59e0b;
color: white;
}
/* 试卷标题 */
.exam-title {
text-align: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #333;
}
/* 基本信息 */
.info-section {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border: 1px solid #ddd;
}
.info-item {
display: flex;
gap: 8px;
}
.info-label {
font-weight: bold;
}
.info-value {
border-bottom: 1px solid #333;
min-width: 100px;
padding: 0 10px;
}
/* 分数区域 */
.score-section {
display: flex;
justify-content: center;
gap: 40px;
margin: 20px 0;
padding: 15px;
border: 2px solid #333;
background: #fafafa;
}
.score-item {
text-align: center;
}
.score-label {
font-size: 12px;
color: #666;
}
.score-value {
font-size: 20px;
font-weight: bold;
color: #c00;
}
/* 题目列表 */
.questions {
margin-top: 30px;
}
.question-item {
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px dashed #ccc;
}
.question-header {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 8px;
}
.question-number {
font-weight: bold;
font-size: 15px;
}
.question-type {
font-size: 12px;
color: #666;
padding: 2px 8px;
background: #eee;
border-radius: 3px;
}
.question-score {
margin-left: auto;
font-weight: bold;
color: #c00;
}
.question-title {
margin-bottom: 10px;
line-height: 1.6;
}
/* 答案区域 */
.answer-section {
margin-top: 10px;
padding: 10px 15px;
background: #f5f5f5;
border-left: 3px solid #f59e0b;
}
.answer-row {
display: flex;
margin-bottom: 5px;
}
.answer-label {
font-weight: bold;
min-width: 80px;
color: #555;
}
.answer-content {
flex: 1;
}
.answer-content.correct {
color: #fbbf24;
font-weight: bold;
}
.answer-content.wrong {
color: #c00;
}
.score-get {
color: #c00;
font-weight: bold;
}
/* 底部签名区 */
.signature-section {
margin-top: 50px;
display: flex;
justify-content: space-between;
padding-top: 30px;
border-top: 1px solid #ccc;
}
.signature-item {
display: flex;
gap: 10px;
align-items: center;
}
.signature-line {
border-bottom: 1px solid #333;
min-width: 120px;
}
/* 错误提示 */
.error-message {
text-align: center;
padding: 50px;
color: #c00;
font-size: 18px;
}
</style>
</head>
<body>
<!-- 打印按钮区域(打印时隐藏) -->
<div class="print-actions no-print">
<button class="btn btn-print" onclick="window.print()">打印试卷</button>
<button class="btn btn-back" onclick="history.back()">返回</button>
</div>
<!-- 错误提示 -->
<div th:if="${error}" class="error-message" th:text="${error}"></div>
<!-- 试卷内容 -->
<div th:if="${data != null}">
<!-- 标题 -->
<div class="exam-title" th:text="${data.examTitle}">试卷标题</div>
<!-- 基本信息 -->
<div class="info-section">
<div class="info-item">
<span class="info-label">姓名:</span>
<span class="info-value" th:text="${data.studentName}"></span>
</div>
<div class="info-item">
<span class="info-label">答题记录UID</span>
<span th:text="${data.answerUid}"></span>
</div>
<div class="info-item">
<span class="info-label">日期:</span>
<span th:text="${#dates.format(new java.util.Date(), 'yyyy年MM月dd日')}"></span>
</div>
</div>
<!-- 分数汇总 -->
<div class="score-section">
<div class="score-item">
<div class="score-label">选择题得分</div>
<div class="score-value" th:text="${data.choiceScore} + '分'"></div>
</div>
<div class="score-item">
<div class="score-label">简答题得分</div>
<div class="score-value" th:text="${data.finalScore != null ? (data.finalScore - data.choiceScore) : '-'} + '分'"></div>
</div>
<div class="score-item">
<div class="score-label">总得分</div>
<div class="score-value" th:text="${data.finalScore != null ? data.finalScore : data.choiceScore} + '/100' + '分'"></div>
</div>
</div>
<!-- 题目答案列表 -->
<div class="questions">
<div th:each="q : ${data.questions}" class="question-item">
<div class="question-header">
<span class="question-number" th:text="'第' + ${q.exmaid} + '题'">第1题</span>
<span class="question-type" th:text="${q.type == 1 ? '选择题' : '简答题'}">选择题</span>
<span class="question-score" th:text="${q.score} + '分'">5分</span>
</div>
<div class="question-title" th:text="${q.title}">题目内容</div>
<div class="answer-section">
<!-- 选择题 -->
<div th:if="${q.type == 1}">
<div class="answer-row">
<span class="answer-label">答案:</span>
<span class="answer-content"
th:classappend="${q.isCorrect ? 'correct' : 'wrong'}"
th:text="${q.userAnswer != null && !q.userAnswer.isEmpty() ? q.userAnswer : '未作答'}">
</span>
</div>
<div class="answer-row">
<span class="answer-label">正确答案:</span>
<span class="answer-content correct" th:text="${q.correctAnswer}"></span>
</div>
<div class="answer-row">
<span class="answer-label">得分:</span>
<span class="score-get" th:text="${q.autoScore} + '/' + ${q.score} + '分'"></span>
<span th:if="${q.isCorrect}" style="color: #fbbf24; margin-left: 10px;">✓ 正确</span>
<span th:if="${!q.isCorrect}" style="color: #c00; margin-left: 10px;">✗ 错误</span>
</div>
</div>
<!-- 简答题 -->
<div th:if="${q.type == 2}">
<div class="answer-row">
<span class="answer-label">答案:</span>
<span class="answer-content" th:text="${q.userAnswer != null && !q.userAnswer.isEmpty() ? q.userAnswer : '未作答'}"></span>
</div>
<div class="answer-row" th:if="${data.isCorrected}">
<span class="answer-label">得分:</span>
<span class="score-get" th:text="${q.autoScore} + '/' + ${q.score} + '分'"></span>
</div>
</div>
</div>
</div>
</div>
<!-- 底部签名区 -->
</div>
</body>
</html>