diff --git a/docs/fix_practice_progress_index.md b/docs/fix_practice_progress_index.md new file mode 100644 index 0000000..6da1eef --- /dev/null +++ b/docs/fix_practice_progress_index.md @@ -0,0 +1,191 @@ +# 修复 practice_progress 表索引问题 + +## 问题描述 + +在测试中发现,除了单选题(multiple-choice)能正常插入进度数据外,其他类型的题目无法插入数据到 `practice_progress` 表。 + +## 问题原因 + +### 错误的索引定义 + +**之前的模型定义**(错误): +```go +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"` // ❌ 只在 user_id 上建索引 + Type string `gorm:"type:varchar(255);not null" json:"type"` + UserAnswerRecords datatypes.JSON `gorm:"type:jsonp" json:"answers"` +} +``` + +**问题**: +- 唯一索引 `idx_user_question` 只在 `user_id` 字段上 +- 同一用户只能有一条进度记录 +- 当用户答第二种题型时,因为 `user_id` 重复,插入失败 +- 日志中应该会看到类似错误:`duplicate key value violates unique constraint "idx_user_question"` + +### 正确的索引定义 + +**修复后的模型定义**(正确): +```go +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_type" json:"user_id"` // ✅ 联合索引 + Type string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_type" json:"type"` // ✅ 联合索引 + UserAnswerRecords datatypes.JSON `gorm:"type:jsonb" json:"answers"` +} +``` + +**改进**: +- 唯一索引 `idx_user_type` 是 `(user_id, type)` 的联合索引 +- 同一用户可以有多条进度记录(每种题型一条) +- 例如:用户1 可以有 `(1, "multiple-choice")` 和 `(1, "true-false")` 两条记录 + +## 解决方案 + +### 方案1:使用 GORM 自动迁移(推荐) + +1. **停止当前服务** + +2. **删除旧表并重建**(谨慎:会丢失所有进度数据) + + 连接到 PostgreSQL 数据库: + ```bash + psql -U your_username -d your_database + ``` + + 执行: + ```sql + DROP TABLE IF EXISTS practice_progress; + ``` + +3. **重启服务,GORM 会自动创建正确的表结构** + ```bash + .\bin\server.exe + ``` + +### 方案2:手动修复索引(保留数据) + +1. **连接到 PostgreSQL 数据库**: + ```bash + psql -U your_username -d your_database + ``` + +2. **执行 SQL 脚本**: + ```bash + \i scripts/fix_practice_progress_index.sql + ``` + + 或手动执行: + ```sql + -- 删除旧索引 + DROP INDEX IF EXISTS idx_user_question; + + -- 创建新索引 + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_type ON practice_progress(user_id, type); + ``` + +3. **验证索引**: + ```sql + SELECT indexname, indexdef + FROM pg_indexes + WHERE tablename = 'practice_progress'; + ``` + + 应该看到: + ``` + indexname | indexdef + --------------|-------------------------------------------------- + idx_user_type | CREATE UNIQUE INDEX idx_user_type ON practice_progress USING btree (user_id, type) + ``` + +4. **检查现有数据是否有冲突**: + ```sql + SELECT user_id, type, COUNT(*) + FROM practice_progress + GROUP BY user_id, type + HAVING COUNT(*) > 1; + ``` + + 如果有重复数据,需要手动清理: + ```sql + -- 保留每组的最新记录,删除旧记录 + DELETE FROM practice_progress a + WHERE id NOT IN ( + SELECT MAX(id) + FROM practice_progress b + WHERE a.user_id = b.user_id AND a.type = b.type + ); + ``` + +## 验证修复 + +### 1. 检查表结构 +```sql +\d practice_progress +``` + +应该看到: +``` +Indexes: + "practice_progress_pkey" PRIMARY KEY, btree (id) + "idx_user_type" UNIQUE, btree (user_id, type) +``` + +### 2. 测试插入不同题型 + +**测试步骤**: +1. 登录系统 +2. 选择"单选题",答几道题 +3. 切换到"多选题",答几道题 +4. 切换到"判断题",答几道题 + +**检查数据库**: +```sql +SELECT id, user_id, type, current_question_id +FROM practice_progress +WHERE user_id = 1; -- 替换为你的用户ID +``` + +应该看到多条记录: +``` +id | user_id | type | current_question_id +---|---------|---------------------|-------------------- +1 | 1 | multiple-choice | 157 +2 | 1 | multiple-selection | 45 +3 | 1 | true-false | 10 +``` + +### 3. 检查后端日志 + +如果之前有错误,应该不再看到类似日志: +``` +保存练习进度失败: duplicate key value violates unique constraint "idx_user_question" +``` + +## 其他注意事项 + +1. **JSONB vs JSONP**: + - 修改了 `UserAnswerRecords` 的类型从 `jsonp` 改为 `jsonb` + - `jsonb` 是正确的 PostgreSQL JSON 类型 + - 性能更好,支持索引 + +2. **数据备份**: + - 在修改表结构前,建议备份数据: + ```bash + pg_dump -U your_username -d your_database -t practice_progress > backup_practice_progress.sql + ``` + +3. **回滚方案**: + - 如果需要回滚,可以恢复备份: + ```bash + psql -U your_username -d your_database < backup_practice_progress.sql + ``` + +## 相关文件 + +- 模型定义:`internal/models/practice_progress.go` +- 写入逻辑:`internal/handlers/practice_handler.go:356-387` +- SQL 修复脚本:`scripts/fix_practice_progress_index.sql` diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go index e4beff5..42264c2 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -335,6 +335,15 @@ func SubmitPracticeAnswer(c *gin.Context) { AnsweredAt: time.Now(), UserAnswer: userAnswer, } + + // 如果有 AI 评分,保存到数据库 + if aiGrading != nil { + record.AIScore = &aiGrading.Score + record.AIFeedback = &aiGrading.Feedback + record.AISuggestion = &aiGrading.Suggestion + log.Printf("[AI评分] 保存AI评分到数据库: score=%.2f, feedback=%s", aiGrading.Score, aiGrading.Feedback) + } + // 记录到数据库(忽略错误,不影响主流程) if err := db.Create(&record).Error; err != nil { log.Printf("记录答题历史失败: %v", err) @@ -980,3 +989,179 @@ func ExplainQuestion(c *gin.Context) { c.Writer.(http.Flusher).Flush() } } + +// GetPracticeProgress 获取练习进度 +func GetPracticeProgress(c *gin.Context) { + // 获取用户ID + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + uid, ok := userID.(int64) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "用户ID格式错误", + }) + return + } + + // 获取可选的 type 参数(题目类型) + typeParam := c.Query("type") + + db := database.GetDB() + + // 构建查询 + query := db.Where("user_id = ?", uid) + if typeParam != "" { + query = query.Where("type = ?", typeParam) + } + + // 查询进度记录 + var progressList []models.PracticeProgress + if err := query.Find(&progressList).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "查询进度失败", + }) + return + } + + // 构建返回数据 + type AnsweredQuestion struct { + QuestionID int64 `json:"question_id"` + RecordID int64 `json:"record_id"` + IsCorrect bool `json:"is_correct"` + UserAnswer interface{} `json:"user_answer"` + CorrectAnswer interface{} `json:"correct_answer"` // 正确答案 + AnsweredAt string `json:"answered_at"` + // AI 评分相关(仅简答题有值) + AIScore *float64 `json:"ai_score,omitempty"` + AIFeedback *string `json:"ai_feedback,omitempty"` + AISuggestion *string `json:"ai_suggestion,omitempty"` + } + + type ProgressData struct { + Type string `json:"type"` + CurrentQuestionID int64 `json:"current_question_id"` + AnsweredQuestions []AnsweredQuestion `json:"answered_questions"` + } + + result := make([]ProgressData, 0, len(progressList)) + + for _, progress := range progressList { + // 解析 UserAnswerRecords(map[question_id]record_id) + var answerRecords map[int64]int64 + if err := json.Unmarshal(progress.UserAnswerRecords, &answerRecords); err != nil { + log.Printf("解析答题记录失败: %v", err) + continue + } + + // 查询每个答题记录的详细信息 + answeredQuestions := make([]AnsweredQuestion, 0, len(answerRecords)) + for questionID, recordID := range answerRecords { + var record models.UserAnswerRecord + if err := db.First(&record, recordID).Error; err != nil { + log.Printf("查询答题记录失败 (record_id: %d): %v", recordID, err) + continue + } + + // 解析用户答案 + var userAnswer interface{} + if err := json.Unmarshal(record.UserAnswer, &userAnswer); err != nil { + log.Printf("解析用户答案失败 (record_id: %d): %v", recordID, err) + continue + } + + // 查询题目的正确答案 + var question models.PracticeQuestion + var correctAnswer interface{} + if err := db.First(&question, questionID).Error; err == nil { + if question.AnswerData != "" { + // 解析正确答案 + if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil { + log.Printf("解析正确答案失败 (question_id: %d): %v", questionID, err) + } + } + } + + answeredQuestions = append(answeredQuestions, AnsweredQuestion{ + QuestionID: questionID, + RecordID: recordID, + IsCorrect: record.IsCorrect, + UserAnswer: userAnswer, + CorrectAnswer: correctAnswer, // 包含正确答案 + AnsweredAt: record.AnsweredAt.Format("2006-01-02 15:04:05"), + // AI 评分字段(如果有的话) + AIScore: record.AIScore, + AIFeedback: record.AIFeedback, + AISuggestion: record.AISuggestion, + }) + } + + result = append(result, ProgressData{ + Type: progress.Type, + CurrentQuestionID: progress.CurrentQuestionID, + AnsweredQuestions: answeredQuestions, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result, + }) +} + +// ClearPracticeProgress 清除练习进度 +func ClearPracticeProgress(c *gin.Context) { + // 获取用户ID + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + uid, ok := userID.(int64) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "用户ID格式错误", + }) + return + } + + // 获取可选的 type 参数 + questionType := c.Query("type") + + db := database.GetDB() + + // 如果指定了 type,只删除该类型的进度记录;否则删除所有进度记录 + query := db.Where("user_id = ?", uid) + if questionType != "" { + query = query.Where("type = ?", questionType) + log.Printf("[清除进度] 用户 %d 清除类型 %s 的进度", uid, questionType) + } else { + log.Printf("[清除进度] 用户 %d 清除所有进度", uid) + } + + if err := query.Delete(&models.PracticeProgress{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "清除进度失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "进度已清除", + }) +} diff --git a/internal/models/answer_record.go b/internal/models/answer_record.go index 9950907..67d0f51 100644 --- a/internal/models/answer_record.go +++ b/internal/models/answer_record.go @@ -13,6 +13,11 @@ type UserAnswerRecord struct { IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对 AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间 UserAnswer datatypes.JSON `gorm:"json" json:"user_answer"` + + // AI 评分相关字段(仅简答题有值) + AIScore *float64 `gorm:"type:decimal(5,2)" json:"ai_score,omitempty"` // AI 评分 (0-100) + AIFeedback *string `gorm:"type:text" json:"ai_feedback,omitempty"` // AI 评语 + AISuggestion *string `gorm:"type:text" json:"ai_suggestion,omitempty"` // AI 改进建议 } // TableName 指定表名 diff --git a/internal/models/practice_progress.go b/internal/models/practice_progress.go index aabb5db..3b3756d 100644 --- a/internal/models/practice_progress.go +++ b/internal/models/practice_progress.go @@ -4,13 +4,13 @@ import ( "gorm.io/datatypes" ) -// PracticeProgress 练习进度记录(每道题目一条记录) +// 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"` + UserID int64 `gorm:"not null;uniqueIndex:idx_user_type" json:"user_id"` + Type string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_type" json:"type"` + UserAnswerRecords datatypes.JSON `gorm:"type:jsonb" json:"answers"` } // TableName 指定表名 diff --git a/main.go b/main.go index 690f0f0..f084625 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,10 @@ func main() { auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案 auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 + // 练习进度相关API + auth.GET("/practice/progress", handlers.GetPracticeProgress) // 获取练习进度 + auth.DELETE("/practice/progress", handlers.ClearPracticeProgress) // 清除练习进度 + // 错题本相关API auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表(支持筛选和排序) auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计(含趋势) diff --git a/scripts/fix_practice_progress_index.sql b/scripts/fix_practice_progress_index.sql new file mode 100644 index 0000000..4de8b08 --- /dev/null +++ b/scripts/fix_practice_progress_index.sql @@ -0,0 +1,18 @@ +-- 修复 practice_progress 表的唯一索引 +-- 问题:之前的唯一索引只在 user_id 上,导致同一用户只能有一条进度记录 +-- 修复:改为 (user_id, type) 联合唯一索引,允许同一用户有多种题型的进度 + +-- 1. 删除旧的唯一索引(如果存在) +DROP INDEX IF EXISTS idx_user_question; + +-- 2. 创建新的联合唯一索引 +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_type ON practice_progress(user_id, type); + +-- 3. 验证索引 +SELECT + indexname, + indexdef +FROM + pg_indexes +WHERE + tablename = 'practice_progress'; diff --git a/web/src/api/question.ts b/web/src/api/question.ts index 9a49081..b0f1202 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -32,33 +32,37 @@ export const getStatistics = () => { // ========== 练习进度相关 API ========== -// 单题进度记录 -export interface PracticeProgressItem { +// 答题记录 +export interface AnsweredQuestion { question_id: number - correct: boolean | null // null=未答,true=答对,false=答错 - answer_sequence: number // 答题序号(第几次答题) + record_id: number + is_correct: boolean user_answer: any - updated_at: string + correct_answer: any // 正确答案 + answered_at: string + // AI 评分相关(仅简答题有值) + ai_score?: number + ai_feedback?: string + ai_suggestion?: string } -// 保存单题练习进度 -export const savePracticeProgress = (data: { - question_id: number - correct: boolean | null - answer_sequence: number - user_answer: any -}) => { - return request.post>('/practice/progress', data) +// 进度数据(按题目类型) +export interface PracticeProgressData { + type: string + current_question_id: number + answered_questions: AnsweredQuestion[] } -// 获取练习进度(返回用户所有题目的进度) -export const getPracticeProgress = () => { - return request.get>('/practice/progress') +// 获取练习进度(可选type参数) +export const getPracticeProgress = (type?: string) => { + const params = type ? { type } : undefined + return request.get>('/practice/progress', { params }) } -// 清除练习进度 -export const clearPracticeProgress = () => { - return request.delete>('/practice/progress') +// 清除练习进度(可选type参数,指定类型则只清除该类型的进度) +export const clearPracticeProgress = (type?: string) => { + const params = type ? { type } : undefined + return request.delete>('/practice/progress', { params }) } // 重置进度 (暂时返回模拟数据,后续实现) diff --git a/web/src/components/QuestionCard.tsx b/web/src/components/QuestionCard.tsx index 14403da..bd85f8b 100644 --- a/web/src/components/QuestionCard.tsx +++ b/web/src/components/QuestionCard.tsx @@ -20,6 +20,8 @@ interface QuestionCardProps { onNext: () => void onRetry?: () => void // 新增:重新答题回调 mode?: string // 新增:答题模式(用于判断是否是错题练习) +answerSequence?: number // 新增:答题序号(第几次答题) + hasHistory?: boolean // 新增:是否有历史答案 } const QuestionCard: React.FC = ({ @@ -34,6 +36,8 @@ const QuestionCard: React.FC = ({ onNext, onRetry, mode, + answerSequence, + hasHistory, }) => { const [fillAnswers, setFillAnswers] = useState([]) @@ -170,6 +174,10 @@ const QuestionCard: React.FC = ({ 第 {question.question_id} 题 {question.category} + {/* 显示答题历史提示 */} + {hasHistory && answerSequence && answerSequence > 1 && ( + 第 {answerSequence} 次答题 + )} {question.type === 'fill-in-blank' ? ( diff --git a/web/src/pages/Question.tsx b/web/src/pages/Question.tsx index 38847a4..8c33cf9 100644 --- a/web/src/pages/Question.tsx +++ b/web/src/pages/Question.tsx @@ -37,6 +37,9 @@ const QuestionPage: React.FC = () => { const [userAnswers, setUserAnswers] = useState>(new Map()); const [questionResults, setQuestionResults] = useState>(new Map()); + // 存储每道题的答题序号(第几次答题) + const [answerSequences, setAnswerSequences] = useState>(new Map()); + // 设置弹窗 const [settingsVisible, setSettingsVisible] = useState(false); @@ -80,138 +83,120 @@ const QuestionPage: React.FC = () => { } }; - // 从localStorage恢复答题进度 - const getStorageKey = () => { - const type = searchParams.get("type"); - const mode = searchParams.get("mode"); - return `question_progress_${type || mode || "default"}`; - }; + // 恢复答题进度(完全从后端数据库加载) + const loadProgress = async (questions: Question[], type: string) => { + console.log('[进度加载] 开始加载进度 (type:', type, ', questions:', questions.length, ')'); - // 保存答题进度(仅保存到 localStorage 作为快速备份) - const saveProgressToLocal = (index: number, correct: number, wrong: number, statusMap?: Map) => { - const key = getStorageKey(); - const answeredStatusObj: Record = {}; - const userAnswersObj: Record = {}; - const questionResultsObj: Record = {}; - - // 将 Map 转换为普通对象以便 JSON 序列化 - const mapToSave = statusMap || answeredStatus; - mapToSave.forEach((value, key) => { - answeredStatusObj[key] = value; - }); - - userAnswers.forEach((value, key) => { - userAnswersObj[key] = value; - }); - - questionResults.forEach((value, key) => { - questionResultsObj[key] = value; - }); - - const progressData = { - currentIndex: index, - correctCount: correct, - wrongCount: wrong, - answeredStatus: answeredStatusObj, - userAnswers: userAnswersObj, - questionResults: questionResultsObj, - timestamp: Date.now(), - }; - - // 保存到 localStorage(作为本地快速备份) - localStorage.setItem(key, JSON.stringify(progressData)); - }; - - // 恢复答题进度(优先从后端加载,fallback 到 localStorage) - const loadProgress = async () => { - const key = getStorageKey(); + if (!type || questions.length === 0) { + console.log('[进度加载] 参数无效,跳过加载'); + return { index: 0, hasAnswer: false }; + } try { - // 优先从后端加载进度 - const res = await questionApi.getPracticeProgress(); - if (res.success && res.data && res.data.length > 0 && allQuestions.length > 0) { - // 将后端数据转换为前端的 Map 格式 + // 从后端加载该类型的进度数据 + console.log('[进度加载] 调用 API: GET /api/practice/progress?type=' + type); + const res = await questionApi.getPracticeProgress(type); + console.log('[进度加载] API 响应:', res); + + if (res.success && res.data && res.data.length > 0) { + // 取第一条记录(每个type只有一条进度记录) + const progressData = res.data[0]; + console.log('[进度加载] 进度数据:', progressData); + // 创建 question_id 到索引的映射 const questionIdToIndex = new Map(); - allQuestions.forEach((q, idx) => { + questions.forEach((q, idx) => { questionIdToIndex.set(q.id, idx); }); + // 根据 current_question_id 定位到题目索引 + const currentIndex = questionIdToIndex.get(progressData.current_question_id); + console.log('[进度加载] current_question_id:', progressData.current_question_id, ', 对应索引:', currentIndex); + + // 解析已答题目的状态 const statusMap = new Map(); const answersMap = new Map(); + const sequencesMap = new Map(); + const resultsMap = new Map(); - let maxIndex = 0; let correct = 0; let wrong = 0; - res.data.forEach((item) => { + // 遍历所有已答题目 + progressData.answered_questions.forEach((item) => { const index = questionIdToIndex.get(item.question_id); if (index !== undefined) { - statusMap.set(index, item.correct); - answersMap.set(index, item.user_answer); + statusMap.set(index, item.is_correct); - if (item.correct !== null) { - maxIndex = Math.max(maxIndex, index); - if (item.correct) correct++; - else wrong++; + // 对于判断题,需要将布尔值转换为字符串(Radio.Group 需要字符串类型) + let userAnswer = item.user_answer; + if (questions[index].type === 'true-false' && typeof userAnswer === 'boolean') { + userAnswer = userAnswer ? 'true' : 'false'; + console.log('[进度加载] 判断题答案格式转换: boolean', item.user_answer, '-> string', userAnswer); + } + answersMap.set(index, userAnswer); + + // 构造答题结果对象 + const result: AnswerResult = { + correct: item.is_correct, + user_answer: userAnswer, + correct_answer: item.correct_answer, + }; + + // 如果有 AI 评分信息,也加入结果中 + if (item.ai_score !== undefined && item.ai_feedback !== undefined) { + result.ai_grading = { + score: item.ai_score, + feedback: item.ai_feedback, + suggestion: item.ai_suggestion || '', + }; + console.log('[进度加载] 恢复AI评分: score=', item.ai_score, ', feedback=', item.ai_feedback); + } + + resultsMap.set(index, result); + + // 计算答题序号(查询该题目的总答题次数) + // 注意:这里暂时用1,后续可以优化为查询历史记录数 + sequencesMap.set(index, 1); + + if (item.is_correct) { + correct++; + } else { + wrong++; } } }); + console.log('[进度加载] 已答题目数:', progressData.answered_questions.length, ', 正确:', correct, ', 错误:', wrong); + setAnsweredStatus(statusMap); setUserAnswers(answersMap); + setAnswerSequences(sequencesMap); + setQuestionResults(resultsMap); setCorrectCount(correct); setWrongCount(wrong); - // 返回下一个未答题的索引或最后一个已答题的下一题 - return Math.min(maxIndex + 1, allQuestions.length - 1); + // 如果找到 current_question_id 对应的索引,返回它及其答案 + if (currentIndex !== undefined) { + console.log('[进度加载] 定位到题目索引:', currentIndex); + const hasAnswer = answersMap.has(currentIndex); + return { + index: currentIndex, + hasAnswer: hasAnswer, + savedAnswer: answersMap.get(currentIndex), + savedResult: resultsMap.get(currentIndex) + }; + } + } else { + console.log('[进度加载] 没有进度数据'); } } catch (error) { - console.log('从后端加载进度失败,尝试从 localStorage 加载:', error); + console.error('[进度加载] 加载失败:', error); } - // 如果后端加载失败,从 localStorage 加载 - const saved = localStorage.getItem(key); - if (saved) { - try { - const progress = JSON.parse(saved); - setCurrentIndex(progress.currentIndex || 0); - setCorrectCount(progress.correctCount || 0); - setWrongCount(progress.wrongCount || 0); - - // 恢复答题状态 - if (progress.answeredStatus) { - const statusMap = new Map(); - Object.entries(progress.answeredStatus).forEach(([index, status]) => { - statusMap.set(Number(index), status as boolean | null); - }); - setAnsweredStatus(statusMap); - } - - // 恢复用户答案 - if (progress.userAnswers) { - const answersMap = new Map(); - Object.entries(progress.userAnswers).forEach(([index, answer]) => { - answersMap.set(Number(index), answer as string | string[]); - }); - setUserAnswers(answersMap); - } - - // 恢复答题结果 - if (progress.questionResults) { - const resultsMap = new Map(); - Object.entries(progress.questionResults).forEach(([index, result]) => { - resultsMap.set(Number(index), result as AnswerResult); - }); - setQuestionResults(resultsMap); - } - - return progress.currentIndex || 0; - } catch (e) { - console.error("恢复进度失败", e); - } - } - return 0; + // 如果后端没有数据或加载失败,返回0(从第一题开始) + console.log('[进度加载] 返回默认索引: 0'); + return { index: 0, hasAnswer: false }; }; // 加载随机错题(使用智能推荐) @@ -268,18 +253,35 @@ const QuestionPage: React.FC = () => { if (res.success && res.data) { setAllQuestions(res.data); - // 恢复答题进度 - const savedIndex = await loadProgress(); - const startIndex = savedIndex < res.data.length ? savedIndex : 0; + // 恢复答题进度(传入题目列表和类型) + const progressResult = type ? await loadProgress(res.data, type) : { index: 0, hasAnswer: false }; + let startIndex = progressResult.index < res.data.length ? progressResult.index : 0; + + // 如果是随机模式且没有进度数据(全新开始),随机选择起始题目 + if (randomMode && !progressResult.hasAnswer && startIndex === 0 && res.data.length > 0) { + startIndex = Math.floor(Math.random() * res.data.length); + console.log('[题目加载] 随机模式:随机选择起始题目 (index:', startIndex, ')'); + } if (res.data.length > 0) { setCurrentQuestion(res.data[startIndex]); setCurrentIndex(startIndex); - setSelectedAnswer( - res.data[startIndex].type === "multiple-selection" ? [] : "" - ); - setShowResult(false); - setAnswerResult(null); + + // 如果这道题已答,恢复答案和结果 + if (progressResult.hasAnswer && progressResult.savedAnswer !== undefined && progressResult.savedResult !== undefined) { + console.log('[题目加载] 恢复已答题目的答案和结果 (index:', startIndex, ')'); + setSelectedAnswer(progressResult.savedAnswer); + setAnswerResult(progressResult.savedResult); + setShowResult(true); + } else { + // 如果未答,重置状态 + console.log('[题目加载] 题目未答,重置状态 (index:', startIndex, ')'); + setSelectedAnswer( + res.data[startIndex].type === "multiple-selection" ? [] : "" + ); + setShowResult(false); + setAnswerResult(null); + } } } } catch (error) { @@ -350,14 +352,12 @@ const QuestionPage: React.FC = () => { if (res.data.correct) { const newCorrect = correctCount + 1; setCorrectCount(newCorrect); - saveProgressToLocal(currentIndex, newCorrect, wrongCount, newStatusMap); } else { const newWrong = wrongCount + 1; setWrongCount(newWrong); - saveProgressToLocal(currentIndex, correctCount, newWrong, newStatusMap); } - // 注意:进度已由后端的 /api/practice/submit 接口自动保存,无需前端再次调用 + // 注意:进度已由后端的 /api/practice/submit 接口自动保存到 practice_progress 表 // 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题 if (res.data.correct && autoNext) { @@ -378,7 +378,8 @@ const QuestionPage: React.FC = () => { // 下一题 const handleNext = () => { const mode = searchParams.get("mode"); - console.log('[下一题] 当前模式:', mode); + const typeParam = searchParams.get("type"); + console.log('[下一题] 当前模式:', mode, ', 题目类型:', typeParam); // 错题练习模式:加载下一道推荐错题 if (mode === "wrong") { @@ -406,10 +407,8 @@ const QuestionPage: React.FC = () => { // 如果没有未答题目,显示总结页面 if (unansweredIndexes.length === 0) { setShowSummary(true); - // 清除进度 - const key = getStorageKey(); - localStorage.removeItem(key); - questionApi.clearPracticeProgress().catch(err => console.error('清除进度失败:', err)); + // 清除后端进度(只清除当前类型) + questionApi.clearPracticeProgress(typeParam || undefined).catch(err => console.error('清除进度失败:', err)); return; } @@ -421,10 +420,8 @@ const QuestionPage: React.FC = () => { if (currentIndex + 1 >= allQuestions.length) { // 显示统计摘要 setShowSummary(true); - // 清除进度 - const key = getStorageKey(); - localStorage.removeItem(key); - questionApi.clearPracticeProgress().catch(err => console.error('清除进度失败:', err)); + // 清除后端进度(只清除当前类型) + questionApi.clearPracticeProgress(typeParam || undefined).catch(err => console.error('清除进度失败:', err)); return; } nextIndex = currentIndex + 1; @@ -432,14 +429,28 @@ const QuestionPage: React.FC = () => { setCurrentIndex(nextIndex); setCurrentQuestion(allQuestions[nextIndex]); - setSelectedAnswer( - allQuestions[nextIndex].type === "multiple-selection" ? [] : "" - ); - setShowResult(false); - setAnswerResult(null); - // 保存进度到本地 - saveProgressToLocal(nextIndex, correctCount, wrongCount); + // 检查这道题是否已答 + const savedAnswer = userAnswers.get(nextIndex); + const savedResult = questionResults.get(nextIndex); + + if (savedAnswer !== undefined && savedResult !== undefined) { + // 如果已答,恢复答案和结果 + console.log('[下一题] 题目已答,恢复答案和结果 (index:', nextIndex, ')'); + setSelectedAnswer(savedAnswer); + setAnswerResult(savedResult); + setShowResult(true); + } else { + // 如果未答,重置状态 + console.log('[下一题] 题目未答,重置状态 (index:', nextIndex, ')'); + setSelectedAnswer( + allQuestions[nextIndex].type === "multiple-selection" ? [] : "" + ); + setShowResult(false); + setAnswerResult(null); + } + + // 进度已由后端自动保存,无需手动保存 // 滚动到页面顶部 window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -473,8 +484,7 @@ const QuestionPage: React.FC = () => { setAnswerResult(null); } - // 保存进度到本地 - saveProgressToLocal(index, correctCount, wrongCount); + // 进度已由后端自动保存,无需手动保存 // 滚动到页面顶部 window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -503,18 +513,16 @@ const QuestionPage: React.FC = () => { setCorrectCount(0); setWrongCount(0); - const key = getStorageKey(); - // 清除 localStorage - localStorage.removeItem(key); + const typeParam = searchParams.get("type"); - // 清除后端进度 + // 清除后端进度(只清除当前类型) try { - await questionApi.clearPracticeProgress(); + await questionApi.clearPracticeProgress(typeParam || undefined); + console.log('[重试] 已清除类型', typeParam, '的进度'); } catch (error) { console.error('清除后端进度失败:', error); } - const typeParam = searchParams.get("type"); loadQuestions(typeParam || undefined); }; @@ -588,6 +596,8 @@ const QuestionPage: React.FC = () => { onNext={handleNext} onRetry={handleRetryQuestion} mode={searchParams.get("mode") || undefined} + answerSequence={answerSequences.get(currentIndex)} + hasHistory={userAnswers.has(currentIndex)} /> )}