AnCao/internal/handlers/practice_handler.go
yanlongqi de8480a328 重构答题系统:组件拆分、进度跟踪、完成统计
主要改动:
1. 组件拆分:将Question.tsx(605行)拆分为4个子组件(303行)
   - QuestionProgress: 进度条和统计显示
   - QuestionCard: 题目卡片和答题界面
   - AnswerResult: 答案结果展示
   - CompletionSummary: 完成统计摘要

2. 新增功能:
   - 答题进度条:显示当前进度、正确数、错误数
   - 进度保存:使用localStorage持久化答题进度
   - 完成统计:答完所有题目后显示统计摘要和正确率
   - 从第一题开始:改为顺序答题而非随机

3. UI优化:
   - 移除右上角统计按钮
   - 移除底部随机题目、题目列表、筛选按钮
   - 移除"开始xxx答题"提示消息
   - 简化页面布局

4. 代码优化:
   - 提高代码可维护性和可测试性
   - 单一职责原则,每个组件负责一个特定功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 18:39:15 +08:00

429 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"ankao/internal/database"
"ankao/internal/models"
"encoding/json"
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// GetPracticeQuestions 获取练习题目列表
func GetPracticeQuestions(c *gin.Context) {
typeParam := c.Query("type")
category := c.Query("category")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 100
}
db := database.GetDB()
var questions []models.PracticeQuestion
var total int64
query := db.Model(&models.PracticeQuestion{})
// 根据题型过滤 - 直接使用前端传来的type
if typeParam != "" {
query = query.Where("type = ?", typeParam)
}
// 根据分类过滤
if category != "" {
query = query.Where("type_name = ?", category)
}
// 获取总数
query.Count(&total)
// 分页查询
offset := (page - 1) * pageSize
err := query.Offset(offset).Limit(pageSize).Find(&questions).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "查询题目失败",
})
return
}
// 转换为DTO
dtos := make([]models.PracticeQuestionDTO, len(questions))
for i, q := range questions {
dto := convertToDTO(q)
dtos[i] = dto
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dtos,
"total": total,
})
}
// GetPracticeQuestionByID 获取单个练习题目
func GetPracticeQuestionByID(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的题目ID",
})
return
}
db := database.GetDB()
var question models.PracticeQuestion
if err := db.First(&question, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "题目不存在",
})
return
}
// 转换为DTO
dto := convertToDTO(question)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto,
})
}
// GetRandomPracticeQuestion 获取随机练习题目
func GetRandomPracticeQuestion(c *gin.Context) {
typeParam := c.Query("type")
db := database.GetDB()
var question models.PracticeQuestion
query := db.Model(&models.PracticeQuestion{})
if typeParam != "" {
query = query.Where("type = ?", typeParam)
}
// 使用PostgreSQL的随机排序
if err := query.Order("RANDOM()").First(&question).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "暂无题目",
})
return
}
// 转换为DTO
dto := convertToDTO(question)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto,
})
}
// SubmitPracticeAnswer 提交练习答案
func SubmitPracticeAnswer(c *gin.Context) {
var submit models.PracticeAnswerSubmit
if err := c.ShouldBindJSON(&submit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误",
})
return
}
// 获取用户ID认证中间件已确保存在
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
db := database.GetDB()
var question models.PracticeQuestion
if err := db.First(&question, submit.QuestionID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "题目不存在",
})
return
}
// 解析正确答案
var correctAnswer interface{}
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "答案数据错误",
})
return
}
// 验证答案
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
// 记录用户答题历史
if uid, ok := userID.(uint); ok {
record := models.UserAnswerRecord{
UserID: uid,
QuestionID: question.ID,
IsCorrect: correct,
AnsweredAt: time.Now(),
}
// 记录到数据库(忽略错误,不影响主流程)
if err := db.Create(&record).Error; err != nil {
log.Printf("记录答题历史失败: %v", err)
}
}
// 如果答错,记录到错题本
if !correct {
if uid, ok := userID.(uint); ok {
// 记录错题
if err := recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer); err != nil {
// 记录错题失败不影响主流程,只记录日志
log.Printf("记录错题失败: %v", err)
}
}
}
result := models.PracticeAnswerResult{
Correct: correct,
UserAnswer: submit.Answer,
CorrectAnswer: correctAnswer, // 始终返回正确答案
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
// GetPracticeQuestionTypes 获取题型列表
func GetPracticeQuestionTypes(c *gin.Context) {
types := []gin.H{
{
"type": "fill-in-blank",
"type_name": "填空题",
},
{
"type": "true-false",
"type_name": "判断题",
},
{
"type": "multiple-choice",
"type_name": "选择题",
},
{
"type": "multiple-selection",
"type_name": "多选题",
},
{
"type": "short-answer",
"type_name": "简答题",
},
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": types,
})
}
// checkPracticeAnswer 检查练习答案是否正确
func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool {
switch questionType {
case "true-false":
// 判断题: boolean 比较
userBool, ok1 := userAnswer.(bool)
correctBool, ok2 := correctAnswer.(bool)
return ok1 && ok2 && userBool == correctBool
case "multiple-choice":
// 单选题: 字符串比较
userStr, ok1 := userAnswer.(string)
correctStr, ok2 := correctAnswer.(string)
return ok1 && ok2 && userStr == correctStr
case "multiple-selection":
// 多选题: 数组比较
userArr, ok1 := toStringArray(userAnswer)
correctArr, ok2 := toStringArray(correctAnswer)
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
return false
}
// 转换为map进行比较
userMap := make(map[string]bool)
for _, v := range userArr {
userMap[v] = true
}
for _, v := range correctArr {
if !userMap[v] {
return false
}
}
return true
case "fill-in-blank":
// 填空题: 数组比较
userArr, ok1 := toStringArray(userAnswer)
correctArr, ok2 := toStringArray(correctAnswer)
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
return false
}
// 逐个比较填空答案
for i := range correctArr {
if userArr[i] != correctArr[i] {
return false
}
}
return true
case "short-answer":
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
userStr, ok1 := userAnswer.(string)
correctStr, ok2 := correctAnswer.(string)
return ok1 && ok2 && userStr == correctStr
}
return false
}
// toStringArray 将interface{}转换为字符串数组
func toStringArray(v interface{}) ([]string, bool) {
switch arr := v.(type) {
case []string:
return arr, true
case []interface{}:
result := make([]string, len(arr))
for i, item := range arr {
if str, ok := item.(string); ok {
result[i] = str
} else {
return nil, false
}
}
return result, true
default:
return nil, false
}
}
// convertToDTO 将数据库模型转换为前端DTO
func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
dto := models.PracticeQuestionDTO{
ID: question.ID,
QuestionID: question.QuestionID,
Type: question.Type, // 直接使用数据库中的type不做映射
Content: question.Question,
Category: question.TypeName, // 使用typeName作为分类显示
Options: []models.Option{},
}
// 判断题自动生成选项
if question.Type == "true-false" {
dto.Options = []models.Option{
{Key: "true", Value: "正确"},
{Key: "false", Value: "错误"},
}
return dto
}
// 解析选项数据(如果有)
if question.OptionsData != "" {
var optionsMap map[string]string
if err := json.Unmarshal([]byte(question.OptionsData), &optionsMap); err == nil {
// 将map转换为Option数组
for key, value := range optionsMap {
dto.Options = append(dto.Options, models.Option{
Key: key,
Value: value,
})
}
}
}
return dto
}
// GetStatistics 获取用户统计数据
func GetStatistics(c *gin.Context) {
// 获取用户ID
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
uid, ok := userID.(uint)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "用户ID格式错误",
})
return
}
db := database.GetDB()
// 获取题库总数
var totalQuestions int64
db.Model(&models.PracticeQuestion{}).Count(&totalQuestions)
// 获取用户已答题数(去重)
var answeredQuestions int64
db.Model(&models.UserAnswerRecord{}).
Where("user_id = ?", uid).
Distinct("question_id").
Count(&answeredQuestions)
// 获取用户答对题数
var correctAnswers int64
db.Model(&models.UserAnswerRecord{}).
Where("user_id = ? AND is_correct = ?", uid, true).
Count(&correctAnswers)
// 计算正确率
var accuracy float64
if answeredQuestions > 0 {
// 正确率 = 答对题数 / 总答题数
var totalAnswers int64
db.Model(&models.UserAnswerRecord{}).
Where("user_id = ?", uid).
Count(&totalAnswers)
accuracy = float64(correctAnswers) / float64(totalAnswers) * 100
}
stats := models.UserStatistics{
TotalQuestions: int(totalQuestions),
AnsweredQuestions: int(answeredQuestions),
CorrectAnswers: int(correctAnswers),
Accuracy: accuracy,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}