diff --git a/internal/database/database.go b/internal/database/database.go index 20a564f..ec5e493 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -33,7 +33,8 @@ func InitDB() error { err = DB.AutoMigrate( &models.User{}, &models.PracticeQuestion{}, - &models.WrongQuestion{}, // 添加错题表 + &models.WrongQuestion{}, // 添加错题表 + &models.UserAnswerRecord{}, // 添加用户答题记录表 ) 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 b1e02b1..0286bb9 100644 --- a/internal/handlers/practice_handler.go +++ b/internal/handlers/practice_handler.go @@ -4,8 +4,10 @@ import ( "ankao/internal/database" "ankao/internal/models" "encoding/json" + "log" "net/http" "strconv" + "time" "github.com/gin-gonic/gin" ) @@ -143,8 +145,15 @@ func SubmitPracticeAnswer(c *gin.Context) { return } - // 获取用户ID(如果已登录) - userID, _ := c.Get("user_id") + // 获取用户ID(认证中间件已确保存在) + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } db := database.GetDB() var question models.PracticeQuestion @@ -170,11 +179,28 @@ func SubmitPracticeAnswer(c *gin.Context) { // 验证答案 correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer) - // 如果答错且用户已登录,记录到错题本 - if !correct && userID != nil { + // 记录用户答题历史 + if uid, ok := userID.(uint); ok { + record := models.UserAnswerRecord{ + UserID: uid, + QuestionID: question.ID, + IsCorrect: correct, + AnsweredAt: time.Now(), + } + // 记录到数据库(忽略错误,不影响主流程) + if err := db.Create(&record).Error; err != nil { + log.Printf("记录答题历史失败: %v", err) + } + } + + // 如果答错,记录到错题本 + if !correct { if uid, ok := userID.(uint); ok { - // 异步记录错题,不影响主流程 - go recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer) + // 记录错题 + if err := recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer); err != nil { + // 记录错题失败不影响主流程,只记录日志 + log.Printf("记录错题失败: %v", err) + } } } @@ -370,3 +396,67 @@ func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { return dto } + +// GetStatistics 获取用户统计数据 +func GetStatistics(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.(uint) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "用户ID格式错误", + }) + return + } + + db := database.GetDB() + + // 获取题库总数 + var totalQuestions int64 + db.Model(&models.PracticeQuestion{}).Count(&totalQuestions) + + // 获取用户已答题数(去重) + var answeredQuestions int64 + db.Model(&models.UserAnswerRecord{}). + Where("user_id = ?", uid). + Distinct("question_id"). + Count(&answeredQuestions) + + // 获取用户答对题数 + var correctAnswers int64 + db.Model(&models.UserAnswerRecord{}). + Where("user_id = ? AND is_correct = ?", uid, true). + Count(&correctAnswers) + + // 计算正确率 + var accuracy float64 + if answeredQuestions > 0 { + // 正确率 = 答对题数 / 总答题数 + var totalAnswers int64 + db.Model(&models.UserAnswerRecord{}). + Where("user_id = ?", uid). + Count(&totalAnswers) + accuracy = float64(correctAnswers) / float64(totalAnswers) * 100 + } + + stats := models.UserStatistics{ + TotalQuestions: int(totalQuestions), + AnsweredQuestions: int(answeredQuestions), + CorrectAnswers: int(correctAnswers), + Accuracy: accuracy, + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": stats, + }) +} diff --git a/internal/handlers/user.go b/internal/handlers/user.go index 3c643f5..032444c 100644 --- a/internal/handlers/user.go +++ b/internal/handlers/user.go @@ -66,6 +66,16 @@ func Login(c *gin.Context) { // 生成token token := generateToken(req.Username) + // 保存token到数据库 + user.Token = token + if err := db.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "token保存失败", + }) + return + } + // 返回用户信息(不包含密码) userInfo := models.UserInfoResponse{ Username: user.Username, @@ -131,6 +141,12 @@ func Register(c *gin.Context) { return } + // 生成token + token := generateToken(req.Username) + + // 设置token + newUser.Token = token + // 保存到数据库 if err := db.Create(&newUser).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -141,9 +157,6 @@ func Register(c *gin.Context) { return } - // 生成token - token := generateToken(req.Username) - // 返回用户信息 userInfo := models.UserInfoResponse{ Username: newUser.Username, diff --git a/internal/handlers/wrong_question_handler.go b/internal/handlers/wrong_question_handler.go index 6d1431a..ac46d06 100644 --- a/internal/handlers/wrong_question_handler.go +++ b/internal/handlers/wrong_question_handler.go @@ -231,3 +231,35 @@ func recordWrongQuestion(userID, questionID uint, userAnswer, correctAnswer inte return db.Create(&newWrong).Error } + +// GetRandomWrongQuestion 获取随机错题进行练习 +func GetRandomWrongQuestion(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 wrongQuestion models.WrongQuestion + + // 随机获取一个错题 + if err := db.Where("user_id = ?", userID).Order("RANDOM()").Preload("PracticeQuestion").First(&wrongQuestion).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "success": false, + "message": "暂无错题", + }) + return + } + + // 转换为DTO返回 + dto := convertToDTO(wrongQuestion.PracticeQuestion) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": dto, + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..5029025 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "ankao/internal/database" + "ankao/internal/models" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// Auth 认证中间件 +func Auth() gin.HandlerFunc { + return func(c *gin.Context) { + // 从请求头获取token + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + c.Abort() + return + } + + // 解析Bearer token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "token格式错误", + }) + c.Abort() + return + } + + token := parts[1] + + // 从数据库查找token对应的用户 + db := database.GetDB() + var user models.User + if err := db.Where("token = ?", token).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "token无效或已过期", + }) + c.Abort() + return + } + + // 将用户ID设置到上下文 + c.Set("user_id", user.ID) + c.Set("username", user.Username) + + c.Next() + } +} diff --git a/internal/models/answer_record.go b/internal/models/answer_record.go new file mode 100644 index 0000000..27e4204 --- /dev/null +++ b/internal/models/answer_record.go @@ -0,0 +1,29 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// UserAnswerRecord 用户答题记录 +type UserAnswerRecord struct { + gorm.Model + UserID uint `gorm:"index;not null" json:"user_id"` // 用户ID + QuestionID uint `gorm:"index;not null" json:"question_id"` // 题目ID + IsCorrect bool `gorm:"not null" json:"is_correct"` // 是否答对 + AnsweredAt time.Time `gorm:"not null" json:"answered_at"` // 答题时间 +} + +// TableName 指定表名 +func (UserAnswerRecord) TableName() string { + return "user_answer_records" +} + +// UserStatistics 用户统计数据 +type UserStatistics struct { + TotalQuestions int `json:"total_questions"` // 题库总数 + AnsweredQuestions int `json:"answered_questions"` // 已答题数 + CorrectAnswers int `json:"correct_answers"` // 答对题数 + Accuracy float64 `json:"accuracy"` // 正确率 +} diff --git a/internal/models/user.go b/internal/models/user.go index 398a4b9..da5e842 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -12,6 +12,7 @@ type User struct { ID uint `gorm:"primaryKey" json:"id"` Username string `gorm:"uniqueIndex;not null;size:50" json:"username"` Password string `gorm:"not null;size:255" json:"-"` // json:"-" 表示在JSON响应中不返回密码 + Token string `gorm:"size:255;index" json:"-"` // 用户登录token Avatar string `gorm:"size:255" json:"avatar"` Nickname string `gorm:"size:50" json:"nickname"` CreatedAt time.Time `json:"created_at"` diff --git a/main.go b/main.go index bbe17d8..30baccb 100644 --- a/main.go +++ b/main.go @@ -41,14 +41,22 @@ func main() { api.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表 api.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目 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) // 清空错题本 + // 需要认证的路由 + auth := api.Group("", middleware.Auth()) + { + // 练习题提交(需要登录才能记录错题) + auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案 + auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据 + + // 错题本相关API + auth.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表 + auth.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计 + auth.GET("/wrong-questions/random", handlers.GetRandomWrongQuestion) // 获取随机错题 + auth.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握 + auth.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本 + } } // 启动服务器 diff --git a/web/src/api/question.ts b/web/src/api/question.ts index 71085a1..18a9ce1 100644 --- a/web/src/api/question.ts +++ b/web/src/api/question.ts @@ -21,18 +21,9 @@ export const submitAnswer = (data: SubmitAnswer) => { return request.post>('/practice/submit', data) } -// 获取统计数据 (暂时返回模拟数据,后续实现) -export const getStatistics = async () => { - // TODO: 实现真实的统计接口 - return { - success: true, - data: { - total_questions: 0, - answered_questions: 0, - correct_answers: 0, - accuracy: 0, - } - } +// 获取统计数据 +export const getStatistics = () => { + return request.get>('/practice/statistics') } // 重置进度 (暂时返回模拟数据,后续实现) @@ -56,6 +47,11 @@ export const getWrongQuestionStats = () => { return request.get>('/wrong-questions/stats') } +// 获取随机错题 +export const getRandomWrongQuestion = () => { + return request.get>('/wrong-questions/random') +} + // 标记错题为已掌握 export const markWrongQuestionMastered = (id: number) => { return request.put>(`/wrong-questions/${id}/mastered`) diff --git a/web/src/pages/Question.tsx b/web/src/pages/Question.tsx index 1c2f671..b6d51b5 100644 --- a/web/src/pages/Question.tsx +++ b/web/src/pages/Question.tsx @@ -69,7 +69,12 @@ const QuestionPage: React.FC = () => { const loadRandomQuestion = async () => { setLoading(true) try { - const res = await questionApi.getRandomQuestion() + // 检查是否是错题练习模式 + const mode = searchParams.get('mode') + const res = mode === 'wrong' + ? await questionApi.getRandomWrongQuestion() + : await questionApi.getRandomQuestion() + if (res.success && res.data) { setCurrentQuestion(res.data) setSelectedAnswer(res.data.type === 'multiple' ? [] : '') @@ -77,8 +82,14 @@ const QuestionPage: React.FC = () => { setShowResult(false) setAnswerResult(null) } - } catch (error) { - message.error('加载题目失败') + } catch (error: any) { + if (error.response?.status === 401) { + message.error('请先登录') + } else if (error.response?.status === 404) { + message.error('暂无错题') + } else { + message.error('加载题目失败') + } } finally { setLoading(false) } @@ -220,7 +231,15 @@ const QuestionPage: React.FC = () => { useEffect(() => { const typeParam = searchParams.get('type') const categoryParam = searchParams.get('category') + const mode = searchParams.get('mode') + // 错题练习模式 + if (mode === 'wrong') { + loadRandomQuestion() + return + } + + // 普通练习模式 if (typeParam || categoryParam) { loadQuestions(typeParam || undefined, categoryParam || undefined) } else { diff --git a/web/src/pages/WrongQuestions.tsx b/web/src/pages/WrongQuestions.tsx index 1998f69..82c76a9 100644 --- a/web/src/pages/WrongQuestions.tsx +++ b/web/src/pages/WrongQuestions.tsx @@ -1,85 +1,50 @@ 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 { Card, List, Button, Tag, Typography, Space, message, Modal, Empty, Statistic } from 'antd' import { BookOutlined, - CheckCircleOutlined, CloseCircleOutlined, - DeleteOutlined, ReloadOutlined, ArrowLeftOutlined, + PlayCircleOutlined, + DeleteOutlined, } from '@ant-design/icons' import * as questionApi from '../api/question' -import type { WrongQuestion, WrongQuestionStats } from '../types/question' +import type { WrongQuestion } from '../types/question' import styles from './WrongQuestions.module.less' -const { Title, Text, Paragraph } = Typography +const { Title, Text } = 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 }) => { + const loadWrongQuestions = async () => { try { setLoading(true) - const res = await questionApi.getWrongQuestions(params) + const res = await questionApi.getWrongQuestions() if (res.success && res.data) { setWrongQuestions(res.data) } - } catch (error) { - message.error('加载错题列表失败') + } catch (error: any) { + console.error('加载错题列表失败:', error) + if (error.response?.status === 401) { + message.error('请先登录') + navigate('/login') + } else { + 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({ @@ -94,7 +59,6 @@ const WrongQuestions: React.FC = () => { if (res.success) { message.success('已清空错题本') loadWrongQuestions() - loadStats() } } catch (error) { message.error('清空失败') @@ -103,10 +67,10 @@ const WrongQuestions: React.FC = () => { }) } - // 重做题目 - const handleRedo = (questionId: number) => { - // 跳转到答题页面,指定题目ID - navigate(`/question?id=${questionId}`) + // 开始错题练习 + const handlePractice = () => { + // 跳转到答题页面,错题练习模式 + navigate('/question?mode=wrong') } // 格式化答案显示 @@ -120,13 +84,37 @@ const WrongQuestions: React.FC = () => { return String(answer) } + // 获取题型标签颜色 + const getTypeColor = (type: string) => { + const colorMap: Record = { + single: 'blue', + multiple: 'green', + fill: 'cyan', + judge: 'orange', + short: 'purple', + } + return colorMap[type] || 'default' + } + + // 获取题型名称 + const getTypeName = (type: string) => { + const nameMap: Record = { + single: '单选题', + multiple: '多选题', + fill: '填空题', + judge: '判断题', + short: '简答题', + } + return nameMap[type] || type + } + return (
{/* 头部 */}
{/* 统计卡片 */} - {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' ? '多选' : '判断'} + + {getTypeName(item.question.type)} - {item.is_mastered && 已掌握} - 错误 {item.wrong_count} 次 + 错误 {item.wrong_count} 次 } description={
- {item.question.content} +
+ {item.question.content} +
- 你的答案: {formatAnswer(item.wrong_answer, item.question.type)} + 你的答案:{formatAnswer(item.wrong_answer, item.question.type)} - 正确答案: {formatAnswer(item.correct_answer, item.question.type)} + 正确答案:{formatAnswer(item.correct_answer, item.question.type)} - 最后错误时间: {new Date(item.last_wrong_time).toLocaleString()} + 最后错误时间:{new Date(item.last_wrong_time).toLocaleString()}