实现完整的错题本功能模块
后端实现: - 创建错题数据模型和数据库表结构 - 实现错题记录、查询、统计、标记和清空API - 答题错误时自动记录到错题本 - 支持重复错误累计次数和更新时间 前端实现: - 创建错题本页面,支持查看、筛选和管理错题 - 实现错题统计展示(总数、已掌握、待掌握) - 支持标记已掌握、清空错题本和重做题目 - 在首页和个人中心添加错题本入口 - 完整的响应式设计适配移动端和PC端 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e722180c07
commit
6446508954
@ -21,6 +21,7 @@ func InitDB() error {
|
|||||||
var err error
|
var err error
|
||||||
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to database: %w", err)
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
@ -32,6 +33,7 @@ func InitDB() error {
|
|||||||
err = DB.AutoMigrate(
|
err = DB.AutoMigrate(
|
||||||
&models.User{},
|
&models.User{},
|
||||||
&models.PracticeQuestion{},
|
&models.PracticeQuestion{},
|
||||||
|
&models.WrongQuestion{}, // 添加错题表
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to migrate database: %w", err)
|
return fmt.Errorf("failed to migrate database: %w", err)
|
||||||
|
|||||||
@ -143,6 +143,9 @@ func SubmitPracticeAnswer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户ID(如果已登录)
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var question models.PracticeQuestion
|
var question models.PracticeQuestion
|
||||||
|
|
||||||
@ -167,6 +170,14 @@ 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 {
|
||||||
|
// 异步记录错题,不影响主流程
|
||||||
|
go recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := models.PracticeAnswerResult{
|
result := models.PracticeAnswerResult{
|
||||||
Correct: correct,
|
Correct: correct,
|
||||||
UserAnswer: submit.Answer,
|
UserAnswer: submit.Answer,
|
||||||
|
|||||||
233
internal/handlers/wrong_question_handler.go
Normal file
233
internal/handlers/wrong_question_handler.go
Normal file
@ -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
|
||||||
|
}
|
||||||
51
internal/models/wrong_question.go
Normal file
51
internal/models/wrong_question.go
Normal file
@ -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"` // 各分类错题数
|
||||||
|
}
|
||||||
6
main.go
6
main.go
@ -43,6 +43,12 @@ func main() {
|
|||||||
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
|
||||||
api.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
|
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) // 获取错题列表
|
||||||
|
api.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
|
||||||
|
api.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握
|
||||||
|
api.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import Profile from './pages/Profile'
|
|||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import About from './pages/About'
|
import About from './pages/About'
|
||||||
|
import WrongQuestions from './pages/WrongQuestions'
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@ -19,7 +20,10 @@ const App: React.FC = () => {
|
|||||||
<Route path="/profile" element={<Profile />} />
|
<Route path="/profile" element={<Profile />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* 不带TabBar的页面 */}
|
{/* 不带TabBar的页面,但需要登录保护 */}
|
||||||
|
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
|
||||||
|
|
||||||
|
{/* 不带TabBar的页面,不需要登录保护 */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { request } from '../utils/request'
|
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 }) => {
|
export const getQuestions = (params?: { type?: string; category?: string }) => {
|
||||||
@ -43,3 +43,25 @@ export const resetProgress = async () => {
|
|||||||
data: null
|
data: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 错题本相关 API ==========
|
||||||
|
|
||||||
|
// 获取错题列表
|
||||||
|
export const getWrongQuestions = (params?: { is_mastered?: boolean; type?: string }) => {
|
||||||
|
return request.get<ApiResponse<WrongQuestion[]>>('/wrong-questions', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取错题统计
|
||||||
|
export const getWrongQuestionStats = () => {
|
||||||
|
return request.get<ApiResponse<WrongQuestionStats>>('/wrong-questions/stats')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记错题为已掌握
|
||||||
|
export const markWrongQuestionMastered = (id: number) => {
|
||||||
|
return request.put<ApiResponse<null>>(`/wrong-questions/${id}/mastered`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空错题本
|
||||||
|
export const clearWrongQuestions = () => {
|
||||||
|
return request.delete<ApiResponse<null>>('/wrong-questions')
|
||||||
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
RocketOutlined,
|
RocketOutlined,
|
||||||
|
BookOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import * as questionApi from '../api/question'
|
import * as questionApi from '../api/question'
|
||||||
import type { Statistics } from '../types/question'
|
import type { Statistics } from '../types/question'
|
||||||
@ -178,6 +179,23 @@ const Home: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className={styles.quickCard}
|
||||||
|
onClick={() => navigate('/wrong-questions')}
|
||||||
|
style={{ marginTop: '16px' }}
|
||||||
|
>
|
||||||
|
<Space align="center" size="large">
|
||||||
|
<div className={styles.quickIcon}>
|
||||||
|
<BookOutlined style={{ fontSize: '32px', color: '#ff4d4f' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Title level={5} style={{ margin: 0 }}>错题本</Title>
|
||||||
|
<Paragraph type="secondary" style={{ margin: 0 }}>复习错题,巩固薄弱知识点</Paragraph>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
|
BookOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import styles from './Profile.module.less'
|
import styles from './Profile.module.less'
|
||||||
|
|
||||||
@ -94,6 +95,16 @@ const Profile: React.FC = () => {
|
|||||||
{/* 功能列表 */}
|
{/* 功能列表 */}
|
||||||
<Card title="功能" className={styles.menuCard}>
|
<Card title="功能" className={styles.menuCard}>
|
||||||
<List>
|
<List>
|
||||||
|
<List.Item
|
||||||
|
onClick={() => navigate('/wrong-questions')}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<BookOutlined />
|
||||||
|
<span>错题本</span>
|
||||||
|
</Space>
|
||||||
|
<RightOutlined />
|
||||||
|
</List.Item>
|
||||||
<List.Item
|
<List.Item
|
||||||
onClick={() => message.info('功能开发中')}
|
onClick={() => message.info('功能开发中')}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
|
|||||||
110
web/src/pages/WrongQuestions.module.less
Normal file
110
web/src/pages/WrongQuestions.module.less
Normal file
@ -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端不需要底部导航留空
|
||||||
|
}
|
||||||
|
}
|
||||||
264
web/src/pages/WrongQuestions.tsx
Normal file
264
web/src/pages/WrongQuestions.tsx
Normal file
@ -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<WrongQuestion[]>([])
|
||||||
|
const [stats, setStats] = useState<WrongQuestionStats | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
type="text"
|
||||||
|
className={styles.backButton}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<Title level={2} className={styles.title}>
|
||||||
|
<BookOutlined /> 错题本
|
||||||
|
</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
{stats && (
|
||||||
|
<Card className={styles.statsCard}>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={8} sm={8} md={8}>
|
||||||
|
<Statistic
|
||||||
|
title="错题总数"
|
||||||
|
value={stats.total_wrong}
|
||||||
|
valueStyle={{ color: '#ff4d4f', fontSize: '24px' }}
|
||||||
|
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>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={!wrongQuestions.length}
|
||||||
|
>
|
||||||
|
清空错题本
|
||||||
|
</Button>
|
||||||
|
</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="暂无错题" />
|
||||||
|
) : (
|
||||||
|
<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.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>
|
||||||
|
{item.is_mastered && <Tag color="success">已掌握</Tag>}
|
||||||
|
<Tag>错误 {item.wrong_count} 次</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div className={styles.questionContent}>
|
||||||
|
<Paragraph>{item.question.content}</Paragraph>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<Text type="danger">
|
||||||
|
你的答案: {formatAnswer(item.wrong_answer, item.question.type)}
|
||||||
|
</Text>
|
||||||
|
<Text type="success">
|
||||||
|
正确答案: {formatAnswer(item.correct_answer, item.question.type)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
最后错误时间: {new Date(item.last_wrong_time).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WrongQuestions
|
||||||
@ -45,3 +45,24 @@ export interface ApiResponse<T> {
|
|||||||
message?: string
|
message?: string
|
||||||
total?: number
|
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<string, number>
|
||||||
|
category_stats: Record<string, number>
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user