diff --git a/go.mod b/go.mod index ef44eee..8a480f0 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect @@ -19,8 +20,10 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect @@ -48,4 +51,6 @@ require ( golang.org/x/text v0.30.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gorm.io/datatypes v1.2.7 // indirect + gorm.io/driver/mysql v1.5.6 // indirect ) diff --git a/go.sum b/go.sum index 8f07e88..0f833c6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= @@ -23,6 +25,9 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -30,6 +35,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -106,7 +113,12 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/database/database.go b/internal/database/database.go index e92b84e..73de870 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -37,7 +37,9 @@ func InitDB() error { &models.WrongQuestionHistory{}, // 错题历史表 &models.WrongQuestionTag{}, // 错题标签表 &models.UserAnswerRecord{}, // 用户答题记录表 - &models.Exam{}, // 考试表 + &models.Exam{}, // 考试表(试卷) + &models.ExamRecord{}, // 考试记录表 + &models.ExamUserAnswer{}, // 用户答案表 ) if err != nil { return fmt.Errorf("failed to migrate database: %w", err) diff --git a/internal/handlers/exam_grading.go b/internal/handlers/exam_grading.go new file mode 100644 index 0000000..236b33a --- /dev/null +++ b/internal/handlers/exam_grading.go @@ -0,0 +1,257 @@ +package handlers + +import ( + "ankao/internal/database" + "ankao/internal/models" + "ankao/internal/services" + "encoding/json" + "fmt" + "log" + + "gorm.io/datatypes" +) + +// ReGradeExam 公开的重新阅卷函数,可被外部调用 +func ReGradeExam(recordID uint, examID uint, userID uint) { + gradeExam(recordID, examID, userID) +} + +// gradeExam 异步阅卷函数 +func gradeExam(recordID uint, examID uint, userID uint) { + db := database.GetDB() + + // 查询考试记录 + var record models.ExamRecord + if err := db.Where("id = ?", recordID).First(&record).Error; err != nil { + log.Printf("查询考试记录失败: %v", err) + return + } + + // 查询试卷 + var exam models.Exam + if err := db.Where("id = ?", examID).First(&exam).Error; err != nil { + log.Printf("查询试卷失败: %v", err) + return + } + + // 从 ExamUserAnswer 表读取所有答案 + var userAnswers []models.ExamUserAnswer + if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil { + log.Printf("查询用户答案失败: %v", err) + return + } + + // 转换为 map 格式方便查找 + answersMap := make(map[uint]interface{}) + for _, ua := range userAnswers { + var answer interface{} + if err := json.Unmarshal(ua.Answer, &answer); err != nil { + log.Printf("解析答案失败: %v", err) + continue + } + answersMap[ua.QuestionID] = answer + } + + // 解析题目ID列表 + var questionIDs []uint + if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil { + log.Printf("解析题目ID失败: %v", err) + return + } + + // 查询题目详情 + var questions []models.PracticeQuestion + if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil { + log.Printf("查询题目失败: %v", err) + return + } + + // 使用固定的题型分值映射 + scoreMap := map[string]float64{ + "fill-in-blank": 2.0, + "true-false": 2.0, + "multiple-choice": 1.0, + "multiple-selection": 2.5, + "short-answer": 10.0, + "ordinary-essay": 5.0, + "management-essay": 5.0, + } + + // 评分 + totalScore := 0.0 + aiService := services.NewAIGradingService() + + for _, question := range questions { + userAnswerRaw, answered := answersMap[question.ID] + + if !answered { + // 更新数据库中的 ExamUserAnswer 记录为未作答 + var userAnswer models.ExamUserAnswer + result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer) + if result.Error == nil { + updates := map[string]interface{}{ + "is_correct": false, + "score": 0.0, + } + db.Model(&userAnswer).Updates(updates) + } + continue + } + + // 根据题型判断答案 + var isCorrect bool + var score float64 + var aiGrading *models.AIGrading + + switch question.Type { + case "fill-in-blank": + // 填空题:比较数组 + userAnswerArr, ok := userAnswerRaw.([]interface{}) + if !ok { + isCorrect = false + score = 0 + // 更新数据库 + var userAnswer models.ExamUserAnswer + if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil { + db.Model(&userAnswer).Updates(map[string]interface{}{ + "is_correct": false, + "score": 0.0, + }) + } + continue + } + var correctAnswers []string + if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil { + log.Printf("解析填空题答案失败: %v", err) + continue + } + isCorrect = len(userAnswerArr) == len(correctAnswers) + if isCorrect { + for i, ua := range userAnswerArr { + if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] { + isCorrect = false + break + } + } + } + if isCorrect { + score = scoreMap["fill-in-blank"] + } + + case "true-false": + // 判断题 - AnswerData 直接存储 "true" 或 "false" 字符串 + correctAnswer := question.AnswerData + isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer + if isCorrect { + score = scoreMap["true-false"] + } + + case "multiple-choice": + correctAnswer := question.AnswerData + isCorrect = fmt.Sprintf("\"%v\"", userAnswerRaw) == correctAnswer + if isCorrect { + score = scoreMap["multiple-choice"] + } + case "multiple-selection": + // 多选题:比较数组(顺序无关) + userAnswerArr, ok := userAnswerRaw.([]interface{}) + if !ok { + isCorrect = false + score = 0 + // 更新数据库 + var userAnswer models.ExamUserAnswer + if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil { + db.Model(&userAnswer).Updates(map[string]interface{}{ + "is_correct": false, + "score": 0.0, + }) + } + continue + } + var correctAnswers []string + if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil { + log.Printf("解析多选题答案失败: %v", err) + continue + } + userAnswerSet := make(map[string]bool) + for _, ua := range userAnswerArr { + userAnswerSet[fmt.Sprintf("%v", ua)] = true + } + isCorrect = len(userAnswerSet) == len(correctAnswers) + if isCorrect { + for _, ca := range correctAnswers { + if !userAnswerSet[ca] { + isCorrect = false + break + } + } + } + if isCorrect { + score = scoreMap["multiple-selection"] + } + + case "short-answer", "ordinary-essay", "management-essay": + // 简答题和论述题:使用AI评分 + // AnswerData 直接存储答案文本 + correctAnswer := question.AnswerData + userAnswerStr := fmt.Sprintf("%v", userAnswerRaw) + aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr) + if err != nil { + log.Printf("AI评分失败: %v", err) + isCorrect = false + score = 0 + } else { + isCorrect = aiResult.IsCorrect + // 按AI评分比例计算 + var questionScore float64 + if question.Type == "short-answer" { + questionScore = scoreMap["short-answer"] + } else if question.Type == "ordinary-essay" { + questionScore = scoreMap["ordinary-essay"] + } else if question.Type == "management-essay" { + questionScore = scoreMap["management-essay"] + } + score = questionScore * (aiResult.Score / 100.0) + aiGrading = &models.AIGrading{ + Score: aiResult.Score, + Feedback: aiResult.Feedback, + Suggestion: aiResult.Suggestion, + } + } + } + + totalScore += score + + // 更新数据库中的 ExamUserAnswer 记录 + var userAnswer models.ExamUserAnswer + result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer) + if result.Error == nil { + // 序列化 AI 评分数据 + var aiGradingJSON datatypes.JSON + if aiGrading != nil { + aiGradingData, _ := json.Marshal(aiGrading) + aiGradingJSON = datatypes.JSON(aiGradingData) + } + + // 更新评分结果 + updates := map[string]interface{}{ + "is_correct": isCorrect, + "score": score, + "ai_grading_data": aiGradingJSON, + } + db.Model(&userAnswer).Updates(updates) + } + } + + // 保存分数和状态到考试记录 + record.Score = totalScore + record.Status = "graded" + record.IsPassed = totalScore >= float64(exam.PassScore) + + if err := db.Save(&record).Error; err != nil { + log.Printf("保存考试记录失败: %v", err) + return + } + + log.Printf("阅卷完成: 考试记录ID=%d, 总分=%.2f, 是否通过=%v", recordID, totalScore, record.IsPassed) +} diff --git a/internal/handlers/exam_handler.go b/internal/handlers/exam_handler.go index 11b6a18..eed4ea9 100644 --- a/internal/handlers/exam_handler.go +++ b/internal/handlers/exam_handler.go @@ -3,7 +3,6 @@ package handlers import ( "ankao/internal/database" "ankao/internal/models" - "ankao/internal/services" "encoding/json" "fmt" "log" @@ -13,50 +12,73 @@ import ( "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) -// GenerateExam 生成考试 -func GenerateExam(c *gin.Context) { +// CreateExamRequest 创建试卷请求 +type CreateExamRequest struct { + Title string `json:"title" binding:"required"` +} + +// CreateExam 创建试卷 +func CreateExam(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) return } - db := database.GetDB() - - // 使用默认配置 - config := models.DefaultExamConfig - - // 按题型随机抽取题目 - questionsByType := make(map[string][]models.PracticeQuestion) - questionTypes := []struct { - Type string - Count int - }{ - {"fill-in-blank", config.FillInBlank}, - {"true-false", config.TrueFalse}, - {"multiple-choice", config.MultipleChoice}, - {"multiple-selection", config.MultipleSelection}, - {"short-answer", config.ShortAnswer}, - {"ordinary-essay", config.OrdinaryEssay}, - {"management-essay", config.ManagementEssay}, + var req CreateExamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据: " + err.Error()}) + return } + db := database.GetDB() + + // 查询用户信息,获取用户类型 + var user models.User + if err := db.Where("id = ?", userID).First(&user).Error; err != nil { + log.Printf("查询用户失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询用户信息失败"}) + return + } + + // 根据用户类型决定论述题类型 + essayType := "ordinary-essay" // 默认普通涉密人员论述题 + if user.UserType == "management-person" { + essayType = "management-essay" // 保密管理人员论述题 + } + + // 使用固定的题型配置(总分100分) + questionTypes := []models.QuestionTypeConfig{ + {Type: "fill-in-blank", Count: 10, Score: 2.0}, // 20分 + {Type: "true-false", Count: 10, Score: 2.0}, // 20分 + {Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分 + {Type: "multiple-selection", Count: 10, Score: 2.5}, // 25分 + {Type: "short-answer", Count: 2, Score: 10.0}, // 20分 + {Type: essayType, Count: 1, Score: 5.0}, // 5分(根据用户类型选择论述题) + } + + // 按题型配置随机抽取题目 var allQuestionIDs []uint - for _, qt := range questionTypes { + totalScore := 0.0 + + for _, qtConfig := range questionTypes { var questions []models.PracticeQuestion - if err := db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil { + query := db.Where("type = ?", qtConfig.Type) + + if err := query.Find(&questions).Error; err != nil { log.Printf("查询题目失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"}) return } // 检查题目数量是否足够 - if len(questions) < qt.Count { + if len(questions) < qtConfig.Count { c.JSON(http.StatusBadRequest, gin.H{ "success": false, - "message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qt.Type, qt.Count, len(questions)), + "message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qtConfig.Type, qtConfig.Count, len(questions)), }) return } @@ -67,82 +89,167 @@ func GenerateExam(c *gin.Context) { j := rand.Intn(i + 1) questions[i], questions[j] = questions[j], questions[i] } - selectedQuestions := questions[:qt.Count] - questionsByType[qt.Type] = selectedQuestions + selectedQuestions := questions[:qtConfig.Count] // 收集题目ID for _, q := range selectedQuestions { allQuestionIDs = append(allQuestionIDs, q.ID) } + + // 计算总分 + totalScore += float64(qtConfig.Count) * qtConfig.Score } - // 将题目ID列表转为JSON + // 随机打乱题目ID顺序 + rand.Seed(time.Now().UnixNano()) + for i := len(allQuestionIDs) - 1; i > 0; i-- { + j := rand.Intn(i + 1) + allQuestionIDs[i], allQuestionIDs[j] = allQuestionIDs[j], allQuestionIDs[i] + } + + // 序列化题目ID questionIDsJSON, err := json.Marshal(allQuestionIDs) if err != nil { log.Printf("序列化题目ID失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成考试失败"}) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成试卷失败"}) return } - // 创建考试记录 + // 创建试卷 exam := models.Exam{ UserID: userID.(uint), - QuestionIDs: string(questionIDsJSON), - Status: "draft", + Title: req.Title, + TotalScore: int(totalScore), // 总分100分 + Duration: 60, // 固定60分钟 + PassScore: 80, // 固定80分及格 + QuestionIDs: questionIDsJSON, + Status: "active", } if err := db.Create(&exam).Error; err != nil { - log.Printf("创建考试记录失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建考试失败"}) + log.Printf("创建试卷失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建试卷失败"}) return } - // 返回考试信息 + // 返回试卷信息 c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "exam_id": exam.ID, - "question_ids": allQuestionIDs, - "created_at": exam.CreatedAt, + "id": exam.ID, + "title": exam.Title, + "total_score": exam.TotalScore, + "duration": exam.Duration, + "pass_score": exam.PassScore, + "question_count": len(allQuestionIDs), + "created_at": exam.CreatedAt, }, }) } -// GetExam 获取考试详情 -func GetExam(c *gin.Context) { +// GetExamList 获取试卷列表 +func GetExamList(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) return } + db := database.GetDB() + + // 查询用户创建的试卷 + var exams []models.Exam + if err := db.Where("user_id = ? AND status = ?", userID, "active"). + Order("created_at DESC"). + Find(&exams).Error; err != nil { + log.Printf("查询试卷列表失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询试卷列表失败"}) + return + } + + // 为每个试卷计算题目数量和获取考试记录统计 + type ExamWithStats struct { + models.Exam + QuestionCount int `json:"question_count"` + AttemptCount int `json:"attempt_count"` // 考试次数 + BestScore float64 `json:"best_score"` // 最高分 + HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试 + InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID + } + + result := make([]ExamWithStats, 0, len(exams)) + for _, exam := range exams { + var questionIDs []uint + if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err == nil { + stats := ExamWithStats{ + Exam: exam, + QuestionCount: len(questionIDs), + } + + // 查询该试卷的考试记录统计 + var count int64 + db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count) + stats.AttemptCount = int(count) + + // 查询最高分 + var record models.ExamRecord + if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID). + Order("score DESC"). + First(&record).Error; err == nil { + stats.BestScore = record.Score + } + + // 查询是否有进行中的考试(status为in_progress) + var inProgressRecord models.ExamRecord + if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress"). + Order("created_at DESC"). + First(&inProgressRecord).Error; err == nil { + stats.HasInProgressExam = true + stats.InProgressRecordID = inProgressRecord.ID + } + + result = append(result, stats) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result, + }) +} + +// GetExamDetail 获取试卷详情 +func GetExamDetail(c *gin.Context) { + _, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + examIDStr := c.Param("id") examID, err := strconv.ParseUint(examIDStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"}) return } db := database.GetDB() - // 查询考试记录 + // 查询试卷 var exam models.Exam - if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"}) + if err := db.Where("id = ?", examID).First(&exam).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"}) return } // 解析题目ID列表 var questionIDs []uint - if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil { + if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil { log.Printf("解析题目ID失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"}) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析试卷数据失败"}) return } - // 是否显示答案 - showAnswer := c.Query("show_answer") == "true" - // 查询题目详情 var questions []models.PracticeQuestion if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil { @@ -151,7 +258,7 @@ func GetExam(c *gin.Context) { return } - // 按原始顺序排序题目并转换为DTO + // 按原始顺序排序题目并转换为DTO(不显示答案) questionMap := make(map[uint]models.PracticeQuestion) for _, q := range questions { questionMap[q.ID] = q @@ -160,10 +267,7 @@ func GetExam(c *gin.Context) { for _, id := range questionIDs { if q, ok := questionMap[id]; ok { dto := convertToDTO(q) - // 是否显示答案 - if !showAnswer { - dto.Answer = nil - } + dto.Answer = nil // 不显示答案 orderedDTOs = append(orderedDTOs, dto) } } @@ -177,8 +281,8 @@ func GetExam(c *gin.Context) { }) } -// SubmitExam 提交考试 -func SubmitExam(c *gin.Context) { +// StartExam 开始考试(创建考试记录) +func StartExam(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) @@ -188,14 +292,63 @@ func SubmitExam(c *gin.Context) { examIDStr := c.Param("id") examID, err := strconv.ParseUint(examIDStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"}) + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"}) + return + } + + db := database.GetDB() + + // 查询试卷 + var exam models.Exam + if err := db.Where("id = ?", examID).First(&exam).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"}) + return + } + + // 创建考试记录 + now := time.Now() + record := models.ExamRecord{ + ExamID: uint(examID), + UserID: userID.(uint), + StartTime: &now, + TotalScore: exam.TotalScore, + Status: "in_progress", + } + + if err := db.Create(&record).Error; err != nil { + log.Printf("创建考试记录失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "开始考试失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "record_id": record.ID, + "start_time": record.StartTime, + "duration": exam.Duration, + }, + }) +} + +// SubmitExam 提交试卷答案 +func SubmitExam(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + + recordIDStr := c.Param("record_id") + recordID, err := strconv.ParseUint(recordIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"}) return } // 解析请求体 var req struct { - Answers map[string]interface{} `json:"answers"` // question_id -> answer - EssayChoice string `json:"essay_choice"` // 论述题选择: ordinary 或 management + Answers map[string]interface{} `json:"answers"` // question_id -> answer (可选) } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"}) @@ -205,27 +358,106 @@ func SubmitExam(c *gin.Context) { db := database.GetDB() // 查询考试记录 - var exam models.Exam - if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"}) + var record models.ExamRecord + if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"}) return } // 检查是否已提交 - if exam.Status == "submitted" { + if record.Status == "submitted" || record.Status == "graded" { c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已提交"}) return } - // 解析题目ID列表 - var questionIDs []uint - if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil { - log.Printf("解析题目ID失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"}) + // 查询试卷 + var exam models.Exam + if err := db.Where("id = ?", record.ExamID).First(&exam).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"}) + return + } + + // 更新考试记录状态为已提交 + now := time.Now() + record.Status = "submitted" + record.SubmitTime = &now + + // 计算用时(秒) + if record.StartTime != nil { + duration := now.Sub(*record.StartTime) + record.TimeSpent = int(duration.Seconds()) + + // 确保用时不为负数(容错处理) + if record.TimeSpent < 0 { + log.Printf("警告: 计算出负的用时,开始时间=%v, 结束时间=%v", *record.StartTime, now) + record.TimeSpent = 0 + } + } + + if err := db.Save(&record).Error; err != nil { + log.Printf("保存考试记录失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "提交考试失败"}) + return + } + + // 立即返回成功响应 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "提交成功,正在阅卷中...", + "data": gin.H{ + "record_id": record.ID, + "status": "submitted", + "time_spent": record.TimeSpent, + "total_score": exam.TotalScore, + }, + }) + + // 异步执行阅卷(从 exam_user_answers 表读取答案) + go gradeExam(uint(recordID), exam.ID, userID.(uint)) +} + +// GetExamRecord 获取考试记录详情 +func GetExamRecord(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + + recordIDStr := c.Param("record_id") + recordID, err := strconv.ParseUint(recordIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"}) + return + } + + db := database.GetDB() + + // 查询考试记录 + var record models.ExamRecord + if err := db.Where("id = ? AND user_id = ?", recordID, userID). + Preload("Exam"). + First(&record).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"}) + return + } + + // 从 exam_user_answers 表读取所有答案 + var userAnswers []models.ExamUserAnswer + if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil { + log.Printf("查询用户答案失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"}) + return + } + + // 查询所有题目以获取正确答案 + var questionIDs []uint + if err := json.Unmarshal(record.Exam.QuestionIDs, &questionIDs); err != nil { + log.Printf("解析题目ID失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析题目失败"}) return } - // 查询题目详情 var questions []models.PracticeQuestion if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil { log.Printf("查询题目失败: %v", err) @@ -233,188 +465,305 @@ func SubmitExam(c *gin.Context) { return } - // 评分 - totalScore := 0.0 - scoreConfig := models.DefaultScoreConfig - aiService := services.NewAIGradingService() - detailedResults := make(map[string]interface{}) + // 构建题目映射 + questionMap := make(map[uint]models.PracticeQuestion) + for _, q := range questions { + questionMap[q.ID] = q + } - for _, question := range questions { - questionIDStr := fmt.Sprintf("%d", question.ID) - userAnswerRaw, answered := req.Answers[questionIDStr] - if !answered { - detailedResults[questionIDStr] = gin.H{ - "correct": false, - "score": 0, - "message": "未作答", - } + // 构建 ExamAnswer 列表 + examAnswers := make([]models.ExamAnswer, 0, len(userAnswers)) + for _, ua := range userAnswers { + // 解析用户答案 + var userAnswer interface{} + if err := json.Unmarshal(ua.Answer, &userAnswer); err != nil { + log.Printf("解析用户答案失败: %v", err) continue } - // 论述题特殊处理:根据用户选择判断是否计分 - if question.Type == "ordinary-essay" && req.EssayChoice != "ordinary" { - detailedResults[questionIDStr] = gin.H{ - "correct": false, - "score": 0, - "message": "未选择此题", - } - continue - } - if question.Type == "management-essay" && req.EssayChoice != "management" { - detailedResults[questionIDStr] = gin.H{ - "correct": false, - "score": 0, - "message": "未选择此题", - } + // 获取题目并解析正确答案 + question, ok := questionMap[ua.QuestionID] + if !ok { continue } - // 根据题型判断答案 - var isCorrect bool - var score float64 - var aiGrading *models.AIGrading - + var correctAnswerRaw interface{} switch question.Type { - case "fill-in-blank": - // 填空题:比较数组 - userAnswerArr, ok := userAnswerRaw.([]interface{}) - if !ok { - detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"} - continue - } - var correctAnswers []string - if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil { - log.Printf("解析填空题答案失败: %v", err) - continue - } - isCorrect = true - for i, ua := range userAnswerArr { - if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] { - isCorrect = false - break - } - } - if isCorrect { - score = scoreConfig.FillInBlank - } + case "fill-in-blank", "multiple-selection", "multiple-choice": + // 数组类型:需要 JSON 解析(单选题也是数组格式A) + var arr []string + if err := json.Unmarshal([]byte(question.AnswerData), &arr); err != nil { + // 尝试解析为单个字符串(兼容旧数据格式) + var singleStr string + if err2 := json.Unmarshal([]byte(question.AnswerData), &singleStr); err2 == nil { + // 成功解析为字符串,单选题直接使用,其他类型转为数组 + if question.Type == "multiple-choice" { + correctAnswerRaw = singleStr + } else { + correctAnswerRaw = []string{singleStr} - case "true-false": - // 判断题 - var correctAnswer string - if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil { - log.Printf("解析判断题答案失败: %v", err) - continue - } - isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer - if isCorrect { - score = scoreConfig.TrueFalse - } - - case "multiple-choice": - // 单选题 - var correctAnswer string - if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil { - log.Printf("解析单选题答案失败: %v", err) - continue - } - isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer - if isCorrect { - score = scoreConfig.MultipleChoice - } - - case "multiple-selection": - // 多选题:比较数组(顺序无关) - userAnswerArr, ok := userAnswerRaw.([]interface{}) - if !ok { - detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"} - continue - } - var correctAnswers []string - if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil { - log.Printf("解析多选题答案失败: %v", err) - continue - } - userAnswerSet := make(map[string]bool) - for _, ua := range userAnswerArr { - userAnswerSet[fmt.Sprintf("%v", ua)] = true - } - isCorrect = len(userAnswerSet) == len(correctAnswers) - if isCorrect { - for _, ca := range correctAnswers { - if !userAnswerSet[ca] { - isCorrect = false - break } - } - } - if isCorrect { - score = scoreConfig.MultipleSelection - } - - case "short-answer", "ordinary-essay", "management-essay": - // 简答题和论述题:使用AI评分 - var correctAnswer string - if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil { - log.Printf("解析简答题答案失败: %v", err) - continue - } - userAnswerStr := fmt.Sprintf("%v", userAnswerRaw) - aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr) - if err != nil { - log.Printf("AI评分失败: %v", err) - // AI评分失败时,给一个保守的分数 - isCorrect = false - score = 0 - } else { - isCorrect = aiResult.IsCorrect - if question.Type == "short-answer" { - // 简答题不计分,仅供参考 - score = 0 } else { - // 论述题按AI评分比例计算 - score = scoreConfig.Essay * (aiResult.Score / 100.0) + correctAnswerRaw = "解析失败" } - aiGrading = &models.AIGrading{ - Score: aiResult.Score, - Feedback: aiResult.Feedback, - Suggestion: aiResult.Suggestion, + } else { + // 单选题只取第一个元素 + if question.Type == "multiple-choice" && len(arr) > 0 { + correctAnswerRaw = arr[0] + } else { + correctAnswerRaw = arr } } + default: + // 字符串类型:直接使用 AnswerData(true-false, short-answer, essay) + correctAnswerRaw = question.AnswerData + } + + // 构建答案对象 + examAnswer := models.ExamAnswer{ + QuestionID: ua.QuestionID, + Answer: userAnswer, + CorrectAnswer: correctAnswerRaw, + IsCorrect: ua.IsCorrect != nil && *ua.IsCorrect, + Score: ua.Score, + } + + // 添加 AI 评分信息 + if len(ua.AIGradingData) > 0 { + var aiGrading models.AIGrading + if err := json.Unmarshal(ua.AIGradingData, &aiGrading); err == nil { + examAnswer.AIGrading = &aiGrading + } } - totalScore += score - detailedResults[questionIDStr] = gin.H{ - "correct": isCorrect, - "score": score, - "ai_grading": aiGrading, - } - } - - // 保存答案和分数 - answersJSON, err := json.Marshal(req.Answers) - if err != nil { - log.Printf("序列化答案失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"}) - return - } - - now := time.Now() - exam.Answers = string(answersJSON) - exam.Score = totalScore - exam.Status = "submitted" - exam.SubmittedAt = &now - - if err := db.Save(&exam).Error; err != nil { - log.Printf("保存考试记录失败: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存考试失败"}) - return + examAnswers = append(examAnswers, examAnswer) } c.JSON(http.StatusOK, gin.H{ "success": true, "data": gin.H{ - "score": totalScore, - "detailed_results": detailedResults, + "record": record, + "answers": examAnswers, }, }) } + +// GetExamRecordList 获取考试记录列表 +func GetExamRecordList(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + + examIDStr := c.Query("exam_id") + + db := database.GetDB() + query := db.Where("user_id = ?", userID) + + // 如果指定了试卷ID,只查询该试卷的记录 + if examIDStr != "" { + examID, err := strconv.ParseUint(examIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"}) + return + } + query = query.Where("exam_id = ?", examID) + } + + var records []models.ExamRecord + if err := query.Preload("Exam"). + Order("created_at DESC"). + Find(&records).Error; err != nil { + log.Printf("查询考试记录失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询考试记录失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": records, + }) +} + +// DeleteExam 删除试卷 +func DeleteExam(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + + examIDStr := c.Param("id") + examID, err := strconv.ParseUint(examIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"}) + return + } + + db := database.GetDB() + + // 查询试卷 + var exam models.Exam + if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"}) + return + } + + // 软删除 + if err := db.Delete(&exam).Error; err != nil { + log.Printf("删除试卷失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "删除试卷失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "删除成功", + }) +} + +// SaveExamProgressRequest 保存考试进度请求 +type SaveExamProgressRequest struct { + QuestionID uint `json:"question_id"` // 题目ID + Answer interface{} `json:"answer"` // 答案数据 +} + +// SaveExamProgress 保存单题答案 +func SaveExamProgress(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + + recordIDStr := c.Param("record_id") + recordID, err := strconv.ParseUint(recordIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"}) + return + } + + var req SaveExamProgressRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"}) + return + } + + db := database.GetDB() + + // 查询考试记录 + var record models.ExamRecord + if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"}) + return + } + + // 检查考试状态 + if record.Status != "in_progress" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已结束,无法保存答案"}) + return + } + + // 序列化答案数据 + answerJSON, err := json.Marshal(req.Answer) + if err != nil { + log.Printf("序列化答案失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存失败"}) + return + } + + now := time.Now() + + // 查找是否已存在该题的答案 + var userAnswer models.ExamUserAnswer + result := db.Where("exam_record_id = ? AND question_id = ?", recordID, req.QuestionID).First(&userAnswer) + + if result.Error == gorm.ErrRecordNotFound { + // 不存在,创建新记录 + userAnswer = models.ExamUserAnswer{ + ExamRecordID: uint(recordID), + QuestionID: req.QuestionID, + UserID: userID.(uint), + Answer: answerJSON, + AnsweredAt: &now, + LastModifiedAt: now, + } + if err := db.Create(&userAnswer).Error; err != nil { + log.Printf("创建答案记录失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"}) + return + } + } else if result.Error != nil { + log.Printf("查询答案记录失败: %v", result.Error) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"}) + return + } else { + // 已存在,更新答案 + updates := map[string]interface{}{ + "answer": answerJSON, + "last_modified_at": now, + } + if err := db.Model(&userAnswer).Updates(updates).Error; err != nil { + log.Printf("更新答案记录失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新答案失败"}) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "保存成功", + }) +} + +// GetExamUserAnswers 获取用户在考试中的所有答案 +func GetExamUserAnswers(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) + return + } + + recordIDStr := c.Param("record_id") + recordID, err := strconv.ParseUint(recordIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"}) + return + } + + db := database.GetDB() + + // 查询考试记录,确保用户有权限 + var record models.ExamRecord + if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"}) + return + } + + // 查询所有已保存的答案 + var userAnswers []models.ExamUserAnswer + if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil { + log.Printf("查询用户答案失败: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"}) + return + } + + // 转换为前端需要的格式: { question_id: answer } + answers := make(map[string]interface{}) + for _, ua := range userAnswers { + var answer interface{} + if err := json.Unmarshal(ua.Answer, &answer); err != nil { + log.Printf("解析答案失败: %v", err) + continue + } + // 使用 q_ 格式作为key,与前端表单字段名保持一致 + fieldName := fmt.Sprintf("q_%d", ua.QuestionID) + answers[fieldName] = answer + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": answers, + }) +} diff --git a/internal/models/exam.go b/internal/models/exam.go index dd3b87f..f1e625f 100644 --- a/internal/models/exam.go +++ b/internal/models/exam.go @@ -3,22 +3,90 @@ package models import ( "time" + "gorm.io/datatypes" "gorm.io/gorm" ) -// Exam 考试记录 +// Exam 试卷模型 type Exam struct { - ID uint `gorm:"primarykey" json:"id"` + ID uint `gorm:"primaryKey" json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - UserID uint `json:"user_id"` // 用户ID - QuestionIDs string `gorm:"type:text" json:"question_ids"` // 题目ID列表(JSON数组) - Answers string `gorm:"type:text" json:"answers"` // 用户答案(JSON对象) - Score float64 `json:"score"` // 总分 - Status string `gorm:"default:'draft'" json:"status"` // 状态: draft/submitted - SubmittedAt *time.Time `json:"submitted_at"` // 提交时间 - User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID + Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题 + TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分 + Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟) + PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数 + QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组) + Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived +} + +// ExamRecord 考试记录 +type ExamRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID + UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID + StartTime *time.Time `json:"start_time"` // 开始时间 + SubmitTime *time.Time `json:"submit_time"` // 提交时间 + TimeSpent int `json:"time_spent"` // 实际用时(秒) + Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分 + TotalScore int `json:"total_score"` // 总分 + Status string `gorm:"type:varchar(20);not null;default:'in_progress'" json:"status"` // 状态: in_progress, submitted, graded + IsPassed bool `json:"is_passed"` // 是否通过 + + // 关联 + Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"` + User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` +} + +// ExamUserAnswer 用户答案表(记录每道题的答案) +type ExamUserAnswer struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ExamRecordID uint `gorm:"not null;index:idx_record_question" json:"exam_record_id"` // 考试记录ID + QuestionID uint `gorm:"not null;index:idx_record_question" json:"question_id"` // 题目ID + UserID uint `gorm:"not null;index" json:"user_id"` // 用户ID + Answer datatypes.JSON `gorm:"type:json" json:"answer"` // 用户答案 (JSON格式,支持各种题型) + IsCorrect *bool `json:"is_correct,omitempty"` // 是否正确(提交后评分) + Score float64 `gorm:"type:decimal(5,2);default:0" json:"score"` // 得分 + AIGradingData datatypes.JSON `gorm:"type:json" json:"ai_grading_data,omitempty"` // AI评分数据 + AnsweredAt *time.Time `json:"answered_at"` // 答题时间 + LastModifiedAt time.Time `json:"last_modified_at"` // 最后修改时间 + + // 关联 + ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"` + Question *PracticeQuestion `gorm:"foreignKey:QuestionID" json:"-"` +} + +// ExamConfig 试卷配置结构 +type ExamConfig struct { + QuestionTypes []QuestionTypeConfig `json:"question_types"` // 题型配置 + Categories []string `json:"categories"` // 题目分类筛选 + Difficulty []string `json:"difficulty"` // 难度筛选 + RandomOrder bool `json:"random_order"` // 是否随机顺序 +} + +// QuestionTypeConfig 题型配置 +type QuestionTypeConfig struct { + Type string `json:"type"` // 题目类型 + Count int `json:"count"` // 题目数量 + Score float64 `json:"score"` // 每题分数 +} + +// ExamAnswer 考试答案结构 +type ExamAnswer struct { + QuestionID uint `json:"question_id"` + Answer interface{} `json:"answer"` // 用户答案 + CorrectAnswer interface{} `json:"correct_answer"` // 正确答案 + IsCorrect bool `json:"is_correct"` + Score float64 `json:"score"` + AIGrading *AIGrading `json:"ai_grading,omitempty"` } // ExamQuestionConfig 考试题目配置 @@ -32,6 +100,7 @@ type ExamQuestionConfig struct { ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量 } + // DefaultExamConfig 默认考试配置 var DefaultExamConfig = ExamQuestionConfig{ FillInBlank: 10, // 填空题10道 diff --git a/main.go b/main.go index 00d982c..1e1deb8 100644 --- a/main.go +++ b/main.go @@ -71,10 +71,17 @@ func main() { auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签 auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签 - // 考试相关API - auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试 - auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情 - auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试 + // 模拟考试相关API + auth.POST("/exams", handlers.CreateExam) // 创建试卷 + auth.GET("/exams", handlers.GetExamList) // 获取试卷列表 + auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情 + auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试 + auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案 + auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情 + auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表 + auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷 + auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度 + auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案 } // 题库管理API(需要管理员权限) diff --git a/web/src/App.tsx b/web/src/App.tsx index 59f5ce3..baa2fb5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,9 @@ 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' const App: React.FC = () => { return ( @@ -36,9 +39,12 @@ const App: React.FC = () => { {/* 考试相关页面,需要登录保护 */} } /> + } /> } /> + } /> } /> } /> + } /> {/* 题库管理页面,需要管理员权限 */} { + return request.post>('/exams', data) +} + +// 获取试卷列表 +export const getExamList = () => { + return request.get>('/exams') +} + +// 获取试卷详情 +export const getExamDetail = (examId: number) => { + return request.get>(`/exams/${examId}`) +} + +// 开始考试 +export const startExam = (examId: number) => { + return request.post>(`/exams/${examId}/start`) +} + +// 提交试卷答案 +export const submitExamAnswer = (recordId: number, data: SubmitExamRequest) => { + return request.post>(`/exam-records/${recordId}/submit`, data) +} + +// 获取考试记录详情 +export const getExamRecord = (recordId: number) => { + return request.get>(`/exam-records/${recordId}`) +} + +// 获取考试记录列表 +export const getExamRecordList = (examId?: number) => { + return request.get>('/exam-records', { + params: examId ? { exam_id: examId } : undefined + }) +} + +// 删除试卷 +export const deleteExam = (examId: number) => { + return request.delete>(`/exams/${examId}`) +} + +// 保存考试进度(单题答案) +export const saveExamProgress = (recordId: number, data: { question_id: number; answer: any }) => { + return request.post>(`/exam-records/${recordId}/progress`, data) +} + +// 获取用户答案 +export const getExamUserAnswers = (recordId: number) => { + return request.get>>(`/exam-records/${recordId}/answers`) +} + +// === 兼容旧版API === + // 生成考试 export const generateExam = () => { return request.post>('/exam/generate') diff --git a/web/src/pages/ExamManagement.module.less b/web/src/pages/ExamManagement.module.less new file mode 100644 index 0000000..4ac08b7 --- /dev/null +++ b/web/src/pages/ExamManagement.module.less @@ -0,0 +1,105 @@ +.container { + padding: 20px; + max-width: 1400px; + margin: 0 auto; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + + h2 { + margin: 0 0 4px 0; + font-size: 24px; + } + + .subtitle { + margin: 0; + color: rgba(0, 0, 0, 0.45); + } +} + +.cardTitle { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 600; + + svg { + font-size: 18px; + color: #1890ff; + } +} + +.cardContent { + .description { + margin-bottom: 16px; + color: rgba(0, 0, 0, 0.65); + font-size: 14px; + } + + .statItem { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: rgba(0, 0, 0, 0.65); + + svg { + color: #1890ff; + } + } + + .stats { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; + display: flex; + gap: 8px; + } +} + +.divider { + font-weight: 600; + font-size: 16px; + margin: 24px 0 16px 0; + padding-bottom: 8px; + border-bottom: 1px solid #f0f0f0; +} + +// 响应式适配 +@media (max-width: 768px) { + .container { + padding: 12px; + } + + .header { + flex-direction: column; + gap: 16px; + + h2 { + font-size: 20px; + } + + button { + width: 100%; + } + } + + .cardTitle { + font-size: 15px; + } + + .cardContent { + .description { + font-size: 13px; + } + + .statItem { + font-size: 13px; + } + } +} diff --git a/web/src/pages/ExamManagement.tsx b/web/src/pages/ExamManagement.tsx new file mode 100644 index 0000000..f0ca2a9 --- /dev/null +++ b/web/src/pages/ExamManagement.tsx @@ -0,0 +1,398 @@ +import React, { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Card, + Button, + Form, + Input, + Space, + message, + List, + Tag, + Modal, + Row, + Col, + Empty, + Spin, + Drawer, + Descriptions, + Divider +} from 'antd' +import { + PlusOutlined, + DeleteOutlined, + PlayCircleOutlined, + FileTextOutlined, + ClockCircleOutlined, + CheckCircleOutlined, + TrophyOutlined, + HistoryOutlined +} from '@ant-design/icons' +import * as examApi from '../api/exam' +import styles from './ExamManagement.module.less' + +interface ExamListItem { + id: number + title: string + total_score: number + duration: number + pass_score: number + question_count: number + attempt_count: number + best_score: number + has_in_progress_exam: boolean + in_progress_record_id?: number + created_at: string +} + +const ExamManagement: React.FC = () => { + const navigate = useNavigate() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [exams, setExams] = useState([]) + const [loadingExams, setLoadingExams] = useState(false) + const [createModalVisible, setCreateModalVisible] = useState(false) + const [recordsDrawerVisible, setRecordsDrawerVisible] = useState(false) + const [, setCurrentExamId] = useState(null) + const [examRecords, setExamRecords] = useState([]) + const [loadingRecords, setLoadingRecords] = useState(false) + + // 加载试卷列表 + const loadExams = async () => { + setLoadingExams(true) + try { + const res = await examApi.getExamList() + if (res.success) { + setExams(res.data || []) + } + } catch (error) { + message.error('加载试卷列表失败') + } finally { + setLoadingExams(false) + } + } + + useEffect(() => { + loadExams() + }, []) + + // 创建试卷 + const handleCreateExam = async (values: any) => { + setLoading(true) + try { + const params = { + title: values.title, + duration: 60, // 默认60分钟 + question_types: [] // 空配置,后端会使用默认值 + } + + const res = await examApi.createExam(params) + if (res.success) { + message.success('试卷创建成功') + setCreateModalVisible(false) + form.resetFields() + loadExams() + } 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 handleDeleteExam = async (examId: number) => { + Modal.confirm({ + title: '确认删除', + content: '删除试卷后将无法恢复,是否确认删除?', + okText: '确认', + cancelText: '取消', + okType: 'danger', + onOk: async () => { + try { + const res = await examApi.deleteExam(examId) + if (res.success) { + message.success('删除成功') + loadExams() + } else { + message.error(res.message || '删除失败') + } + } catch (error) { + message.error('删除失败') + } + } + }) + } + + // 开始考试 + const handleStartExam = async (examId: number, hasInProgressExam: boolean, inProgressRecordId?: number) => { + try { + if (hasInProgressExam && inProgressRecordId) { + // 有未完成的考试,直接跳转继续答题 + navigate(`/exam/${examId}/taking/${inProgressRecordId}`) + } else { + // 没有未完成的考试,调用开始考试API创建新记录 + const res = await examApi.startExam(examId) + if (res.success && res.data) { + navigate(`/exam/${examId}/taking/${res.data.record_id}`) + } else { + message.error(res.message || '开始考试失败') + } + } + } catch (error) { + message.error('开始考试失败') + } + } + + // 查看考试记录 + const handleViewRecords = async (examId: number) => { + setCurrentExamId(examId) + setRecordsDrawerVisible(true) + setLoadingRecords(true) + try { + const res = await examApi.getExamRecordList(examId) + if (res.success && res.data) { + setExamRecords(res.data) + } + } catch (error) { + message.error('加载考试记录失败') + } finally { + setLoadingRecords(false) + } + } + + // 查看记录详情 + const handleViewRecordDetail = (recordId: number) => { + navigate(`/exam/result/${recordId}`) + } + + return ( +
+ +
+
+

模拟考试

+

创建和管理模拟试卷

+
+ +
+ + + {exams.length === 0 ? ( + + ) : ( + ( + + } + onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)} + > + {exam.has_in_progress_exam ? '继续考试' : '开始考试'} + , + , + + ]} + > + + + {exam.title} + {exam.has_in_progress_exam && ( + 进行中 + )} +
+ } + description={ +
+ + +
+ + {exam.duration} 分钟 +
+ + +
+ + 及格 {exam.pass_score} 分 +
+ +
+
+ } color="gold"> + 最高分: {exam.best_score || 0} + + 已考 {exam.attempt_count} 次 +
+
+ } + /> + + + )} + /> + )} + + + + {/* 考试记录抽屉 */} + setRecordsDrawerVisible(false)} + > + + {examRecords.length === 0 ? ( + + ) : ( + ( + + + + {record.status === 'in_progress' && 进行中} + {record.status === 'submitted' && 已提交} + {record.status === 'graded' && ( + + {record.is_passed ? '已通过' : '未通过'} + + )} + + + {record.status === 'in_progress' ? ( + - + ) : ( + + {record.score} / {record.total_score} + + )} + + + {record.start_time ? new Date(record.start_time).toLocaleString() : '-'} + + + {record.submit_time ? new Date(record.submit_time).toLocaleString() : '-'} + + + {record.time_spent ? `${Math.floor(record.time_spent / 60)} 分 ${record.time_spent % 60} 秒` : '-'} + + + + + {record.status === 'in_progress' && ( + + )} + {record.status !== 'in_progress' && ( + + )} + + + )} + /> + )} + + + + {/* 创建试卷模态框 */} + { + setCreateModalVisible(false) + form.resetFields() + }} + footer={null} + width={500} + > +
+ + + + + + + + + + +
+
+ + ) +} + +export default ExamManagement diff --git a/web/src/pages/ExamResultNew.module.less b/web/src/pages/ExamResultNew.module.less new file mode 100644 index 0000000..1410a7b --- /dev/null +++ b/web/src/pages/ExamResultNew.module.less @@ -0,0 +1,221 @@ +.loadingContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; + + :global(.ant-result) { + padding: 40px 32px; + } +} + +.statsCard { + margin-bottom: 20px; + + .passMark { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #f0f0f0; + text-align: center; + } +} + +.typeScoreCard { + margin-bottom: 20px; + + .typeScoreItem { + padding: 16px; + background: #fafafa; + border-radius: 8px; + transition: all 0.3s ease; + + &:hover { + background: #f0f5ff; + box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15); + } + + .typeScoreHeader { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + } + + .typeScoreContent { + display: flex; + align-items: baseline; + margin-bottom: 12px; + } + + .typeScoreProgress { + width: 100%; + height: 8px; + background: #e8e8e8; + border-radius: 4px; + overflow: hidden; + + .typeScoreBar { + height: 100%; + border-radius: 4px; + transition: width 0.6s ease; + } + } + } +} + +.detailCard { + margin-bottom: 20px; + + .panelHeader { + display: flex; + align-items: center; + gap: 8px; + } + + .questionItem { + .questionNumber { + display: inline-block; + background: #1890ff; + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + margin-bottom: 8px; + } + + .answerDetail { + .questionContent { + padding: 12px; + background: #fafafa; + border-radius: 4px; + margin-bottom: 16px; + } + + .answerSection { + padding-left: 12px; + + .answerItem { + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + + .correct { + color: #52c41a; + font-weight: 500; + } + + .incorrect { + color: #ff4d4f; + font-weight: 500; + } + } + + .aiGrading { + margin-top: 16px; + padding: 12px; + background: #f6ffed; + border: 1px solid #b7eb8f; + border-radius: 4px; + } + } + } + } +} + +.actionsCard { + text-align: center; +} + +// 响应式适配 +@media (max-width: 768px) { + .container { + padding: 12px; + + :global(.ant-result) { + padding: 24px 16px; + + :global(.ant-result-title) { + font-size: 18px; + } + } + } + + .statsCard { + :global(.ant-col) { + margin-bottom: 16px; + } + + :global(.ant-statistic) { + :global(.ant-statistic-title) { + font-size: 13px; + } + + :global(.ant-statistic-content) { + font-size: 20px; + } + } + } + + .typeScoreCard { + .typeScoreItem { + padding: 12px; + + .typeScoreHeader { + gap: 2px; + margin-bottom: 8px; + + :global(.ant-typography) { + font-size: 14px !important; + } + } + + .typeScoreContent { + margin-bottom: 8px; + + :global(.ant-typography) { + &:first-child { + font-size: 22px !important; + } + &:last-child { + font-size: 14px !important; + } + } + } + + .typeScoreProgress { + height: 6px; + } + } + } + + .detailCard { + .questionItem { + .answerSection { + padding-left: 0; + + .answerItem { + flex-wrap: wrap; + font-size: 14px; + } + } + } + } + + .actionsCard { + :global(.ant-space) { + width: 100%; + flex-direction: column; + + button { + width: 100%; + } + } + } +} diff --git a/web/src/pages/ExamResultNew.tsx b/web/src/pages/ExamResultNew.tsx new file mode 100644 index 0000000..71ea638 --- /dev/null +++ b/web/src/pages/ExamResultNew.tsx @@ -0,0 +1,498 @@ +import React, { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + Card, + Result, + Button, + Typography, + Tag, + Space, + Spin, + message, + Row, + Col, + Statistic, + Divider +} from 'antd' +import { + CheckCircleOutlined, + CloseCircleOutlined, + TrophyOutlined, + ClockCircleOutlined, + FileTextOutlined, + HomeOutlined +} from '@ant-design/icons' +import * as examApi from '../api/exam' +import type { ExamRecordResponse, ExamAnswer } from '../types/exam' +import type { Question } from '../types/question' +import styles from './ExamResultNew.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 ExamResultNew: React.FC = () => { + const { recordId } = useParams<{ recordId: string }>() + const navigate = useNavigate() + + const [loading, setLoading] = useState(true) + const [data, setData] = useState(null) + const [questions, setQuestions] = useState([]) + + useEffect(() => { + if (!recordId) { + message.error('参数错误') + navigate('/exam/management') + return + } + + loadResult() + }, [recordId]) + + const loadResult = async () => { + setLoading(true) + try { + const recordRes = await examApi.getExamRecord(Number(recordId)) + + if (recordRes.success && recordRes.data) { + setData(recordRes.data) + + // 获取试卷详情 + if (recordRes.data.record.exam?.id) { + const examRes = await examApi.getExamDetail(recordRes.data.record.exam.id) + if (examRes.success && examRes.data) { + setQuestions(examRes.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 (!data) { + return null + } + + const { record, answers } = data + const isPassed = record.is_passed + // 总分统一为100分 + const scorePercent = record.score + + // 构建答案映射 + const answerMap = new Map() + answers.forEach(ans => { + answerMap.set(ans.question_id, ans) + }) + + // 统计正确率 + const correctCount = answers.filter(a => a.is_correct).length + const totalCount = answers.length + const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0 + + // 格式化时间 + const formatTime = (seconds: number) => { + const totalSeconds = Math.floor(seconds) // 确保是整数 + const minutes = Math.floor(totalSeconds / 60) + const secs = totalSeconds % 60 + return `${minutes}分${secs}秒` + } + + // 渲染答案详情 + const renderAnswerDetail = (question: Question, answer: ExamAnswer) => { + const isCorrect = answer.is_correct + + return ( +
+ {/* 题目内容 - 填空题特殊处理 */} +
+ {question.type === 'fill-in-blank' ? ( + + {renderFillInBlankQuestion(question.content)} + + ) : ( + + {question.content} + + )} +
+ +
+ + {/* 用户答案 */} +
+ + 你的答案: + + {formatAnswer(answer.answer, question.type)} + + {isCorrect ? ( + + ) : ( + + )} + +
+ + {/* 正确答案 */} +
+ + 正确答案: + + {formatAnswer(answer.correct_answer, question.type)} + + +
+ + {/* 得分 */} +
+ + 得分: + + {answer.score.toFixed(1)} 分 + + +
+ + {/* AI评分详情 */} + {answer.ai_grading && ( +
+
+ AI评分详情: +
+ +
+ AI得分: + {answer.ai_grading.score} / 100 +
+
+ 评语: + {answer.ai_grading.feedback} +
+ {answer.ai_grading.suggestion && ( +
+ 改进建议: + {answer.ai_grading.suggestion} +
+ )} +
+
+ )} +
+
+
+ ) + } + + // 渲染填空题题目(将 **** 替换为下划线) + const renderFillInBlankQuestion = (content: string) => { + const parts = content.split('****') + return ( + + {parts.map((part, i) => ( + + {part} + {i < parts.length - 1 && ( + +          + + )} + + ))} + + ) + } + + // 格式化用户答案 + 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 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) + + // 计算各题型得分(已在 groupedQuestions 中合并论述题) + const typeScores = Object.entries(groupedQuestions) + .map(([type, qs]) => { + const typeAnswers = qs.map(q => answerMap.get(q.id)).filter(Boolean) as ExamAnswer[] + const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0) + const maxScore = typeAnswers.length * ( + type === 'fill-in-blank' ? 2.0 : + type === 'true-false' ? 2.0 : + type === 'multiple-choice' ? 1.0 : + type === 'multiple-selection' ? 2.5 : + type === 'short-answer' ? 10.0 : + (type === 'essay' || type === 'ordinary-essay' || type === 'management-essay') ? 5.0 : 0 + ) + const correctCount = typeAnswers.filter(ans => ans.is_correct).length + + return { + type, + typeName: TYPE_NAME[type] || type, + totalScore, + maxScore, + correctCount, + totalCount: typeAnswers.length, + order: TYPE_ORDER[type] || 999 + } + }) + .sort((a, b) => a.order - b.order) + + return ( +
+ {/* 成绩展示 */} + + + {record.exam?.title || '模拟考试'} + + + } + /> + + {/* 成绩统计 */} + + + + } + valueStyle={{ color: isPassed ? '#52c41a' : '#ff4d4f', fontSize: 32 }} + /> + + + } + valueStyle={{ color: '#1890ff', fontSize: 32 }} + /> + + + } + valueStyle={{ fontSize: 32 }} + /> + + +
+
+ 考试状态 +
+ + {isPassed ? '已通过' : '未通过'} + +
+ 及格分数:{record.exam?.pass_score || 60} 分 +
+
+ +
+
+ + {/* 各题型得分情况 */} + 题型得分统计} + className={styles.typeScoreCard} + > + + {typeScores.map(ts => ( + +
+
+ {ts.typeName} + + ({ts.correctCount}/{ts.totalCount}题正确) + +
+
+ + {ts.totalScore.toFixed(1)} + + + / {ts.maxScore.toFixed(1)} + +
+
+
+
+
+ + ))} + + + + {/* 答题详情 - 直接展示,不使用折叠 */} + 答题详情} 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]) => ( +
+ {/* 题型标题 */} +
+ + {TYPE_NAME[type] || type} + (共 {qs.length} 题) + +
+ + {/* 题目列表 */} + + {qs.map((q, idx) => { + const ans = answerMap.get(q.id) + if (!ans) return null + return ( + +
+ 第 {idx + 1} 题 +
+ {renderAnswerDetail(q, ans)} +
+ ) + })} +
+ + {/* 题型之间的分隔线 */} + +
+ ))} +
+ + {/* 操作按钮 */} + + + + {record.exam?.id && ( + + )} + + +
+ ) +} + +export default ExamResultNew diff --git a/web/src/pages/ExamTaking.module.less b/web/src/pages/ExamTaking.module.less new file mode 100644 index 0000000..3f58f49 --- /dev/null +++ b/web/src/pages/ExamTaking.module.less @@ -0,0 +1,400 @@ +.loadingContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; +} + +.container { + padding: 20px; + max-width: 1200px; + margin: 0 auto; + background: #f5f5f5; + min-height: 100vh; +} + +.header { + margin-bottom: 20px; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + border-radius: 8px; + overflow: hidden; + background: #fff; + + :global(.ant-card-body) { + padding: 20px 24px; + } + + .headerContent { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + + h3 { + margin: 0 0 8px 0; + color: #1f2937; + font-weight: 600; + } + + > div:first-child { + :global(.ant-typography) { + color: rgba(0, 0, 0, 0.65); + } + } + } + + .stats { + display: flex; + align-items: center; + gap: 24px; + + :global(.ant-statistic-title) { + color: rgba(0, 0, 0, 0.65); + } + + :global(.ant-statistic-content) { + color: #1f2937; + font-weight: 600; + } + + :global(.anticon) { + color: #1890ff; + } + + :global(.ant-divider-vertical) { + background: #e5e7eb; + } + + .statCard { + display: flex; + flex-direction: column; + min-width: 120px; + + .statLabel { + font-size: 14px; + margin-bottom: 8px; + } + + .statValue { + display: flex; + align-items: center; + gap: 8px; + + :global(.ant-typography) { + color: #1f2937; + } + } + } + + .progressInfo { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 8px; + + :global(.ant-typography) { + color: #1f2937; + } + } + } +} + +.content { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + border-radius: 8px; + overflow: hidden; + background: #fff; + + :global(.ant-card-body) { + padding: 32px; + } + + .questionContainer { + min-height: 400px; + padding: 24px 0; + + .questionHeader { + margin-bottom: 20px; + + :global(.ant-tag) { + padding: 4px 16px; + font-size: 14px; + border-radius: 4px; + } + } + + :global(.ant-form-item-label > label) { + font-size: 16px; + font-weight: 500; + color: #1f2937; + line-height: 1.6; + } + + :global(.ant-input), + :global(.ant-input-textarea) { + border-radius: 4px; + border: 1px solid #d9d9d9; + + &:hover { + border-color: #40a9ff; + } + + &:focus { + border-color: #40a9ff; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); + } + } + + :global(.ant-radio-wrapper), + :global(.ant-checkbox-wrapper) { + font-size: 15px; + padding: 4px 0; + } + } + + .navigation { + display: flex; + justify-content: center; + align-items: center; + padding: 24px 0; + border-top: 1px solid #f0f0f0; + } + + .questionGroup { + margin-bottom: 32px; + + .groupHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + h4 { + margin: 0; + } + } + + :global { + .ant-form-item-label > label { + font-size: 15px; + font-weight: 500; + color: rgba(0, 0, 0, 0.85); + } + } + } + + .actions { + display: flex; + justify-content: center; + padding: 32px 0; + border-top: 1px solid #f0f0f0; + } +} + +// 抽屉样式 +.drawerContent { + .questionTypeSection { + margin-bottom: 12px; + + .typeHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + background: #fafafa; + border-radius: 4px; + margin-bottom: 8px; + + :global(.ant-typography) { + margin: 0; + } + } + } + + .questionGrid { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 8px; + padding: 8px 0; + + .questionItem { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #d9d9d9; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; + font-size: 13px; + background: #fff; + + &:hover { + border-color: #1890ff; + color: #1890ff; + } + + &.current { + border-color: #1890ff; + background: #1890ff; + color: #fff; + } + + &.answered:not(.current) { + border-color: #52c41a; + background: #f6ffed; + color: #52c41a; + } + } + } + + .legend { + display: flex; + justify-content: space-around; + align-items: center; + padding: 16px 0 0 0; + margin-top: 12px; + border-top: 1px solid #f0f0f0; + + .legendItem { + display: flex; + align-items: center; + gap: 8px; + + .legendBox { + width: 20px; + height: 20px; + border: 1px solid #d9d9d9; + border-radius: 4px; + background: #fff; + flex-shrink: 0; + + &.current { + border-color: #1890ff; + background: #1890ff; + } + + &.answered { + border-color: #52c41a; + background: #f6ffed; + } + + &.unanswered { + border-color: #d9d9d9; + background: #fff; + } + } + + :global(.ant-typography) { + font-size: 13px; + color: rgba(0, 0, 0, 0.65); + } + } + } +} + +// 响应式适配 +@media (max-width: 768px) { + .container { + padding: 12px; + } + + .header { + .headerContent { + flex-direction: column; + align-items: flex-start; + gap: 16px; + + h3 { + font-size: 18px; + } + } + + .stats { + width: 100%; + justify-content: space-between; + gap: 16px; + + :global(.ant-divider) { + display: none; + } + + :global(.ant-statistic) { + :global(.ant-statistic-title) { + font-size: 13px; + } + + :global(.ant-statistic-content) { + font-size: 20px; + } + } + + .progressInfo { + :global(.ant-typography) { + font-size: 20px; + } + } + } + } + + .content { + .questionContainer { + min-height: 300px; + padding: 16px 0; + } + + .navigation { + padding: 16px 0; + + :global(.ant-space) { + width: 100%; + justify-content: space-between; + } + } + + .questionGroup { + .groupHeader { + h4 { + font-size: 16px; + } + } + + :global { + .ant-form-item-label > label { + font-size: 14px; + } + } + } + + .actions { + padding: 20px 0; + + :global(.ant-space) { + width: 100%; + flex-direction: column; + + button { + width: 100%; + } + } + } + } + + // 抽屉响应式 + .drawerContent { + .questionGrid { + grid-template-columns: repeat(10, 1fr); + gap: 8px; + + .questionItem { + width: 36px; + height: 36px; + } + } + } +} diff --git a/web/src/pages/ExamTaking.tsx b/web/src/pages/ExamTaking.tsx new file mode 100644 index 0000000..60ed048 --- /dev/null +++ b/web/src/pages/ExamTaking.tsx @@ -0,0 +1,728 @@ +import React, { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + Card, + Form, + Input, + Radio, + Checkbox, + Button, + Typography, + message, + Spin, + Space, + Divider, + Modal, + Statistic, + FloatButton, + Drawer, + Tag +} from 'antd' +import { + ArrowLeftOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + ExclamationCircleOutlined, + LeftOutlined, + RightOutlined, + UnorderedListOutlined, +} from '@ant-design/icons' +import * as examApi from '../api/exam' +import type { Question } from '../types/question' +import type { ExamDetailResponse } from '../types/exam' +import styles from './ExamTaking.module.less' + +const { Title, Text } = Typography +const { TextArea } = Input +const { Countdown } = Statistic + +// 题型名称映射 +const TYPE_NAME: Record = { + 'fill-in-blank': '填空题', + 'true-false': '判断题', + 'multiple-choice': '单选题', + 'multiple-selection': '多选题', + 'short-answer': '简答题', + 'ordinary-essay': '论述题', + 'management-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 +} + +const ExamTaking: React.FC = () => { + const { examId, recordId } = useParams<{ examId: string; recordId: string }>() + const navigate = useNavigate() + const [form] = Form.useForm() + + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [examData, setExamData] = useState(null) + const [groupedQuestions, setGroupedQuestions] = useState>({}) + const [answeredCount, setAnsweredCount] = useState(0) + const [endTime, setEndTime] = useState(0) + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) + const [drawerVisible, setDrawerVisible] = useState(false) + + // 加载考试详情 + useEffect(() => { + if (!examId || !recordId) { + message.error('参数错误') + navigate('/exam/management') + return + } + + const loadExam = async () => { + setLoading(true) + try { + const res = await examApi.getExamDetail(Number(examId)) + if (res.success && res.data) { + // 获取用户信息,判断用户类型 + const userStr = localStorage.getItem('user') + const user = userStr ? JSON.parse(userStr) : null + const userType = user?.user_type // 'ordinary-person' 或 'management-person' + + // 过滤题目:根据用户类型只保留对应的论述题 + let filteredQuestions = res.data.questions.filter(q => { + if (q.type === 'ordinary-essay') { + return userType === 'ordinary-person' + } + if (q.type === 'management-essay') { + return userType === 'management-person' + } + return true // 其他题目全部保留 + }) + + // 按照题型顺序排序题目 + filteredQuestions.sort((a, b) => { + const orderA = TYPE_ORDER[a.type] || 999 + const orderB = TYPE_ORDER[b.type] || 999 + return orderA - orderB + }) + + setExamData({ + ...res.data, + questions: filteredQuestions + }) + + // 按题型分组 + const grouped = groupQuestionsByType(filteredQuestions) + setGroupedQuestions(grouped) + + // 检查是否有保存的剩余时间(暂停状态) + const savedProgress = localStorage.getItem(`exam_progress_${recordId}`) + if (savedProgress) { + try { + const progress = JSON.parse(savedProgress) + if (progress.remainingTime) { + // 恢复暂停时的剩余时间 + setEndTime(Date.now() + progress.remainingTime) + } else { + // 没有暂停记录,使用完整考试时长 + const duration = res.data.exam.duration * 60 * 1000 + setEndTime(Date.now() + duration) + } + } catch (e) { + console.error('解析进度失败', e) + const duration = res.data.exam.duration * 60 * 1000 + setEndTime(Date.now() + duration) + } + } else { + // 首次进入,使用完整考试时长 + const duration = res.data.exam.duration * 60 * 1000 + setEndTime(Date.now() + duration) + } + + // 恢复答题进度(先从服务器,再从localStorage) + await loadProgressFromServer() + } else { + message.error('加载考试失败') + navigate('/exam/management') + } + } catch (error: any) { + message.error(error.response?.data?.message || '加载考试失败') + navigate('/exam/management') + } finally { + setLoading(false) + } + } + + loadExam() + }, [examId, recordId, navigate]) + + // 定时保存进度(每30秒) + useEffect(() => { + if (!recordId) return + + const interval = setInterval(() => { + saveCurrentQuestionToServer() + }, 30000) // 30秒 + + return () => clearInterval(interval) + }, [recordId, currentQuestionIndex, examData]) // 依赖当前题目索引 + + // 按题型分组题目 + const groupQuestionsByType = (questions: Question[]) => { + const grouped: Record = {} + questions.forEach((q) => { + if (!grouped[q.type]) { + grouped[q.type] = [] + } + grouped[q.type].push(q) + }) + return grouped + } + + // 保存当前题目答案到数据库 + const saveCurrentQuestionToServer = async () => { + if (!recordId || !examData || currentQuestionIndex < 0) return + + try { + const currentQuestion = examData.questions[currentQuestionIndex] + const fieldName = `q_${currentQuestion.id}` + const answer = form.getFieldValue(fieldName) + + // 只有当答案不为空时才保存 + if (answer !== undefined && answer !== null && answer !== '') { + await examApi.saveExamProgress(Number(recordId), { + question_id: currentQuestion.id, + answer: answer + }) + } + } catch (error: any) { + console.error('保存答案失败:', error) + // 静默失败,不打扰用户 + } + } + + // 保存答题进度(仅localStorage,用于快速保存) + const saveProgress = () => { + if (!recordId) return + const values = form.getFieldsValue() + const remaining = endTime - Date.now() + const progress = { + answers: values, + remainingTime: remaining > 0 ? remaining : 0, + timestamp: Date.now(), + } + localStorage.setItem(`exam_progress_${recordId}`, JSON.stringify(progress)) + } + + // 从服务器恢复答题进度 + const loadProgressFromServer = async () => { + if (!recordId) return + + try { + // 1. 先尝试从服务器加载答案 + const res = await examApi.getExamUserAnswers(Number(recordId)) + if (res.success && res.data && Object.keys(res.data).length > 0) { + form.setFieldsValue(res.data) + updateAnsweredCount(res.data) + message.success('已恢复服务器保存的答题进度') + return + } + } catch (error: any) { + console.error('从服务器恢复进度失败:', error) + } + + // 2. 如果服务器没有数据,尝试从localStorage恢复 + const saved = localStorage.getItem(`exam_progress_${recordId}`) + if (saved) { + try { + const progress = JSON.parse(saved) + if (progress.answers) { + form.setFieldsValue(progress.answers) + updateAnsweredCount(progress.answers) + message.success('已恢复本地保存的答题进度') + } + } catch (e) { + console.error('恢复本地进度失败', e) + } + } + } + + // 更新已答题目数量 + const updateAnsweredCount = (values: any) => { + let count = 0 + Object.values(values).forEach((val: any) => { + if (val !== undefined && val !== null && val !== '') { + if (Array.isArray(val) && val.length > 0) { + count++ + } else if (!Array.isArray(val)) { + count++ + } + } + }) + setAnsweredCount(count) + } + + // 监听表单变化 + const handleFormChange = () => { + const values = form.getFieldsValue() + updateAnsweredCount(values) + saveProgress() + } + + // 提交考试 + const handleSubmit = async () => { + const totalQuestions = examData?.questions.length || 0 + const unanswered = totalQuestions - answeredCount + + // 先保存当前题目答案 + await saveCurrentQuestionToServer() + + if (unanswered > 0) { + Modal.confirm({ + title: '确认提交', + icon: , + content: `还有 ${unanswered} 道题未作答,确认提交吗?`, + okText: '确认提交', + cancelText: '继续答题', + onOk: () => submitExam() + }) + } else { + Modal.confirm({ + title: '确认提交', + icon: , + content: '已完成所有题目,确认提交吗?', + okText: '确认提交', + cancelText: '检查答案', + onOk: () => submitExam() + }) + } + } + + // 提交答案并触发阅卷 + const submitExam = async () => { + setSubmitting(true) + try { + // 直接提交,后端会从数据库读取答案并阅卷 + const res = await examApi.submitExamAnswer(Number(recordId), {}) + if (res.success) { + message.success('提交成功,正在阅卷...') + // 清除进度 + localStorage.removeItem(`exam_progress_${recordId}`) + // 跳转到试卷列表页面 + navigate('/exam/management') + } else { + message.error(res.message || '提交失败') + } + } catch (error: any) { + message.error(error.response?.data?.message || '提交失败') + } finally { + setSubmitting(false) + } + } + + // 时间到自动提交 + const handleTimeFinish = async () => { + message.warning('考试时间已到,系统将自动提交') + await saveCurrentQuestionToServer() // 先保存当前答案 + submitExam() + } + + // 上一题 + const handlePrevQuestion = async () => { + if (currentQuestionIndex > 0) { + await saveCurrentQuestionToServer() // 保存当前题目答案到服务器 + setCurrentQuestionIndex(currentQuestionIndex - 1) + } + } + + // 下一题 + const handleNextQuestion = async () => { + if (examData && currentQuestionIndex < examData.questions.length - 1) { + await saveCurrentQuestionToServer() // 保存当前题目答案到服务器 + setCurrentQuestionIndex(currentQuestionIndex + 1) + } + } + + // 跳转到指定题目 + const handleJumpToQuestion = (index: number) => { + setCurrentQuestionIndex(index) + setDrawerVisible(false) + } + + // 检查题目是否已答 + const isQuestionAnswered = (question: Question): boolean => { + const fieldName = `q_${question.id}` + const value = form.getFieldValue(fieldName) + + if (value === undefined || value === null || value === '') { + return false + } + + if (Array.isArray(value)) { + // 处理多选题和填空题 + return value.length > 0 && value.some(v => v !== undefined && v !== null && v !== '') + } + + return true + } + + // 渲染题目 + const renderQuestion = (question: Question) => { + const fieldName = `q_${question.id}` + + switch (question.type) { + case 'fill-in-blank': + // 将题目按 **** 分割,在占位符位置插入输入框 + const parts = question.content.split('****') + const blankCount = parts.length - 1 // 填空数量 = 分割后的部分数 - 1 + + if (blankCount === 0) { + // 如果没有 ****,显示警告并提供一个文本框 + return ( + +
+ + 提示:题目格式错误,缺少 **** 占位符,请联系管理员修正。 + +
+ + + +
+ ) + } + + return ( + +
+ {parts.map((part, i) => ( + + {part} + {i < blankCount && ( + + + + )} + + ))} +
+
+ ) + + case 'true-false': + return ( + + + + 正确 + 错误 + + + + ) + + case 'multiple-choice': + return ( + + + + {question.options?.map((opt) => ( + + {opt.key}. {opt.value} + + ))} + + + + ) + + case 'multiple-selection': + return ( + + + + {question.options?.map((opt) => ( + + {opt.key}. {opt.value} + + ))} + + + + ) + + case 'short-answer': + case 'ordinary-essay': + case 'management-essay': + return ( + +