diff --git a/internal/database/database.go b/internal/database/database.go index c841f72..20a564f 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -20,7 +20,8 @@ func InitDB() error { var err error 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, // 迁移时禁用外键约束 }) if err != nil { return fmt.Errorf("failed to connect to database: %w", err) @@ -32,6 +33,7 @@ func InitDB() error { err = DB.AutoMigrate( &models.User{}, &models.PracticeQuestion{}, + &models.WrongQuestion{}, // 添加错题表 ) if err != nil { return fmt.Errorf("failed to migrate database: %w", err) diff --git a/internal/handlers/practice_handler.go b/internal/handlers/practice_handler.go index f741253..b1e02b1 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -143,6 +143,9 @@ func SubmitPracticeAnswer(c *gin.Context) { return } + // 获取用户ID(如果已登录) + userID, _ := c.Get("user_id") + db := database.GetDB() var question models.PracticeQuestion @@ -167,6 +170,14 @@ func SubmitPracticeAnswer(c *gin.Context) { // 验证答案 correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer) + // 如果答错且用户已登录,记录到错题本 + if !correct && userID != nil { + if uid, ok := userID.(uint); ok { + // 异步记录错题,不影响主流程 + go recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer) + } + } + result := models.PracticeAnswerResult{ Correct: correct, UserAnswer: submit.Answer, diff --git a/internal/handlers/wrong_question_handler.go b/internal/handlers/wrong_question_handler.go new file mode 100644 index 0000000..6d1431a --- /dev/null +++ b/internal/handlers/wrong_question_handler.go @@ -0,0 +1,233 @@ +package handlers + +import ( + "ankao/internal/database" + "ankao/internal/models" + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// GetWrongQuestions 获取错题列表 +func GetWrongQuestions(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + db := database.GetDB() + var wrongQuestions []models.WrongQuestion + + // 查询参数 + isMastered := c.Query("is_mastered") // "true" 或 "false" + questionType := c.Query("type") // 题型筛选 + + query := db.Where("user_id = ?", userID).Preload("PracticeQuestion") + + // 筛选是否已掌握 + if isMastered == "true" { + query = query.Where("is_mastered = ?", true) + } else if isMastered == "false" { + query = query.Where("is_mastered = ?", false) + } + + // 按最后错误时间倒序 + if err := query.Order("last_wrong_time DESC").Find(&wrongQuestions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "查询失败", + }) + return + } + + // 转换为DTO + dtos := make([]models.WrongQuestionDTO, 0, len(wrongQuestions)) + for _, wq := range wrongQuestions { + // 题型筛选 + if questionType != "" && mapBackendToFrontendType(wq.PracticeQuestion.Type) != questionType { + continue + } + + // 解析答案 + var wrongAnswer, correctAnswer interface{} + json.Unmarshal([]byte(wq.WrongAnswer), &wrongAnswer) + json.Unmarshal([]byte(wq.CorrectAnswer), &correctAnswer) + + dto := models.WrongQuestionDTO{ + ID: wq.ID, + QuestionID: wq.QuestionID, + Question: convertToDTO(wq.PracticeQuestion), + WrongAnswer: wrongAnswer, + CorrectAnswer: correctAnswer, + WrongCount: wq.WrongCount, + LastWrongTime: wq.LastWrongTime, + IsMastered: wq.IsMastered, + } + dtos = append(dtos, dto) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": dtos, + "total": len(dtos), + }) +} + +// GetWrongQuestionStats 获取错题统计 +func GetWrongQuestionStats(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + db := database.GetDB() + var wrongQuestions []models.WrongQuestion + + if err := db.Where("user_id = ?", userID).Preload("PracticeQuestion").Find(&wrongQuestions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "查询失败", + }) + return + } + + stats := models.WrongQuestionStats{ + TotalWrong: len(wrongQuestions), + Mastered: 0, + NotMastered: 0, + TypeStats: make(map[string]int), + CategoryStats: make(map[string]int), + } + + for _, wq := range wrongQuestions { + if wq.IsMastered { + stats.Mastered++ + } else { + stats.NotMastered++ + } + + // 统计题型 + frontendType := mapBackendToFrontendType(wq.PracticeQuestion.Type) + stats.TypeStats[frontendType]++ + + // 统计分类 + stats.CategoryStats[wq.PracticeQuestion.TypeName]++ + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": stats, + }) +} + +// MarkWrongQuestionMastered 标记错题为已掌握 +func MarkWrongQuestionMastered(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + wrongQuestionID := c.Param("id") + db := database.GetDB() + + var wrongQuestion models.WrongQuestion + if err := db.Where("id = ? AND user_id = ?", wrongQuestionID, userID).First(&wrongQuestion).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "错题不存在", + }) + return + } + + wrongQuestion.IsMastered = true + if err := db.Save(&wrongQuestion).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "更新失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "已标记为掌握", + }) +} + +// ClearWrongQuestions 清空错题本 +func ClearWrongQuestions(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + db := database.GetDB() + + // 删除用户所有错题记录 + if err := db.Where("user_id = ?", userID).Delete(&models.WrongQuestion{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "清空失败", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "错题本已清空", + }) +} + +// recordWrongQuestion 记录错题(内部函数,在答题错误时调用) +func recordWrongQuestion(userID, questionID uint, userAnswer, correctAnswer interface{}) error { + db := database.GetDB() + + // 将答案序列化为JSON + wrongAnswerJSON, _ := json.Marshal(userAnswer) + correctAnswerJSON, _ := json.Marshal(correctAnswer) + + // 查找是否已存在该错题 + var existingWrong models.WrongQuestion + result := db.Where("user_id = ? AND question_id = ?", userID, questionID).First(&existingWrong) + + if result.Error == nil { + // 已存在,更新错误次数和时间 + existingWrong.WrongCount++ + existingWrong.LastWrongTime = time.Now() + existingWrong.WrongAnswer = string(wrongAnswerJSON) + existingWrong.CorrectAnswer = string(correctAnswerJSON) + existingWrong.IsMastered = false // 重新标记为未掌握 + return db.Save(&existingWrong).Error + } + + // 不存在,创建新记录 + newWrong := models.WrongQuestion{ + UserID: userID, + QuestionID: questionID, + WrongAnswer: string(wrongAnswerJSON), + CorrectAnswer: string(correctAnswerJSON), + WrongCount: 1, + LastWrongTime: time.Now(), + IsMastered: false, + } + + return db.Create(&newWrong).Error +} diff --git a/internal/models/wrong_question.go b/internal/models/wrong_question.go new file mode 100644 index 0000000..250df41 --- /dev/null +++ b/internal/models/wrong_question.go @@ -0,0 +1,51 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// WrongQuestion 错题记录模型 +type WrongQuestion struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` // 用户ID + QuestionID uint `gorm:"index;not null" json:"question_id"` // 题目ID(关联practice_questions表) + WrongAnswer string `gorm:"type:text;not null" json:"wrong_answer"` // 错误答案(JSON格式) + CorrectAnswer string `gorm:"type:text;not null" json:"correct_answer"` // 正确答案(JSON格式) + WrongCount int `gorm:"default:1" json:"wrong_count"` // 错误次数 + LastWrongTime time.Time `gorm:"not null" json:"last_wrong_time"` // 最后一次错误时间 + IsMastered bool `gorm:"default:false" json:"is_mastered"` // 是否已掌握 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // 关联 - 明确指定外键和引用 + PracticeQuestion PracticeQuestion `gorm:"foreignKey:QuestionID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL" json:"-"` +} + +// TableName 指定表名 +func (WrongQuestion) TableName() string { + return "wrong_questions" +} + +// WrongQuestionDTO 错题数据传输对象 +type WrongQuestionDTO struct { + ID uint `json:"id"` + QuestionID uint `json:"question_id"` + Question PracticeQuestionDTO `json:"question"` // 题目详情 + WrongAnswer interface{} `json:"wrong_answer"` // 错误答案 + CorrectAnswer interface{} `json:"correct_answer"` // 正确答案 + WrongCount int `json:"wrong_count"` // 错误次数 + LastWrongTime time.Time `json:"last_wrong_time"` // 最后错误时间 + IsMastered bool `json:"is_mastered"` // 是否已掌握 +} + +// WrongQuestionStats 错题统计 +type WrongQuestionStats struct { + TotalWrong int `json:"total_wrong"` // 总错题数 + Mastered int `json:"mastered"` // 已掌握数 + NotMastered int `json:"not_mastered"` // 未掌握数 + TypeStats map[string]int `json:"type_stats"` // 各题型错题数 + CategoryStats map[string]int `json:"category_stats"` // 各分类错题数 +} diff --git a/main.go b/main.go index e00a7cf..bbe17d8 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,12 @@ func main() { api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目 api.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案 api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表 + + // 错题本相关API + api.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表 + api.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计 + api.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握 + api.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 } // 启动服务器 diff --git a/web/src/App.tsx b/web/src/App.tsx index 4ed26d7..3924fdc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import Profile from './pages/Profile' import Login from './pages/Login' import Home from './pages/Home' import About from './pages/About' +import WrongQuestions from './pages/WrongQuestions' const App: React.FC = () => { return ( @@ -19,7 +20,10 @@ const App: React.FC = () => { } /> - {/* 不带TabBar的页面 */} + {/* 不带TabBar的页面,但需要登录保护 */} + } /> + + {/* 不带TabBar的页面,不需要登录保护 */} } /> } /> diff --git a/web/src/api/question.ts b/web/src/api/question.ts index 05061ad..71085a1 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -1,5 +1,5 @@ import { request } from '../utils/request' -import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse } from '../types/question' +import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse, WrongQuestion, WrongQuestionStats } from '../types/question' // 获取题目列表 export const getQuestions = (params?: { type?: string; category?: string }) => { @@ -43,3 +43,25 @@ export const resetProgress = async () => { data: null } } + +// ========== 错题本相关 API ========== + +// 获取错题列表 +export const getWrongQuestions = (params?: { is_mastered?: boolean; type?: string }) => { + return request.get>('/wrong-questions', { params }) +} + +// 获取错题统计 +export const getWrongQuestionStats = () => { + return request.get>('/wrong-questions/stats') +} + +// 标记错题为已掌握 +export const markWrongQuestionMastered = (id: number) => { + return request.put>(`/wrong-questions/${id}/mastered`) +} + +// 清空错题本 +export const clearWrongQuestions = () => { + return request.delete>('/wrong-questions') +} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index b6ff87a..89a2cf4 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -7,6 +7,7 @@ import { UnorderedListOutlined, EditOutlined, RocketOutlined, + BookOutlined, } from '@ant-design/icons' import * as questionApi from '../api/question' import type { Statistics } from '../types/question' @@ -178,6 +179,23 @@ const Home: React.FC = () => { + + navigate('/wrong-questions')} + style={{ marginTop: '16px' }} + > + +
+ +
+
+ 错题本 + 复习错题,巩固薄弱知识点 +
+
+
) diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index a527db4..7711777 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -15,6 +15,7 @@ import { SettingOutlined, FileTextOutlined, UserOutlined, + BookOutlined, } from '@ant-design/icons' import styles from './Profile.module.less' @@ -94,6 +95,16 @@ const Profile: React.FC = () => { {/* 功能列表 */} + navigate('/wrong-questions')} + style={{ cursor: 'pointer' }} + > + + + 错题本 + + + message.info('功能开发中')} style={{ cursor: 'pointer' }} diff --git a/web/src/pages/WrongQuestions.module.less b/web/src/pages/WrongQuestions.module.less new file mode 100644 index 0000000..6677193 --- /dev/null +++ b/web/src/pages/WrongQuestions.module.less @@ -0,0 +1,110 @@ +.container { + min-height: 100vh; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 0; +} + +.header { + padding: 20px; + padding-bottom: 16px; +} + +.backButton { + color: white; + margin-bottom: 12px; + + &:hover { + color: rgba(255, 255, 255, 0.85); + } +} + +.title { + color: white !important; + margin: 0 !important; + font-size: 28px; +} + +.statsCard { + margin: 0 20px 20px; + border-radius: 16px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} + +.actions { + padding: 0 20px 16px; + display: flex; + justify-content: flex-end; +} + +.listCard { + margin: 0 20px 20px; + border-radius: 16px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + padding-bottom: 60px; // 为底部导航留空间 +} + +.listItem { + padding: 16px 0 !important; + border-bottom: 1px solid #f0f0f0 !important; + + &:last-child { + border-bottom: none !important; + } +} + +.questionContent { + margin-top: 8px; +} + +// 响应式设计 - 移动端 +@media (max-width: 768px) { + .header { + padding: 16px; + } + + .title { + font-size: 24px; + } + + .statsCard { + margin: 0 16px 16px; + border-radius: 12px; + } + + .actions { + padding: 0 16px 12px; + } + + .listCard { + margin: 0 16px 16px; + border-radius: 12px; + } + + .listItem { + padding: 12px 0 !important; + } +} + +// 响应式设计 - PC端 +@media (min-width: 769px) { + .header { + padding: 32px 32px 24px; + } + + .title { + font-size: 32px; + } + + .statsCard { + margin: 0 32px 24px; + } + + .actions { + padding: 0 32px 20px; + } + + .listCard { + margin: 0 32px 32px; + padding-bottom: 0; // PC端不需要底部导航留空 + } +} diff --git a/web/src/pages/WrongQuestions.tsx b/web/src/pages/WrongQuestions.tsx new file mode 100644 index 0000000..1998f69 --- /dev/null +++ b/web/src/pages/WrongQuestions.tsx @@ -0,0 +1,264 @@ +import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, List, Button, Tag, Typography, Space, message, Modal, Statistic, Row, Col, Tabs, Empty } from 'antd' +import { + BookOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + DeleteOutlined, + ReloadOutlined, + ArrowLeftOutlined, +} from '@ant-design/icons' +import * as questionApi from '../api/question' +import type { WrongQuestion, WrongQuestionStats } from '../types/question' +import styles from './WrongQuestions.module.less' + +const { Title, Text, Paragraph } = Typography + +const WrongQuestions: React.FC = () => { + const navigate = useNavigate() + const [loading, setLoading] = useState(false) + const [wrongQuestions, setWrongQuestions] = useState([]) + const [stats, setStats] = useState(null) + const [activeTab, setActiveTab] = useState('all') + + // 加载错题列表 + const loadWrongQuestions = async (params?: { is_mastered?: boolean }) => { + try { + setLoading(true) + const res = await questionApi.getWrongQuestions(params) + if (res.success && res.data) { + setWrongQuestions(res.data) + } + } catch (error) { + message.error('加载错题列表失败') + } finally { + setLoading(false) + } + } + + // 加载错题统计 + const loadStats = async () => { + try { + const res = await questionApi.getWrongQuestionStats() + if (res.success && res.data) { + setStats(res.data) + } + } catch (error) { + console.error('加载统计失败:', error) + } + } + + useEffect(() => { + loadWrongQuestions() + loadStats() + }, []) + + // 切换标签 + const handleTabChange = (key: string) => { + setActiveTab(key) + if (key === 'all') { + loadWrongQuestions() + } else if (key === 'not_mastered') { + loadWrongQuestions({ is_mastered: false }) + } else if (key === 'mastered') { + loadWrongQuestions({ is_mastered: true }) + } + } + + // 标记为已掌握 + const handleMarkMastered = async (id: number) => { + try { + const res = await questionApi.markWrongQuestionMastered(id) + if (res.success) { + message.success('已标记为掌握') + loadWrongQuestions() + loadStats() + } + } catch (error) { + message.error('操作失败') + } + } + + // 清空错题本 + const handleClear = () => { + Modal.confirm({ + title: '确认清空错题本?', + content: '清空后将无法恢复,请确认操作', + okText: '确认清空', + cancelText: '取消', + okType: 'danger', + onOk: async () => { + try { + const res = await questionApi.clearWrongQuestions() + if (res.success) { + message.success('已清空错题本') + loadWrongQuestions() + loadStats() + } + } catch (error) { + message.error('清空失败') + } + }, + }) + } + + // 重做题目 + const handleRedo = (questionId: number) => { + // 跳转到答题页面,指定题目ID + navigate(`/question?id=${questionId}`) + } + + // 格式化答案显示 + const formatAnswer = (answer: string | string[], questionType: string) => { + if (questionType === 'judge') { + return answer === 'true' || answer === true ? '正确' : '错误' + } + if (Array.isArray(answer)) { + return answer.join(', ') + } + return String(answer) + } + + return ( +
+ {/* 头部 */} +
+ + + <BookOutlined /> 错题本 + +
+ + {/* 统计卡片 */} + {stats && ( + + + + } + /> + + + } + /> + + + + + + + )} + + {/* 操作按钮 */} +
+ +
+ + {/* 错题列表 */} + + + + {wrongQuestions.length === 0 ? ( + + ) : ( + ( + } + onClick={() => handleRedo(item.question.id)} + > + 重做 + , + !item.is_mastered && ( + + ), + ].filter(Boolean)} + > + + 题目 {item.question.question_id || item.question.id} + + {item.question.type === 'single' ? '单选' : item.question.type === 'multiple' ? '多选' : '判断'} + + {item.is_mastered && 已掌握} + 错误 {item.wrong_count} 次 + + } + description={ +
+ {item.question.content} + + + 你的答案: {formatAnswer(item.wrong_answer, item.question.type)} + + + 正确答案: {formatAnswer(item.correct_answer, item.question.type)} + + + 最后错误时间: {new Date(item.last_wrong_time).toLocaleString()} + + +
+ } + /> +
+ )} + /> + )} +
+
+ ) +} + +export default WrongQuestions diff --git a/web/src/types/question.ts b/web/src/types/question.ts index ebe604d..a64d289 100644 --- a/web/src/types/question.ts +++ b/web/src/types/question.ts @@ -45,3 +45,24 @@ export interface ApiResponse { message?: string total?: number } + +// 错题记录 +export interface WrongQuestion { + id: number + question_id: number + question: Question + wrong_answer: string | string[] + correct_answer: string | string[] + wrong_count: number + last_wrong_time: string + is_mastered: boolean +} + +// 错题统计 +export interface WrongQuestionStats { + total_wrong: number + mastered: number + not_mastered: number + type_stats: Record + category_stats: Record +}