实现完整的错题本功能模块

后端实现:
- 创建错题数据模型和数据库表结构
- 实现错题记录、查询、统计、标记和清空API
- 答题错误时自动记录到错题本
- 支持重复错误累计次数和更新时间

前端实现:
- 创建错题本页面,支持查看、筛选和管理错题
- 实现错题统计展示(总数、已掌握、待掌握)
- 支持标记已掌握、清空错题本和重做题目
- 在首页和个人中心添加错题本入口
- 完整的响应式设计适配移动端和PC端

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yanlongqi 2025-11-04 13:44:51 +08:00
parent e722180c07
commit 6446508954
12 changed files with 756 additions and 3 deletions

View File

@ -21,6 +21,7 @@ func InitDB() error {
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // 开启SQL日志
DisableForeignKeyConstraintWhenMigrating: true, // 迁移时禁用外键约束
})
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
@ -32,6 +33,7 @@ func InitDB() error {
err = DB.AutoMigrate(
&models.User{},
&models.PracticeQuestion{},
&models.WrongQuestion{}, // 添加错题表
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)

View File

@ -143,6 +143,9 @@ func SubmitPracticeAnswer(c *gin.Context) {
return
}
// 获取用户ID如果已登录
userID, _ := c.Get("user_id")
db := database.GetDB()
var question models.PracticeQuestion
@ -167,6 +170,14 @@ func SubmitPracticeAnswer(c *gin.Context) {
// 验证答案
correct := checkPracticeAnswer(question.Type, submit.Answer, correctAnswer)
// 如果答错且用户已登录,记录到错题本
if !correct && userID != nil {
if uid, ok := userID.(uint); ok {
// 异步记录错题,不影响主流程
go recordWrongQuestion(uid, question.ID, submit.Answer, correctAnswer)
}
}
result := models.PracticeAnswerResult{
Correct: correct,
UserAnswer: submit.Answer,

View 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
}

View 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"` // 各分类错题数
}

View File

@ -43,6 +43,12 @@ func main() {
api.GET("/practice/questions/:id", handlers.GetPracticeQuestionByID) // 获取指定练习题目
api.POST("/practice/submit", handlers.SubmitPracticeAnswer) // 提交练习答案
api.GET("/practice/types", handlers.GetPracticeQuestionTypes) // 获取题型列表
// 错题本相关API
api.GET("/wrong-questions", handlers.GetWrongQuestions) // 获取错题列表
api.GET("/wrong-questions/stats", handlers.GetWrongQuestionStats) // 获取错题统计
api.PUT("/wrong-questions/:id/mastered", handlers.MarkWrongQuestionMastered) // 标记已掌握
api.DELETE("/wrong-questions", handlers.ClearWrongQuestions) // 清空错题本
}
// 启动服务器

View File

@ -7,6 +7,7 @@ import Profile from './pages/Profile'
import Login from './pages/Login'
import Home from './pages/Home'
import About from './pages/About'
import WrongQuestions from './pages/WrongQuestions'
const App: React.FC = () => {
return (
@ -19,7 +20,10 @@ const App: React.FC = () => {
<Route path="/profile" element={<Profile />} />
</Route>
{/* 不带TabBar的页面 */}
{/* 不带TabBar的页面但需要登录保护 */}
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
{/* 不带TabBar的页面不需要登录保护 */}
<Route path="/login" element={<Login />} />
<Route path="/about" element={<About />} />
</Routes>

View File

@ -1,5 +1,5 @@
import { request } from '../utils/request'
import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse } from '../types/question'
import type { Question, SubmitAnswer, AnswerResult, Statistics, ApiResponse, WrongQuestion, WrongQuestionStats } from '../types/question'
// 获取题目列表
export const getQuestions = (params?: { type?: string; category?: string }) => {
@ -43,3 +43,25 @@ export const resetProgress = async () => {
data: null
}
}
// ========== 错题本相关 API ==========
// 获取错题列表
export const getWrongQuestions = (params?: { is_mastered?: boolean; type?: string }) => {
return request.get<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')
}

View File

@ -7,6 +7,7 @@ import {
UnorderedListOutlined,
EditOutlined,
RocketOutlined,
BookOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { Statistics } from '../types/question'
@ -178,6 +179,23 @@ const Home: React.FC = () => {
</div>
</Space>
</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>
)

View File

@ -15,6 +15,7 @@ import {
SettingOutlined,
FileTextOutlined,
UserOutlined,
BookOutlined,
} from '@ant-design/icons'
import styles from './Profile.module.less'
@ -94,6 +95,16 @@ const Profile: React.FC = () => {
{/* 功能列表 */}
<Card title="功能" className={styles.menuCard}>
<List>
<List.Item
onClick={() => navigate('/wrong-questions')}
style={{ cursor: 'pointer' }}
>
<Space>
<BookOutlined />
<span></span>
</Space>
<RightOutlined />
</List.Item>
<List.Item
onClick={() => message.info('功能开发中')}
style={{ cursor: 'pointer' }}

View 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端不需要底部导航留空
}
}

View 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

View File

@ -45,3 +45,24 @@ export interface ApiResponse<T> {
message?: string
total?: number
}
// 错题记录
export interface WrongQuestion {
id: number
question_id: number
question: Question
wrong_answer: string | string[]
correct_answer: string | string[]
wrong_count: number
last_wrong_time: string
is_mastered: boolean
}
// 错题统计
export interface WrongQuestionStats {
total_wrong: number
mastered: number
not_mastered: number
type_stats: Record<string, number>
category_stats: Record<string, number>
}