实现完整的用户统计功能和认证系统
**统计功能**: - 新增UserAnswerRecord模型记录用户答题历史 - 实现GetStatistics接口,统计题库总数、已答题数、正确率 - 在提交答案时自动记录答题历史 - 前端连接真实统计接口,显示实时数据 **认证系统优化**: - 新增Auth中间件,实现基于Token的身份验证 - 登录和注册时自动生成并保存Token到数据库 - 所有需要登录的接口都通过Auth中间件保护 - 统一处理未授权请求,返回401状态码 **错题练习功能**: - 新增GetRandomWrongQuestion接口,随机获取错题 - 支持错题练习模式(/question?mode=wrong) - 优化错题本页面UI,移除已掌握功能 - 新增"开始错题练习"按钮,直接进入练习模式 **数据库迁移**: - 新增user_answer_records表,记录用户答题历史 - User表新增token字段,存储用户登录凭证 **技术改进**: - 统一错误处理,区分401未授权和404未找到 - 优化答题流程,记录历史和错题分离处理 - 移除异步记录错题,改为同步处理保证数据一致性 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9ca8f123e7
commit
2bcf6bdacc
@ -33,7 +33,8 @@ func InitDB() error {
|
|||||||
err = DB.AutoMigrate(
|
err = DB.AutoMigrate(
|
||||||
&models.User{},
|
&models.User{},
|
||||||
&models.PracticeQuestion{},
|
&models.PracticeQuestion{},
|
||||||
&models.WrongQuestion{}, // 添加错题表
|
&models.WrongQuestion{}, // 添加错题表
|
||||||
|
&models.UserAnswerRecord{}, // 添加用户答题记录表
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to migrate database: %w", err)
|
return fmt.Errorf("failed to migrate database: %w", err)
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import (
|
|||||||
"ankao/internal/database"
|
"ankao/internal/database"
|
||||||
"ankao/internal/models"
|
"ankao/internal/models"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -143,8 +145,15 @@ func SubmitPracticeAnswer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户ID(如果已登录)
|
// 获取用户ID(认证中间件已确保存在)
|
||||||
userID, _ := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "未登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var question models.PracticeQuestion
|
var question models.PracticeQuestion
|
||||||
@ -170,11 +179,28 @@ func SubmitPracticeAnswer(c *gin.Context) {
|
|||||||
// 验证答案
|
// 验证答案
|
||||||
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
|
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 {
|
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
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -66,6 +66,16 @@ func Login(c *gin.Context) {
|
|||||||
// 生成token
|
// 生成token
|
||||||
token := generateToken(req.Username)
|
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{
|
userInfo := models.UserInfoResponse{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
@ -131,6 +141,12 @@ func Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成token
|
||||||
|
token := generateToken(req.Username)
|
||||||
|
|
||||||
|
// 设置token
|
||||||
|
newUser.Token = token
|
||||||
|
|
||||||
// 保存到数据库
|
// 保存到数据库
|
||||||
if err := db.Create(&newUser).Error; err != nil {
|
if err := db.Create(&newUser).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@ -141,9 +157,6 @@ func Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成token
|
|
||||||
token := generateToken(req.Username)
|
|
||||||
|
|
||||||
// 返回用户信息
|
// 返回用户信息
|
||||||
userInfo := models.UserInfoResponse{
|
userInfo := models.UserInfoResponse{
|
||||||
Username: newUser.Username,
|
Username: newUser.Username,
|
||||||
|
|||||||
@ -231,3 +231,35 @@ func recordWrongQuestion(userID, questionID uint, userAnswer, correctAnswer inte
|
|||||||
|
|
||||||
return db.Create(&newWrong).Error
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
57
internal/middleware/auth.go
Normal file
57
internal/middleware/auth.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/models/answer_record.go
Normal file
29
internal/models/answer_record.go
Normal file
@ -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"` // 正确率
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ type User struct {
|
|||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `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
|
||||||
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"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
20
main.go
20
main.go
@ -41,14 +41,22 @@ func main() {
|
|||||||
api.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
api.GET("/practice/questions", handlers.GetPracticeQuestions) // 获取练习题目列表
|
||||||
api.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
|
api.GET("/practice/questions/random", handlers.GetRandomPracticeQuestion) // 获取随机练习题目
|
||||||
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
||||||
api.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
|
||||||
api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
|
api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
|
||||||
|
|
||||||
// 错题本相关API
|
// 需要认证的路由
|
||||||
api.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
|
auth := api.Group("", middleware.Auth())
|
||||||
api.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
|
{
|
||||||
api.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握
|
// 练习题提交(需要登录才能记录错题)
|
||||||
api.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
|
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) // 清空错题本
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
|
|||||||
@ -21,18 +21,9 @@ export const submitAnswer = (data: SubmitAnswer) => {
|
|||||||
return request.post<ApiResponse<AnswerResult>>('/practice/submit', data)
|
return request.post<ApiResponse<AnswerResult>>('/practice/submit', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取统计数据 (暂时返回模拟数据,后续实现)
|
// 获取统计数据
|
||||||
export const getStatistics = async () => {
|
export const getStatistics = () => {
|
||||||
// TODO: 实现真实的统计接口
|
return request.get<ApiResponse<Statistics>>('/practice/statistics')
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: {
|
|
||||||
total_questions: 0,
|
|
||||||
answered_questions: 0,
|
|
||||||
correct_answers: 0,
|
|
||||||
accuracy: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置进度 (暂时返回模拟数据,后续实现)
|
// 重置进度 (暂时返回模拟数据,后续实现)
|
||||||
@ -56,6 +47,11 @@ export const getWrongQuestionStats = () => {
|
|||||||
return request.get<ApiResponse<WrongQuestionStats>>('/wrong-questions/stats')
|
return request.get<ApiResponse<WrongQuestionStats>>('/wrong-questions/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取随机错题
|
||||||
|
export const getRandomWrongQuestion = () => {
|
||||||
|
return request.get<ApiResponse<Question>>('/wrong-questions/random')
|
||||||
|
}
|
||||||
|
|
||||||
// 标记错题为已掌握
|
// 标记错题为已掌握
|
||||||
export const markWrongQuestionMastered = (id: number) => {
|
export const markWrongQuestionMastered = (id: number) => {
|
||||||
return request.put<ApiResponse<null>>(`/wrong-questions/${id}/mastered`)
|
return request.put<ApiResponse<null>>(`/wrong-questions/${id}/mastered`)
|
||||||
|
|||||||
@ -69,7 +69,12 @@ const QuestionPage: React.FC = () => {
|
|||||||
const loadRandomQuestion = async () => {
|
const loadRandomQuestion = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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) {
|
if (res.success && res.data) {
|
||||||
setCurrentQuestion(res.data)
|
setCurrentQuestion(res.data)
|
||||||
setSelectedAnswer(res.data.type === 'multiple' ? [] : '')
|
setSelectedAnswer(res.data.type === 'multiple' ? [] : '')
|
||||||
@ -77,8 +82,14 @@ const QuestionPage: React.FC = () => {
|
|||||||
setShowResult(false)
|
setShowResult(false)
|
||||||
setAnswerResult(null)
|
setAnswerResult(null)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
message.error('加载题目失败')
|
if (error.response?.status === 401) {
|
||||||
|
message.error('请先登录')
|
||||||
|
} else if (error.response?.status === 404) {
|
||||||
|
message.error('暂无错题')
|
||||||
|
} else {
|
||||||
|
message.error('加载题目失败')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -220,7 +231,15 @@ const QuestionPage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const typeParam = searchParams.get('type')
|
const typeParam = searchParams.get('type')
|
||||||
const categoryParam = searchParams.get('category')
|
const categoryParam = searchParams.get('category')
|
||||||
|
const mode = searchParams.get('mode')
|
||||||
|
|
||||||
|
// 错题练习模式
|
||||||
|
if (mode === 'wrong') {
|
||||||
|
loadRandomQuestion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通练习模式
|
||||||
if (typeParam || categoryParam) {
|
if (typeParam || categoryParam) {
|
||||||
loadQuestions(typeParam || undefined, categoryParam || undefined)
|
loadQuestions(typeParam || undefined, categoryParam || undefined)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,85 +1,50 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 {
|
import {
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
CheckCircleOutlined,
|
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
DeleteOutlined,
|
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import * as questionApi from '../api/question'
|
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'
|
import styles from './WrongQuestions.module.less'
|
||||||
|
|
||||||
const { Title, Text, Paragraph } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
const WrongQuestions: React.FC = () => {
|
const WrongQuestions: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
||||||
const [stats, setStats] = useState<WrongQuestionStats | null>(null)
|
|
||||||
const [activeTab, setActiveTab] = useState<string>('all')
|
|
||||||
|
|
||||||
// 加载错题列表
|
// 加载错题列表
|
||||||
const loadWrongQuestions = async (params?: { is_mastered?: boolean }) => {
|
const loadWrongQuestions = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const res = await questionApi.getWrongQuestions(params)
|
const res = await questionApi.getWrongQuestions()
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setWrongQuestions(res.data)
|
setWrongQuestions(res.data)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
message.error('加载错题列表失败')
|
console.error('加载错题列表失败:', error)
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
message.error('请先登录')
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
message.error('加载错题列表失败')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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(() => {
|
useEffect(() => {
|
||||||
loadWrongQuestions()
|
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 = () => {
|
const handleClear = () => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
@ -94,7 +59,6 @@ const WrongQuestions: React.FC = () => {
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
message.success('已清空错题本')
|
message.success('已清空错题本')
|
||||||
loadWrongQuestions()
|
loadWrongQuestions()
|
||||||
loadStats()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('清空失败')
|
message.error('清空失败')
|
||||||
@ -103,10 +67,10 @@ const WrongQuestions: React.FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重做题目
|
// 开始错题练习
|
||||||
const handleRedo = (questionId: number) => {
|
const handlePractice = () => {
|
||||||
// 跳转到答题页面,指定题目ID
|
// 跳转到答题页面,错题练习模式
|
||||||
navigate(`/question?id=${questionId}`)
|
navigate('/question?mode=wrong')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化答案显示
|
// 格式化答案显示
|
||||||
@ -120,13 +84,37 @@ const WrongQuestions: React.FC = () => {
|
|||||||
return String(answer)
|
return String(answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取题型标签颜色
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
single: 'blue',
|
||||||
|
multiple: 'green',
|
||||||
|
fill: 'cyan',
|
||||||
|
judge: 'orange',
|
||||||
|
short: 'purple',
|
||||||
|
}
|
||||||
|
return colorMap[type] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取题型名称
|
||||||
|
const getTypeName = (type: string) => {
|
||||||
|
const nameMap: Record<string, string> = {
|
||||||
|
single: '单选题',
|
||||||
|
multiple: '多选题',
|
||||||
|
fill: '填空题',
|
||||||
|
judge: '判断题',
|
||||||
|
short: '简答题',
|
||||||
|
}
|
||||||
|
return nameMap[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => navigate('/profile')}
|
onClick={() => navigate('/')}
|
||||||
type="text"
|
type="text"
|
||||||
className={styles.backButton}
|
className={styles.backButton}
|
||||||
>
|
>
|
||||||
@ -138,115 +126,75 @@ const WrongQuestions: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
{stats && (
|
<Card className={styles.statsCard}>
|
||||||
<Card className={styles.statsCard}>
|
<Space size="large">
|
||||||
<Row gutter={[16, 16]}>
|
<Statistic
|
||||||
<Col xs={8} sm={8} md={8}>
|
title="错题总数"
|
||||||
<Statistic
|
value={wrongQuestions.length}
|
||||||
title="错题总数"
|
valueStyle={{ color: '#ff4d4f', fontSize: '32px' }}
|
||||||
value={stats.total_wrong}
|
prefix={<CloseCircleOutlined />}
|
||||||
valueStyle={{ color: '#ff4d4f', fontSize: '24px' }}
|
/>
|
||||||
prefix={<CloseCircleOutlined />}
|
</Space>
|
||||||
/>
|
</Card>
|
||||||
</Col>
|
|
||||||
<Col xs={8} sm={8} md={8}>
|
|
||||||
<Statistic
|
|
||||||
title="已掌握"
|
|
||||||
value={stats.mastered}
|
|
||||||
valueStyle={{ color: '#52c41a', fontSize: '24px' }}
|
|
||||||
prefix={<CheckCircleOutlined />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col xs={8} sm={8} md={8}>
|
|
||||||
<Statistic
|
|
||||||
title="待掌握"
|
|
||||||
value={stats.not_mastered}
|
|
||||||
valueStyle={{ color: '#faad14', fontSize: '24px' }}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button
|
<Space>
|
||||||
type="primary"
|
<Button
|
||||||
danger
|
type="primary"
|
||||||
icon={<DeleteOutlined />}
|
icon={<PlayCircleOutlined />}
|
||||||
onClick={handleClear}
|
onClick={handlePractice}
|
||||||
disabled={!wrongQuestions.length}
|
disabled={!wrongQuestions.length}
|
||||||
>
|
size="large"
|
||||||
清空错题本
|
>
|
||||||
</Button>
|
开始错题练习
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={!wrongQuestions.length}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
清空错题本
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 错题列表 */}
|
{/* 错题列表 */}
|
||||||
<Card className={styles.listCard}>
|
<Card className={styles.listCard}>
|
||||||
<Tabs
|
|
||||||
activeKey={activeTab}
|
|
||||||
onChange={handleTabChange}
|
|
||||||
items={[
|
|
||||||
{ key: 'all', label: '全部错题' },
|
|
||||||
{ key: 'not_mastered', label: '未掌握' },
|
|
||||||
{ key: 'mastered', label: '已掌握' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{wrongQuestions.length === 0 ? (
|
{wrongQuestions.length === 0 ? (
|
||||||
<Empty description="暂无错题" />
|
<Empty description="暂无错题,继续加油!" />
|
||||||
) : (
|
) : (
|
||||||
<List
|
<List
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dataSource={wrongQuestions}
|
dataSource={wrongQuestions}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item key={item.id} className={styles.listItem}>
|
||||||
key={item.id}
|
|
||||||
className={styles.listItem}
|
|
||||||
actions={[
|
|
||||||
<Button
|
|
||||||
key="redo"
|
|
||||||
type="link"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={() => handleRedo(item.question.id)}
|
|
||||||
>
|
|
||||||
重做
|
|
||||||
</Button>,
|
|
||||||
!item.is_mastered && (
|
|
||||||
<Button
|
|
||||||
key="master"
|
|
||||||
type="link"
|
|
||||||
icon={<CheckCircleOutlined />}
|
|
||||||
onClick={() => handleMarkMastered(item.id)}
|
|
||||||
>
|
|
||||||
标记掌握
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
].filter(Boolean)}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<Text strong>题目 {item.question.question_id || item.question.id}</Text>
|
<Text strong>题目 {item.question.question_id || item.question.id}</Text>
|
||||||
<Tag color={item.question.type === 'single' ? 'blue' : item.question.type === 'multiple' ? 'green' : 'orange'}>
|
<Tag color={getTypeColor(item.question.type)}>
|
||||||
{item.question.type === 'single' ? '单选' : item.question.type === 'multiple' ? '多选' : '判断'}
|
{getTypeName(item.question.type)}
|
||||||
</Tag>
|
</Tag>
|
||||||
{item.is_mastered && <Tag color="success">已掌握</Tag>}
|
<Tag color="error">错误 {item.wrong_count} 次</Tag>
|
||||||
<Tag>错误 {item.wrong_count} 次</Tag>
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<div className={styles.questionContent}>
|
<div className={styles.questionContent}>
|
||||||
<Paragraph>{item.question.content}</Paragraph>
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text>{item.question.content}</Text>
|
||||||
|
</div>
|
||||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
<Text type="danger">
|
<Text type="danger">
|
||||||
你的答案: {formatAnswer(item.wrong_answer, item.question.type)}
|
<strong>你的答案:</strong>{formatAnswer(item.wrong_answer, item.question.type)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="success">
|
<Text type="success">
|
||||||
正确答案: {formatAnswer(item.correct_answer, item.question.type)}
|
<strong>正确答案:</strong>{formatAnswer(item.correct_answer, item.question.type)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
最后错误时间: {new Date(item.last_wrong_time).toLocaleString()}
|
最后错误时间:{new Date(item.last_wrong_time).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user