实现完整的用户统计功能和认证系统
**统计功能**: - 新增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
@ -34,6 +34,7 @@ func InitDB() error {
|
||||
&models.User{},
|
||||
&models.PracticeQuestion{},
|
||||
&models.WrongQuestion{}, // 添加错题表
|
||||
&models.UserAnswerRecord{}, // 添加用户答题记录表
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
|
||||
@ -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 {
|
||||
// 异步记录错题,不影响主流程
|
||||
go recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer)
|
||||
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 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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
|
||||
18
main.go
18
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) // 获取题型列表
|
||||
|
||||
// 需要认证的路由
|
||||
auth := api.Group("", middleware.Auth())
|
||||
{
|
||||
// 练习题提交(需要登录才能记录错题)
|
||||
auth.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
||||
auth.GET("/practice/statistics", handlers.GetStatistics) // 获取统计数据
|
||||
|
||||
// 错题本相关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.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)
|
||||
}
|
||||
|
||||
// 获取统计数据 (暂时返回模拟数据,后续实现)
|
||||
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<ApiResponse<Statistics>>('/practice/statistics')
|
||||
}
|
||||
|
||||
// 重置进度 (暂时返回模拟数据,后续实现)
|
||||
@ -56,6 +47,11 @@ export const getWrongQuestionStats = () => {
|
||||
return request.get<ApiResponse<WrongQuestionStats>>('/wrong-questions/stats')
|
||||
}
|
||||
|
||||
// 获取随机错题
|
||||
export const getRandomWrongQuestion = () => {
|
||||
return request.get<ApiResponse<Question>>('/wrong-questions/random')
|
||||
}
|
||||
|
||||
// 标记错题为已掌握
|
||||
export const markWrongQuestionMastered = (id: number) => {
|
||||
return request.put<ApiResponse<null>>(`/wrong-questions/${id}/mastered`)
|
||||
|
||||
@ -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) {
|
||||
} 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 {
|
||||
|
||||
@ -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<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 {
|
||||
setLoading(true)
|
||||
const res = await questionApi.getWrongQuestions(params)
|
||||
const res = await questionApi.getWrongQuestions()
|
||||
if (res.success && res.data) {
|
||||
setWrongQuestions(res.data)
|
||||
}
|
||||
} catch (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<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 (
|
||||
<div className={styles.container}>
|
||||
{/* 头部 */}
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/profile')}
|
||||
onClick={() => navigate('/')}
|
||||
type="text"
|
||||
className={styles.backButton}
|
||||
>
|
||||
@ -138,115 +126,75 @@ const WrongQuestions: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<Card className={styles.statsCard}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={8} sm={8} md={8}>
|
||||
<Space size="large">
|
||||
<Statistic
|
||||
title="错题总数"
|
||||
value={stats.total_wrong}
|
||||
valueStyle={{ color: '#ff4d4f', fontSize: '24px' }}
|
||||
value={wrongQuestions.length}
|
||||
valueStyle={{ color: '#ff4d4f', fontSize: '32px' }}
|
||||
prefix={<CloseCircleOutlined />}
|
||||
/>
|
||||
</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>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className={styles.actions}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={handlePractice}
|
||||
disabled={!wrongQuestions.length}
|
||||
size="large"
|
||||
>
|
||||
开始错题练习
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleClear}
|
||||
disabled={!wrongQuestions.length}
|
||||
size="large"
|
||||
>
|
||||
清空错题本
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 错题列表 */}
|
||||
<Card className={styles.listCard}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={[
|
||||
{ key: 'all', label: '全部错题' },
|
||||
{ key: 'not_mastered', label: '未掌握' },
|
||||
{ key: 'mastered', label: '已掌握' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{wrongQuestions.length === 0 ? (
|
||||
<Empty description="暂无错题" />
|
||||
<Empty description="暂无错题,继续加油!" />
|
||||
) : (
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={wrongQuestions}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
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 key={item.id} className={styles.listItem}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>题目 {item.question.question_id || item.question.id}</Text>
|
||||
<Tag color={item.question.type === 'single' ? 'blue' : item.question.type === 'multiple' ? 'green' : 'orange'}>
|
||||
{item.question.type === 'single' ? '单选' : item.question.type === 'multiple' ? '多选' : '判断'}
|
||||
<Tag color={getTypeColor(item.question.type)}>
|
||||
{getTypeName(item.question.type)}
|
||||
</Tag>
|
||||
{item.is_mastered && <Tag color="success">已掌握</Tag>}
|
||||
<Tag>错误 {item.wrong_count} 次</Tag>
|
||||
<Tag color="error">错误 {item.wrong_count} 次</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<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%' }}>
|
||||
<Text type="danger">
|
||||
你的答案: {formatAnswer(item.wrong_answer, item.question.type)}
|
||||
<strong>你的答案:</strong>{formatAnswer(item.wrong_answer, item.question.type)}
|
||||
</Text>
|
||||
<Text type="success">
|
||||
正确答案: {formatAnswer(item.correct_answer, item.question.type)}
|
||||
<strong>正确答案:</strong>{formatAnswer(item.correct_answer, item.question.type)}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
最后错误时间: {new Date(item.last_wrong_time).toLocaleString()}
|
||||
最后错误时间:{new Date(item.last_wrong_time).toLocaleString()}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user