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

38
init.sql Normal file
View File

@@ -0,0 +1,38 @@
-- 创建数据库 (如果不存在)
CREATE DATABASE IF NOT EXISTS `exam` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `exam`;
-- 1. 题库表 (question)
CREATE TABLE IF NOT EXISTS `question` (
`id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '题目ID',
`title` TEXT NOT NULL COMMENT '题目内容',
`type` INT NOT NULL COMMENT '题目类型: 1-选择题, 2-简答题',
`answer` TEXT COMMENT '学生答案参考(仅简答题)',
`answer_a` VARCHAR(255) COMMENT '选项A(仅选择题)',
`answer_b` VARCHAR(255) COMMENT '选项B(仅选择题)',
`answer_c` VARCHAR(255) COMMENT '选项C(仅选择题)',
`answer_d` VARCHAR(255) COMMENT '选项D(仅选择题)',
`r_answer` TEXT COMMENT '正确答案',
`score` INT NOT NULL DEFAULT 0 COMMENT '题目分值'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题库表';
-- 2. 试卷表 (exam_paper)
CREATE TABLE IF NOT EXISTS `exam_paper` (
`id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '试卷自增ID',
`uid` VARCHAR(64) NOT NULL UNIQUE COMMENT '试卷唯一标识(UUID等)',
`title` VARCHAR(255) COMMENT '试卷标题',
`select` TEXT COMMENT '选择题集合或相关配置',
`content` TEXT COMMENT '简答题集合或相关内容'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生成的试卷表';
-- 3. 答题记录表/批改记录表 (exam_back)
CREATE TABLE IF NOT EXISTS `exam_back` (
`id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '答题记录自增ID',
`uid` VARCHAR(64) NOT NULL UNIQUE COMMENT '答题记录唯一标识',
`examId` VARCHAR(64) NOT NULL COMMENT '关联的试卷UID',
`title` VARCHAR(255) COMMENT '试卷标题(冗余防丢)',
`name` VARCHAR(100) NOT NULL COMMENT '做题人姓名',
`content` JSON COMMENT '包含题目和所填答案的JSON数组',
`score` VARCHAR(50) COMMENT '总得分/批改状态'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生答卷与批改表';

120
questions_data.sql Normal file
View File

@@ -0,0 +1,120 @@
-- 题库数据插入脚本
-- 共100道题选择题70道分值2-3分简答题30道分值4-5分
-- 组合示例14道选择题(3分)+6道简答题(5分)=42+30=72分20道选择题(3分)+8道简答题(5分)=60+40=100分
USE exam;
-- 清空已有数据(可选)
-- TRUNCATE TABLE question;
-- 选择题70道- 35道3分35道2分
INSERT INTO question (title, type, answer_a, answer_b, answer_c, answer_d, r_answer, score) VALUES
('Java中以下哪个关键字用于定义类', 1, 'class', 'interface', 'extends', 'implements', 'A', 3),
('Python中列表推导式的语法是', 1, '[x for x in iterable]', '(x for x in iterable)', '{x for x in iterable}', '<x for x in iterable>', 'A', 2),
('HTTP协议默认使用的端口号是', 1, '80', '8080', '443', '3306', 'A', 3),
('MySQL中用于删除表的命令是', 1, 'DELETE TABLE', 'DROP TABLE', 'REMOVE TABLE', 'CLEAR TABLE', 'B', 2),
('CSS中用于设置背景颜色的属性是', 1, 'background-color', 'bgcolor', 'color', 'background', 'A', 3),
('JavaScript中以下哪个不是原始数据类型', 1, 'String', 'Number', 'Object', 'Boolean', 'C', 2),
('Spring Boot中主类上的核心注解是', 1, '@SpringApplication', '@SpringBootApplication', '@EnableAutoConfiguration', '@ComponentScan', 'B', 3),
('Git中用于提交代码的命令是', 1, 'git push', 'git commit', 'git add', 'git pull', 'B', 2),
('Linux中查看当前目录的命令是', 1, 'ls', 'cd', 'pwd', 'dir', 'C', 3),
('HTML中用于定义超链接的标签是', 1, '<link>', '<a>', '<href>', '<url>', 'B', 2),
('Java中String类的equals方法用于', 1, '比较引用', '比较内容', '赋值', '连接字符串', 'B', 3),
('数据库中ACID特性不包括', 1, 'Atomicity', 'Consistency', 'Isolation', 'Distribution', 'D', 2),
('Python中len函数的作用是', 1, '计算长度', '计算总和', '计算平均值', '计算最大值', 'A', 3),
('Redis是一种', 1, '关系型数据库', '内存数据库', '文档数据库', '图数据库', 'B', 2),
('Docker中用于构建镜像的命令是', 1, 'docker run', 'docker build', 'docker pull', 'docker push', 'B', 3),
('Java中以下哪个集合类是线程安全的', 1, 'ArrayList', 'HashMap', 'Vector', 'HashSet', 'C', 2),
('Maven中用于定义项目依赖的文件是', 1, 'build.xml', 'pom.xml', 'config.xml', 'project.xml', 'B', 3),
('JavaScript中===运算符的作用是?', 1, '赋值', '相等比较', '严格相等比较', '不等于', 'C', 2),
('Linux中查看文件内容的命令是', 1, 'open', 'cat', 'view', 'show', 'B', 3),
('HTTP状态码404表示', 1, '服务器错误', '未授权', '未找到', '重定向', 'C', 2),
('Java中final关键字不能用于修饰', 1, '', '方法', '变量', '接口', 'D', 3),
('Python中def关键字用于', 1, '定义变量', '定义函数', '定义类', '定义模块', 'B', 2),
('MySQL中主键的特点是', 1, '可以为空', '可以重复', '唯一且非空', '自动生成', 'C', 3),
('Vue.js中用于数据绑定的指令是', 1, 'v-if', 'v-for', 'v-model', 'v-bind', 'C', 2),
('Nginx主要用于', 1, '数据库服务器', 'Web服务器/反向代理', '缓存服务器', '消息队列', 'B', 3),
('Java中异常处理的关键字不包括', 1, 'try', 'catch', 'throw', 'error', 'D', 2),
('CSS中盒模型不包括', 1, 'content', 'padding', 'border', 'layer', 'D', 3),
('Spring中依赖注入的方式不包括', 1, '构造器注入', 'Setter注入', '接口注入', '字段注入', 'C', 2),
('Git中创建新分支的命令是', 1, 'git branch', 'git checkout', 'git switch', '以上都可以', 'D', 3),
('Python中导入模块的关键字是', 1, 'include', 'import', 'using', 'require', 'B', 2),
('数据库中,用于排序的关键字是?', 1, 'GROUP BY', 'ORDER BY', 'SORT BY', 'ARRANGE BY', 'B', 3),
('JavaScript中数组的push方法作用是', 1, '删除末尾元素', '在末尾添加元素', '在开头添加元素', '删除开头元素', 'B', 2),
('Linux中修改文件权限的命令是', 1, 'chown', 'chmod', 'chgrp', 'perm', 'B', 3),
('HTML5中用于本地存储的API是', 1, 'cookie', 'localStorage', 'session', 'cache', 'B', 2),
('Java中接口可以包含', 1, '构造方法', '静态方法Java8+', '实例变量', '普通方法体Java7', 'B', 3),
('MongoDB是一种', 1, '关系型数据库', '文档型NoSQL数据库', '键值对数据库', '列族数据库', 'B', 2),
('Kafka主要用于', 1, '数据存储', '消息队列/流处理', '负载均衡', '缓存', 'B', 3),
('Python中列表和元组的区别是', 1, '列表有序,元组无序', '列表可变,元组不可变', '列表可以重复,元组不可以', '没有区别', 'B', 2),
('CSS中Flex布局的主轴方向默认是', 1, 'row', 'column', 'row-reverse', 'column-reverse', 'A', 3),
('Java中synchronized关键字用于', 1, '继承', '多态', '线程同步', '异常处理', 'C', 2),
('MySQL中JOIN操作的作用是', 1, '删除数据', '更新数据', '连接多表查询', '插入数据', 'C', 3),
('React中用于状态管理的Hook是', 1, 'useEffect', 'useState', 'useContext', 'useReducer', 'B', 2),
('Linux中查找文件的命令是', 1, 'grep', 'find', 'locate', 'search', 'B', 3),
('HTTP状态码500表示', 1, '客户端错误', '服务器内部错误', '未授权', '禁止访问', 'B', 2),
('Java中abstract类不能', 1, '有构造方法', '有抽象方法', '直接实例化', '有成员变量', 'C', 3),
('Python中__init__方法是', 1, '构造函数', '析构函数', '普通方法', '静态方法', 'A', 2),
('Elasticsearch主要用于', 1, '关系存储', '全文搜索', '图计算', '事务处理', 'B', 3),
('Git中合并分支的命令是', 1, 'git combine', 'git merge', 'git join', 'git integrate', 'B', 2),
('CSS中z-index属性用于', 1, '水平定位', '垂直定位', '层级控制', '透明度控制', 'C', 3),
('Spring Boot中application.yml用于', 1, '编写业务代码', '配置应用程序', '定义数据模型', '编写测试', 'B', 2),
('JavaScript中typeof null返回', 1, '"null"', '"undefined"', '"object"', '"number"', 'C', 3),
('Redis的数据类型不包括', 1, 'String', 'List', 'Table', 'Set', 'C', 2),
('Docker中用于查看运行容器的命令是', 1, 'docker images', 'docker ps', 'docker list', 'docker show', 'B', 3),
('MySQL中索引的作用是', 1, '增加存储空间', '提高查询效率', '保证数据完整性', '加密数据', 'B', 2),
('Vue.js中v-if和v-show的区别是', 1, '没有区别', 'v-if是条件渲染v-show是CSS切换', 'v-if更快', 'v-show只能用于div', 'B', 3),
('Linux中压缩文件的命令是', 1, 'zip', 'tar', 'gzip', '以上都可以', 'D', 2),
('Java中 transient关键字的作用是', 1, '序列化时忽略该字段', '线程安全', '常量定义', '同步锁', 'A', 3),
('Python的装饰器是', 1, '一种设计模式', '语法糖,用于修改函数', '一种数据结构', '一种算法', 'B', 2),
('Nginx配置文件的默认路径是', 1, '/etc/nginx/nginx.conf', '/usr/local/nginx/conf/nginx.conf', 'A和B都可能', '/var/nginx/nginx.conf', 'C', 3),
('React中props的作用是', 1, '存储组件内部状态', '父组件向子组件传递数据', '事件处理', '路由跳转', 'B', 2),
('SQL中GROUP BY用于', 1, '排序', '分组聚合', '筛选', '连接表', 'B', 3),
('Java中volatile关键字用于', 1, '声明常量', '保证变量可见性', '序列化', '异常处理', 'B', 2),
('CSS中@media用于', 1, '导入样式', '定义动画', '响应式布局', '字体设置', 'C', 3),
('Git中撤销上次提交的命令是', 1, 'git reset', 'git revert', 'git undo', 'git rollback', 'A', 2),
('Spring Cloud中服务注册的组件是', 1, 'Zuul', 'Eureka', 'Ribbon', 'Hystrix', 'B', 3),
('Python中lambda表达式是', 1, '匿名函数', '装饰器', '生成器', '迭代器', 'A', 2),
('Linux中查看内存使用情况的命令是', 1, 'df', 'du', 'free', 'top', 'C', 3),
('HTTP是无状态协议解决会话保持的技术是', 1, 'Cookie/Session', 'WebSocket', 'TCP', 'UDP', 'A', 2),
('Java中默认的类加载器是', 1, 'Bootstrap ClassLoader', 'Extension ClassLoader', 'Application ClassLoader', 'User ClassLoader', 'C', 3),
('Node.js是基于什么语言开发的', 1, 'Python', 'Java', 'C++', 'JavaScript', 'D', 2),
('Java中==和equals的区别是', 1, '没有区别', '==比较引用equals比较内容', 'equals比较引用==比较内容', '都比较内容', 'B', 3),
('Python中字典的键可以是', 1, '列表', '字典', '元组', '集合', 'C', 2),
('MySQL中事务的隔离级别不包括', 1, 'READ UNCOMMITTED', 'READ COMMITTED', 'WRITE COMMITTED', 'SERIALIZABLE', 'C', 3),
('JavaScript中闭包的作用是', 1, '提高性能', '实现封装和私有变量', '简化代码', '减少内存', 'B', 2),
('Linux中grep命令的作用是', 1, '查找文件', '过滤文本', '压缩文件', '排序文本', 'B', 3),
('Spring中@Autowired的作用是', 1, '声明Bean', '自动注入依赖', '配置属性', '定义切面', 'B', 2);
-- 简答题30道- 20道5分10道4分
INSERT INTO question (title, type, answer, r_answer, score) VALUES
('简述Java中ArrayList和LinkedList的区别及适用场景。', 2, '', 'ArrayList基于动态数组实现查询快O(1)、增删慢O(n)适合随机访问多的场景LinkedList基于双向链表实现增删快O(1)、查询慢O(n),适合频繁插入删除的场景。', 5),
('什么是数据库事务的ACID特性请分别解释。', 2, '', 'ACID包括原子性(Atomicity)事务要么全完成要么全不完成;一致性(Consistency)事务前后数据完整性一致;隔离性(Isolation)事务之间互不干扰;持久性(Durability)事务完成后数据永久保存。', 5),
('解释Spring框架中的IOC和AOP概念。', 2, '', 'IOC(控制反转)是将对象创建和依赖关系管理交给Spring容器降低耦合AOP(面向切面编程)将横切关注点如日志、事务从业务逻辑中分离,通过代理实现功能增强。', 5),
('简述Redis的五种基本数据类型及其使用场景。', 2, '', 'String缓存、计数器List消息队列、时间线Set去重、集合运算ZSet排行榜Hash存储对象属性。', 5),
('什么是RESTful API它有哪些特点', 2, '', 'RESTful是一种软件架构风格使用URL定位资源HTTP动词(GET/POST/PUT/DELETE)操作资源。特点:无状态、统一接口、可缓存、分层系统。', 4),
('解释Java中的多线程同步机制有哪些', 2, '', 'synchronized关键字、ReentrantLock显式锁、volatile保证可见性、Atomic原子类、Concurrent并发集合、Semaphore信号量等。', 5),
('MySQL中索引失效的常见情况有哪些', 2, '', '使用!=或<>、对列进行函数操作、LIKE以%开头、类型隐式转换、OR条件无索引、复合索引未用左前缀、IS NOT NULL等。', 5),
('简述TCP三次握手的过程。', 2, '', '第一次客户端发送SYN第二次服务端回复SYN+ACK第三次客户端回复ACK。目的是确认双方收发能力正常同步初始序列号。', 4),
('什么是JVM内存模型主要包括哪些区域', 2, '', 'JVM内存模型定义多线程共享变量的访问规则。运行时数据区包括堆、方法区、虚拟机栈、本地方法栈、程序计数器。', 5),
('简述Vue.js的生命周期钩子函数。', 2, '', '创建前/后beforeCreate/created挂载前/后beforeMount/mounted更新前/后beforeUpdate/updated销毁前/后beforeUnmount/unmounted。', 5),
('什么是Docker它解决了什么问题', 2, '', 'Docker是容器化平台将应用及其依赖打包为镜像实现一次构建到处运行。解决了环境不一致、部署复杂、资源利用率低等问题。', 4),
('解释Git的工作流程和常用分支策略。', 2, '', '工作区→暂存区→本地仓库→远程仓库。常见策略Git Flowmaster/develop/feature/release/hotfix分支或GitHub Flow主干开发', 5),
('什么是微服务架构?它的优缺点是什么?', 2, '', '将单体应用拆分为小型、独立部署的服务。优点:技术异构、独立扩展、故障隔离;缺点:分布式复杂性、运维成本高、数据一致性难保证。', 5),
('简述HTTPS的加密流程。', 2, '', '客户端请求证书→服务端发送证书(含公钥)→客户端验证证书→生成随机对称密钥→用公钥加密发送→双方用对称密钥加密通信。', 5),
('Java 8引入了哪些重要新特性', 2, '', 'Lambda表达式、Stream API、Optional类、新日期时间API、默认方法、方法引用、函数式接口、接口中的静态方法等。', 4),
('什么是数据库的范式?请列举前三种。', 2, '', '1NF属性原子性2NF消除部分依赖3NF消除传递依赖。目的是减少数据冗余保证数据一致性。', 5),
('解释消息队列MQ的主要作用和使用场景。', 2, '', '作用:异步处理、应用解耦、流量削峰、日志处理。场景:订单处理、秒杀系统、日志收集、通知推送等。', 5),
('简述Java垃圾回收的基本原理。', 2, '', 'JVM自动回收不再使用的对象内存。常用算法标记-清除、复制、标记-整理。现代JVM采用分代收集新生代用复制老年代用标记-整理。', 5),
('什么是CSS盒模型标准盒模型和IE盒模型有何区别', 2, '', '盒模型包含content、padding、border、margin。标准盒模型width/height只含contentIE盒模型包含content+padding+border。通过box-sizing切换。', 4),
('解释OAuth 2.0的授权流程。', 2, '', '四种模式授权码模式最安全、简化模式、密码凭证、客户端凭证。主流授权码流程请求授权→获取code→换取token→访问资源。', 5),
('什么是设计模式?请列举三种常用创建型模式。', 2, '', '设计模式是解决特定问题的最佳实践。创建型模式:单例模式、工厂模式、建造者模式、原型模式。', 5),
('简述Nginx的负载均衡策略有哪些', 2, '', '轮询(默认)、权重(weight)、IP哈希(ip_hash)、最少连接(least_conn)、一致性哈希等。', 4),
('什么是WebSocket与HTTP有什么区别', 2, '', 'WebSocket是全双工通信协议建立连接后可双向实时通信。HTTP是请求-响应模式、无状态、单向。WebSocket适合实时应用如聊天、股票行情。', 5),
('解释Spring Boot的自动配置原理。', 2, '', '@EnableAutoConfiguration通过classpath扫描依赖根据META-INF/spring.factories中定义的自动配置类结合条件注解如@ConditionalOnClass进行条件装配。', 5),
('MySQL中InnoDB和MyISAM存储引擎的区别', 2, '', 'InnoDB支持事务、行级锁、外键、MVCC适合高并发MyISAM表级锁、全文索引、速度快适合读多写少。', 5),
('什么是前端路由React Router的工作原理是什么', 2, '', '前端路由在不刷新页面的情况下切换视图。React Router通过history API监听URL变化匹配Route组件渲染对应组件实现SPA导航。', 4),
('简述分布式系统中的CAP理论。', 2, '', 'CAP指一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。分布式系统无法同时满足三者最多满足两个通常是AP或CP。', 5),
('什么是线程池Java中如何创建线程池', 2, '', '线程池复用线程减少创建销毁开销。通过Executors工厂方法或ThreadPoolExecutor构造函数创建参数包括核心线程数、最大线程数、队列、拒绝策略等。', 5),
('解释跨域问题产生的原因及解决方案。', 2, '', '浏览器同源策略限制不同源请求。方案CORS服务端设置响应头、JSONP只支持GET、代理服务器、Nginx反向代理、WebSocket等。', 4),
('简述Kafka的基本架构和核心概念。', 2, '', 'Kafka是分布式流处理平台。核心概念Producer生产者、Consumer消费者、Broker服务器、Topic主题、Partition分区、Offset偏移量、Replication副本。', 5);

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>