优化模拟考试答案查看页面的显示效果
主要改进: 1. 新增ExamAnswerView页面和样式文件 2. 优化填空题间距,减少过大的垂直边距 3. 紧凑化题型之间的间距,提升页面密度 4. 去掉题型标题的背景色和左侧竖线 5. 为题型标题添加汉字序号(一、二、三等) 6. 去掉选择题表格的边框,简化界面 7. 解决打印时显示"试卷答案"标题的问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
48730369e5
commit
c4a8b28abe
@ -117,7 +117,7 @@ func CreateExam(c *gin.Context) {
|
|||||||
|
|
||||||
// 创建试卷
|
// 创建试卷
|
||||||
exam := models.Exam{
|
exam := models.Exam{
|
||||||
UserID: userID.(uint),
|
UserID: uint(userID.(int64)),
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
TotalScore: int(totalScore), // 总分100分
|
TotalScore: int(totalScore), // 总分100分
|
||||||
Duration: 60, // 固定60分钟
|
Duration: 60, // 固定60分钟
|
||||||
@ -233,6 +233,9 @@ func GetExamDetail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否需要显示答案
|
||||||
|
showAnswer := c.Query("show_answer") == "true"
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// 查询试卷
|
// 查询试卷
|
||||||
@ -258,7 +261,7 @@ func GetExamDetail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按原始顺序排序题目并转换为DTO(不显示答案)
|
// 按原始顺序排序题目并转换为DTO
|
||||||
questionMap := make(map[int64]models.PracticeQuestion)
|
questionMap := make(map[int64]models.PracticeQuestion)
|
||||||
for _, q := range questions {
|
for _, q := range questions {
|
||||||
questionMap[q.ID] = q
|
questionMap[q.ID] = q
|
||||||
@ -267,7 +270,10 @@ func GetExamDetail(c *gin.Context) {
|
|||||||
for _, id := range questionIDs {
|
for _, id := range questionIDs {
|
||||||
if q, ok := questionMap[id]; ok {
|
if q, ok := questionMap[id]; ok {
|
||||||
dto := convertToDTO(q)
|
dto := convertToDTO(q)
|
||||||
dto.Answer = nil // 不显示答案
|
// 根据showAnswer参数决定是否显示答案
|
||||||
|
if !showAnswer {
|
||||||
|
dto.Answer = nil // 不显示答案
|
||||||
|
}
|
||||||
orderedDTOs = append(orderedDTOs, dto)
|
orderedDTOs = append(orderedDTOs, dto)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -309,7 +315,7 @@ func StartExam(c *gin.Context) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
record := models.ExamRecord{
|
record := models.ExamRecord{
|
||||||
ExamID: uint(examID),
|
ExamID: uint(examID),
|
||||||
UserID: userID.(uint),
|
UserID: uint(userID.(int64)),
|
||||||
StartTime: &now,
|
StartTime: &now,
|
||||||
TotalScore: exam.TotalScore,
|
TotalScore: exam.TotalScore,
|
||||||
Status: "in_progress",
|
Status: "in_progress",
|
||||||
@ -413,7 +419,7 @@ func SubmitExam(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 异步执行阅卷(从 exam_user_answers 表读取答案)
|
// 异步执行阅卷(从 exam_user_answers 表读取答案)
|
||||||
go gradeExam(uint(recordID), exam.ID, userID.(uint))
|
go gradeExam(uint(recordID), exam.ID, uint(userID.(int64)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetExamRecord 获取考试记录详情
|
// GetExamRecord 获取考试记录详情
|
||||||
|
|||||||
405
internal/services/baidu_ai_grading.go
Normal file
405
internal/services/baidu_ai_grading.go
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ankao/pkg/config"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/baidubce/app-builder/go/appbuilder"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BaiduAIGradingService 百度云AI评分服务
|
||||||
|
type BaiduAIGradingService struct {
|
||||||
|
client *appbuilder.AppBuilderClient
|
||||||
|
config *config.AIConfig
|
||||||
|
conversationID string // 会话ID,用于保持上下文
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局单例和锁
|
||||||
|
var (
|
||||||
|
globalBaiduService *BaiduAIGradingService
|
||||||
|
serviceMutex sync.Mutex
|
||||||
|
serviceInitialized bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewBaiduAIGradingService 创建百度云AI评分服务实例(使用单例模式)
|
||||||
|
func NewBaiduAIGradingService() (*BaiduAIGradingService, error) {
|
||||||
|
serviceMutex.Lock()
|
||||||
|
defer serviceMutex.Unlock()
|
||||||
|
|
||||||
|
// 如果已经初始化过,直接返回
|
||||||
|
if serviceInitialized && globalBaiduService != nil {
|
||||||
|
return globalBaiduService, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.GetAIConfig()
|
||||||
|
|
||||||
|
// 设置百度云AppBuilder Token
|
||||||
|
clientConfig, err := appbuilder.NewSDKConfig("", cfg.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建SDK配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建AppBuilder客户端
|
||||||
|
client, err := appbuilder.NewAppBuilderClient(cfg.BaiduAppID, clientConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建AppBuilder客户端失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
conversationID, err := client.CreateConversation()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建会话失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("百度云AI服务初始化成功,会话ID: %s", conversationID)
|
||||||
|
|
||||||
|
globalBaiduService = &BaiduAIGradingService{
|
||||||
|
client: client,
|
||||||
|
config: cfg,
|
||||||
|
conversationID: conversationID,
|
||||||
|
}
|
||||||
|
serviceInitialized = true
|
||||||
|
|
||||||
|
return globalBaiduService, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GradeEssay 对论述题进行AI评分(不需要标准答案)
|
||||||
|
func (s *BaiduAIGradingService) GradeEssay(question, userAnswer string) (*AIGradingResult, error) {
|
||||||
|
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请对以下论述题进行评分。
|
||||||
|
|
||||||
|
题目:%s
|
||||||
|
|
||||||
|
学生答案:%s
|
||||||
|
|
||||||
|
评分依据:
|
||||||
|
请依据以下保密法律法规和管理制度进行分析和评分:
|
||||||
|
1. 《中华人民共和国保守国家秘密法》
|
||||||
|
2. 《中华人民共和国保守国家秘密法实施条例》
|
||||||
|
3. 《保密工作管理制度2025.9.9》
|
||||||
|
4. 《软件开发管理制度》
|
||||||
|
5. 《涉密信息系统集成资质保密标准》
|
||||||
|
6. 《涉密信息系统集成资质管理办法》
|
||||||
|
|
||||||
|
评分标准(论述题没有固定标准答案,请根据答题质量和法规符合度评分):
|
||||||
|
1. 论点是否明确,是否符合保密法规要求(30分)
|
||||||
|
2. 内容是否充实,论据是否引用相关法规条文(30分)
|
||||||
|
3. 逻辑是否严密,分析是否符合保密工作实际(25分)
|
||||||
|
4. 语言表达是否准确、专业(15分)
|
||||||
|
|
||||||
|
评分等级:
|
||||||
|
- 85-100分:论述优秀,论点明确、论据充分、符合法规要求、分析专业
|
||||||
|
- 70-84分:论述良好,基本要素齐全,符合保密工作要求
|
||||||
|
- 60-69分:论述基本合格,要点基本涵盖但不够深入
|
||||||
|
- 40-59分:论述不够完整,缺乏法规支撑或逻辑性较差
|
||||||
|
- 0-39分:论述严重缺失或完全离题
|
||||||
|
|
||||||
|
判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
|
||||||
|
|
||||||
|
评分要求:
|
||||||
|
1. 给出一个0-100的精确分数
|
||||||
|
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
|
||||||
|
3. 生成一个专业的参考答案(reference_answer,150-300字,必须引用相关法规条文)
|
||||||
|
4. 给出评分依据(scoring_rationale,说明依据了哪些法规和条文,80-150字)
|
||||||
|
5. 给出简短的评语(feedback,说明得分情况,不超过80字)
|
||||||
|
6. 给出具体的改进建议(suggestion,如果分数在90分以上可以简短,否则必须指出具体改进方向,不超过80字)
|
||||||
|
|
||||||
|
请按照以下JSON格式返回结果:
|
||||||
|
{
|
||||||
|
"score": 75,
|
||||||
|
"is_correct": true,
|
||||||
|
"reference_answer": "根据《中华人民共和国保守国家秘密法》第XX条...",
|
||||||
|
"scoring_rationale": "依据《保密法》第XX条、《保密法实施条例》第XX条...",
|
||||||
|
"feedback": "论述较为完整,论点明确,但论据不够充分,缺少具体法规引用",
|
||||||
|
"suggestion": "建议补充《保密法》相关条文,加强论点之间的逻辑联系"
|
||||||
|
}
|
||||||
|
|
||||||
|
注意:
|
||||||
|
1. 只返回JSON格式的结果,不要有其他内容
|
||||||
|
2. 参考答案必须专业、准确,体现保密法规要求
|
||||||
|
3. 评分依据必须具体引用法规条文`, question, userAnswer)
|
||||||
|
|
||||||
|
// 调用百度云AI
|
||||||
|
answer, err := s.runAppBuilder(prompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析返回结果
|
||||||
|
var result AIGradingResult
|
||||||
|
if err := parseAIResponse(answer, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析AI响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GradeShortAnswer 对简答题进行AI评分
|
||||||
|
func (s *BaiduAIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
|
||||||
|
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请严格按照标准答案对以下简答题进行评分。
|
||||||
|
|
||||||
|
题目:%s
|
||||||
|
|
||||||
|
标准答案:%s
|
||||||
|
|
||||||
|
学生答案:%s
|
||||||
|
|
||||||
|
评分依据:
|
||||||
|
请依据以下保密法律法规和管理制度进行分析和评分:
|
||||||
|
1. 《中华人民共和国保守国家秘密法》
|
||||||
|
2. 《中华人民共和国保守国家秘密法实施条例》
|
||||||
|
3. 《保密工作管理制度2025.9.9》
|
||||||
|
4. 《软件开发管理制度》
|
||||||
|
5. 《涉密信息系统集成资质保密标准》
|
||||||
|
6. 《涉密信息系统集成资质管理办法》
|
||||||
|
|
||||||
|
评分标准(请严格遵守):
|
||||||
|
1. 必须与标准答案进行逐项对比
|
||||||
|
2. 答案要点完全覆盖标准答案且表述准确、符合法规要求的,给85-100分
|
||||||
|
3. 答案要点基本覆盖但有缺漏或表述不够准确的,给60-84分
|
||||||
|
4. 答案要点缺失较多或有明显错误的,给40-59分
|
||||||
|
5. 答案完全错误或离题的,给0-39分
|
||||||
|
6. 判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
|
||||||
|
|
||||||
|
评分要求:
|
||||||
|
1. 给出一个0-100的精确分数
|
||||||
|
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
|
||||||
|
3. 给出评分依据(scoring_rationale,说明依据了哪些法规和标准答案的哪些要点,80-150字)
|
||||||
|
4. 给出简短的评语(feedback,说明得分和失分原因,不超过80字)
|
||||||
|
5. 给出具体的改进建议(suggestion,如果答案满分可以为空,否则必须指出具体改进方向,不超过80字)
|
||||||
|
|
||||||
|
请按照以下JSON格式返回结果:
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
|
"is_correct": true,
|
||||||
|
"scoring_rationale": "依据《保密法》第XX条和标准答案要点分析...",
|
||||||
|
"feedback": "答案覆盖了主要要点,但XXX部分描述不够准确",
|
||||||
|
"suggestion": "建议补充XXX内容,并完善XXX的描述"
|
||||||
|
}
|
||||||
|
|
||||||
|
注意:
|
||||||
|
1. 只返回JSON格式的结果,不要有其他内容
|
||||||
|
2. 必须严格对照标准答案评分,不要过于宽松
|
||||||
|
3. 评分依据必须说明符合或违反了哪些法规要求`, question, standardAnswer, userAnswer)
|
||||||
|
|
||||||
|
// 调用百度云AI
|
||||||
|
answer, err := s.runAppBuilder(prompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析返回结果
|
||||||
|
var result AIGradingResult
|
||||||
|
if err := parseAIResponse(answer, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析AI响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplainQuestionStream 生成题目解析(流式输出)
|
||||||
|
func (s *BaiduAIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
|
||||||
|
prompt := fmt.Sprintf(`你是一位严谨、专业的保密领域专家老师,请对以下题目进行详细解析。
|
||||||
|
|
||||||
|
题目类型:%s
|
||||||
|
|
||||||
|
题目内容:%s
|
||||||
|
|
||||||
|
标准答案:%s
|
||||||
|
|
||||||
|
**重要依据**:请基于以下保密法律法规和管理制度进行专业分析:
|
||||||
|
1. 《中华人民共和国保守国家秘密法》
|
||||||
|
2. 《中华人民共和国保守国家秘密法实施条例》
|
||||||
|
3. 《保密工作管理制度2025.9.9》
|
||||||
|
4. 《软件开发管理制度》
|
||||||
|
5. 《涉密信息系统集成资质保密标准》
|
||||||
|
6. 《涉密信息系统集成资质管理办法》
|
||||||
|
|
||||||
|
请提供一个详细的解析,要求:
|
||||||
|
1. **必须基于保密法规**:解析时必须引用相关法规条文,说明依据哪些具体法律法规
|
||||||
|
2. **必须实事求是**:只基于题目内容、标准答案和实际法规进行解析
|
||||||
|
3. **不要胡编乱造**:如果某些信息不确定或题目没有提供,请如实说明,不要编造法规条文
|
||||||
|
|
||||||
|
解析内容要求:
|
||||||
|
- **知识点**:说明题目考查的核心知识点,指出涉及哪些保密法规
|
||||||
|
- **法规依据**:明确引用相关法律法规的具体条文(如:《保密法》第X条、《保密法实施条例》第X条等)
|
||||||
|
- **解题思路**:提供清晰的解题步骤和方法,结合保密工作实际
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
示例输出格式:
|
||||||
|
## 知识点
|
||||||
|
本题考查的是[知识点名称],涉及《XX法规》第XX条...
|
||||||
|
|
||||||
|
## 法规依据
|
||||||
|
- 《中华人民共和国保守国家秘密法》第XX条规定:...
|
||||||
|
- 《保密工作管理制度2025.9.9》第X章第X节:...
|
||||||
|
|
||||||
|
## 解题思路
|
||||||
|
1. 首先根据《XX法规》第XX条,我们可以判断...
|
||||||
|
2. 然后结合保密工作实际,分析...
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
%s
|
||||||
|
|
||||||
|
**重要提醒**:请务必在解析中引用具体的法规条文,不要空泛地提及法规名称。如果不确定具体条文编号,可以说明法规的精神和要求。
|
||||||
|
|
||||||
|
请使用markdown格式输出解析内容。`,
|
||||||
|
questionType,
|
||||||
|
question,
|
||||||
|
standardAnswer,
|
||||||
|
// 根据题目类型添加特定要求
|
||||||
|
func() string {
|
||||||
|
if questionType == "single-selection" || questionType == "multiple-selection" {
|
||||||
|
return `- **选项分析**:对于选择题,必须逐项分析每个选项的对错及原因,并说明依据哪些法规
|
||||||
|
- **记忆口诀**:如果适用,提供便于记忆的口诀或技巧`
|
||||||
|
}
|
||||||
|
return "- **答案解析**:详细说明为什么这个答案是正确的,并引用相关法规依据"
|
||||||
|
}(),
|
||||||
|
// 根据题目类型添加示例格式
|
||||||
|
func() string {
|
||||||
|
if questionType == "single-selection" || questionType == "multiple-selection" {
|
||||||
|
return `## 选项分析
|
||||||
|
- **A选项**:[分析该选项],根据《XX法规》第XX条...
|
||||||
|
- **B选项**:[分析该选项],根据《XX法规》第XX条...
|
||||||
|
- **C选项**:[分析该选项],根据《XX法规》第XX条...
|
||||||
|
- **D选项**:[分析该选项],根据《XX法规》第XX条...
|
||||||
|
|
||||||
|
## 正确答案
|
||||||
|
正确答案是... 因为根据《XX法规》第XX条规定...`
|
||||||
|
}
|
||||||
|
return `## 答案解析
|
||||||
|
正确答案是... 根据《XX法规》第XX条的规定...`
|
||||||
|
}(),
|
||||||
|
// 根据题目类型添加总结要求
|
||||||
|
func() string {
|
||||||
|
if questionType == "single-selection" || questionType == "multiple-selection" {
|
||||||
|
return "对于选择题,可以提供记忆口诀或关键要点总结,并总结涉及的主要法规要求"
|
||||||
|
}
|
||||||
|
return "总结本题的关键要点、涉及的主要法规要求和在保密工作中的实际应用"
|
||||||
|
}(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 调用百度云AI(流式)
|
||||||
|
return s.runAppBuilderStream(writer, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplainQuestion 生成题目解析
|
||||||
|
func (s *BaiduAIGradingService) ExplainQuestion(question, standardAnswer, questionType string) (*AIExplanationResult, error) {
|
||||||
|
prompt := fmt.Sprintf(`你是一位经验丰富的老师,请对以下题目进行详细解析。
|
||||||
|
|
||||||
|
题目类型:%s
|
||||||
|
|
||||||
|
题目内容:%s
|
||||||
|
|
||||||
|
标准答案:%s
|
||||||
|
|
||||||
|
请提供一个详细的解析,包括:
|
||||||
|
1. 题目考查的知识点
|
||||||
|
2. 解题思路和方法
|
||||||
|
3. 为什么选择这个答案
|
||||||
|
4. 相关的重要概念或注意事项
|
||||||
|
|
||||||
|
请按照以下JSON格式返回结果:
|
||||||
|
{
|
||||||
|
"explanation": "这道题考查的是...(200字以内的详细解析)"
|
||||||
|
}
|
||||||
|
|
||||||
|
注意:只返回JSON格式的结果,不要有其他内容。`, questionType, question, standardAnswer)
|
||||||
|
|
||||||
|
// 调用百度云AI
|
||||||
|
answer, err := s.runAppBuilder(prompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析返回结果
|
||||||
|
var result AIExplanationResult
|
||||||
|
if err := parseAIResponse(answer, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析AI响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAppBuilder 调用百度云AppBuilder(非流式)
|
||||||
|
func (s *BaiduAIGradingService) runAppBuilder(query string) (string, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
log.Printf("[百度云AI] 开始调用,会话ID: %s", s.conversationID)
|
||||||
|
|
||||||
|
// 调用AppBuilder Run方法 - 注意:即使stream=false,依然需要迭代读取
|
||||||
|
iterator, err := s.client.Run(s.conversationID, query, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("调用AppBuilder失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有返回内容
|
||||||
|
var fullAnswer strings.Builder
|
||||||
|
|
||||||
|
// 只读取一次 - 非流式模式下SDK应该一次性返回完整结果
|
||||||
|
answer, err := iterator.Next()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return "", fmt.Errorf("AI未返回任何内容")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("读取AppBuilder响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if answer != nil && answer.Answer != "" {
|
||||||
|
fullAnswer.WriteString(answer.Answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := fullAnswer.String()
|
||||||
|
if result == "" {
|
||||||
|
return "", fmt.Errorf("AI未返回任何内容")
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
log.Printf("[百度云AI] 调用完成,耗时: %v, 返回长度: %d", elapsed, len(result))
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAppBuilderStream 调用百度云AppBuilder(流式)
|
||||||
|
func (s *BaiduAIGradingService) runAppBuilderStream(writer http.ResponseWriter, query string) error {
|
||||||
|
// 调用AppBuilder Run方法(流式)
|
||||||
|
iterator, err := s.client.Run(s.conversationID, query, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建流式请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("响应写入器不支持Flush")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取流式响应并发送给客户端 - 参考百度云SDK示例代码的方式
|
||||||
|
var answer *appbuilder.AppBuilderClientAnswer
|
||||||
|
for answer, err = iterator.Next(); err == nil; answer, err = iterator.Next() {
|
||||||
|
// 发送增量内容
|
||||||
|
if answer != nil && answer.Answer != "" {
|
||||||
|
// 使用SSE格式发送
|
||||||
|
fmt.Fprintf(writer, "data: %s\n\n", answer.Answer)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否因为EOF之外的错误退出 - 使用errors.Is更安全
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return fmt.Errorf("接收流式响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流结束
|
||||||
|
fmt.Fprintf(writer, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -14,13 +14,12 @@ import QuestionManagement from './pages/QuestionManagement'
|
|||||||
import QuestionList from './pages/QuestionList'
|
import QuestionList from './pages/QuestionList'
|
||||||
import UserManagement from './pages/UserManagement'
|
import UserManagement from './pages/UserManagement'
|
||||||
import UserDetail from './pages/UserDetail'
|
import UserDetail from './pages/UserDetail'
|
||||||
import ExamPrepare from './pages/ExamPrepare'
|
|
||||||
import ExamOnline from './pages/ExamOnline'
|
import ExamOnline from './pages/ExamOnline'
|
||||||
import ExamPrint from './pages/ExamPrint'
|
import ExamPrint from './pages/ExamPrint'
|
||||||
import ExamResult from './pages/ExamResult'
|
|
||||||
import ExamManagement from './pages/ExamManagement'
|
import ExamManagement from './pages/ExamManagement'
|
||||||
import ExamTaking from './pages/ExamTaking'
|
import ExamTaking from './pages/ExamTaking'
|
||||||
import ExamResultNew from './pages/ExamResultNew'
|
import ExamResultNew from './pages/ExamResultNew'
|
||||||
|
import ExamAnswerView from './pages/ExamAnswerView'
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@ -38,13 +37,12 @@ const App: React.FC = () => {
|
|||||||
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
|
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* 考试相关页面,需要登录保护 */}
|
{/* 考试相关页面,需要登录保护 */}
|
||||||
<Route path="/exam/new" element={<ProtectedRoute><ExamPrepare /></ProtectedRoute>} />
|
|
||||||
<Route path="/exam/management" element={<ProtectedRoute><ExamManagement /></ProtectedRoute>} />
|
<Route path="/exam/management" element={<ProtectedRoute><ExamManagement /></ProtectedRoute>} />
|
||||||
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
|
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
|
||||||
<Route path="/exam/:examId/taking/:recordId" element={<ProtectedRoute><ExamTaking /></ProtectedRoute>} />
|
<Route path="/exam/:examId/taking/:recordId" element={<ProtectedRoute><ExamTaking /></ProtectedRoute>} />
|
||||||
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
|
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
|
||||||
<Route path="/exam/:examId/result" element={<ProtectedRoute><ExamResult /></ProtectedRoute>} />
|
|
||||||
<Route path="/exam/result/:recordId" element={<ProtectedRoute><ExamResultNew /></ProtectedRoute>} />
|
<Route path="/exam/result/:recordId" element={<ProtectedRoute><ExamResultNew /></ProtectedRoute>} />
|
||||||
|
<Route path="/exam/:examId/answer" element={<ProtectedRoute><ExamAnswerView /></ProtectedRoute>} />
|
||||||
|
|
||||||
{/* 题库管理页面,需要管理员权限 */}
|
{/* 题库管理页面,需要管理员权限 */}
|
||||||
<Route path="/question-management" element={
|
<Route path="/question-management" element={
|
||||||
|
|||||||
@ -75,7 +75,7 @@ export const generateExam = () => {
|
|||||||
|
|
||||||
// 获取考试详情
|
// 获取考试详情
|
||||||
export const getExam = (examId: number, showAnswer?: boolean) => {
|
export const getExam = (examId: number, showAnswer?: boolean) => {
|
||||||
return request.get<ApiResponse<GetExamResponse>>(`/exam/${examId}`, {
|
return request.get<ApiResponse<GetExamResponse>>(`/exams/${examId}`, {
|
||||||
params: { show_answer: showAnswer },
|
params: { show_answer: showAnswer },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
378
web/src/pages/ExamAnswerView.module.less
Normal file
378
web/src/pages/ExamAnswerView.module.less
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 14px; // 设置基础字体大小
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsCard {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailCard {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
// 适合打印的样式
|
||||||
|
@media print {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsCard {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionCard {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerDetail {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questionContent {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerSection {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answerItem {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correct {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incorrect {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreCard {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreItem {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreHeader {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreContent {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreProgress {
|
||||||
|
height: 6px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeScoreBar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印样式优化
|
||||||
|
@media print {
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 12pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化打印时的卡片显示
|
||||||
|
.detailCard {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
// 打印时移除卡片头部的内边距
|
||||||
|
.ant-card-head {
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除卡片内容区域的内边距
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免在卡片前后分页
|
||||||
|
page-break-inside: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印时优化Card显示
|
||||||
|
.detailCard {
|
||||||
|
@media print {
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保题型标题在打印时不会被分页打断
|
||||||
|
.typeTitle {
|
||||||
|
page-break-after: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化打印时的分隔线
|
||||||
|
.ant-divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印时隐藏分隔线容器的外边距
|
||||||
|
.ant-divider-horizontal {
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
margin: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 专门用于隐藏打印时分隔线的类
|
||||||
|
.noPrintDivider {
|
||||||
|
@media print {
|
||||||
|
display: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏打印按钮所在的卡片
|
||||||
|
.statsCard {
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏返回按钮和打印按钮
|
||||||
|
button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止最后一页出现空白页
|
||||||
|
.fillBlankContainer,
|
||||||
|
.fillBlankItem,
|
||||||
|
.optionsTable {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除页面底部可能的空白
|
||||||
|
.container:last-child {
|
||||||
|
page-break-after: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填空题容器样式
|
||||||
|
.fillBlankContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填空题项目样式
|
||||||
|
.fillBlankItem {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
padding: 2px 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格样式用于选择题
|
||||||
|
.optionsTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 8px;
|
||||||
|
table-layout: fixed; // 固定表格布局,确保列宽一致
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: none;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
th, td {
|
||||||
|
border: none;
|
||||||
|
padding: 6px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印时增加分页控制
|
||||||
|
tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择题答案显示
|
||||||
|
.choiceAnswer {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题号和答案的显示样式
|
||||||
|
.answerDetail {
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 14px; // 调整字体大小以适应打印
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word; // 自动换行
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
font-size: 12px; // 打印时使用稍小的字体
|
||||||
|
padding: 2px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格样式优化
|
||||||
|
.optionsTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 8px;
|
||||||
|
table-layout: fixed; // 固定表格布局,确保列宽一致
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: none;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: none;
|
||||||
|
padding: 8px 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印时增加分页控制
|
||||||
|
tr {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避免表格后产生空白页
|
||||||
|
tbody:last-child {
|
||||||
|
page-break-after: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
web/src/pages/ExamAnswerView.tsx
Normal file
317
web/src/pages/ExamAnswerView.tsx
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Divider,
|
||||||
|
message
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
FileTextOutlined,
|
||||||
|
HomeOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import * as examApi from '../api/exam'
|
||||||
|
import type { Question } from '../types/question'
|
||||||
|
import type { GetExamResponse } from '../types/exam'
|
||||||
|
import styles from './ExamAnswerView.module.less'
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography
|
||||||
|
|
||||||
|
// 题型名称映射
|
||||||
|
const TYPE_NAME: Record<string, string> = {
|
||||||
|
'fill-in-blank': '填空题',
|
||||||
|
'true-false': '判断题',
|
||||||
|
'multiple-choice': '单选题',
|
||||||
|
'multiple-selection': '多选题',
|
||||||
|
'short-answer': '简答题',
|
||||||
|
'ordinary-essay': '论述题',
|
||||||
|
'management-essay': '论述题',
|
||||||
|
'essay': '论述题' // 合并后的论述题类型
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题型顺序定义
|
||||||
|
const TYPE_ORDER: Record<string, number> = {
|
||||||
|
'fill-in-blank': 1,
|
||||||
|
'true-false': 2,
|
||||||
|
'multiple-choice': 3,
|
||||||
|
'multiple-selection': 4,
|
||||||
|
'short-answer': 5,
|
||||||
|
'ordinary-essay': 6,
|
||||||
|
'management-essay': 6,
|
||||||
|
'essay': 6 // 合并后的论述题顺序
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamAnswerView: React.FC = () => {
|
||||||
|
const { examId } = useParams<{ examId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [examData, setExamData] = useState<GetExamResponse | null>(null)
|
||||||
|
const [questions, setQuestions] = useState<Question[]>([])
|
||||||
|
|
||||||
|
// 处理打印功能
|
||||||
|
const handlePrint = () => {
|
||||||
|
// 设置打印标题
|
||||||
|
document.title = `${examData?.exam?.title || '试卷答案'}_打印版`
|
||||||
|
|
||||||
|
// 触发打印
|
||||||
|
window.print()
|
||||||
|
|
||||||
|
// 打印完成后恢复标题
|
||||||
|
setTimeout(() => {
|
||||||
|
document.title = 'AnKao - 智能考试系统'
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!examId) {
|
||||||
|
message.error('参数错误')
|
||||||
|
navigate('/exam/management')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadExamData()
|
||||||
|
}, [examId])
|
||||||
|
|
||||||
|
const loadExamData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// 获取带答案的试卷详情
|
||||||
|
const res = await examApi.getExam(Number(examId), true)
|
||||||
|
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setExamData(res.data)
|
||||||
|
setQuestions(res.data.questions)
|
||||||
|
} else {
|
||||||
|
message.error('加载试卷失败')
|
||||||
|
navigate('/exam/management')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.response?.data?.message || '加载试卷失败')
|
||||||
|
navigate('/exam/management')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<Text style={{ marginTop: 16 }}>加载试卷中...</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!examData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 格式化答案显示
|
||||||
|
const formatAnswer = (answer: any, type: string): string => {
|
||||||
|
if (answer === null || answer === undefined || answer === '') {
|
||||||
|
return '未设置答案'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(answer)) {
|
||||||
|
if (answer.length === 0) return '未设置答案'
|
||||||
|
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'true-false') {
|
||||||
|
// 处理判断题:支持字符串和布尔值
|
||||||
|
const answerStr = String(answer).toLowerCase()
|
||||||
|
return answerStr === 'true' ? '正确' : '错误'
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染答案详情
|
||||||
|
const renderAnswerDetail = (question: Question, index: number, type: string) => {
|
||||||
|
// 格式化答案显示
|
||||||
|
const formatAnswer = (answer: any, type: string): string => {
|
||||||
|
if (answer === null || answer === undefined || answer === '') {
|
||||||
|
return '未设置答案'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(answer)) {
|
||||||
|
if (answer.length === 0) return '未设置答案'
|
||||||
|
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'true-false') {
|
||||||
|
// 处理判断题:支持字符串和布尔值
|
||||||
|
const answerStr = String(answer).toLowerCase()
|
||||||
|
return answerStr === 'true' ? '正确' : '错误'
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊处理填空题,按要求格式显示
|
||||||
|
if (question.type === 'fill-in-blank') {
|
||||||
|
const answers = Array.isArray(question.answer) ? question.answer : [question.answer];
|
||||||
|
// 过滤掉空答案并转换为字符串
|
||||||
|
const validAnswers = answers
|
||||||
|
.filter(answer => answer !== null && answer !== undefined && answer !== '')
|
||||||
|
.map(answer => String(answer));
|
||||||
|
|
||||||
|
// 如果没有有效答案,显示提示
|
||||||
|
if (validAnswers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.answerDetail}>
|
||||||
|
{index + 1}. (无答案)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用逗号分隔显示答案
|
||||||
|
return (
|
||||||
|
<div className={styles.answerDetail}>
|
||||||
|
{index + 1}. {validAnswers.join(',')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特殊处理判断题,显示对号或X
|
||||||
|
if (question.type === 'true-false') {
|
||||||
|
const answerStr = String(question.answer).toLowerCase();
|
||||||
|
const isCorrect = answerStr === 'true';
|
||||||
|
return (
|
||||||
|
<div className={styles.answerDetail}>
|
||||||
|
{index + 1}. {isCorrect ? '√' : '×'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他题型显示题号和答案
|
||||||
|
return (
|
||||||
|
<div className={styles.answerDetail}>
|
||||||
|
{index + 1}. {formatAnswer(question.answer, question.type)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汉字数字映射
|
||||||
|
const chineseNumbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||||||
|
|
||||||
|
// 按题型分组(合并两种论述题)
|
||||||
|
const groupedQuestions = questions.reduce((acc, q) => {
|
||||||
|
// 将两种论述题统一为 'essay'
|
||||||
|
const displayType = (q.type === 'ordinary-essay' || q.type === 'management-essay') ? 'essay' : q.type
|
||||||
|
if (!acc[displayType]) {
|
||||||
|
acc[displayType] = []
|
||||||
|
}
|
||||||
|
acc[displayType].push(q)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, Question[]>)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 试卷标题和返回按钮 */}
|
||||||
|
<Card className={styles.statsCard}>
|
||||||
|
<Row align="middle" justify="space-between">
|
||||||
|
<Col>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<HomeOutlined />}
|
||||||
|
onClick={() => navigate('/exam/management')}
|
||||||
|
>
|
||||||
|
返回试卷列表
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Col flex="auto" style={{ textAlign: 'center' }}>
|
||||||
|
<Text strong style={{ fontSize: 20 }}>
|
||||||
|
{examData.exam?.title || '试卷答案'}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handlePrint}
|
||||||
|
>
|
||||||
|
打印
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 答题详情 - 使用表格展示 */}
|
||||||
|
<Card className={styles.detailCard}>
|
||||||
|
{Object.entries(groupedQuestions)
|
||||||
|
.sort(([typeA], [typeB]) => {
|
||||||
|
const orderA = TYPE_ORDER[typeA] || 999
|
||||||
|
const orderB = TYPE_ORDER[typeB] || 999
|
||||||
|
return orderA - orderB
|
||||||
|
})
|
||||||
|
.map(([type, qs], typeIndex) => (
|
||||||
|
<div key={type} style={{ marginBottom: 16 }}>
|
||||||
|
{/* 题型标题 */}
|
||||||
|
<div className={styles.typeTitle} style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
marginBottom: 8
|
||||||
|
}}>
|
||||||
|
<Space>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
{chineseNumbers[typeIndex] || typeIndex + 1}、{TYPE_NAME[type] || type}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">(共 {qs.length} 题)</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 填空题、简答题和论述题特殊处理:每行一个答案,不使用表格 */}
|
||||||
|
{type === 'fill-in-blank' || type === 'short-answer' || type === 'essay' || type === 'ordinary-essay' || type === 'management-essay' ? (
|
||||||
|
<div className={styles.fillBlankContainer}>
|
||||||
|
{qs.map((q, index) => (
|
||||||
|
<div key={q.id} className={styles.fillBlankItem}>
|
||||||
|
{renderAnswerDetail(q, index, type)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 其他题型使用表格显示答案,每行5列,确保题号和答案分行显示 */
|
||||||
|
<table className={styles.optionsTable}>
|
||||||
|
<tbody>
|
||||||
|
{/* 每5个题目为一行,确保更好的打印效果 */}
|
||||||
|
{Array.from({ length: Math.ceil(qs.length / 5) }).map((_, rowIndex) => (
|
||||||
|
<tr key={rowIndex}>
|
||||||
|
{qs.slice(rowIndex * 5, (rowIndex + 1) * 5).map((q, colIndex) => {
|
||||||
|
const globalIndex = rowIndex * 5 + colIndex;
|
||||||
|
return (
|
||||||
|
<td key={q.id} style={{ width: '20%' }}>
|
||||||
|
{renderAnswerDetail(q, globalIndex, type)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 如果最后一行不足5列,用空单元格填充 */}
|
||||||
|
{qs.length - rowIndex * 5 < 5 &&
|
||||||
|
Array.from({ length: 5 - (qs.length - rowIndex * 5) }).map((_, emptyIndex) => (
|
||||||
|
<td key={`empty-${emptyIndex}`} style={{ width: '20%' }}></td>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 题型之间的分隔线 */}
|
||||||
|
<Divider className={styles.noPrintDivider} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExamAnswerView
|
||||||
@ -218,6 +218,13 @@ const ExamManagement: React.FC = () => {
|
|||||||
>
|
>
|
||||||
考试记录
|
考试记录
|
||||||
</Button>,
|
</Button>,
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={() => navigate(`/exam/${exam.id}/answer`)}
|
||||||
|
>
|
||||||
|
查看答案
|
||||||
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
danger
|
danger
|
||||||
@ -344,6 +351,18 @@ const ExamManagement: React.FC = () => {
|
|||||||
查看详情
|
查看详情
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{record.status === 'graded' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setRecordsDrawerVisible(false)
|
||||||
|
navigate(`/exam/result/${record.id}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查看答案
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -66,7 +66,7 @@ const ExamOnline: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!examId) {
|
if (!examId) {
|
||||||
message.error('考试ID不存在')
|
message.error('考试ID不存在')
|
||||||
navigate('/exam/prepare')
|
navigate('/exam/management')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,11 +83,11 @@ const ExamOnline: React.FC = () => {
|
|||||||
loadProgress(res.data.questions)
|
loadProgress(res.data.questions)
|
||||||
} else {
|
} else {
|
||||||
message.error('加载考试失败')
|
message.error('加载考试失败')
|
||||||
navigate('/exam/prepare')
|
navigate('/exam/management')
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '加载考试失败')
|
message.error(error.response?.data?.message || '加载考试失败')
|
||||||
navigate('/exam/prepare')
|
navigate('/exam/management')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -195,7 +195,7 @@ const ExamOnline: React.FC = () => {
|
|||||||
// 清除进度
|
// 清除进度
|
||||||
localStorage.removeItem(`exam_progress_${examId}`)
|
localStorage.removeItem(`exam_progress_${examId}`)
|
||||||
// 跳转到成绩页,传递提交结果
|
// 跳转到成绩页,传递提交结果
|
||||||
navigate(`/exam/${examId}/result`, {
|
navigate(`/exam/result/${res.data.record_id}`, {
|
||||||
state: { submitResult: res.data }
|
state: { submitResult: res.data }
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@ -478,7 +478,7 @@ const ExamOnline: React.FC = () => {
|
|||||||
<div className={styles.topBarContent}>
|
<div className={styles.topBarContent}>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => navigate('/exam/prepare')}
|
onClick={() => navigate('/exam/management')}
|
||||||
className={styles.backButton}
|
className={styles.backButton}
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
.container {
|
|
||||||
min-height: calc(100vh - 64px);
|
|
||||||
padding: 16px;
|
|
||||||
background: #f0f2f5;
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.backButton {
|
|
||||||
border: none;
|
|
||||||
padding: 4px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleSection {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
|
|
||||||
.titleIcon {
|
|
||||||
font-size: 48px;
|
|
||||||
color: #fa8c16;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoCard {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tipCard {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionButtons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.primaryButton {
|
|
||||||
background: #fa8c16;
|
|
||||||
border-color: #fa8c16;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #ff9c2e;
|
|
||||||
border-color: #ff9c2e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
import React, { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { Card, Button, Typography, Descriptions, Space, message } from 'antd'
|
|
||||||
import {
|
|
||||||
ArrowLeftOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
PrinterOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import * as examApi from '../api/exam'
|
|
||||||
import { DEFAULT_EXAM_CONFIG, DEFAULT_SCORE_CONFIG } from '../types/exam'
|
|
||||||
import styles from './ExamPrepare.module.less'
|
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography
|
|
||||||
|
|
||||||
const ExamPrepare: React.FC = () => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
// 生成在线考试
|
|
||||||
const handleStartOnlineExam = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await examApi.generateExam()
|
|
||||||
if (res.success && res.data) {
|
|
||||||
const examId = res.data.exam_id
|
|
||||||
message.success('考试已生成')
|
|
||||||
navigate(`/exam/${examId}/online`)
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '生成考试失败')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.data?.message) {
|
|
||||||
message.error(error.response.data.message)
|
|
||||||
} else {
|
|
||||||
message.error('生成考试失败,请稍后重试')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成试卷打印
|
|
||||||
const handlePrintExam = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await examApi.generateExam()
|
|
||||||
if (res.success && res.data) {
|
|
||||||
const examId = res.data.exam_id
|
|
||||||
message.success('试卷已生成')
|
|
||||||
navigate(`/exam/${examId}/print`)
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '生成试卷失败')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.data?.message) {
|
|
||||||
message.error(error.response.data.message)
|
|
||||||
} else {
|
|
||||||
message.error('生成试卷失败,请稍后重试')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<Card className={styles.header}>
|
|
||||||
<Button
|
|
||||||
icon={<ArrowLeftOutlined />}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
className={styles.backButton}
|
|
||||||
>
|
|
||||||
返回首页
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className={styles.content}>
|
|
||||||
<div className={styles.titleSection}>
|
|
||||||
<FileTextOutlined className={styles.titleIcon} />
|
|
||||||
<Title level={2}>模拟考试</Title>
|
|
||||||
<Paragraph type="secondary">
|
|
||||||
系统将随机抽取题目组成试卷,您可以选择在线答题或打印试卷
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card title="考试说明" className={styles.infoCard}>
|
|
||||||
<Descriptions column={1} bordered>
|
|
||||||
<Descriptions.Item label="填空题">
|
|
||||||
{DEFAULT_EXAM_CONFIG.fill_in_blank} 道(每题 {DEFAULT_SCORE_CONFIG.fill_in_blank} 分,共 {DEFAULT_EXAM_CONFIG.fill_in_blank * DEFAULT_SCORE_CONFIG.fill_in_blank} 分)
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="判断题">
|
|
||||||
{DEFAULT_EXAM_CONFIG.true_false} 道(每题 {DEFAULT_SCORE_CONFIG.true_false} 分,共 {DEFAULT_EXAM_CONFIG.true_false * DEFAULT_SCORE_CONFIG.true_false} 分)
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="单选题">
|
|
||||||
{DEFAULT_EXAM_CONFIG.multiple_choice} 道(每题 {DEFAULT_SCORE_CONFIG.multiple_choice} 分,共 {DEFAULT_EXAM_CONFIG.multiple_choice * DEFAULT_SCORE_CONFIG.multiple_choice} 分)
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="多选题">
|
|
||||||
{DEFAULT_EXAM_CONFIG.multiple_selection} 道(每题 {DEFAULT_SCORE_CONFIG.multiple_selection} 分,共 {DEFAULT_EXAM_CONFIG.multiple_selection * DEFAULT_SCORE_CONFIG.multiple_selection} 分)
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="简答题">
|
|
||||||
{DEFAULT_EXAM_CONFIG.short_answer} 道(仅供参考,不计分)
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="论述题">
|
|
||||||
2 道任选 1 道作答({DEFAULT_SCORE_CONFIG.essay} 分)
|
|
||||||
<br />
|
|
||||||
<span style={{ fontSize: '13px', color: '#8c8c8c' }}>
|
|
||||||
包含:普通涉密人员论述题 1 道,保密管理人员论述题 1 道
|
|
||||||
</span>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="总分">100 分</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="建议时间">120 分钟</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="温馨提示" className={styles.tipCard}>
|
|
||||||
<ul>
|
|
||||||
<li>在线答题模式下,系统将自动评分并生成成绩报告</li>
|
|
||||||
<li>打印试卷模式下,您可以在纸上作答,查看答案后自行核对</li>
|
|
||||||
<li>论述题使用 AI 智能评分,会给出分数、评语和改进建议</li>
|
|
||||||
<li>简答题仅供参考,不计入总分</li>
|
|
||||||
<li>每次生成的试卷题目都是随机抽取的</li>
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Space size="large" className={styles.actionButtons}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={handleStartOnlineExam}
|
|
||||||
loading={loading}
|
|
||||||
className={styles.primaryButton}
|
|
||||||
>
|
|
||||||
开始在线答题
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
icon={<PrinterOutlined />}
|
|
||||||
onClick={handlePrintExam}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
生成打印试卷
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExamPrepare
|
|
||||||
@ -56,7 +56,7 @@ const ExamPrint: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!examId) {
|
if (!examId) {
|
||||||
message.error('考试ID不存在')
|
message.error('考试ID不存在')
|
||||||
navigate('/exam/prepare')
|
navigate('/exam/management')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,11 +71,11 @@ const ExamPrint: React.FC = () => {
|
|||||||
setGroupedQuestions(grouped)
|
setGroupedQuestions(grouped)
|
||||||
} else {
|
} else {
|
||||||
message.error('加载考试失败')
|
message.error('加载考试失败')
|
||||||
navigate('/exam/prepare')
|
navigate('/exam/management')
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '加载考试失败')
|
message.error(error.response?.data?.message || '加载考试失败')
|
||||||
navigate('/exam/prepare')
|
navigate('/exam/management')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -392,7 +392,7 @@ const ExamPrint: React.FC = () => {
|
|||||||
<div className={`${styles.actionBar} noPrint`}>
|
<div className={`${styles.actionBar} noPrint`}>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => navigate('/exam/new')}
|
onClick={() => navigate('/exam/management')}
|
||||||
className={styles.backButton}
|
className={styles.backButton}
|
||||||
>
|
>
|
||||||
返回
|
返回
|
||||||
|
|||||||
@ -1,468 +0,0 @@
|
|||||||
.container {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 24px 16px;
|
|
||||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
padding: 40px 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
.loadingContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成绩大卡片
|
|
||||||
.scoreCard {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto 24px;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
|
||||||
border: none;
|
|
||||||
overflow: hidden;
|
|
||||||
animation: slideDown 0.5s ease-out;
|
|
||||||
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 40px 32px;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreHeader {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
|
|
||||||
.trophyIcon {
|
|
||||||
font-size: 72px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
animation: bounce 1s ease-in-out infinite;
|
|
||||||
|
|
||||||
&.passed {
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.failed {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreTitle {
|
|
||||||
margin: 0 !important;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreContent {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
|
|
||||||
:global(.ant-statistic) {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-statistic-title) {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreBadge {
|
|
||||||
margin-top: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressSection {
|
|
||||||
margin-top: 32px;
|
|
||||||
padding-top: 24px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 信息卡片
|
|
||||||
.infoCard,
|
|
||||||
.detailCard,
|
|
||||||
.wrongQuestionsCard,
|
|
||||||
.actionCard {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
:global(.ant-card-head) {
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 12px 12px 0 0;
|
|
||||||
|
|
||||||
:global(.ant-card-head-title) {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 24px;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 题型得分卡片
|
|
||||||
.typeCard {
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-statistic-title) {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.typeInfo {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错题列表
|
|
||||||
.wrongQuestionsCollapse {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
:global(.ant-collapse-item) {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #ffffff;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-collapse-header) {
|
|
||||||
background: #fafafa;
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-size: 15px;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-collapse-content) {
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-collapse-content-box) {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrongQuestionHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.wrongIcon {
|
|
||||||
color: #ff4d4f;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrongQuestionContent {
|
|
||||||
:global(.ant-typography) {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options {
|
|
||||||
margin: 16px 0;
|
|
||||||
padding: 16px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option {
|
|
||||||
padding: 8px 0;
|
|
||||||
color: #595959;
|
|
||||||
line-height: 1.6;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.answerBlock {
|
|
||||||
padding: 16px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
.answerContent {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrongAnswer {
|
|
||||||
color: #ff4d4f;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.correctAnswer {
|
|
||||||
color: #52c41a;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI评分块
|
|
||||||
.aiGradingBlock {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding: 20px;
|
|
||||||
background: linear-gradient(135deg, #fef3e7 0%, #fef9f3 100%);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #ffd591;
|
|
||||||
|
|
||||||
:global(.ant-divider) {
|
|
||||||
margin: 16px 0;
|
|
||||||
border-color: #ffd591;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-divider-inner-text) {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fa8c16;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-typography) {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.messageBlock {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 操作按钮区域
|
|
||||||
.actionCard {
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 32px 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionButtons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
|
|
||||||
:global(.ant-btn) {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-btn-lg) {
|
|
||||||
height: 48px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: 24px;
|
|
||||||
min-width: 180px;
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-btn-primary) {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
|
||||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-btn:not(.ant-btn-primary)) {
|
|
||||||
&:hover {
|
|
||||||
border-color: #667eea;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动画
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-30px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式优化
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: 16px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreCard {
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 24px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreHeader {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
.trophyIcon {
|
|
||||||
font-size: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreTitle {
|
|
||||||
font-size: 24px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreContent {
|
|
||||||
:global(.ant-statistic-content-value) {
|
|
||||||
font-size: 48px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressSection {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoCard,
|
|
||||||
.detailCard,
|
|
||||||
.wrongQuestionsCard,
|
|
||||||
.actionCard {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrongQuestionsCollapse {
|
|
||||||
:global(.ant-collapse-header) {
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.ant-collapse-content-box) {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.answerBlock {
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aiGradingBlock {
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 769px) and (max-width: 1024px) {
|
|
||||||
.container {
|
|
||||||
padding: 32px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreCard,
|
|
||||||
.infoCard,
|
|
||||||
.detailCard,
|
|
||||||
.wrongQuestionsCard,
|
|
||||||
.actionCard {
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1025px) {
|
|
||||||
.container {
|
|
||||||
padding: 48px 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreCard,
|
|
||||||
.infoCard,
|
|
||||||
.detailCard,
|
|
||||||
.wrongQuestionsCard,
|
|
||||||
.actionCard {
|
|
||||||
max-width: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreCard {
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 48px 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scoreHeader {
|
|
||||||
.trophyIcon {
|
|
||||||
font-size: 80px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actionCard {
|
|
||||||
:global(.ant-card-body) {
|
|
||||||
padding: 40px 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,590 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Button,
|
|
||||||
Typography,
|
|
||||||
Descriptions,
|
|
||||||
Statistic,
|
|
||||||
Progress,
|
|
||||||
Space,
|
|
||||||
Collapse,
|
|
||||||
Tag,
|
|
||||||
Spin,
|
|
||||||
message,
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Divider,
|
|
||||||
} from 'antd'
|
|
||||||
import {
|
|
||||||
TrophyOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
CloseCircleOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
RedoOutlined,
|
|
||||||
HomeOutlined,
|
|
||||||
WarningOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import * as examApi from '../api/exam'
|
|
||||||
import type { GetExamResponse, SubmitExamResponse } from '../types/exam'
|
|
||||||
import type { Question, QuestionType } from '../types/question'
|
|
||||||
import styles from './ExamResult.module.less'
|
|
||||||
|
|
||||||
const { Title, Paragraph, Text } = Typography
|
|
||||||
const { Panel } = Collapse
|
|
||||||
|
|
||||||
// 题型名称映射
|
|
||||||
const TYPE_NAME: Record<QuestionType, string> = {
|
|
||||||
'fill-in-blank': '填空题',
|
|
||||||
'true-false': '判断题',
|
|
||||||
'multiple-choice': '单选题',
|
|
||||||
'multiple-selection': '多选题',
|
|
||||||
'short-answer': '简答题',
|
|
||||||
'ordinary-essay': '论述题(普通)',
|
|
||||||
'management-essay': '论述题(管理)',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 题型顺序映射
|
|
||||||
const TYPE_ORDER: Record<string, number> = {
|
|
||||||
'fill-in-blank': 1,
|
|
||||||
'true-false': 2,
|
|
||||||
'multiple-choice': 3,
|
|
||||||
'multiple-selection': 4,
|
|
||||||
'short-answer': 5,
|
|
||||||
'ordinary-essay': 6,
|
|
||||||
'management-essay': 6,
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailedResult {
|
|
||||||
correct: boolean
|
|
||||||
score: number
|
|
||||||
message?: string
|
|
||||||
ai_grading?: {
|
|
||||||
score: number
|
|
||||||
feedback: string
|
|
||||||
suggestion: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExamResult: React.FC = () => {
|
|
||||||
const { examId } = useParams<{ examId: string }>()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [examData, setExamData] = useState<GetExamResponse | null>(null)
|
|
||||||
const [submitResult, setSubmitResult] = useState<SubmitExamResponse | null>(null)
|
|
||||||
|
|
||||||
// 从导航 state 获取提交结果,或从 API 加载
|
|
||||||
useEffect(() => {
|
|
||||||
if (!examId) {
|
|
||||||
message.error('考试ID不存在')
|
|
||||||
navigate('/exam/new')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果从提交页面跳转过来,会带有 submitResult
|
|
||||||
const stateResult = location.state?.submitResult as SubmitExamResponse | undefined
|
|
||||||
if (stateResult) {
|
|
||||||
setSubmitResult(stateResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载考试详情(包含题目信息)
|
|
||||||
const loadExamData = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
// showAnswer=true 可以获取正确答案
|
|
||||||
const res = await examApi.getExam(Number(examId), true)
|
|
||||||
if (res.success && res.data) {
|
|
||||||
setExamData(res.data)
|
|
||||||
|
|
||||||
// 如果没有从 state 获取到结果,提示用户
|
|
||||||
if (!stateResult) {
|
|
||||||
// 检查考试是否已提交
|
|
||||||
if (res.data.exam.status !== 'submitted') {
|
|
||||||
message.warning('考试尚未提交,请先完成考试')
|
|
||||||
navigate(`/exam/${examId}/online`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 由于详细结果仅在提交时返回,直接访问URL无法获取详细评分
|
|
||||||
// 我们只显示总分,建议用户查看试卷答案
|
|
||||||
message.info('详细评分仅在提交考试时显示,您可以查看试卷答案')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.error('加载考试详情失败')
|
|
||||||
navigate('/exam/new')
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.response?.data?.message || '加载失败')
|
|
||||||
navigate('/exam/new')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadExamData()
|
|
||||||
}, [examId, navigate, location.state])
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (time?: string) => {
|
|
||||||
if (!time) return '-'
|
|
||||||
return new Date(time).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化答案显示
|
|
||||||
const formatAnswer = (answer: any): string => {
|
|
||||||
if (answer === null || answer === undefined) return '-'
|
|
||||||
if (Array.isArray(answer)) {
|
|
||||||
return answer.join(', ')
|
|
||||||
}
|
|
||||||
if (typeof answer === 'boolean') {
|
|
||||||
return answer ? '正确' : '错误'
|
|
||||||
}
|
|
||||||
return String(answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算统计数据
|
|
||||||
const calculateStats = () => {
|
|
||||||
if (!submitResult || !examData) {
|
|
||||||
return {
|
|
||||||
totalQuestions: 0,
|
|
||||||
correctCount: 0,
|
|
||||||
wrongCount: 0,
|
|
||||||
accuracy: 0,
|
|
||||||
typeScores: {},
|
|
||||||
wrongQuestions: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = submitResult.detailed_results
|
|
||||||
let correctCount = 0
|
|
||||||
let wrongCount = 0
|
|
||||||
const typeScores: Record<string, { correct: number; total: number; score: number }> = {}
|
|
||||||
const wrongQuestions: Array<{ question: Question; result: DetailedResult }> = []
|
|
||||||
|
|
||||||
examData.questions.forEach((question) => {
|
|
||||||
const result = results[String(question.id)]
|
|
||||||
if (!result) return
|
|
||||||
|
|
||||||
const typeName = TYPE_NAME[question.type]
|
|
||||||
if (!typeScores[typeName]) {
|
|
||||||
typeScores[typeName] = { correct: 0, total: 0, score: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
typeScores[typeName].total += 1
|
|
||||||
typeScores[typeName].score += result.score
|
|
||||||
|
|
||||||
if (result.correct) {
|
|
||||||
correctCount += 1
|
|
||||||
typeScores[typeName].correct += 1
|
|
||||||
} else {
|
|
||||||
wrongCount += 1
|
|
||||||
wrongQuestions.push({ question, result })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const accuracy = examData.questions.length > 0
|
|
||||||
? (correctCount / examData.questions.length) * 100
|
|
||||||
: 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalQuestions: examData.questions.length,
|
|
||||||
correctCount,
|
|
||||||
wrongCount,
|
|
||||||
accuracy,
|
|
||||||
typeScores,
|
|
||||||
wrongQuestions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染错题详情
|
|
||||||
const renderWrongQuestion = (question: Question, result: DetailedResult, index: number) => {
|
|
||||||
// 解析用户答案和正确答案
|
|
||||||
let userAnswer = '-'
|
|
||||||
let correctAnswer = '-'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const answers = JSON.parse(examData!.exam.answers)
|
|
||||||
const userAnswerData = answers.answers?.[String(question.id)]
|
|
||||||
userAnswer = formatAnswer(userAnswerData)
|
|
||||||
|
|
||||||
// 从 question.answer 获取正确答案
|
|
||||||
correctAnswer = formatAnswer(question.answer)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('解析答案失败', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel
|
|
||||||
header={
|
|
||||||
<div className={styles.wrongQuestionHeader}>
|
|
||||||
<Space>
|
|
||||||
<CloseCircleOutlined className={styles.wrongIcon} />
|
|
||||||
<Text strong>{index + 1}. {TYPE_NAME[question.type]}</Text>
|
|
||||||
</Space>
|
|
||||||
<Tag color="red">-{result.score} 分</Tag>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
key={question.id}
|
|
||||||
>
|
|
||||||
<div className={styles.wrongQuestionContent}>
|
|
||||||
<Paragraph strong>{question.content}</Paragraph>
|
|
||||||
|
|
||||||
{/* 选项(如果有) */}
|
|
||||||
{question.options && question.options.length > 0 && (
|
|
||||||
<div className={styles.options}>
|
|
||||||
{question.options.map((opt) => (
|
|
||||||
<div key={opt.key} className={styles.option}>
|
|
||||||
{opt.key}. {opt.value}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<div className={styles.answerBlock}>
|
|
||||||
<Text type="secondary">你的答案:</Text>
|
|
||||||
<div className={styles.answerContent}>
|
|
||||||
<Text className={styles.wrongAnswer}>{userAnswer}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} md={12}>
|
|
||||||
<div className={styles.answerBlock}>
|
|
||||||
<Text type="secondary">正确答案:</Text>
|
|
||||||
<div className={styles.answerContent}>
|
|
||||||
<Text className={styles.correctAnswer}>{correctAnswer}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* AI评分反馈 */}
|
|
||||||
{result.ai_grading && (
|
|
||||||
<div className={styles.aiGradingBlock}>
|
|
||||||
<Divider>AI 智能评分</Divider>
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
|
||||||
<div>
|
|
||||||
<Text strong>得分:</Text>
|
|
||||||
<Text type="warning" style={{ fontSize: '16px' }}>
|
|
||||||
{result.ai_grading.score} 分
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
{result.ai_grading.feedback && (
|
|
||||||
<div>
|
|
||||||
<Text strong>评语:</Text>
|
|
||||||
<Paragraph>{result.ai_grading.feedback}</Paragraph>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{result.ai_grading.suggestion && (
|
|
||||||
<div>
|
|
||||||
<Text strong>改进建议:</Text>
|
|
||||||
<Paragraph type="secondary">{result.ai_grading.suggestion}</Paragraph>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 其他提示信息 */}
|
|
||||||
{result.message && !result.ai_grading && (
|
|
||||||
<div className={styles.messageBlock}>
|
|
||||||
<Text type="secondary">{result.message}</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<Spin size="large" />
|
|
||||||
<Text style={{ marginTop: 16 }}>加载成绩中...</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!examData) {
|
|
||||||
return (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<WarningOutlined style={{ fontSize: 48, color: '#faad14', marginBottom: 16 }} />
|
|
||||||
<Text>暂无成绩数据</Text>
|
|
||||||
<Button type="primary" onClick={() => navigate('/exam/new')} style={{ marginTop: 16 }}>
|
|
||||||
开始考试
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有详细结果,只显示总分
|
|
||||||
if (!submitResult) {
|
|
||||||
const score = examData.exam.score
|
|
||||||
const isPassed = score >= 60
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* 简化的成绩卡片 */}
|
|
||||||
<Card className={styles.scoreCard}>
|
|
||||||
<div className={styles.scoreHeader}>
|
|
||||||
<TrophyOutlined className={`${styles.trophyIcon} ${isPassed ? styles.passed : styles.failed}`} />
|
|
||||||
<Title level={2} className={styles.scoreTitle}>
|
|
||||||
{isPassed ? '恭喜通过!' : '继续加油!'}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.scoreContent}>
|
|
||||||
<Statistic
|
|
||||||
title={<span style={{ fontSize: '18px' }}>总分</span>}
|
|
||||||
value={score}
|
|
||||||
suffix="/ 100"
|
|
||||||
valueStyle={{
|
|
||||||
color: isPassed ? '#52c41a' : '#ff4d4f',
|
|
||||||
fontSize: '64px',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={styles.scoreBadge}>
|
|
||||||
{isPassed ? (
|
|
||||||
<Tag color="success" style={{ fontSize: '16px', padding: '8px 16px' }}>
|
|
||||||
<CheckCircleOutlined /> 及格
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="error" style={{ fontSize: '16px', padding: '8px 16px' }}>
|
|
||||||
<CloseCircleOutlined /> 不及格
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 考试信息 */}
|
|
||||||
<Card title="考试信息" className={styles.infoCard}>
|
|
||||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
|
|
||||||
<Descriptions.Item label="创建时间">
|
|
||||||
{formatTime(examData.exam.created_at)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="提交时间">
|
|
||||||
{formatTime(examData.exam.submitted_at)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="题目总数">
|
|
||||||
{examData.questions.length} 道
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="总分">
|
|
||||||
<Text style={{ fontWeight: 600, color: isPassed ? '#52c41a' : '#ff4d4f' }}>
|
|
||||||
{score} 分
|
|
||||||
</Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
<Paragraph type="secondary" style={{ marginTop: 16, marginBottom: 0 }}>
|
|
||||||
注意:详细的题目评分和错题分析仅在提交考试后立即显示。您可以查看试卷答案了解详情。
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<Card className={styles.actionCard}>
|
|
||||||
<Space size="large" wrap className={styles.actionButtons}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<FileTextOutlined />}
|
|
||||||
onClick={() => navigate(`/exam/${examId}/print?show_answer=true`)}
|
|
||||||
>
|
|
||||||
查看试卷(带答案)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
icon={<RedoOutlined />}
|
|
||||||
onClick={() => navigate('/exam/new')}
|
|
||||||
>
|
|
||||||
重新考试
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
icon={<HomeOutlined />}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
>
|
|
||||||
返回首页
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = calculateStats()
|
|
||||||
const isPassed = submitResult.score >= 60
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* 成绩大卡片 */}
|
|
||||||
<Card className={styles.scoreCard}>
|
|
||||||
<div className={styles.scoreHeader}>
|
|
||||||
<TrophyOutlined className={`${styles.trophyIcon} ${isPassed ? styles.passed : styles.failed}`} />
|
|
||||||
<Title level={2} className={styles.scoreTitle}>
|
|
||||||
{isPassed ? '恭喜通过!' : '继续加油!'}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.scoreContent}>
|
|
||||||
<Statistic
|
|
||||||
title={<span style={{ fontSize: '18px' }}>总分</span>}
|
|
||||||
value={submitResult.score}
|
|
||||||
suffix="/ 100"
|
|
||||||
valueStyle={{
|
|
||||||
color: isPassed ? '#52c41a' : '#ff4d4f',
|
|
||||||
fontSize: '64px',
|
|
||||||
fontWeight: 700,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={styles.scoreBadge}>
|
|
||||||
{isPassed ? (
|
|
||||||
<Tag color="success" style={{ fontSize: '16px', padding: '8px 16px' }}>
|
|
||||||
<CheckCircleOutlined /> 及格
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="error" style={{ fontSize: '16px', padding: '8px 16px' }}>
|
|
||||||
<CloseCircleOutlined /> 不及格
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 正确率进度条 */}
|
|
||||||
<div className={styles.progressSection}>
|
|
||||||
<Text strong style={{ fontSize: '16px', marginBottom: 8, display: 'block' }}>
|
|
||||||
正确率
|
|
||||||
</Text>
|
|
||||||
<Progress
|
|
||||||
percent={Math.round(stats.accuracy)}
|
|
||||||
strokeColor={{
|
|
||||||
'0%': isPassed ? '#87d068' : '#ff7875',
|
|
||||||
'100%': isPassed ? '#52c41a' : '#ff4d4f',
|
|
||||||
}}
|
|
||||||
strokeWidth={12}
|
|
||||||
format={(percent) => (
|
|
||||||
<span style={{ fontSize: '16px', fontWeight: 600 }}>
|
|
||||||
{percent}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 考试信息 */}
|
|
||||||
<Card title="考试信息" className={styles.infoCard}>
|
|
||||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
|
|
||||||
<Descriptions.Item label="创建时间">
|
|
||||||
{formatTime(examData.exam.created_at)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="提交时间">
|
|
||||||
{formatTime(examData.exam.submitted_at)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="题目总数">
|
|
||||||
{stats.totalQuestions} 道
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="答对题数">
|
|
||||||
<Text style={{ color: '#52c41a', fontWeight: 600 }}>
|
|
||||||
{stats.correctCount} 道
|
|
||||||
</Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="答错题数">
|
|
||||||
<Text style={{ color: '#ff4d4f', fontWeight: 600 }}>
|
|
||||||
{stats.wrongCount} 道
|
|
||||||
</Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="正确率">
|
|
||||||
<Text style={{ fontWeight: 600 }}>
|
|
||||||
{stats.accuracy.toFixed(1)}%
|
|
||||||
</Text>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 各题型得分明细 */}
|
|
||||||
<Card title="各题型得分明细" className={styles.detailCard}>
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
{Object.entries(stats.typeScores).map(([typeName, data]) => (
|
|
||||||
<Col xs={12} sm={8} md={6} key={typeName}>
|
|
||||||
<Card className={styles.typeCard}>
|
|
||||||
<Statistic
|
|
||||||
title={typeName}
|
|
||||||
value={data.score}
|
|
||||||
suffix="分"
|
|
||||||
valueStyle={{ fontSize: '24px', fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
<div className={styles.typeInfo}>
|
|
||||||
<Text type="secondary">
|
|
||||||
{data.correct}/{data.total} 题正确
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 错题列表 */}
|
|
||||||
{stats.wrongQuestions.length > 0 && (
|
|
||||||
<Card
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
|
||||||
<span>错题详情({stats.wrongQuestions.length} 道)</span>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
className={styles.wrongQuestionsCard}
|
|
||||||
>
|
|
||||||
<Collapse accordion className={styles.wrongQuestionsCollapse}>
|
|
||||||
{stats.wrongQuestions
|
|
||||||
.sort((a, b) => TYPE_ORDER[a.question.type] - TYPE_ORDER[b.question.type])
|
|
||||||
.map(({ question, result }, index) =>
|
|
||||||
renderWrongQuestion(question, result, index)
|
|
||||||
)}
|
|
||||||
</Collapse>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<Card className={styles.actionCard}>
|
|
||||||
<Space size="large" wrap className={styles.actionButtons}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<FileTextOutlined />}
|
|
||||||
onClick={() => navigate(`/exam/${examId}/print?show_answer=true`)}
|
|
||||||
>
|
|
||||||
查看试卷(带答案)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
icon={<RedoOutlined />}
|
|
||||||
onClick={() => navigate('/exam/new')}
|
|
||||||
>
|
|
||||||
重新考试
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
icon={<HomeOutlined />}
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
>
|
|
||||||
返回首页
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExamResult
|
|
||||||
Loading…
x
Reference in New Issue
Block a user