diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go index 0286bb9..2ced27a 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -32,10 +32,9 @@ func GetPracticeQuestions(c *gin.Context) { query := db.Model(&models.PracticeQuestion{}) - // 根据题型过滤 - 将前端类型映射到后端类型 + // 根据题型过滤 - 直接使用前端传来的type if typeParam != "" { - backendType := mapFrontendToBackendType(typeParam) - query = query.Where("type = ?", backendType) + query = query.Where("type = ?", typeParam) } // 根据分类过滤 @@ -112,8 +111,7 @@ func GetRandomPracticeQuestion(c *gin.Context) { query := db.Model(&models.PracticeQuestion{}) if typeParam != "" { - backendType := mapFrontendToBackendType(typeParam) - query = query.Where("type = ?", backendType) + query = query.Where("type = ?", typeParam) } // 使用PostgreSQL的随机排序 @@ -220,23 +218,23 @@ func SubmitPracticeAnswer(c *gin.Context) { func GetPracticeQuestionTypes(c *gin.Context) { types := []gin.H{ { - "type": models.FillInBlank, + "type": "fill-in-blank", "type_name": "填空题", }, { - "type": models.TrueFalseType, + "type": "true-false", "type_name": "判断题", }, { - "type": models.MultipleChoiceQ, + "type": "multiple-choice", "type_name": "选择题", }, { - "type": models.MultipleSelection, + "type": "multiple-selection", "type_name": "多选题", }, { - "type": models.ShortAnswer, + "type": "short-answer", "type_name": "简答题", }, } @@ -248,21 +246,21 @@ func GetPracticeQuestionTypes(c *gin.Context) { } // checkPracticeAnswer 检查练习答案是否正确 -func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, correctAnswer interface{}) bool { +func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool { switch questionType { - case models.TrueFalseType: + case "true-false": // 判断题: boolean 比较 userBool, ok1 := userAnswer.(bool) correctBool, ok2 := correctAnswer.(bool) return ok1 && ok2 && userBool == correctBool - case models.MultipleChoiceQ: + case "multiple-choice": // 单选题: 字符串比较 userStr, ok1 := userAnswer.(string) correctStr, ok2 := correctAnswer.(string) return ok1 && ok2 && userStr == correctStr - case models.MultipleSelection: + case "multiple-selection": // 多选题: 数组比较 userArr, ok1 := toStringArray(userAnswer) correctArr, ok2 := toStringArray(correctAnswer) @@ -282,7 +280,7 @@ func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, c } return true - case models.FillInBlank: + case "fill-in-blank": // 填空题: 数组比较 userArr, ok1 := toStringArray(userAnswer) correctArr, ok2 := toStringArray(correctAnswer) @@ -298,7 +296,7 @@ func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, c } return true - case models.ShortAnswer: + case "short-answer": // 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断) userStr, ok1 := userAnswer.(string) correctStr, ok2 := correctAnswer.(string) @@ -328,51 +326,19 @@ func toStringArray(v interface{}) ([]string, bool) { } } -// mapFrontendToBackendType 将前端类型映射到后端类型 -func mapFrontendToBackendType(frontendType string) models.PracticeQuestionType { - typeMap := map[string]models.PracticeQuestionType{ - "single": models.MultipleChoiceQ, // 单选 - "multiple": models.MultipleSelection, // 多选 - "judge": models.TrueFalseType, // 判断 - "fill": models.FillInBlank, // 填空 - "short": models.ShortAnswer, // 简答 - } - - if backendType, ok := typeMap[frontendType]; ok { - return backendType - } - return models.MultipleChoiceQ // 默认返回单选 -} - -// mapBackendToFrontendType 将后端类型映射到前端类型 -func mapBackendToFrontendType(backendType models.PracticeQuestionType) string { - typeMap := map[models.PracticeQuestionType]string{ - models.MultipleChoiceQ: "single", // 单选 - models.MultipleSelection: "multiple", // 多选 - models.TrueFalseType: "judge", // 判断 - models.FillInBlank: "fill", // 填空 - models.ShortAnswer: "short", // 简答 - } - - if frontendType, ok := typeMap[backendType]; ok { - return frontendType - } - return "single" // 默认返回单选 -} - // convertToDTO 将数据库模型转换为前端DTO func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { dto := models.PracticeQuestionDTO{ ID: question.ID, - QuestionID: question.QuestionID, // 添加题目编号 - Type: mapBackendToFrontendType(question.Type), + QuestionID: question.QuestionID, + Type: question.Type, // 直接使用数据库中的type,不做映射 Content: question.Question, - Category: question.TypeName, + Category: question.TypeName, // 使用typeName作为分类显示 Options: []models.Option{}, } // 判断题自动生成选项 - if question.Type == models.TrueFalseType { + if question.Type == "true-false" { dto.Options = []models.Option{ {Key: "true", Value: "正确"}, {Key: "false", Value: "错误"}, diff --git a/internal/handlers/wrong_question_handler.go b/internal/handlers/wrong_question_handler.go index a54ac23..371393c 100644 --- a/internal/handlers/wrong_question_handler.go +++ b/internal/handlers/wrong_question_handler.go @@ -49,8 +49,8 @@ func GetWrongQuestions(c *gin.Context) { // 转换为DTO dtos := make([]models.WrongQuestionDTO, 0, len(wrongQuestions)) for _, wq := range wrongQuestions { - // 题型筛选 - if questionType != "" && mapBackendToFrontendType(wq.PracticeQuestion.Type) != questionType { + // 题型筛选 - 直接比较type字段 + if questionType != "" && wq.PracticeQuestion.Type != questionType { continue } @@ -116,9 +116,8 @@ func GetWrongQuestionStats(c *gin.Context) { stats.NotMastered++ } - // 统计题型 - frontendType := mapBackendToFrontendType(wq.PracticeQuestion.Type) - stats.TypeStats[frontendType]++ + // 统计题型 - 直接使用type字段 + stats.TypeStats[wq.PracticeQuestion.Type]++ // 统计分类 stats.CategoryStats[wq.PracticeQuestion.TypeName]++ diff --git a/internal/models/practice_question.go b/internal/models/practice_question.go index f66a0f6..2c7ceb0 100644 --- a/internal/models/practice_question.go +++ b/internal/models/practice_question.go @@ -2,26 +2,15 @@ package models import "gorm.io/gorm" -// PracticeQuestionType 题目类型 -type PracticeQuestionType string - -const ( - FillInBlank PracticeQuestionType = "fill-in-blank" // 填空题 - TrueFalseType PracticeQuestionType = "true-false" // 判断题 - MultipleChoiceQ PracticeQuestionType = "multiple-choice" // 单选题 - MultipleSelection PracticeQuestionType = "multiple-selection" // 多选题 - ShortAnswer PracticeQuestionType = "short-answer" // 简答题 -) - // PracticeQuestion 练习题目模型 type PracticeQuestion struct { gorm.Model - QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段) - Type PracticeQuestionType `gorm:"index;size:30;not null" json:"type"` // 题目类型 - TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文) - Question string `gorm:"type:text;not null" json:"question"` // 题目内容 - AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储) - OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题) + QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段) + Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型 + TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文) + Question string `gorm:"type:text;not null" json:"question"` // 题目内容 + AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储) + OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题) } // TableName 指定表名 diff --git a/scripts/check_db.go b/scripts/check_db.go new file mode 100644 index 0000000..1a56c85 --- /dev/null +++ b/scripts/check_db.go @@ -0,0 +1,26 @@ +package main + +import ( + "ankao/internal/database" + "ankao/internal/models" + "fmt" + "log" +) + +func main() { + // 初始化数据库 + if err := database.InitDB(); err != nil { + log.Fatal("数据库初始化失败:", err) + } + + db := database.GetDB() + + // 查询前5条记录 + var questions []models.PracticeQuestion + db.Limit(5).Find(&questions) + + fmt.Println("数据库中的题目:") + for _, q := range questions { + fmt.Printf("ID: %d, QuestionID: %s, Type: %s, TypeName: %s\n", q.ID, q.QuestionID, q.Type, q.TypeName) + } +} diff --git a/scripts/clear_questions.go b/scripts/clear_questions.go new file mode 100644 index 0000000..0ca9767 --- /dev/null +++ b/scripts/clear_questions.go @@ -0,0 +1,28 @@ +package main + +import ( + "ankao/internal/database" + "ankao/internal/models" + "log" +) + +func main() { + log.Println("开始清空题目数据...") + + // 初始化数据库 + if err := database.InitDB(); err != nil { + log.Fatal("数据库初始化失败:", err) + } + + db := database.GetDB() + + // 清空practice_questions表 + if err := db.Exec("DELETE FROM practice_questions").Error; err != nil { + log.Fatal("清空题目表失败:", err) + } + + // 获取清空后的数量 + var count int64 + db.Model(&models.PracticeQuestion{}).Count(&count) + log.Printf("题目数据已清空,当前数量: %d", count) +} diff --git a/scripts/import_questions.go b/scripts/import_questions.go index 2745efe..956c05a 100644 --- a/scripts/import_questions.go +++ b/scripts/import_questions.go @@ -69,13 +69,10 @@ func main() { optionsJSON = string(optJSON) } - // 处理题型映射 - questionType := mapQuestionType(group.Type) - - // 创建题目记录 + // 创建题目记录 - 直接使用group.Type,不做类型映射 question := models.PracticeQuestion{ QuestionID: q.ID, - Type: questionType, + Type: group.Type, TypeName: group.TypeName, Question: q.Question, AnswerData: string(answerJSON), @@ -93,21 +90,3 @@ func main() { log.Printf("数据导入完成! 共导入 %d 道题目", totalCount) } - -// mapQuestionType 映射题型 -func mapQuestionType(jsonType string) models.PracticeQuestionType { - switch jsonType { - case "fill-in-blank": - return models.FillInBlank - case "true-false": - return models.TrueFalseType - case "multiple-choice": - return models.MultipleChoiceQ - case "multiple-selection": - return models.MultipleSelection - case "short-answer": - return models.ShortAnswer - default: - return models.PracticeQuestionType(jsonType) - } -} diff --git a/scripts/import_test.go b/scripts/import_test.go new file mode 100644 index 0000000..bf19db1 --- /dev/null +++ b/scripts/import_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +// 测试JSON解析 +func TestJSONParsing(t *testing.T) { + // 读取JSON文件 + data, err := os.ReadFile("../practice_question_pool.json") + if err != nil { + t.Fatalf("读取JSON文件失败: %v", err) + } + + // 解析JSON + var groups []JSONQuestionGroup + if err := json.Unmarshal(data, &groups); err != nil { + t.Fatalf("解析JSON失败: %v", err) + } + + t.Logf("成功解析 %d 个题目组", len(groups)) + + // 检查每个题目组的类型信息 + for i, group := range groups { + t.Logf("\n题目组 %d:", i+1) + t.Logf(" Type: %s", group.Type) + t.Logf(" TypeName: %s", group.TypeName) + t.Logf(" 题目数量: %d", len(group.List)) + + // 检查类型映射 + t.Logf(" 原始类型: %s", group.Type) + + // 检查是否为空 + if group.Type == "" { + t.Errorf(" ❌ 题目组 %d 的 Type 为空!", i+1) + } + if group.TypeName == "" { + t.Errorf(" ❌ 题目组 %d 的 TypeName 为空!", i+1) + } + if len(group.List) == 0 { + t.Errorf(" ❌ 题目组 %d 的题目列表为空!", i+1) + } + } +} + +// 测试单个题目组解析 +func TestSingleGroupParsing(t *testing.T) { + // 测试第一个题目组(填空题,type在list之后) + jsonStr1 := `{ + "list": [{"id": "1", "question": "test", "answers": ["answer1"]}], + "type": "fill-in-blank", + "typeName": "填空题" + }` + + var group1 JSONQuestionGroup + if err := json.Unmarshal([]byte(jsonStr1), &group1); err != nil { + t.Fatalf("解析题目组1失败: %v", err) + } + t.Logf("题目组1 - Type: %s, TypeName: %s, 题目数: %d", group1.Type, group1.TypeName, len(group1.List)) + + // 测试第二个题目组(type在list之前) + jsonStr2 := `{ + "type": "fill-in-blank", + "typeName": "填空题", + "list": [{"id": "1", "question": "test", "answers": ["answer1"]}] + }` + + var group2 JSONQuestionGroup + if err := json.Unmarshal([]byte(jsonStr2), &group2); err != nil { + t.Fatalf("解析题目组2失败: %v", err) + } + t.Logf("题目组2 - Type: %s, TypeName: %s, 题目数: %d", group2.Type, group2.TypeName, len(group2.List)) + + // 验证两种顺序解析结果是否一致 + if group1.Type != group2.Type || group1.TypeName != group2.TypeName { + t.Errorf("不同字段顺序导致解析结果不一致!") + } else { + t.Log("✓ JSON字段顺序不影响解析结果") + } +} diff --git a/web/src/components/AnswerResult.tsx b/web/src/components/AnswerResult.tsx new file mode 100644 index 0000000..d4bf185 --- /dev/null +++ b/web/src/components/AnswerResult.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { Alert, Typography } from 'antd' +import { CheckOutlined, CloseOutlined } from '@ant-design/icons' +import type { AnswerResult as AnswerResultType } from '../types/question' + +const { Text } = Typography + +interface AnswerResultProps { + answerResult: AnswerResultType + selectedAnswer: string | string[] + questionType: string +} + +const AnswerResult: React.FC = ({ + answerResult, + selectedAnswer, + questionType, +}) => { + // 格式化答案显示(判断题特殊处理) + const formatAnswer = (answer: string | string[]) => { + const answerStr = Array.isArray(answer) ? answer.join(', ') : answer + + if (questionType === 'true-false') { + return answerStr === 'true' ? '正确' : answerStr === 'false' ? '错误' : answerStr + } + + return answerStr + } + + return ( + : } + message={ +
+ {answerResult.correct ? '回答正确!' : '回答错误'} +
+ } + description={ +
+
+ 你的答案: + + {formatAnswer(selectedAnswer)} + +
+
+ + 正确答案: + + + {formatAnswer( + answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无') + )} + +
+ {answerResult.explanation && ( +
+ 解析: +
{answerResult.explanation}
+
+ )} +
+ } + style={{ marginTop: 20 }} + /> + ) +} + +export default AnswerResult diff --git a/web/src/components/CompletionSummary.tsx b/web/src/components/CompletionSummary.tsx new file mode 100644 index 0000000..cebd5e6 --- /dev/null +++ b/web/src/components/CompletionSummary.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { Modal, Button, Space, Typography } from 'antd' +import { TrophyOutlined } from '@ant-design/icons' + +const { Title, Text } = Typography + +interface CompletionSummaryProps { + visible: boolean + totalQuestions: number + correctCount: number + wrongCount: number + category?: string + onClose: () => void + onRetry: () => void +} + +const CompletionSummary: React.FC = ({ + visible, + totalQuestions, + correctCount, + wrongCount, + category, + onClose, + onRetry, +}) => { + const accuracy = totalQuestions > 0 ? Math.round((correctCount / totalQuestions) * 100) : 0 + + return ( + + + + 完成统计 + + + } + open={visible} + onCancel={onClose} + footer={[ + , + , + ]} + width={500} + > +
+ +
+ + 本次答题已完成! + + 题目类型:{category || '全部题型'} +
+ +
+
+
+ {totalQuestions} +
+ 总题数 +
+
+
+ {correctCount} +
+ 正确数 +
+
+
+ {wrongCount} +
+ 错误数 +
+
+ +
+ 正确率: + = 60 ? '#52c41a' : '#ff4d4f', + fontSize: 32, + }} + > + {accuracy}% + +
+
+
+
+ ) +} + +export default CompletionSummary diff --git a/web/src/components/QuestionCard.tsx b/web/src/components/QuestionCard.tsx new file mode 100644 index 0000000..3dc353e --- /dev/null +++ b/web/src/components/QuestionCard.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react' +import { Card, Space, Tag, Typography, Radio, Checkbox, Input, Button } from 'antd' +import type { Question, AnswerResult as AnswerResultType } from '../types/question' +import AnswerResult from './AnswerResult' +import styles from '../pages/Question.module.less' + +const { TextArea } = Input +const { Title } = Typography + +interface QuestionCardProps { + question: Question + selectedAnswer: string | string[] + showResult: boolean + answerResult: AnswerResultType | null + loading: boolean + autoNextLoading: boolean + onAnswerChange: (answer: string | string[]) => void + onSubmit: () => void + onNext: () => void +} + +const QuestionCard: React.FC = ({ + question, + selectedAnswer, + showResult, + answerResult, + loading, + autoNextLoading, + onAnswerChange, + onSubmit, + onNext, +}) => { + const [fillAnswers, setFillAnswers] = useState([]) + + // 渲染填空题内容 + const renderFillContent = () => { + const content = question.content + const parts = content.split('****') + + if (parts.length === 1) { + return
{content}
+ } + + if (fillAnswers.length === 0) { + setFillAnswers(new Array(parts.length - 1).fill('')) + } + + return ( +
+ {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + { + const newAnswers = [...fillAnswers] + newAnswers[index] = e.target.value + setFillAnswers(newAnswers) + onAnswerChange(newAnswers) + }} + disabled={showResult} + style={{ + display: 'inline-block', + width: '120px', + margin: '0 8px', + }} + /> + )} + + ))} +
+ ) + } + + // 渲染题目选项 + const renderOptions = () => { + if (question.type === 'fill-in-blank') { + return null + } + + if (question.type === 'short-answer') { + return ( +