diff --git a/internal/handlers/exam_handler.go b/internal/handlers/exam_handler.go index 27b9851..23c0c90 100644 --- a/internal/handlers/exam_handler.go +++ b/internal/handlers/exam_handler.go @@ -117,7 +117,7 @@ func CreateExam(c *gin.Context) { // 创建试卷 exam := models.Exam{ - UserID: userID.(uint), + UserID: uint(userID.(int64)), Title: req.Title, TotalScore: int(totalScore), // 总分100分 Duration: 60, // 固定60分钟 @@ -233,6 +233,9 @@ func GetExamDetail(c *gin.Context) { return } + // 检查是否需要显示答案 + showAnswer := c.Query("show_answer") == "true" + db := database.GetDB() // 查询试卷 @@ -258,7 +261,7 @@ func GetExamDetail(c *gin.Context) { return } - // 按原始顺序排序题目并转换为DTO(不显示答案) + // 按原始顺序排序题目并转换为DTO questionMap := make(map[int64]models.PracticeQuestion) for _, q := range questions { questionMap[q.ID] = q @@ -267,7 +270,10 @@ func GetExamDetail(c *gin.Context) { for _, id := range questionIDs { if q, ok := questionMap[id]; ok { dto := convertToDTO(q) - dto.Answer = nil // 不显示答案 + // 根据showAnswer参数决定是否显示答案 + if !showAnswer { + dto.Answer = nil // 不显示答案 + } orderedDTOs = append(orderedDTOs, dto) } } @@ -309,7 +315,7 @@ func StartExam(c *gin.Context) { now := time.Now() record := models.ExamRecord{ ExamID: uint(examID), - UserID: userID.(uint), + UserID: uint(userID.(int64)), StartTime: &now, TotalScore: exam.TotalScore, Status: "in_progress", @@ -413,7 +419,7 @@ func SubmitExam(c *gin.Context) { }) // 异步执行阅卷(从 exam_user_answers 表读取答案) - go gradeExam(uint(recordID), exam.ID, userID.(uint)) + go gradeExam(uint(recordID), exam.ID, uint(userID.(int64))) } // GetExamRecord 获取考试记录详情 diff --git a/internal/services/baidu_ai_grading.go b/internal/services/baidu_ai_grading.go new file mode 100644 index 0000000..dff07e7 --- /dev/null +++ b/internal/services/baidu_ai_grading.go @@ -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 +} diff --git a/web/src/App.tsx b/web/src/App.tsx index baa2fb5..bdee7c9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,13 +14,12 @@ import QuestionManagement from './pages/QuestionManagement' import QuestionList from './pages/QuestionList' import UserManagement from './pages/UserManagement' import UserDetail from './pages/UserDetail' -import ExamPrepare from './pages/ExamPrepare' import ExamOnline from './pages/ExamOnline' import ExamPrint from './pages/ExamPrint' -import ExamResult from './pages/ExamResult' import ExamManagement from './pages/ExamManagement' import ExamTaking from './pages/ExamTaking' import ExamResultNew from './pages/ExamResultNew' +import ExamAnswerView from './pages/ExamAnswerView' const App: React.FC = () => { return ( @@ -38,13 +37,12 @@ const App: React.FC = () => { } /> {/* 考试相关页面,需要登录保护 */} - } /> } /> } /> } /> } /> - } /> } /> + } /> {/* 题库管理页面,需要管理员权限 */} { // 获取考试详情 export const getExam = (examId: number, showAnswer?: boolean) => { - return request.get>(`/exam/${examId}`, { + return request.get>(`/exams/${examId}`, { params: { show_answer: showAnswer }, }) } diff --git a/web/src/pages/ExamAnswerView.module.less b/web/src/pages/ExamAnswerView.module.less new file mode 100644 index 0000000..2263f5e --- /dev/null +++ b/web/src/pages/ExamAnswerView.module.less @@ -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; + } + } +} \ No newline at end of file diff --git a/web/src/pages/ExamAnswerView.tsx b/web/src/pages/ExamAnswerView.tsx new file mode 100644 index 0000000..180c0a3 --- /dev/null +++ b/web/src/pages/ExamAnswerView.tsx @@ -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 = { + 'fill-in-blank': '填空题', + 'true-false': '判断题', + 'multiple-choice': '单选题', + 'multiple-selection': '多选题', + 'short-answer': '简答题', + 'ordinary-essay': '论述题', + 'management-essay': '论述题', + 'essay': '论述题' // 合并后的论述题类型 +} + +// 题型顺序定义 +const TYPE_ORDER: Record = { + '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(null) + const [questions, setQuestions] = useState([]) + + // 处理打印功能 + 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 ( +
+ + 加载试卷中... +
+ ) + } + + 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 ( +
+ {index + 1}. (无答案) +
+ ); + } + + // 使用逗号分隔显示答案 + return ( +
+ {index + 1}. {validAnswers.join(',')} +
+ ); + } + + // 特殊处理判断题,显示对号或X + if (question.type === 'true-false') { + const answerStr = String(question.answer).toLowerCase(); + const isCorrect = answerStr === 'true'; + return ( +
+ {index + 1}. {isCorrect ? '√' : '×'} +
+ ); + } + + // 其他题型显示题号和答案 + return ( +
+ {index + 1}. {formatAnswer(question.answer, question.type)} +
+ ); + } + + // 汉字数字映射 + 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) + + return ( +
+ {/* 试卷标题和返回按钮 */} + + + + + + + + {examData.exam?.title || '试卷答案'} + + + + + + + + + {/* 答题详情 - 使用表格展示 */} + + {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) => ( +
+ {/* 题型标题 */} +
+ + + {chineseNumbers[typeIndex] || typeIndex + 1}、{TYPE_NAME[type] || type} + + (共 {qs.length} 题) + +
+ + {/* 填空题、简答题和论述题特殊处理:每行一个答案,不使用表格 */} + {type === 'fill-in-blank' || type === 'short-answer' || type === 'essay' || type === 'ordinary-essay' || type === 'management-essay' ? ( +
+ {qs.map((q, index) => ( +
+ {renderAnswerDetail(q, index, type)} +
+ ))} +
+ ) : ( + /* 其他题型使用表格显示答案,每行5列,确保题号和答案分行显示 */ + + + {/* 每5个题目为一行,确保更好的打印效果 */} + {Array.from({ length: Math.ceil(qs.length / 5) }).map((_, rowIndex) => ( + + {qs.slice(rowIndex * 5, (rowIndex + 1) * 5).map((q, colIndex) => { + const globalIndex = rowIndex * 5 + colIndex; + return ( + + ); + })} + {/* 如果最后一行不足5列,用空单元格填充 */} + {qs.length - rowIndex * 5 < 5 && + Array.from({ length: 5 - (qs.length - rowIndex * 5) }).map((_, emptyIndex) => ( + + )) + } + + ))} + +
+ {renderAnswerDetail(q, globalIndex, type)} +
+ )} + + {/* 题型之间的分隔线 */} + +
+ ))} +
+
+ ) +} + +export default ExamAnswerView \ No newline at end of file diff --git a/web/src/pages/ExamManagement.tsx b/web/src/pages/ExamManagement.tsx index f0ca2a9..f10f070 100644 --- a/web/src/pages/ExamManagement.tsx +++ b/web/src/pages/ExamManagement.tsx @@ -218,6 +218,13 @@ const ExamManagement: React.FC = () => { > 考试记录 , + , )} + {record.status === 'graded' && ( + + )} )} diff --git a/web/src/pages/ExamOnline.tsx b/web/src/pages/ExamOnline.tsx index e125d6c..30da4cf 100644 --- a/web/src/pages/ExamOnline.tsx +++ b/web/src/pages/ExamOnline.tsx @@ -66,7 +66,7 @@ const ExamOnline: React.FC = () => { useEffect(() => { if (!examId) { message.error('考试ID不存在') - navigate('/exam/prepare') + navigate('/exam/management') return } @@ -83,11 +83,11 @@ const ExamOnline: React.FC = () => { loadProgress(res.data.questions) } else { message.error('加载考试失败') - navigate('/exam/prepare') + navigate('/exam/management') } } catch (error: any) { message.error(error.response?.data?.message || '加载考试失败') - navigate('/exam/prepare') + navigate('/exam/management') } finally { setLoading(false) } @@ -195,7 +195,7 @@ const ExamOnline: React.FC = () => { // 清除进度 localStorage.removeItem(`exam_progress_${examId}`) // 跳转到成绩页,传递提交结果 - navigate(`/exam/${examId}/result`, { + navigate(`/exam/result/${res.data.record_id}`, { state: { submitResult: res.data } }) } else { @@ -478,7 +478,7 @@ const ExamOnline: React.FC = () => {
- - - -
- - 模拟考试 - - 系统将随机抽取题目组成试卷,您可以选择在线答题或打印试卷 - -
- - - - - {DEFAULT_EXAM_CONFIG.fill_in_blank} 道(每题 {DEFAULT_SCORE_CONFIG.fill_in_blank} 分,共 {DEFAULT_EXAM_CONFIG.fill_in_blank * DEFAULT_SCORE_CONFIG.fill_in_blank} 分) - - - {DEFAULT_EXAM_CONFIG.true_false} 道(每题 {DEFAULT_SCORE_CONFIG.true_false} 分,共 {DEFAULT_EXAM_CONFIG.true_false * DEFAULT_SCORE_CONFIG.true_false} 分) - - - {DEFAULT_EXAM_CONFIG.multiple_choice} 道(每题 {DEFAULT_SCORE_CONFIG.multiple_choice} 分,共 {DEFAULT_EXAM_CONFIG.multiple_choice * DEFAULT_SCORE_CONFIG.multiple_choice} 分) - - - {DEFAULT_EXAM_CONFIG.multiple_selection} 道(每题 {DEFAULT_SCORE_CONFIG.multiple_selection} 分,共 {DEFAULT_EXAM_CONFIG.multiple_selection * DEFAULT_SCORE_CONFIG.multiple_selection} 分) - - - {DEFAULT_EXAM_CONFIG.short_answer} 道(仅供参考,不计分) - - - 2 道任选 1 道作答({DEFAULT_SCORE_CONFIG.essay} 分) -
- - 包含:普通涉密人员论述题 1 道,保密管理人员论述题 1 道 - -
- 100 分 - 120 分钟 -
-
- - -
    -
  • 在线答题模式下,系统将自动评分并生成成绩报告
  • -
  • 打印试卷模式下,您可以在纸上作答,查看答案后自行核对
  • -
  • 论述题使用 AI 智能评分,会给出分数、评语和改进建议
  • -
  • 简答题仅供参考,不计入总分
  • -
  • 每次生成的试卷题目都是随机抽取的
  • -
-
- - - - - -
-
- ) -} - -export default ExamPrepare diff --git a/web/src/pages/ExamPrint.tsx b/web/src/pages/ExamPrint.tsx index 2681a79..eae1d72 100644 --- a/web/src/pages/ExamPrint.tsx +++ b/web/src/pages/ExamPrint.tsx @@ -56,7 +56,7 @@ const ExamPrint: React.FC = () => { useEffect(() => { if (!examId) { message.error('考试ID不存在') - navigate('/exam/prepare') + navigate('/exam/management') return } @@ -71,11 +71,11 @@ const ExamPrint: React.FC = () => { setGroupedQuestions(grouped) } else { message.error('加载考试失败') - navigate('/exam/prepare') + navigate('/exam/management') } } catch (error: any) { message.error(error.response?.data?.message || '加载考试失败') - navigate('/exam/prepare') + navigate('/exam/management') } finally { setLoading(false) } @@ -392,7 +392,7 @@ const ExamPrint: React.FC = () => {
- } - key={question.id} - > -
- {question.content} - - {/* 选项(如果有) */} - {question.options && question.options.length > 0 && ( -
- {question.options.map((opt) => ( -
- {opt.key}. {opt.value} -
- ))} -
- )} - - - - - -
- 你的答案: -
- {userAnswer} -
-
- - -
- 正确答案: -
- {correctAnswer} -
-
- -
- - {/* AI评分反馈 */} - {result.ai_grading && ( -
- AI 智能评分 - -
- 得分: - - {result.ai_grading.score} 分 - -
- {result.ai_grading.feedback && ( -
- 评语: - {result.ai_grading.feedback} -
- )} - {result.ai_grading.suggestion && ( -
- 改进建议: - {result.ai_grading.suggestion} -
- )} -
-
- )} - - {/* 其他提示信息 */} - {result.message && !result.ai_grading && ( -
- {result.message} -
- )} -
- - ) - } - - if (loading) { - return ( -
- - 加载成绩中... -
- ) - } - - if (!examData) { - return ( -
- - 暂无成绩数据 - -
- ) - } - - // 如果没有详细结果,只显示总分 - if (!submitResult) { - const score = examData.exam.score - const isPassed = score >= 60 - - return ( -
- {/* 简化的成绩卡片 */} - -
- - - {isPassed ? '恭喜通过!' : '继续加油!'} - -
- -
- 总分} - value={score} - suffix="/ 100" - valueStyle={{ - color: isPassed ? '#52c41a' : '#ff4d4f', - fontSize: '64px', - fontWeight: 700, - }} - /> -
- {isPassed ? ( - - 及格 - - ) : ( - - 不及格 - - )} -
-
-
- - {/* 考试信息 */} - - - - {formatTime(examData.exam.created_at)} - - - {formatTime(examData.exam.submitted_at)} - - - {examData.questions.length} 道 - - - - {score} 分 - - - - - 注意:详细的题目评分和错题分析仅在提交考试后立即显示。您可以查看试卷答案了解详情。 - - - - {/* 操作按钮 */} - - - - - - - -
- ) - } - - const stats = calculateStats() - const isPassed = submitResult.score >= 60 - - return ( -
- {/* 成绩大卡片 */} - -
- - - {isPassed ? '恭喜通过!' : '继续加油!'} - -
- -
- 总分} - value={submitResult.score} - suffix="/ 100" - valueStyle={{ - color: isPassed ? '#52c41a' : '#ff4d4f', - fontSize: '64px', - fontWeight: 700, - }} - /> -
- {isPassed ? ( - - 及格 - - ) : ( - - 不及格 - - )} -
-
- - {/* 正确率进度条 */} -
- - 正确率 - - ( - - {percent}% - - )} - /> -
-
- - {/* 考试信息 */} - - - - {formatTime(examData.exam.created_at)} - - - {formatTime(examData.exam.submitted_at)} - - - {stats.totalQuestions} 道 - - - - {stats.correctCount} 道 - - - - - {stats.wrongCount} 道 - - - - - {stats.accuracy.toFixed(1)}% - - - - - - {/* 各题型得分明细 */} - - - {Object.entries(stats.typeScores).map(([typeName, data]) => ( - - - -
- - {data.correct}/{data.total} 题正确 - -
-
- - ))} -
-
- - {/* 错题列表 */} - {stats.wrongQuestions.length > 0 && ( - - - 错题详情({stats.wrongQuestions.length} 道) - - } - className={styles.wrongQuestionsCard} - > - - {stats.wrongQuestions - .sort((a, b) => TYPE_ORDER[a.question.type] - TYPE_ORDER[b.question.type]) - .map(({ question, result }, index) => - renderWrongQuestion(question, result, index) - )} - - - )} - - {/* 操作按钮 */} - - - - - - - -
- ) -} - -export default ExamResult