AnCao/internal/handlers/wrong_question_handler.go
yanlongqi 2fbeb23947 优化错题本功能和UI设计
1. 错题本系统重构:
   - 新增错题服务层 (wrong_question_service.go)
   - 实现智能推荐算法(基于掌握度和错误次数)
   - 添加掌握度追踪机制(连续答对6次标记为已掌握)
   - 支持错题筛选和排序功能
   - 新增错题统计趋势分析

2. UI优化:
   - 美化错题本界面,采用毛玻璃卡片设计
   - 添加四宫格统计卡片(错题总数、已掌握、未掌握、掌握率)
   - 优化筛选和操作按钮布局
   - 使用条状进度条显示掌握度
   - 改进响应式设计,优化移动端体验

3. 功能完善:
   - 修复判断题答案显示问题
   - 当掌握率100%时禁用"开始练习"按钮
   - 删除测试文件和 nul 文件
   - 更新文档 (CLAUDE.md)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 04:20:42 +08:00

533 lines
14 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"
"ankao/internal/services"
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ==================== 错题管理 API ====================
// GetWrongQuestions 获取错题列表(新版)
// GET /api/v2/wrong-questions?is_mastered=false&type=single-choice&tag=数学&sort=review_time
func GetWrongQuestions(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
db := database.GetDB()
query := db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID)
// 筛选条件
if isMastered := c.Query("is_mastered"); isMastered != "" {
query = query.Where("is_mastered = ?", isMastered == "true")
}
if tag := c.Query("tag"); tag != "" {
query = query.Where("tags LIKE ?", "%"+tag+"%")
}
// 排序
switch c.Query("sort") {
case "wrong_count":
// 按错误次数排序(错误最多的在前)
query = query.Order("total_wrong_count DESC")
case "mastery_level":
// 按掌握度排序(掌握度最低的在前)
query = query.Order("mastery_level ASC")
default:
// 默认按最后错误时间排序
query = query.Order("last_wrong_time DESC")
}
var wrongQuestions []models.WrongQuestion
// 先查询错题记录
if err := query.Find(&wrongQuestions).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询错题失败"})
return
}
// 手动加载关联数据
for i := range wrongQuestions {
// 加载题目信息(确保使用正确的关联)
var practiceQuestion models.PracticeQuestion
if err := db.Where("id = ?", wrongQuestions[i].QuestionID).First(&practiceQuestion).Error; err == nil {
wrongQuestions[i].PracticeQuestion = &practiceQuestion
}
// 加载最近3次历史
var history []models.WrongQuestionHistory
if err := db.Where("wrong_question_id = ?", wrongQuestions[i].ID).
Order("answered_at DESC").
Limit(3).
Find(&history).Error; err == nil {
wrongQuestions[i].History = history
}
}
// 转换为 DTO
dtos := make([]models.WrongQuestionDTO, len(wrongQuestions))
for i, wq := range wrongQuestions {
dtos[i] = convertWrongQuestionToDTO(&wq, true) // 包含最近历史
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dtos,
})
}
// GetWrongQuestionDetail 获取错题详情(包含完整历史)
// GET /api/v2/wrong-questions/:id
func GetWrongQuestionDetail(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"})
return
}
db := database.GetDB()
var wrongQuestion models.WrongQuestion
if err := db.Where("id = ? AND user_id = ?", id, userID).
Preload("PracticeQuestion").
Preload("History", func(db *gorm.DB) *gorm.DB {
return db.Order("answered_at DESC")
}).
First(&wrongQuestion).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
return
}
// 转换为 DTO包含完整历史
dto := convertToDetailDTO(&wrongQuestion)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dto,
})
}
// GetWrongQuestionStats 获取错题统计(新版)
// GET /api/v2/wrong-questions/stats
func GetWrongQuestionStats(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
stats, err := services.GetWrongQuestionStats(userID.(uint))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": stats,
})
}
// GetRecommendedWrongQuestions 获取推荐练习的错题(智能推荐)
// GET /api/v2/wrong-questions/recommended?limit=10&exclude=123
func GetRecommendedWrongQuestions(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
limit := 10
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
// 获取要排除的题目ID前端传递当前题目ID避免重复推荐
excludeQuestionID := uint(0)
if e := c.Query("exclude"); e != "" {
if parsed, err := strconv.ParseUint(e, 10, 32); err == nil {
excludeQuestionID = uint(parsed)
}
}
questions, err := services.GetRecommendedWrongQuestions(userID.(uint), limit, excludeQuestionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取推荐错题失败"})
return
}
// 转换为 DTO
dtos := make([]models.WrongQuestionDTO, len(questions))
for i, wq := range questions {
dtos[i] = convertWrongQuestionToDTO(&wq, false)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": dtos,
})
}
// DeleteWrongQuestion 删除错题(新版)
// DELETE /api/v2/wrong-questions/:id
func DeleteWrongQuestion(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"})
return
}
db := database.GetDB()
// 删除历史记录
db.Where("wrong_question_id = ?", id).Delete(&models.WrongQuestionHistory{})
// 删除错题记录
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.WrongQuestion{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除错题失败"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "删除成功",
})
}
// ClearWrongQuestions 清空错题本(新版)
// DELETE /api/v2/wrong-questions
func ClearWrongQuestions(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
db := database.GetDB()
// 获取所有错题ID
var wrongQuestionIDs []uint
db.Model(&models.WrongQuestion{}).Where("user_id = ?", userID).Pluck("id", &wrongQuestionIDs)
// 删除历史记录
if len(wrongQuestionIDs) > 0 {
db.Where("wrong_question_id IN ?", wrongQuestionIDs).Delete(&models.WrongQuestionHistory{})
}
// 删除错题记录
if err := db.Where("user_id = ?", userID).Delete(&models.WrongQuestion{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "清空错题本失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "错题本已清空",
})
}
// UpdateWrongQuestionTags 更新错题标签
// PUT /api/v2/wrong-questions/:id/tags
func UpdateWrongQuestionTags(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的错题ID"})
return
}
var req struct {
Tags []string `json:"tags"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
db := database.GetDB()
var wrongQuestion models.WrongQuestion
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&wrongQuestion).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "错题不存在"})
return
}
wrongQuestion.Tags = req.Tags
if err := db.Save(&wrongQuestion).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新标签失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "标签更新成功",
})
}
// ==================== 标签管理 API ====================
// GetWrongQuestionTags 获取用户的所有标签
// GET /api/v2/wrong-question-tags
func GetWrongQuestionTags(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
db := database.GetDB()
var tags []models.WrongQuestionTag
if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&tags).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取标签失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tags,
})
}
// CreateWrongQuestionTag 创建标签
// POST /api/v2/wrong-question-tags
func CreateWrongQuestionTag(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
db := database.GetDB()
// 检查标签名是否已存在
var existing models.WrongQuestionTag
if err := db.Where("user_id = ? AND name = ?", userID, req.Name).First(&existing).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "标签名已存在"})
return
}
tag := models.WrongQuestionTag{
UserID: userID.(uint),
Name: req.Name,
Color: req.Color,
Description: req.Description,
}
if tag.Color == "" {
tag.Color = "#1890ff" // 默认颜色
}
if err := db.Create(&tag).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建标签失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tag,
})
}
// UpdateWrongQuestionTag 更新标签
// PUT /api/v2/wrong-question-tags/:id
func UpdateWrongQuestionTag(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的标签ID"})
return
}
var req struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数"})
return
}
db := database.GetDB()
var tag models.WrongQuestionTag
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&tag).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"})
return
}
if req.Name != "" {
tag.Name = req.Name
}
if req.Color != "" {
tag.Color = req.Color
}
if req.Description != "" {
tag.Description = req.Description
}
if err := db.Save(&tag).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新标签失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": tag,
})
}
// DeleteWrongQuestionTag 删除标签
// DELETE /api/v2/wrong-question-tags/:id
func DeleteWrongQuestionTag(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的标签ID"})
return
}
db := database.GetDB()
result := db.Where("id = ? AND user_id = ?", id, userID).Delete(&models.WrongQuestionTag{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"})
return
}
// TODO: 从所有错题中移除该标签
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "删除成功",
})
}
// ==================== 辅助函数 ====================
// convertWrongQuestionToDTO 转换为 DTO V2可选是否包含最近历史
func convertWrongQuestionToDTO(wq *models.WrongQuestion, includeHistory bool) models.WrongQuestionDTO {
dto := models.WrongQuestionDTO{
ID: wq.ID,
QuestionID: wq.QuestionID,
FirstWrongTime: wq.FirstWrongTime,
LastWrongTime: wq.LastWrongTime,
TotalWrongCount: wq.TotalWrongCount,
MasteryLevel: wq.MasteryLevel,
ConsecutiveCorrect: wq.ConsecutiveCorrect,
IsMastered: wq.IsMastered,
Tags: wq.Tags,
}
// 转换题目信息
if wq.PracticeQuestion != nil {
questionDTO := convertToDTO(*wq.PracticeQuestion)
dto.Question = &questionDTO
}
// 包含最近3次历史
if includeHistory && len(wq.History) > 0 {
count := 3
if len(wq.History) < count {
count = len(wq.History)
}
dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, count)
for i := 0; i < count; i++ {
dto.RecentHistory[i] = convertWrongHistoryToDTO(&wq.History[i])
}
}
return dto
}
// convertToDetailDTO 转换为详情 DTO包含完整历史
func convertToDetailDTO(wq *models.WrongQuestion) models.WrongQuestionDTO {
dto := convertWrongQuestionToDTO(wq, false)
// 包含完整历史
if len(wq.History) > 0 {
dto.RecentHistory = make([]models.WrongQuestionHistoryDTO, len(wq.History))
for i, h := range wq.History {
dto.RecentHistory[i] = convertWrongHistoryToDTO(&h)
}
}
return dto
}
// convertWrongHistoryToDTO 转换历史记录为 DTO
func convertWrongHistoryToDTO(h *models.WrongQuestionHistory) models.WrongQuestionHistoryDTO {
return models.WrongQuestionHistoryDTO{
ID: h.ID,
UserAnswer: parseJSONAnswer(h.UserAnswer),
CorrectAnswer: parseJSONAnswer(h.CorrectAnswer),
AnsweredAt: h.AnsweredAt,
TimeSpent: h.TimeSpent,
IsCorrect: h.IsCorrect,
}
}
// parseJSONAnswer 解析 JSON 答案
func parseJSONAnswer(answerStr string) interface{} {
var answer interface{}
if err := json.Unmarshal([]byte(answerStr), &answer); err != nil {
return answerStr
}
return answer
}