Compare commits
2 Commits
42c54ec90a
...
61e32ef970
| Author | SHA1 | Date | |
|---|---|---|---|
| 61e32ef970 | |||
| e651910e74 |
1
go.mod
1
go.mod
@ -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
2
go.sum
@ -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=
|
||||||
|
|||||||
@ -21,7 +21,7 @@ func InitDB() error {
|
|||||||
var err error
|
var err error
|
||||||
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Info), // 开启SQL日志
|
Logger: logger.Default.LogMode(logger.Info), // 开启SQL日志
|
||||||
DisableForeignKeyConstraintWhenMigrating: true, // 迁移时禁用外键约束
|
DisableForeignKeyConstraintWhenMigrating: true, // 迁移时禁用外键约束
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to database: %w", err)
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
@ -33,13 +33,13 @@ func InitDB() error {
|
|||||||
err = DB.AutoMigrate(
|
err = DB.AutoMigrate(
|
||||||
&models.User{},
|
&models.User{},
|
||||||
&models.PracticeQuestion{},
|
&models.PracticeQuestion{},
|
||||||
&models.WrongQuestion{}, // 错题表
|
&models.PracticeProgress{}, // 练习进度表
|
||||||
&models.WrongQuestionHistory{}, // 错题历史表
|
&models.WrongQuestion{}, // 错题表
|
||||||
&models.WrongQuestionTag{}, // 错题标签表
|
&models.WrongQuestionHistory{}, // 错题历史表
|
||||||
&models.UserAnswerRecord{}, // 用户答题记录表
|
&models.UserAnswerRecord{}, // 用户答题记录表
|
||||||
&models.Exam{}, // 考试表(试卷)
|
&models.Exam{}, // 考试表(试卷)
|
||||||
&models.ExamRecord{}, // 考试记录表
|
&models.ExamRecord{}, // 考试记录表
|
||||||
&models.ExamUserAnswer{}, // 用户答案表
|
&models.ExamUserAnswer{}, // 用户答案表
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to migrate database: %w", err)
|
return fmt.Errorf("failed to migrate database: %w", err)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
@ -120,8 +120,8 @@ func CreateExam(c *gin.Context) {
|
|||||||
UserID: userID.(uint),
|
UserID: userID.(uint),
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
TotalScore: int(totalScore), // 总分100分
|
TotalScore: int(totalScore), // 总分100分
|
||||||
Duration: 60, // 固定60分钟
|
Duration: 60, // 固定60分钟
|
||||||
PassScore: 80, // 固定80分及格
|
PassScore: 80, // 固定80分及格
|
||||||
QuestionIDs: questionIDsJSON,
|
QuestionIDs: questionIDsJSON,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,38 +372,63 @@ 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 {
|
// 记录到数据库(忽略错误,不影响主流程)
|
||||||
log.Printf("记录答题历史失败: %v", err)
|
if err := db.Create(&record).Error; err != nil {
|
||||||
}
|
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 {
|
||||||
// 答错,记录到错题本
|
log.Printf("记录错题失败: %v", err)
|
||||||
var wrongAnswer interface{} = submit.Answer
|
}
|
||||||
var stdAnswer interface{} = correctAnswer
|
} else {
|
||||||
if strings.HasSuffix(question.Type, "-essay") {
|
// 答对,如果这道题在错题本中,更新连续答对次数
|
||||||
stdAnswer = "" // 论述题没有标准答案
|
if err := services.RecordCorrectAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil {
|
||||||
}
|
log.Printf("更新错题记录失败: %v", err)
|
||||||
if err := services.RecordWrongAnswer(uid, question.ID, wrongAnswer, stdAnswer, timeSpent); err != nil {
|
}
|
||||||
log.Printf("记录错题失败: %v", err)
|
}
|
||||||
}
|
|
||||||
} else {
|
// 保存练习进度到数据库
|
||||||
// 答对,如果这道题在错题本中,更新连续答对次数
|
// 查找或创建进度记录
|
||||||
if err := services.RecordCorrectAnswer(uid, question.ID, submit.Answer, correctAnswer, timeSpent); err != nil {
|
var progress models.PracticeProgress
|
||||||
log.Printf("更新错题记录失败: %v", err)
|
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,71 +491,116 @@ 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 比较
|
|
||||||
userBool, ok1 := userAnswer.(bool)
|
|
||||||
correctBool, ok2 := correctAnswer.(bool)
|
|
||||||
return ok1 && ok2 && userBool == correctBool
|
|
||||||
|
|
||||||
case "multiple-choice":
|
type CheckTrueFalse struct {
|
||||||
// 单选题: 字符串比较
|
}
|
||||||
userStr, ok1 := userAnswer.(string)
|
|
||||||
correctStr, ok2 := correctAnswer.(string)
|
|
||||||
return ok1 && ok2 && userStr == correctStr
|
|
||||||
|
|
||||||
case "multiple-selection":
|
func (*CheckTrueFalse) check(userAnswer, correctAnswer interface{}) bool {
|
||||||
// 多选题: 数组比较
|
userBool, ok1 := userAnswer.(bool)
|
||||||
userArr, ok1 := toStringArray(userAnswer)
|
correctBool, ok2 := correctAnswer.(bool)
|
||||||
correctArr, ok2 := toStringArray(correctAnswer)
|
return ok1 && ok2 && userBool == correctBool
|
||||||
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为map进行比较
|
type MultipleChoice struct {
|
||||||
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":
|
func (*MultipleChoice) check(userAnswer, correctAnswer interface{}) bool {
|
||||||
// 填空题: 数组比较
|
userStr, ok1 := userAnswer.(string)
|
||||||
userArr, ok1 := toStringArray(userAnswer)
|
correctStr, ok2 := correctAnswer.(string)
|
||||||
correctArr, ok2 := toStringArray(correctAnswer)
|
return ok1 && ok2 && userStr == correctStr
|
||||||
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
|
}
|
||||||
log.Printf("填空题验证失败 - 数组转换或长度不匹配: ok1=%v, ok2=%v, userLen=%d, correctLen=%d",
|
|
||||||
ok1, ok2, len(userArr), len(correctArr))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 逐个比较填空答案(去除前后空格)
|
type MultipleSelection struct {
|
||||||
for i := range correctArr {
|
}
|
||||||
userTrimmed := strings.TrimSpace(userArr[i])
|
|
||||||
correctTrimmed := strings.TrimSpace(correctArr[i])
|
|
||||||
if userTrimmed != correctTrimmed {
|
|
||||||
log.Printf("填空题验证失败 - 第%d个答案不匹配: user='%s', correct='%s'",
|
|
||||||
i+1, userTrimmed, correctTrimmed)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
|
|
||||||
case "short-answer":
|
func (*MultipleSelection) check(userAnswer, correctAnswer interface{}) bool {
|
||||||
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
|
// 多选题: 数组比较
|
||||||
userStr, ok1 := userAnswer.(string)
|
userArr, ok1 := toStringArray(userAnswer)
|
||||||
correctStr, ok2 := correctAnswer.(string)
|
correctArr, ok2 := toStringArray(correctAnswer)
|
||||||
return ok1 && ok2 && userStr == correctStr
|
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type FillInBlank struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*FillInBlank) check(userAnswer, correctAnswer interface{}) bool {
|
||||||
|
// 填空题: 数组比较
|
||||||
|
userArr, ok1 := toStringArray(userAnswer)
|
||||||
|
correctArr, ok2 := toStringArray(correctAnswer)
|
||||||
|
if !ok1 || !ok2 || len(userArr) != len(correctArr) {
|
||||||
|
log.Printf("填空题验证失败 - 数组转换或长度不匹配: ok1=%v, ok2=%v, userLen=%d, correctLen=%d",
|
||||||
|
ok1, ok2, len(userArr), len(correctArr))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐个比较填空答案(去除前后空格)
|
||||||
|
for i := range correctArr {
|
||||||
|
userTrimmed := strings.TrimSpace(userArr[i])
|
||||||
|
correctTrimmed := strings.TrimSpace(correctArr[i])
|
||||||
|
if userTrimmed != correctTrimmed {
|
||||||
|
log.Printf("填空题验证失败 - 第%d个答案不匹配: user='%s', correct='%s'",
|
||||||
|
i+1, userTrimmed, correctTrimmed)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortAnswer struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ShortAnswer) check(userAnswer, correctAnswer interface{}) bool {
|
||||||
|
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
|
||||||
|
userStr, ok1 := userAnswer.(string)
|
||||||
|
correctStr, ok2 := correctAnswer.(string)
|
||||||
|
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 answers.check(questionType, userAnswer, correctAnswer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toStringArray 将interface{}转换为字符串数组
|
// toStringArray 将interface{}转换为字符串数组
|
||||||
@ -556,7 +628,7 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
|
|||||||
dto := models.PracticeQuestionDTO{
|
dto := models.PracticeQuestionDTO{
|
||||||
ID: question.ID,
|
ID: question.ID,
|
||||||
QuestionID: question.QuestionID,
|
QuestionID: question.QuestionID,
|
||||||
Type: question.Type, // 直接使用数据库中的type,不做映射
|
Type: question.Type, // 直接使用数据库中的type,不做映射
|
||||||
Content: question.Question,
|
Content: question.Question,
|
||||||
Category: question.TypeName, // 使用typeName作为分类显示
|
Category: question.TypeName, // 使用typeName作为分类显示
|
||||||
Options: []models.Option{},
|
Options: []models.Option{},
|
||||||
@ -697,11 +769,11 @@ func GetStatistics(c *gin.Context) {
|
|||||||
// CreatePracticeQuestion 创建新的练习题目
|
// CreatePracticeQuestion 创建新的练习题目
|
||||||
func CreatePracticeQuestion(c *gin.Context) {
|
func CreatePracticeQuestion(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Type string `json:"type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
TypeName string `json:"type_name"`
|
TypeName string `json:"type_name"`
|
||||||
Question string `json:"question" binding:"required"`
|
Question string `json:"question" binding:"required"`
|
||||||
Answer interface{} `json:"answer" binding:"required"`
|
Answer interface{} `json:"answer" binding:"required"`
|
||||||
Options map[string]string `json:"options"`
|
Options map[string]string `json:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@ -801,11 +873,11 @@ func UpdatePracticeQuestion(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
TypeName string `json:"type_name"`
|
TypeName string `json:"type_name"`
|
||||||
Question string `json:"question"`
|
Question string `json:"question"`
|
||||||
Answer interface{} `json:"answer"`
|
Answer interface{} `json:"answer"`
|
||||||
Options map[string]string `json:"options"`
|
Options map[string]string `json:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换题目信息
|
// 转换题目信息
|
||||||
|
|||||||
@ -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 指定表名
|
||||||
|
|||||||
@ -13,12 +13,12 @@ type Exam struct {
|
|||||||
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:"-"`
|
||||||
UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID
|
UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID
|
||||||
Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题
|
Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题
|
||||||
TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分
|
TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分
|
||||||
Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟)
|
Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟)
|
||||||
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
||||||
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
|
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
|
Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,15 +28,15 @@ type ExamRecord struct {
|
|||||||
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:"-"`
|
||||||
ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID
|
ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID
|
||||||
UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID
|
UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID
|
||||||
StartTime *time.Time `json:"start_time"` // 开始时间
|
StartTime *time.Time `json:"start_time"` // 开始时间
|
||||||
SubmitTime *time.Time `json:"submit_time"` // 提交时间
|
SubmitTime *time.Time `json:"submit_time"` // 提交时间
|
||||||
TimeSpent int `json:"time_spent"` // 实际用时(秒)
|
TimeSpent int `json:"time_spent"` // 实际用时(秒)
|
||||||
Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分
|
Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分
|
||||||
TotalScore int `json:"total_score"` // 总分
|
TotalScore int `json:"total_score"` // 总分
|
||||||
Status string `gorm:"type:varchar(20);not null;default:'in_progress'" json:"status"` // 状态: in_progress, submitted, graded
|
Status string `gorm:"type:varchar(20);not null;default:'in_progress'" json:"status"` // 状态: in_progress, submitted, graded
|
||||||
IsPassed bool `json:"is_passed"` // 是否通过
|
IsPassed bool `json:"is_passed"` // 是否通过
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
|
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
|
||||||
@ -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"` // 得分
|
||||||
@ -60,7 +60,7 @@ type ExamUserAnswer struct {
|
|||||||
LastModifiedAt time.Time `json:"last_modified_at"` // 最后修改时间
|
LastModifiedAt time.Time `json:"last_modified_at"` // 最后修改时间
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"`
|
ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"`
|
||||||
Question *PracticeQuestion `gorm:"foreignKey:QuestionID" json:"-"`
|
Question *PracticeQuestion `gorm:"foreignKey:QuestionID" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,9 +81,9 @@ 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"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
AIGrading *AIGrading `json:"ai_grading,omitempty"`
|
AIGrading *AIGrading `json:"ai_grading,omitempty"`
|
||||||
@ -91,41 +91,40 @@ type ExamAnswer struct {
|
|||||||
|
|
||||||
// ExamQuestionConfig 考试题目配置
|
// ExamQuestionConfig 考试题目配置
|
||||||
type ExamQuestionConfig struct {
|
type ExamQuestionConfig struct {
|
||||||
FillInBlank int `json:"fill_in_blank"` // 填空题数量
|
FillInBlank int `json:"fill_in_blank"` // 填空题数量
|
||||||
TrueFalse int `json:"true_false"` // 判断题数量
|
TrueFalse int `json:"true_false"` // 判断题数量
|
||||||
MultipleChoice int `json:"multiple_choice"` // 单选题数量
|
MultipleChoice int `json:"multiple_choice"` // 单选题数量
|
||||||
MultipleSelection int `json:"multiple_selection"` // 多选题数量
|
MultipleSelection int `json:"multiple_selection"` // 多选题数量
|
||||||
ShortAnswer int `json:"short_answer"` // 简答题数量
|
ShortAnswer int `json:"short_answer"` // 简答题数量
|
||||||
OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量
|
OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量
|
||||||
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
|
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DefaultExamConfig 默认考试配置
|
// DefaultExamConfig 默认考试配置
|
||||||
var DefaultExamConfig = ExamQuestionConfig{
|
var DefaultExamConfig = ExamQuestionConfig{
|
||||||
FillInBlank: 10, // 填空题10道
|
FillInBlank: 10, // 填空题10道
|
||||||
TrueFalse: 10, // 判断题10道
|
TrueFalse: 10, // 判断题10道
|
||||||
MultipleChoice: 10, // 单选题10道
|
MultipleChoice: 10, // 单选题10道
|
||||||
MultipleSelection: 10, // 多选题10道
|
MultipleSelection: 10, // 多选题10道
|
||||||
ShortAnswer: 2, // 简答题2道
|
ShortAnswer: 2, // 简答题2道
|
||||||
OrdinaryEssay: 1, // 普通论述题1道
|
OrdinaryEssay: 1, // 普通论述题1道
|
||||||
ManagementEssay: 1, // 管理论述题1道
|
ManagementEssay: 1, // 管理论述题1道
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExamScoreConfig 考试分值配置
|
// ExamScoreConfig 考试分值配置
|
||||||
type ExamScoreConfig struct {
|
type ExamScoreConfig struct {
|
||||||
FillInBlank float64 `json:"fill_in_blank"` // 填空题分值
|
FillInBlank float64 `json:"fill_in_blank"` // 填空题分值
|
||||||
TrueFalse float64 `json:"true_false"` // 判断题分值
|
TrueFalse float64 `json:"true_false"` // 判断题分值
|
||||||
MultipleChoice float64 `json:"multiple_choice"` // 单选题分值
|
MultipleChoice float64 `json:"multiple_choice"` // 单选题分值
|
||||||
MultipleSelection float64 `json:"multiple_selection"` // 多选题分值
|
MultipleSelection float64 `json:"multiple_selection"` // 多选题分值
|
||||||
Essay float64 `json:"essay"` // 论述题分值
|
Essay float64 `json:"essay"` // 论述题分值
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultScoreConfig 默认分值配置
|
// DefaultScoreConfig 默认分值配置
|
||||||
var DefaultScoreConfig = ExamScoreConfig{
|
var DefaultScoreConfig = ExamScoreConfig{
|
||||||
FillInBlank: 2.0, // 填空题每题2分 (共20分)
|
FillInBlank: 2.0, // 填空题每题2分 (共20分)
|
||||||
TrueFalse: 2.0, // 判断题每题2分 (共20分)
|
TrueFalse: 2.0, // 判断题每题2分 (共20分)
|
||||||
MultipleChoice: 1.0, // 单选题每题1分 (共10分)
|
MultipleChoice: 1.0, // 单选题每题1分 (共10分)
|
||||||
MultipleSelection: 2.5, // 多选题每题2.5分 (共25分)
|
MultipleSelection: 2.5, // 多选题每题2.5分 (共25分)
|
||||||
Essay: 25.0, // 论述题25分
|
Essay: 25.0, // 论述题25分
|
||||||
}
|
}
|
||||||
|
|||||||
19
internal/models/practice_progress.go
Normal file
19
internal/models/practice_progress.go
Normal 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"
|
||||||
|
}
|
||||||
@ -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"` // 用户答案
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,10 +9,10 @@ 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
|
||||||
Avatar string `gorm:"size:255" json:"avatar"`
|
Avatar string `gorm:"size:255" json:"avatar"`
|
||||||
Nickname string `gorm:"size:50" json:"nickname"`
|
Nickname string `gorm:"size:50" json:"nickname"`
|
||||||
UserType string `gorm:"size:50" json:"user_type"` // 用户类型: ordinary-person 或 management-person
|
UserType string `gorm:"size:50" json:"user_type"` // 用户类型: ordinary-person 或 management-person
|
||||||
@ -48,7 +48,7 @@ type LoginRequest struct {
|
|||||||
|
|
||||||
// LoginResponse 登录响应
|
// LoginResponse 登录响应
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
User UserInfoResponse `json:"user"`
|
User UserInfoResponse `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -10,82 +8,35 @@ 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:"-"`
|
||||||
|
|
||||||
// 关联
|
// 关联
|
||||||
PracticeQuestion *PracticeQuestion `gorm:"foreignKey:QuestionID;references:ID" json:"question,omitempty"`
|
PracticeQuestion *PracticeQuestion `gorm:"foreignKey:QuestionID;references:ID" json:"question,omitempty"`
|
||||||
History []WrongQuestionHistory `gorm:"foreignKey:WrongQuestionID" json:"history,omitempty"`
|
History []WrongQuestionHistory `gorm:"foreignKey:WrongQuestionID" json:"history,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,29 +47,24 @@ 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"`
|
||||||
TotalWrongCount int `json:"total_wrong_count"`
|
TotalWrongCount int `json:"total_wrong_count"`
|
||||||
MasteryLevel int `json:"mastery_level"`
|
MasteryLevel int `json:"mastery_level"`
|
||||||
ConsecutiveCorrect int `json:"consecutive_correct"`
|
ConsecutiveCorrect int `json:"consecutive_correct"`
|
||||||
IsMastered bool `json:"is_mastered"`
|
IsMastered bool `json:"is_mastered"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
RecentHistory []WrongQuestionHistoryDTO `json:"recent_history,omitempty"` // 最近3次历史
|
RecentHistory []WrongQuestionHistoryDTO `json:"recent_history,omitempty"` // 最近3次历史
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"`
|
||||||
@ -128,15 +74,14 @@ type WrongQuestionHistoryDTO struct {
|
|||||||
|
|
||||||
// WrongQuestionStats 错题统计
|
// WrongQuestionStats 错题统计
|
||||||
type WrongQuestionStats struct {
|
type WrongQuestionStats struct {
|
||||||
TotalWrong int `json:"total_wrong"` // 总错题数
|
TotalWrong int `json:"total_wrong"` // 总错题数
|
||||||
Mastered int `json:"mastered"` // 已掌握数
|
Mastered int `json:"mastered"` // 已掌握数
|
||||||
NotMastered int `json:"not_mastered"` // 未掌握数
|
NotMastered int `json:"not_mastered"` // 未掌握数
|
||||||
NeedReview int `json:"need_review"` // 需要复习数
|
NeedReview int `json:"need_review"` // 需要复习数
|
||||||
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"` // 错题趋势
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrendPoint 趋势数据点
|
// TrendPoint 趋势数据点
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
23
main.go
23
main.go
@ -63,23 +63,16 @@ 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) // 创建试卷
|
||||||
auth.GET("/exams", handlers.GetExamList) // 获取试卷列表
|
auth.GET("/exams", handlers.GetExamList) // 获取试卷列表
|
||||||
auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情
|
auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情
|
||||||
auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试
|
auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试
|
||||||
auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案
|
auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案
|
||||||
auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情
|
auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情
|
||||||
auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表
|
auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表
|
||||||
auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷
|
auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷
|
||||||
auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度
|
auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度
|
||||||
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ==========
|
||||||
|
|
||||||
// 创建题目
|
// 创建题目
|
||||||
|
|||||||
@ -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') {
|
||||||
setFillAnswers([])
|
// 如果 selectedAnswer 是数组且有内容,使用它;否则重置为空数组
|
||||||
|
if (Array.isArray(selectedAnswer) && selectedAnswer.length > 0) {
|
||||||
|
setFillAnswers(selectedAnswer)
|
||||||
|
} else {
|
||||||
|
setFillAnswers([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [question.id, question.type])
|
}, [question.id, question.type, selectedAnswer])
|
||||||
|
|
||||||
// 渲染填空题内容
|
// 渲染填空题内容
|
||||||
const renderFillContent = () => {
|
const renderFillContent = () => {
|
||||||
|
|||||||
@ -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,23 +108,69 @@ const QuestionPage: React.FC = () => {
|
|||||||
questionResultsObj[key] = value;
|
questionResultsObj[key] = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
localStorage.setItem(
|
const progressData = {
|
||||||
key,
|
currentIndex: index,
|
||||||
JSON.stringify({
|
correctCount: correct,
|
||||||
currentIndex: index,
|
wrongCount: wrong,
|
||||||
correctCount: correct,
|
answeredStatus: answeredStatusObj,
|
||||||
wrongCount: wrong,
|
userAnswers: userAnswersObj,
|
||||||
answeredStatus: answeredStatusObj,
|
questionResults: questionResultsObj,
|
||||||
userAnswers: userAnswersObj,
|
timestamp: Date.now(),
|
||||||
questionResults: questionResultsObj,
|
};
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
// 保存到 localStorage(作为本地快速备份)
|
||||||
);
|
localStorage.setItem(key, JSON.stringify(progressData));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 恢复答题进度
|
// 恢复答题进度(优先从后端加载,fallback 到 localStorage)
|
||||||
const loadProgress = () => {
|
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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user