Compare commits

...

2 Commits

Author SHA1 Message Date
61e32ef970 修改错题的字段 2025-11-13 03:34:24 +08:00
e651910e74 refactor: 数据库模型优化和答题逻辑重构
主要变更:
- 数据库ID字段统一从 uint 改为 int64,提升数据容量和兼容性
- 重构答题检查逻辑,采用策略模式替代 switch-case
- 新增 PracticeProgress 模型,支持练习进度持久化
- 优化错题本系统,自动记录答题进度和错误历史
- 添加 lib/pq PostgreSQL 驱动依赖
- 移除错题标签管理 API(待后续迁移)
- 前端类型定义同步更新,适配后端模型变更

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 03:28:39 +08:00
19 changed files with 452 additions and 642 deletions

1
go.mod
View File

@ -33,6 +33,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect

2
go.sum
View File

@ -55,6 +55,8 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

View File

@ -33,9 +33,9 @@ func InitDB() error {
err = DB.AutoMigrate( err = DB.AutoMigrate(
&models.User{}, &models.User{},
&models.PracticeQuestion{}, &models.PracticeQuestion{},
&models.PracticeProgress{}, // 练习进度表
&models.WrongQuestion{}, // 错题表 &models.WrongQuestion{}, // 错题表
&models.WrongQuestionHistory{}, // 错题历史表 &models.WrongQuestionHistory{}, // 错题历史表
&models.WrongQuestionTag{}, // 错题标签表
&models.UserAnswerRecord{}, // 用户答题记录表 &models.UserAnswerRecord{}, // 用户答题记录表
&models.Exam{}, // 考试表(试卷) &models.Exam{}, // 考试表(试卷)
&models.ExamRecord{}, // 考试记录表 &models.ExamRecord{}, // 考试记录表

View File

@ -42,7 +42,7 @@ func gradeExam(recordID uint, examID uint, userID uint) {
} }
// 转换为 map 格式方便查找 // 转换为 map 格式方便查找
answersMap := make(map[uint]interface{}) answersMap := make(map[int64]interface{})
for _, ua := range userAnswers { for _, ua := range userAnswers {
var answer interface{} var answer interface{}
if err := json.Unmarshal(ua.Answer, &answer); err != nil { if err := json.Unmarshal(ua.Answer, &answer); err != nil {

View File

@ -61,7 +61,7 @@ func CreateExam(c *gin.Context) {
} }
// 按题型配置随机抽取题目 // 按题型配置随机抽取题目
var allQuestionIDs []uint var allQuestionIDs []int64
totalScore := 0.0 totalScore := 0.0
for _, qtConfig := range questionTypes { for _, qtConfig := range questionTypes {
@ -243,7 +243,7 @@ func GetExamDetail(c *gin.Context) {
} }
// 解析题目ID列表 // 解析题目ID列表
var questionIDs []uint var questionIDs []int64
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil { if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
log.Printf("解析题目ID失败: %v", err) log.Printf("解析题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析试卷数据失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析试卷数据失败"})
@ -259,7 +259,7 @@ func GetExamDetail(c *gin.Context) {
} }
// 按原始顺序排序题目并转换为DTO不显示答案 // 按原始顺序排序题目并转换为DTO不显示答案
questionMap := make(map[uint]models.PracticeQuestion) questionMap := make(map[int64]models.PracticeQuestion)
for _, q := range questions { for _, q := range questions {
questionMap[q.ID] = q questionMap[q.ID] = q
} }
@ -466,7 +466,7 @@ func GetExamRecord(c *gin.Context) {
} }
// 构建题目映射 // 构建题目映射
questionMap := make(map[uint]models.PracticeQuestion) questionMap := make(map[int64]models.PracticeQuestion)
for _, q := range questions { for _, q := range questions {
questionMap[q.ID] = q questionMap[q.ID] = q
} }
@ -625,7 +625,7 @@ func DeleteExam(c *gin.Context) {
// SaveExamProgressRequest 保存考试进度请求 // SaveExamProgressRequest 保存考试进度请求
type SaveExamProgressRequest struct { type SaveExamProgressRequest struct {
QuestionID uint `json:"question_id"` // 题目ID QuestionID int64 `json:"question_id"` // 题目ID
Answer interface{} `json:"answer"` // 答案数据 Answer interface{} `json:"answer"` // 答案数据
} }
@ -638,7 +638,7 @@ func SaveExamProgress(c *gin.Context) {
} }
recordIDStr := c.Param("record_id") recordIDStr := c.Param("record_id")
recordID, err := strconv.ParseUint(recordIDStr, 10, 32) recordID, err := strconv.ParseInt(recordIDStr, 10, 64)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"}) c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"})
return return
@ -682,9 +682,9 @@ func SaveExamProgress(c *gin.Context) {
if result.Error == gorm.ErrRecordNotFound { if result.Error == gorm.ErrRecordNotFound {
// 不存在,创建新记录 // 不存在,创建新记录
userAnswer = models.ExamUserAnswer{ userAnswer = models.ExamUserAnswer{
ExamRecordID: uint(recordID), ExamRecordID: recordID,
QuestionID: req.QuestionID, QuestionID: req.QuestionID,
UserID: userID.(uint), UserID: userID.(int64),
Answer: answerJSON, Answer: answerJSON,
AnsweredAt: &now, AnsweredAt: &now,
LastModifiedAt: now, LastModifiedAt: now,

View File

@ -5,6 +5,7 @@ import (
"ankao/internal/models" "ankao/internal/models"
"ankao/internal/services" "ankao/internal/services"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@ -14,6 +15,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// checkEssayPermission 检查用户是否有权限访问论述题 // checkEssayPermission 检查用户是否有权限访问论述题
@ -370,31 +372,24 @@ func SubmitPracticeAnswer(c *gin.Context) {
} }
} }
// 记录用户答题历史 userAnswer, _ := json.Marshal(submit.Answer)
if uid, ok := userID.(uint); ok { uid := userID.(int64)
record := models.UserAnswerRecord{ record := models.UserAnswerRecord{
UserID: uid, UserID: uid,
QuestionID: question.ID, QuestionID: question.ID,
IsCorrect: correct, IsCorrect: correct,
AnsweredAt: time.Now(), AnsweredAt: time.Now(),
UserAnswer: userAnswer,
} }
// 记录到数据库(忽略错误,不影响主流程) // 记录到数据库(忽略错误,不影响主流程)
if err := db.Create(&record).Error; err != nil { if err := db.Create(&record).Error; err != nil {
log.Printf("记录答题历史失败: %v", err) log.Printf("记录答题历史失败: %v", err)
} }
}
// 记录到错题本(新版)- 使用 V2 API // 记录到错题本(新版)- 使用 V2 API
if uid, ok := userID.(uint); ok { timeSpent := 0
timeSpent := 0 // TODO: 从前端获取答题用时
if !correct { if !correct {
// 答错,记录到错题本 if err := services.RecordWrongAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil {
var wrongAnswer interface{} = submit.Answer
var stdAnswer interface{} = correctAnswer
if strings.HasSuffix(question.Type, "-essay") {
stdAnswer = "" // 论述题没有标准答案
}
if err := services.RecordWrongAnswer(uid, question.ID, wrongAnswer, stdAnswer, timeSpent); err != nil {
log.Printf("记录错题失败: %v", err) log.Printf("记录错题失败: %v", err)
} }
} else { } else {
@ -403,6 +398,38 @@ func SubmitPracticeAnswer(c *gin.Context) {
log.Printf("更新错题记录失败: %v", err) log.Printf("更新错题记录失败: %v", err)
} }
} }
// 保存练习进度到数据库
// 查找或创建进度记录
var progress models.PracticeProgress
findResult := db.Where("user_id = ? and type = ?", uid, question.Type).First(&progress)
if errors.Is(findResult.Error, gorm.ErrRecordNotFound) {
// 创建新记录
answerRecords := map[int64]int64{submit.QuestionID: record.ID}
answerRecordsJSON, _ := json.Marshal(answerRecords)
progress = models.PracticeProgress{
UserID: uid,
CurrentQuestionID: submit.QuestionID,
Type: question.Type,
UserAnswerRecords: answerRecordsJSON,
}
if err := db.Create(&progress).Error; err != nil {
log.Printf("保存练习进度失败: %v", err)
}
} else if findResult.Error == nil {
// 解析现有的答题记录
var answerRecords map[int64]int64
if err := json.Unmarshal(progress.UserAnswerRecords, &answerRecords); err != nil {
answerRecords = make(map[int64]int64)
}
// 更新记录
progress.CurrentQuestionID = submit.QuestionID
answerRecords[submit.QuestionID] = record.ID
answerRecordsJSON, _ := json.Marshal(answerRecords)
progress.UserAnswerRecords = answerRecordsJSON
if err := db.Save(&progress).Error; err != nil {
log.Printf("更新练习进度失败: %v", err)
}
} }
// 构建返回结果 // 构建返回结果
@ -464,22 +491,32 @@ func GetPracticeQuestionTypes(c *gin.Context) {
}) })
} }
// checkPracticeAnswer 检查练习答案是否正确 type CheckAnswer interface {
func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool { check(userAnswer, correctAnswer interface{}) bool
switch questionType { }
case "true-false":
// 判断题: boolean 比较 type CheckTrueFalse struct {
}
func (*CheckTrueFalse) check(userAnswer, correctAnswer interface{}) bool {
userBool, ok1 := userAnswer.(bool) userBool, ok1 := userAnswer.(bool)
correctBool, ok2 := correctAnswer.(bool) correctBool, ok2 := correctAnswer.(bool)
return ok1 && ok2 && userBool == correctBool return ok1 && ok2 && userBool == correctBool
}
case "multiple-choice": type MultipleChoice struct {
// 单选题: 字符串比较 }
func (*MultipleChoice) check(userAnswer, correctAnswer interface{}) bool {
userStr, ok1 := userAnswer.(string) userStr, ok1 := userAnswer.(string)
correctStr, ok2 := correctAnswer.(string) correctStr, ok2 := correctAnswer.(string)
return ok1 && ok2 && userStr == correctStr return ok1 && ok2 && userStr == correctStr
}
case "multiple-selection": type MultipleSelection struct {
}
func (*MultipleSelection) check(userAnswer, correctAnswer interface{}) bool {
// 多选题: 数组比较 // 多选题: 数组比较
userArr, ok1 := toStringArray(userAnswer) userArr, ok1 := toStringArray(userAnswer)
correctArr, ok2 := toStringArray(correctAnswer) correctArr, ok2 := toStringArray(correctAnswer)
@ -498,8 +535,12 @@ func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interfac
} }
} }
return true return true
}
case "fill-in-blank": type FillInBlank struct {
}
func (*FillInBlank) check(userAnswer, correctAnswer interface{}) bool {
// 填空题: 数组比较 // 填空题: 数组比较
userArr, ok1 := toStringArray(userAnswer) userArr, ok1 := toStringArray(userAnswer)
correctArr, ok2 := toStringArray(correctAnswer) correctArr, ok2 := toStringArray(correctAnswer)
@ -520,15 +561,46 @@ func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interfac
} }
} }
return true return true
}
case "short-answer": type ShortAnswer struct {
}
func (*ShortAnswer) check(userAnswer, correctAnswer interface{}) bool {
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断) // 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
userStr, ok1 := userAnswer.(string) userStr, ok1 := userAnswer.(string)
correctStr, ok2 := correctAnswer.(string) correctStr, ok2 := correctAnswer.(string)
return ok1 && ok2 && userStr == correctStr return ok1 && ok2 && userStr == correctStr
} }
type CheckAnswers struct {
checkAnswers map[string]CheckAnswer
}
func (c CheckAnswers) check(questionType string, userAnswer, correctAnswer interface{}) bool {
checkAnswer := c.checkAnswers[questionType]
return checkAnswer.check(userAnswer, correctAnswer)
}
func NewCheckAnswers() *CheckAnswers {
return &CheckAnswers{
checkAnswers: map[string]CheckAnswer{
"true-false": &CheckTrueFalse{},
"multiple-choice": &MultipleChoice{},
"multiple-selection": &MultipleSelection{},
"fill-in-blank": &FillInBlank{},
"short-answer": &ShortAnswer{},
},
}
}
// checkPracticeAnswer 检查练习答案是否正确
func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool {
answers := NewCheckAnswers()
if answers == nil {
return false return false
}
return answers.check(questionType, userAnswer, correctAnswer)
} }
// toStringArray 将interface{}转换为字符串数组 // toStringArray 将interface{}转换为字符串数组

