实现完整的用户统计功能和认证系统

**统计功能**:
- 新增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:
yanlongqi 2025-11-04 15:26:27 +08:00
parent 9ca8f123e7
commit 2bcf6bdacc
11 changed files with 362 additions and 168 deletions

View File

@ -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)

View File

@ -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,
})
}

View File

@ -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,

View File

@ -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,
})
}

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

View 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"` // 正确率
}

View File

@ -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
View File

@ -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) // 清空错题本
}
} }
// 启动服务器 // 启动服务器

View File

@ -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`)

View File

@ -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 {

View File

@ -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>