View File

@ -254,210 +254,6 @@ func ClearWrongQuestions(c *gin.Context) {
}) })
} }
// 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可选是否包含最近历史 // convertWrongQuestionToDTO 转换为 DTO V2可选是否包含最近历史
@ -471,7 +267,6 @@ func convertWrongQuestionToDTO(wq *models.WrongQuestion, includeHistory bool) mo
MasteryLevel: wq.MasteryLevel, MasteryLevel: wq.MasteryLevel,
ConsecutiveCorrect: wq.ConsecutiveCorrect, ConsecutiveCorrect: wq.ConsecutiveCorrect,
IsMastered: wq.IsMastered, IsMastered: wq.IsMastered,
Tags: wq.Tags,
} }
// 转换题目信息 // 转换题目信息

View File

@ -1,18 +1,18 @@
package models package models
import ( import (
"gorm.io/datatypes"
"time" "time"
"gorm.io/gorm"
) )
// UserAnswerRecord 用户答题记录 // UserAnswerRecord 用户答题记录
type UserAnswerRecord struct { type UserAnswerRecord struct {
gorm.Model ID int64 `gorm:"primarykey"`
UserID uint `gorm:"index;not null" json:"user_id"` // 用户ID UserID int64 `gorm:"index;not null" json:"user_id"` // 用户ID
QuestionID uint `gorm:"index;not null" json:"question_id"` // 题目ID QuestionID int64 `gorm:"index;not null" json:"question_id"` // 题目ID
IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对 IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对
AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间 AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间
UserAnswer datatypes.JSON `gorm:"json" json:"user_answer"`
} }
// TableName 指定表名 // TableName 指定表名

View File

@ -45,13 +45,13 @@ type ExamRecord struct {
// ExamUserAnswer 用户答案表(记录每道题的答案) // ExamUserAnswer 用户答案表(记录每道题的答案)
type ExamUserAnswer struct { type ExamUserAnswer struct {
ID uint `gorm:"primaryKey" json:"id"` ID int64 `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ExamRecordID uint `gorm:"not null;index:idx_record_question" json:"exam_record_id"` // 考试记录ID ExamRecordID int64 `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 QuestionID int64 `gorm:"not null;index:idx_record_question" json:"question_id"` // 题目ID
UserID uint `gorm:"not null;index" json:"user_id"` // 用户ID UserID int64 `gorm:"not null;index" json:"user_id"` // 用户ID
Answer datatypes.JSON `gorm:"type:json" json:"answer"` // 用户答案 (JSON格式支持各种题型) Answer datatypes.JSON `gorm:"type:json" json:"answer"` // 用户答案 (JSON格式支持各种题型)
IsCorrect *bool `json:"is_correct,omitempty"` // 是否正确(提交后评分) IsCorrect *bool `json:"is_correct,omitempty"` // 是否正确(提交后评分)
Score float64 `gorm:"type:decimal(5,2);default:0" json:"score"` // 得分 Score float64 `gorm:"type:decimal(5,2);default:0" json:"score"` // 得分
@ -81,7 +81,7 @@ type QuestionTypeConfig struct {
// ExamAnswer 考试答案结构 // ExamAnswer 考试答案结构
type ExamAnswer struct { type ExamAnswer struct {
QuestionID uint `json:"question_id"` QuestionID int64 `json:"question_id"`
Answer interface{} `json:"answer"` // 用户答案 Answer interface{} `json:"answer"` // 用户答案
CorrectAnswer interface{} `json:"correct_answer"` // 正确答案 CorrectAnswer interface{} `json:"correct_answer"` // 正确答案
IsCorrect bool `json:"is_correct"` IsCorrect bool `json:"is_correct"`
@ -100,7 +100,6 @@ type ExamQuestionConfig struct {
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量 ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
} }
// DefaultExamConfig 默认考试配置 // DefaultExamConfig 默认考试配置
var DefaultExamConfig = ExamQuestionConfig{ var DefaultExamConfig = ExamQuestionConfig{
FillInBlank: 10, // 填空题10道 FillInBlank: 10, // 填空题10道

View File

@ -0,0 +1,19 @@
package models
import (
"gorm.io/datatypes"
)
// PracticeProgress 练习进度记录(每道题目一条记录)
type PracticeProgress struct {
ID int64 `gorm:"primarykey" json:"id"`
CurrentQuestionID int64 `gorm:"not null" json:"current_question_id"`
UserID int64 `gorm:"not null;uniqueIndex:idx_user_question" json:"user_id"`
Type string `gorm:"type:varchar(255);not null" json:"type"`
UserAnswerRecords datatypes.JSON `gorm:"type:jsonp" json:"answers"`
}
// TableName 指定表名
func (PracticeProgress) TableName() string {
return "practice_progress"
}

View File

@ -1,16 +1,14 @@
package models package models
import "gorm.io/gorm"
// PracticeQuestion 练习题目模型 // PracticeQuestion 练习题目模型
type PracticeQuestion struct { type PracticeQuestion struct {
gorm.Model ID int64 `gorm:"primarykey"`
QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段) QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段)
Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型 Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型
TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文) TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文)
Question string `gorm:"type:text;not null" json:"question"` // 题目内容 Question string `gorm:"type:text;not null" json:"question"` // 题目内容
AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储) AnswerData string `gorm:"type:jsonb" json:"-"` // 答案数据(JSON格式存储)
OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题) OptionsData string `gorm:"type:jsonb" json:"-"` // 选项数据(JSON格式存储,用于选择题)
} }
// TableName 指定表名 // TableName 指定表名
@ -20,7 +18,7 @@ func (PracticeQuestion) TableName() string {
// PracticeQuestionDTO 用于前端返回的数据传输对象 // PracticeQuestionDTO 用于前端返回的数据传输对象
type PracticeQuestionDTO struct { type PracticeQuestionDTO struct {
ID uint `json:"id"` // 数据库自增ID ID int64 `json:"id"` // 数据库自增ID
QuestionID string `json:"question_id"` // 题目编号(原JSON中的id) QuestionID string `json:"question_id"` // 题目编号(原JSON中的id)
Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill Type string `json:"type"` // 前端使用的简化类型: single, multiple, judge, fill
Content string `json:"content"` // 题目内容 Content string `json:"content"` // 题目内容
@ -31,7 +29,7 @@ type PracticeQuestionDTO struct {
// PracticeAnswerSubmit 练习题答案提交 // PracticeAnswerSubmit 练习题答案提交
type PracticeAnswerSubmit struct { type PracticeAnswerSubmit struct {
QuestionID uint `json:"question_id" binding:"required"` // 数据库ID QuestionID int64 `json:"question_id" binding:"required"` // 数据库ID
Answer interface{} `json:"answer" binding:"required"` // 用户答案 Answer interface{} `json:"answer" binding:"required"` // 用户答案
} }

View File

@ -9,7 +9,7 @@ import (
// User 用户结构 // User 用户结构
type User struct { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID int64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;not null;size:50" json:"username"` Username string `gorm:"uniqueIndex;not null;size:50" json:"username"`
Password string `gorm:"not null;size:255" json:"-"` // json:"-" 表示在JSON响应中不返回密码 Password string `gorm:"not null;size:255" json:"-"` // json:"-" 表示在JSON响应中不返回密码
Token string `gorm:"size:255;index" json:"-"` // 用户登录token Token string `gorm:"size:255;index" json:"-"` // 用户登录token

View File

@ -1,8 +1,6 @@
package models package models
import ( import (
"database/sql/driver"
"encoding/json"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@ -10,16 +8,15 @@ import (
// WrongQuestion 错题记录 // WrongQuestion 错题记录
type WrongQuestion struct { type WrongQuestion struct {
ID uint `gorm:"primarykey" json:"id"` ID int64 `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"` UserID int64 `gorm:"index;not null" json:"user_id"`
QuestionID uint `gorm:"index;not null" json:"question_id"` QuestionID int64 `gorm:"index;not null" json:"question_id"`
FirstWrongTime time.Time `json:"first_wrong_time"` FirstWrongTime time.Time `json:"first_wrong_time"`
LastWrongTime time.Time `json:"last_wrong_time"` LastWrongTime time.Time `json:"last_wrong_time"`
TotalWrongCount int `gorm:"default:1" json:"total_wrong_count"` TotalWrongCount int `gorm:"default:1" json:"total_wrong_count"`
MasteryLevel int `gorm:"default:0" json:"mastery_level"` // 0-100 MasteryLevel int `gorm:"default:0" json:"mastery_level"` // 0-100
ConsecutiveCorrect int `gorm:"default:0" json:"consecutive_correct"` ConsecutiveCorrect int `gorm:"default:0" json:"consecutive_correct"`
IsMastered bool `gorm:"default:false" json:"is_mastered"` IsMastered bool `gorm:"default:false" json:"is_mastered"`
Tags QuestionTags `gorm:"type:text" json:"tags"` // JSON 存储标签
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@ -31,61 +28,15 @@ type WrongQuestion struct {
// WrongQuestionHistory 错误历史记录 // WrongQuestionHistory 错误历史记录
type WrongQuestionHistory struct { type WrongQuestionHistory struct {
ID uint `gorm:"primarykey" json:"id"` ID int64 `gorm:"primarykey" json:"id"`
WrongQuestionID uint `gorm:"index;not null" json:"wrong_question_id"` WrongQuestionID int64 `gorm:"index;not null" json:"wrong_question_id"`
UserAnswer string `gorm:"type:text;not null" json:"user_answer"` // JSON 存储 UserAnswer string `gorm:"type:jsonb;not null" json:"user_answer"` // JSON 存储
CorrectAnswer string `gorm:"type:text;not null" json:"correct_answer"` // JSON 存储 CorrectAnswer string `gorm:"type:jsonb;not null" json:"correct_answer"` // JSON 存储
AnsweredAt time.Time `gorm:"index" json:"answered_at"` AnsweredAt time.Time `gorm:"index" json:"answered_at"`
TimeSpent int `json:"time_spent"` // 答题用时(秒) TimeSpent int `json:"time_spent"` // 答题用时(秒)
IsCorrect bool `json:"is_correct"` IsCorrect bool `json:"is_correct"`
} }
// WrongQuestionTag 错题标签
type WrongQuestionTag struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:50;not null" json:"name"`
Color string `gorm:"size:20;default:'#1890ff'" json:"color"`
Description string `gorm:"size:200" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// QuestionTags 标签列表(用于 JSON 存储)
type QuestionTags []string
// Scan 从数据库读取
func (t *QuestionTags) Scan(value interface{}) error {
if value == nil {
*t = []string{}
return nil
}
bytes, ok := value.([]byte)
if !ok {
str, ok := value.(string)
if !ok {
*t = []string{}
return nil
}
bytes = []byte(str)
}
if len(bytes) == 0 || string(bytes) == "null" {
*t = []string{}
return nil
}
return json.Unmarshal(bytes, t)
}
// Value 写入数据库
func (t QuestionTags) Value() (driver.Value, error) {
if len(t) == 0 {
return "[]", nil
}
bytes, err := json.Marshal(t)
return string(bytes), err
}
// TableName 指定表名 // TableName 指定表名
func (WrongQuestion) TableName() string { func (WrongQuestion) TableName() string {
return "wrong_questions" return "wrong_questions"
@ -96,15 +47,10 @@ func (WrongQuestionHistory) TableName() string {
return "wrong_question_history" return "wrong_question_history"
} }
// TableName 指定表名
func (WrongQuestionTag) TableName() string {
return "wrong_question_tags"
}
// WrongQuestionDTO 错题数据传输对象 // WrongQuestionDTO 错题数据传输对象
type WrongQuestionDTO struct { type WrongQuestionDTO struct {
ID uint `json:"id"` ID int64 `json:"id"`
QuestionID uint `json:"question_id"` QuestionID int64 `json:"question_id"`
Question *PracticeQuestionDTO `json:"question"` Question *PracticeQuestionDTO `json:"question"`
FirstWrongTime time.Time `json:"first_wrong_time"` FirstWrongTime time.Time `json:"first_wrong_time"`
LastWrongTime time.Time `json:"last_wrong_time"` LastWrongTime time.Time `json:"last_wrong_time"`
@ -118,7 +64,7 @@ type WrongQuestionDTO struct {
// WrongQuestionHistoryDTO 错误历史 DTO // WrongQuestionHistoryDTO 错误历史 DTO
type WrongQuestionHistoryDTO struct { type WrongQuestionHistoryDTO struct {
ID uint `json:"id"` ID int64 `json:"id"`
UserAnswer interface{} `json:"user_answer"` UserAnswer interface{} `json:"user_answer"`
CorrectAnswer interface{} `json:"correct_answer"` CorrectAnswer interface{} `json:"correct_answer"`
AnsweredAt time.Time `json:"answered_at"` AnsweredAt time.Time `json:"answered_at"`
@ -135,7 +81,6 @@ type WrongQuestionStats struct {
TypeStats map[string]int `json:"type_stats"` // 按题型统计 TypeStats map[string]int `json:"type_stats"` // 按题型统计
CategoryStats map[string]int `json:"category_stats"` // 按分类统计 CategoryStats map[string]int `json:"category_stats"` // 按分类统计
MasteryLevelDist map[string]int `json:"mastery_level_dist"` // 掌握度分布 MasteryLevelDist map[string]int `json:"mastery_level_dist"` // 掌握度分布
TagStats map[string]int `json:"tag_stats"` // 按标签统计
TrendData []TrendPoint `json:"trend_data"` // 错题趋势 TrendData []TrendPoint `json:"trend_data"` // 错题趋势
} }
@ -175,24 +120,3 @@ func (wq *WrongQuestion) RecordCorrectAnswer() {
wq.MasteryLevel = 100 wq.MasteryLevel = 100
} }
} }
// AddTag 添加标签
func (wq *WrongQuestion) AddTag(tag string) {
for _, t := range wq.Tags {
if t == tag {
return // 标签已存在
}
}
wq.Tags = append(wq.Tags, tag)
}
// RemoveTag 移除标签
func (wq *WrongQuestion) RemoveTag(tag string) {
newTags := []string{}
for _, t := range wq.Tags {
if t != tag {
newTags = append(newTags, t)
}
}
wq.Tags = newTags
}

View File

@ -14,7 +14,7 @@ import (
// ==================== 错题服务 ==================== // ==================== 错题服务 ====================
// RecordWrongAnswer 记录错误答案 // RecordWrongAnswer 记录错误答案
func RecordWrongAnswer(userID, questionID uint, userAnswer, correctAnswer interface{}, timeSpent int) error { func RecordWrongAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error {
db := database.GetDB() db := database.GetDB()
log.Printf("[错题记录] 开始记录错题 (userID: %d, questionID: %d)", userID, questionID) log.Printf("[错题记录] 开始记录错题 (userID: %d, questionID: %d)", userID, questionID)
@ -33,7 +33,6 @@ func RecordWrongAnswer(userID, questionID uint, userAnswer, correctAnswer interf
wrongQuestion = models.WrongQuestion{ wrongQuestion = models.WrongQuestion{
UserID: userID, UserID: userID,
QuestionID: questionID, QuestionID: questionID,
Tags: []string{},
} }
wrongQuestion.RecordWrongAnswer() wrongQuestion.RecordWrongAnswer()
@ -73,7 +72,7 @@ func RecordWrongAnswer(userID, questionID uint, userAnswer, correctAnswer interf
} }
// RecordCorrectAnswer 记录正确答案(用于错题练习) // RecordCorrectAnswer 记录正确答案(用于错题练习)
func RecordCorrectAnswer(userID, questionID uint, userAnswer, correctAnswer interface{}, timeSpent int) error { func RecordCorrectAnswer(userID, questionID int64, userAnswer, correctAnswer interface{}, timeSpent int) error {
db := database.GetDB() db := database.GetDB()
// 查找错题记录 // 查找错题记录
@ -119,7 +118,6 @@ func GetWrongQuestionStats(userID uint) (*models.WrongQuestionStats, error) {
TypeStats: make(map[string]int), TypeStats: make(map[string]int),
CategoryStats: make(map[string]int), CategoryStats: make(map[string]int),
MasteryLevelDist: make(map[string]int), MasteryLevelDist: make(map[string]int),
TagStats: make(map[string]int),
} }
// 基础统计 // 基础统计
@ -188,15 +186,6 @@ func GetWrongQuestionStats(userID uint) (*models.WrongQuestionStats, error) {
stats.MasteryLevelDist[md.Level] = md.Count stats.MasteryLevelDist[md.Level] = md.Count
} }
// 标签统计
var wrongQuestions []models.WrongQuestion
db.Where("user_id = ?", userID).Find(&wrongQuestions)
for _, wq := range wrongQuestions {
for _, tag := range wq.Tags {
stats.TagStats[tag]++
}
}
// 错题趋势最近7天 // 错题趋势最近7天
stats.TrendData = calculateTrendData(db, userID, 7) stats.TrendData = calculateTrendData(db, userID, 7)
@ -288,49 +277,13 @@ func GetRecommendedWrongQuestions(userID uint, limit int, excludeQuestionID uint
} }
// getIDs 获取错题记录的ID列表 // getIDs 获取错题记录的ID列表
func getIDs(questions []models.WrongQuestion) []uint { func getIDs(questions []models.WrongQuestion) []int64 {
if len(questions) == 0 { if len(questions) == 0 {
return []uint{0} // 避免 SQL 错误 return []int64{0} // 避免 SQL 错误
} }
ids := make([]uint, len(questions)) ids := make([]int64, len(questions))
for i, q := range questions { for i, q := range questions {
ids[i] = q.ID ids[i] = q.ID
} }
return ids return ids
} }
// AnalyzeWeakPoints 分析薄弱知识点
func AnalyzeWeakPoints(userID uint) (map[string]float64, error) {
db := database.GetDB()
// 按分类统计错误率
var categoryStats []struct {
Category string
WrongCount int
TotalCount int
WrongRate float64
}
// 查询每个分类的错题数和总题数
db.Raw(`
SELECT
pq.category,
COUNT(DISTINCT wq.question_id) as wrong_count,
(SELECT COUNT(*) FROM practice_questions WHERE category = pq.category) as total_count,
CAST(COUNT(DISTINCT wq.question_id) AS FLOAT) / NULLIF((SELECT COUNT(*) FROM practice_questions WHERE category = pq.category), 0) as wrong_rate
FROM wrong_questions wq
LEFT JOIN practice_questions pq ON wq.question_id = pq.id
WHERE wq.user_id = ?
GROUP BY pq.category
ORDER BY wrong_rate DESC
`, userID).Scan(&categoryStats)
result := make(map[string]float64)
for _, cs := range categoryStats {
if cs.Category != "" {
result[cs.Category] = cs.WrongRate
}
}
return result, nil
}

View File

@ -63,13 +63,6 @@ func main() {
auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情 auth.GET("/wrong-questions/:id", handlers.GetWrongQuestionDetail) // 获取错题详情
auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题 auth.DELETE("/wrong-questions/:id", handlers.DeleteWrongQuestion) // 删除错题
auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
auth.PUT("/wrong-questions/:id/tags", handlers.UpdateWrongQuestionTags) // 更新错题标签
// 标签管理API
auth.GET("/wrong-question-tags", handlers.GetWrongQuestionTags) // 获取标签列表
auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签
auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签
auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签
// 模拟考试相关API // 模拟考试相关API
auth.POST("/exams", handlers.CreateExam) // 创建试卷 auth.POST("/exams", handlers.CreateExam) // 创建试卷

View File

@ -7,7 +7,6 @@ import type {
ApiResponse, ApiResponse,
WrongQuestion, WrongQuestion,
WrongQuestionStats, WrongQuestionStats,
WrongQuestionTag,
WrongQuestionFilter WrongQuestionFilter
} from '../types/question' } from '../types/question'
@ -31,6 +30,37 @@ export const getStatistics = () => {
return request.get<ApiResponse<Statistics>>('/practice/statistics') return request.get<ApiResponse<Statistics>>('/practice/statistics')
} }
// ========== 练习进度相关 API ==========
// 单题进度记录
export interface PracticeProgressItem {
question_id: number
correct: boolean | null // null=未答true=答对false=答错
answer_sequence: number // 答题序号(第几次答题)
user_answer: any
updated_at: string
}
// 保存单题练习进度
export const savePracticeProgress = (data: {
question_id: number
correct: boolean | null
answer_sequence: number
user_answer: any
}) => {
return request.post<ApiResponse<PracticeProgressItem>>('/practice/progress', data)
}
// 获取练习进度(返回用户所有题目的进度)
export const getPracticeProgress = () => {
return request.get<ApiResponse<PracticeProgressItem[]>>('/practice/progress')
}
// 清除练习进度
export const clearPracticeProgress = () => {
return request.delete<ApiResponse<null>>('/practice/progress')
}
// 重置进度 (暂时返回模拟数据,后续实现) // 重置进度 (暂时返回模拟数据,后续实现)
export const resetProgress = async () => { export const resetProgress = async () => {
// TODO: 实现真实的重置接口 // TODO: 实现真实的重置接口
@ -86,41 +116,6 @@ export const clearWrongQuestions = () => {
return request.delete<ApiResponse<null>>('/wrong-questions') return request.delete<ApiResponse<null>>('/wrong-questions')
} }
// 更新错题标签
export const updateWrongQuestionTags = (id: number, tags: string[]) => {
return request.put<ApiResponse<null>>(`/wrong-questions/${id}/tags`, { tags })
}
// ========== 标签管理 API ==========
// 获取标签列表
export const getWrongQuestionTags = () => {
return request.get<ApiResponse<WrongQuestionTag[]>>('/wrong-question-tags')
}
// 创建标签
export const createWrongQuestionTag = (data: {
name: string
color?: string
description?: string
}) => {
return request.post<ApiResponse<WrongQuestionTag>>('/wrong-question-tags', data)
}
// 更新标签
export const updateWrongQuestionTag = (id: number, data: {
name?: string
color?: string
description?: string
}) => {
return request.put<ApiResponse<WrongQuestionTag>>(`/wrong-question-tags/${id}`, data)
}
// 删除标签
export const deleteWrongQuestionTag = (id: number) => {
return request.delete<ApiResponse<null>>(`/wrong-question-tags/${id}`)
}
// ========== 题库管理相关 API ========== // ========== 题库管理相关 API ==========
// 创建题目 // 创建题目

View File

@ -37,12 +37,17 @@ const QuestionCard: React.FC<QuestionCardProps> = ({
}) => { }) => {
const [fillAnswers, setFillAnswers] = useState<string[]>([]) const [fillAnswers, setFillAnswers] = useState<string[]>([])
// 当题目ID变化时重置填空题答案 // 当题目或答案变化时,同步填空题答案
useEffect(() => { useEffect(() => {
if (question.type === 'fill-in-blank') { if (question.type === 'fill-in-blank') {
// 如果 selectedAnswer 是数组且有内容,使用它;否则重置为空数组
if (Array.isArray(selectedAnswer) && selectedAnswer.length > 0) {
setFillAnswers(selectedAnswer)
} else {
setFillAnswers([]) setFillAnswers([])
} }
}, [question.id, question.type]) }
}, [question.id, question.type, selectedAnswer])
// 渲染填空题内容 // 渲染填空题内容
const renderFillContent = () => { const renderFillContent = () => {

View File

@ -87,8 +87,8 @@ const QuestionPage: React.FC = () => {
return `question_progress_${type || mode || "default"}`; return `question_progress_${type || mode || "default"}`;
}; };
// 保存答题进度 // 保存答题进度(仅保存到 localStorage 作为快速备份)
const saveProgress = (index: number, correct: number, wrong: number, statusMap?: Map<number, boolean | null>) => { const saveProgressToLocal = (index: number, correct: number, wrong: number, statusMap?: Map<number, boolean | null>) => {
const key = getStorageKey(); const key = getStorageKey();
const answeredStatusObj: Record<number, boolean | null> = {}; const answeredStatusObj: Record<number, boolean | null> = {};
const userAnswersObj: Record<number, string | string[]> = {}; const userAnswersObj: Record<number, string | string[]> = {};
@ -108,9 +108,7 @@ const QuestionPage: React.FC = () => {
questionResultsObj[key] = value; questionResultsObj[key] = value;
}); });
localStorage.setItem( const progressData = {
key,
JSON.stringify({
currentIndex: index, currentIndex: index,
correctCount: correct, correctCount: correct,
wrongCount: wrong, wrongCount: wrong,
@ -118,13 +116,61 @@ const QuestionPage: React.FC = () => {
userAnswers: userAnswersObj, userAnswers: userAnswersObj,
questionResults: questionResultsObj, questionResults: questionResultsObj,
timestamp: Date.now(), timestamp: Date.now(),
})
);
}; };
// 恢复答题进度 // 保存到 localStorage作为本地快速备份
const loadProgress = () => { localStorage.setItem(key, JSON.stringify(progressData));
};
// 恢复答题进度优先从后端加载fallback 到 localStorage
const loadProgress = async () => {
const key = getStorageKey(); const key = getStorageKey();
try {
// 优先从后端加载进度
const res = await questionApi.getPracticeProgress();
if (res.success && res.data && res.data.length > 0 && allQuestions.length > 0) {
// 将后端数据转换为前端的 Map 格式
// 创建 question_id 到索引的映射
const questionIdToIndex = new Map<number, number>();
allQuestions.forEach((q, idx) => {
questionIdToIndex.set(q.id, idx);
});
const statusMap = new Map<number, boolean | null>();
const answersMap = new Map<number, string | string[]>();
let maxIndex = 0;
let correct = 0;
let wrong = 0;
res.data.forEach((item) => {
const index = questionIdToIndex.get(item.question_id);
if (index !== undefined) {
statusMap.set(index, item.correct);
answersMap.set(index, item.user_answer);
if (item.correct !== null) {
maxIndex = Math.max(maxIndex, index);
if (item.correct) correct++;
else wrong++;
}
}
});
setAnsweredStatus(statusMap);
setUserAnswers(answersMap);
setCorrectCount(correct);
setWrongCount(wrong);
// 返回下一个未答题的索引或最后一个已答题的下一题
return Math.min(maxIndex + 1, allQuestions.length - 1);
}
} catch (error) {
console.log('从后端加载进度失败,尝试从 localStorage 加载:', error);
}
// 如果后端加载失败,从 localStorage 加载
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved) { if (saved) {
try { try {
@ -223,7 +269,7 @@ const QuestionPage: React.FC = () => {
setAllQuestions(res.data); setAllQuestions(res.data);
// 恢复答题进度 // 恢复答题进度
const savedIndex = loadProgress(); const savedIndex = await loadProgress();
const startIndex = savedIndex < res.data.length ? savedIndex : 0; const startIndex = savedIndex < res.data.length ? savedIndex : 0;
if (res.data.length > 0) { if (res.data.length > 0) {
@ -304,13 +350,15 @@ const QuestionPage: React.FC = () => {
if (res.data.correct) { if (res.data.correct) {
const newCorrect = correctCount + 1; const newCorrect = correctCount + 1;
setCorrectCount(newCorrect); setCorrectCount(newCorrect);
saveProgress(currentIndex, newCorrect, wrongCount, newStatusMap); saveProgressToLocal(currentIndex, newCorrect, wrongCount, newStatusMap);
} else { } else {
const newWrong = wrongCount + 1; const newWrong = wrongCount + 1;
setWrongCount(newWrong); setWrongCount(newWrong);
saveProgress(currentIndex, correctCount, newWrong, newStatusMap); saveProgressToLocal(currentIndex, correctCount, newWrong, newStatusMap);
} }
// 注意:进度已由后端的 /api/practice/submit 接口自动保存,无需前端再次调用
// 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题 // 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题
if (res.data.correct && autoNext) { if (res.data.correct && autoNext) {
setAutoNextLoading(true); setAutoNextLoading(true);
@ -359,7 +407,9 @@ const QuestionPage: React.FC = () => {
if (unansweredIndexes.length === 0) { if (unansweredIndexes.length === 0) {
setShowSummary(true); setShowSummary(true);
// 清除进度 // 清除进度
localStorage.removeItem(getStorageKey()); const key = getStorageKey();
localStorage.removeItem(key);
questionApi.clearPracticeProgress().catch(err => console.error('清除进度失败:', err));
return; return;
} }
@ -372,7 +422,9 @@ const QuestionPage: React.FC = () => {
// 显示统计摘要 // 显示统计摘要
setShowSummary(true); setShowSummary(true);
// 清除进度 // 清除进度
localStorage.removeItem(getStorageKey()); const key = getStorageKey();
localStorage.removeItem(key);
questionApi.clearPracticeProgress().catch(err => console.error('清除进度失败:', err));
return; return;
} }
nextIndex = currentIndex + 1; nextIndex = currentIndex + 1;
@ -386,8 +438,8 @@ const QuestionPage: React.FC = () => {
setShowResult(false); setShowResult(false);
setAnswerResult(null); setAnswerResult(null);
// 保存进度 // 保存进度到本地
saveProgress(nextIndex, correctCount, wrongCount); saveProgressToLocal(nextIndex, correctCount, wrongCount);
// 滚动到页面顶部 // 滚动到页面顶部
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
@ -397,13 +449,16 @@ const QuestionPage: React.FC = () => {
// 跳转到指定题目 // 跳转到指定题目
const handleQuestionSelect = (index: number) => { const handleQuestionSelect = (index: number) => {
if (index >= 0 && index < allQuestions.length) { if (index >= 0 && index < allQuestions.length) {
setCurrentIndex(index); const question = allQuestions[index];
setCurrentQuestion(allQuestions[index]);
// 检查是否有保存的答案和结果 // 检查是否有保存的答案和结果
const savedAnswer = userAnswers.get(index); const savedAnswer = userAnswers.get(index);
const savedResult = questionResults.get(index); const savedResult = questionResults.get(index);
// 先更新题目索引和题目内容
setCurrentIndex(index);
setCurrentQuestion(question);
if (savedAnswer !== undefined && savedResult !== undefined) { if (savedAnswer !== undefined && savedResult !== undefined) {
// 恢复之前的答案和结果 // 恢复之前的答案和结果
setSelectedAnswer(savedAnswer); setSelectedAnswer(savedAnswer);
@ -412,14 +467,14 @@ const QuestionPage: React.FC = () => {
} else { } else {
// 没有答案,重置状态 // 没有答案,重置状态
setSelectedAnswer( setSelectedAnswer(
allQuestions[index].type === "multiple-selection" ? [] : "" question.type === "multiple-selection" ? [] : ""
); );
setShowResult(false); setShowResult(false);
setAnswerResult(null); setAnswerResult(null);
} }
// 保存进度 // 保存进度到本地
saveProgress(index, correctCount, wrongCount); saveProgressToLocal(index, correctCount, wrongCount);
// 滚动到页面顶部 // 滚动到页面顶部
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
@ -442,12 +497,23 @@ const QuestionPage: React.FC = () => {
}, [searchParams]); }, [searchParams]);
// 重试处理 // 重试处理
const handleRetry = () => { const handleRetry = async () => {
setShowSummary(false); setShowSummary(false);
setCurrentIndex(0); setCurrentIndex(0);
setCorrectCount(0); setCorrectCount(0);
setWrongCount(0); setWrongCount(0);
localStorage.removeItem(getStorageKey());
const key = getStorageKey();
// 清除 localStorage
localStorage.removeItem(key);
// 清除后端进度
try {
await questionApi.clearPracticeProgress();
} catch (error) {
console.error('清除后端进度失败:', error);
}
const typeParam = searchParams.get("type"); const typeParam = searchParams.get("type");
loadQuestions(typeParam || undefined); loadQuestions(typeParam || undefined);
}; };

View File

@ -100,21 +100,9 @@ export interface WrongQuestionStats {
type_stats: Record<string, number> // 按题型统计 type_stats: Record<string, number> // 按题型统计
category_stats: Record<string, number> // 按分类统计 category_stats: Record<string, number> // 按分类统计
mastery_level_dist: Record<string, number> // 掌握度分布 mastery_level_dist: Record<string, number> // 掌握度分布
tag_stats: Record<string, number> // 按标签统计
trend_data: TrendPoint[] // 错题趋势最近7天 trend_data: TrendPoint[] // 错题趋势最近7天
} }
// 错题标签
export interface WrongQuestionTag {
id: number
user_id: number
name: string
color: string
description: string
created_at: string
updated_at: string
}
// 错题筛选参数 // 错题筛选参数
export interface WrongQuestionFilter { export interface WrongQuestionFilter {
is_mastered?: boolean is_mastered?: boolean