重构答题系统:组件拆分、进度跟踪、完成统计

主要改动:
1. 组件拆分:将Question.tsx(605行)拆分为4个子组件(303行)
   - QuestionProgress: 进度条和统计显示
   - QuestionCard: 题目卡片和答题界面
   - AnswerResult: 答案结果展示
   - CompletionSummary: 完成统计摘要

2. 新增功能:
   - 答题进度条:显示当前进度、正确数、错误数
   - 进度保存:使用localStorage持久化答题进度
   - 完成统计:答完所有题目后显示统计摘要和正确率
   - 从第一题开始:改为顺序答题而非随机

3. UI优化:
   - 移除右上角统计按钮
   - 移除底部随机题目、题目列表、筛选按钮
   - 移除"开始xxx答题"提示消息
   - 简化页面布局

4. 代码优化:
   - 提高代码可维护性和可测试性
   - 单一职责原则,每个组件负责一个特定功能

🤖 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 18:39:15 +08:00
parent 6082ca0bf3
commit de8480a328
17 changed files with 937 additions and 669 deletions

View File

@ -32,10 +32,9 @@ func GetPracticeQuestions(c *gin.Context) {
query := db.Model(&models.PracticeQuestion{}) query := db.Model(&models.PracticeQuestion{})
// 根据题型过滤 - 将前端类型映射到后端类型 // 根据题型过滤 - 直接使用前端传来的type
if typeParam != "" { if typeParam != "" {
backendType := mapFrontendToBackendType(typeParam) query = query.Where("type = ?", typeParam)
query = query.Where("type = ?", backendType)
} }
// 根据分类过滤 // 根据分类过滤
@ -112,8 +111,7 @@ func GetRandomPracticeQuestion(c *gin.Context) {
query := db.Model(&models.PracticeQuestion{}) query := db.Model(&models.PracticeQuestion{})
if typeParam != "" { if typeParam != "" {
backendType := mapFrontendToBackendType(typeParam) query = query.Where("type = ?", typeParam)
query = query.Where("type = ?", backendType)
} }
// 使用PostgreSQL的随机排序 // 使用PostgreSQL的随机排序
@ -220,23 +218,23 @@ func SubmitPracticeAnswer(c *gin.Context) {
func GetPracticeQuestionTypes(c *gin.Context) { func GetPracticeQuestionTypes(c *gin.Context) {
types := []gin.H{ types := []gin.H{
{ {
"type": models.FillInBlank, "type": "fill-in-blank",
"type_name": "填空题", "type_name": "填空题",
}, },
{ {
"type": models.TrueFalseType, "type": "true-false",
"type_name": "判断题", "type_name": "判断题",
}, },
{ {
"type": models.MultipleChoiceQ, "type": "multiple-choice",
"type_name": "选择题", "type_name": "选择题",
}, },
{ {
"type": models.MultipleSelection, "type": "multiple-selection",
"type_name": "多选题", "type_name": "多选题",
}, },
{ {
"type": models.ShortAnswer, "type": "short-answer",
"type_name": "简答题", "type_name": "简答题",
}, },
} }
@ -248,21 +246,21 @@ func GetPracticeQuestionTypes(c *gin.Context) {
} }
// checkPracticeAnswer 检查练习答案是否正确 // checkPracticeAnswer 检查练习答案是否正确
func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, correctAnswer interface{}) bool { func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool {
switch questionType { switch questionType {
case models.TrueFalseType: case "true-false":
// 判断题: boolean 比较 // 判断题: boolean 比较
userBool, ok1 := userAnswer.(bool) userBool, ok1 := userAnswer.(bool)
correctBool, ok2 := correctAnswer.(bool) correctBool, ok2 := correctAnswer.(bool)
return ok1 && ok2 && userBool == correctBool return ok1 && ok2 && userBool == correctBool
case models.MultipleChoiceQ: case "multiple-choice":
// 单选题: 字符串比较 // 单选题: 字符串比较
userStr, ok1 := userAnswer.(string) userStr, ok1 := userAnswer.(string)
correctStr, ok2 := correctAnswer.(string) correctStr, ok2 := correctAnswer.(string)
return ok1 && ok2 && userStr == correctStr return ok1 && ok2 && userStr == correctStr
case models.MultipleSelection: case "multiple-selection":
// 多选题: 数组比较 // 多选题: 数组比较
userArr, ok1 := toStringArray(userAnswer) userArr, ok1 := toStringArray(userAnswer)
correctArr, ok2 := toStringArray(correctAnswer) correctArr, ok2 := toStringArray(correctAnswer)
@ -282,7 +280,7 @@ func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, c
} }
return true return true
case models.FillInBlank: case "fill-in-blank":
// 填空题: 数组比较 // 填空题: 数组比较
userArr, ok1 := toStringArray(userAnswer) userArr, ok1 := toStringArray(userAnswer)
correctArr, ok2 := toStringArray(correctAnswer) correctArr, ok2 := toStringArray(correctAnswer)
@ -298,7 +296,7 @@ func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, c
} }
return true return true
case models.ShortAnswer: case "short-answer":
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断) // 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
userStr, ok1 := userAnswer.(string) userStr, ok1 := userAnswer.(string)
correctStr, ok2 := correctAnswer.(string) correctStr, ok2 := correctAnswer.(string)
@ -328,51 +326,19 @@ func toStringArray(v interface{}) ([]string, bool) {
} }
} }
// mapFrontendToBackendType 将前端类型映射到后端类型
func mapFrontendToBackendType(frontendType string) models.PracticeQuestionType {
typeMap := map[string]models.PracticeQuestionType{
"single": models.MultipleChoiceQ, // 单选
"multiple": models.MultipleSelection, // 多选
"judge": models.TrueFalseType, // 判断
"fill": models.FillInBlank, // 填空
"short": models.ShortAnswer, // 简答
}
if backendType, ok := typeMap[frontendType]; ok {
return backendType
}
return models.MultipleChoiceQ // 默认返回单选
}
// mapBackendToFrontendType 将后端类型映射到前端类型
func mapBackendToFrontendType(backendType models.PracticeQuestionType) string {
typeMap := map[models.PracticeQuestionType]string{
models.MultipleChoiceQ: "single", // 单选
models.MultipleSelection: "multiple", // 多选
models.TrueFalseType: "judge", // 判断
models.FillInBlank: "fill", // 填空
models.ShortAnswer: "short", // 简答
}
if frontendType, ok := typeMap[backendType]; ok {
return frontendType
}
return "single" // 默认返回单选
}
// convertToDTO 将数据库模型转换为前端DTO // convertToDTO 将数据库模型转换为前端DTO
func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO { func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
dto := models.PracticeQuestionDTO{ dto := models.PracticeQuestionDTO{
ID: question.ID, ID: question.ID,
QuestionID: question.QuestionID, // 添加题目编号 QuestionID: question.QuestionID,
Type: mapBackendToFrontendType(question.Type), Type: question.Type, // 直接使用数据库中的type不做映射
Content: question.Question, Content: question.Question,
Category: question.TypeName, Category: question.TypeName, // 使用typeName作为分类显示
Options: []models.Option{}, Options: []models.Option{},
} }
// 判断题自动生成选项 // 判断题自动生成选项
if question.Type == models.TrueFalseType { if question.Type == "true-false" {
dto.Options = []models.Option{ dto.Options = []models.Option{
{Key: "true", Value: "正确"}, {Key: "true", Value: "正确"},
{Key: "false", Value: "错误"}, {Key: "false", Value: "错误"},

View File

@ -49,8 +49,8 @@ func GetWrongQuestions(c *gin.Context) {
// 转换为DTO // 转换为DTO
dtos := make([]models.WrongQuestionDTO, 0, len(wrongQuestions)) dtos := make([]models.WrongQuestionDTO, 0, len(wrongQuestions))
for _, wq := range wrongQuestions { for _, wq := range wrongQuestions {
// 题型筛选 // 题型筛选 - 直接比较type字段
if questionType != "" && mapBackendToFrontendType(wq.PracticeQuestion.Type) != questionType { if questionType != "" && wq.PracticeQuestion.Type != questionType {
continue continue
} }
@ -116,9 +116,8 @@ func GetWrongQuestionStats(c *gin.Context) {
stats.NotMastered++ stats.NotMastered++
} }
// 统计题型 // 统计题型 - 直接使用type字段
frontendType := mapBackendToFrontendType(wq.PracticeQuestion.Type) stats.TypeStats[wq.PracticeQuestion.Type]++
stats.TypeStats[frontendType]++
// 统计分类 // 统计分类
stats.CategoryStats[wq.PracticeQuestion.TypeName]++ stats.CategoryStats[wq.PracticeQuestion.TypeName]++

View File

@ -2,26 +2,15 @@ package models
import "gorm.io/gorm" import "gorm.io/gorm"
// PracticeQuestionType 题目类型
type PracticeQuestionType string
const (
FillInBlank PracticeQuestionType = "fill-in-blank" // 填空题
TrueFalseType PracticeQuestionType = "true-false" // 判断题
MultipleChoiceQ PracticeQuestionType = "multiple-choice" // 单选题
MultipleSelection PracticeQuestionType = "multiple-selection" // 多选题
ShortAnswer PracticeQuestionType = "short-answer" // 简答题
)
// PracticeQuestion 练习题目模型 // PracticeQuestion 练习题目模型
type PracticeQuestion struct { type PracticeQuestion struct {
gorm.Model gorm.Model
QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段) QuestionID string `gorm:"size:50;not null" json:"question_id"` // 题目ID (原JSON中的id字段)
Type PracticeQuestionType `gorm:"index;size:30;not null" json:"type"` // 题目类型 Type string `gorm:"index;size:30;not null" json:"type"` // 题目类型
TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文) TypeName string `gorm:"size:50" json:"type_name"` // 题型名称(中文)
Question string `gorm:"type:text;not null" json:"question"` // 题目内容 Question string `gorm:"type:text;not null" json:"question"` // 题目内容
AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储) AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储)
OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题) OptionsData string `gorm:"type:text" json:"-"` // 选项数据(JSON格式存储,用于选择题)
} }
// TableName 指定表名 // TableName 指定表名

26
scripts/check_db.go Normal file
View File

@ -0,0 +1,26 @@
package main
import (
"ankao/internal/database"
"ankao/internal/models"
"fmt"
"log"
)
func main() {
// 初始化数据库
if err := database.InitDB(); err != nil {
log.Fatal("数据库初始化失败:", err)
}
db := database.GetDB()
// 查询前5条记录
var questions []models.PracticeQuestion
db.Limit(5).Find(&questions)
fmt.Println("数据库中的题目:")
for _, q := range questions {
fmt.Printf("ID: %d, QuestionID: %s, Type: %s, TypeName: %s\n", q.ID, q.QuestionID, q.Type, q.TypeName)
}
}

View File

@ -0,0 +1,28 @@
package main
import (
"ankao/internal/database"
"ankao/internal/models"
"log"
)
func main() {
log.Println("开始清空题目数据...")
// 初始化数据库
if err := database.InitDB(); err != nil {
log.Fatal("数据库初始化失败:", err)
}
db := database.GetDB()
// 清空practice_questions表
if err := db.Exec("DELETE FROM practice_questions").Error; err != nil {
log.Fatal("清空题目表失败:", err)
}
// 获取清空后的数量
var count int64
db.Model(&models.PracticeQuestion{}).Count(&count)
log.Printf("题目数据已清空,当前数量: %d", count)
}

View File

@ -69,13 +69,10 @@ func main() {
optionsJSON = string(optJSON) optionsJSON = string(optJSON)
} }
// 处理题型映射 // 创建题目记录 - 直接使用group.Type不做类型映射
questionType := mapQuestionType(group.Type)
// 创建题目记录
question := models.PracticeQuestion{ question := models.PracticeQuestion{
QuestionID: q.ID, QuestionID: q.ID,
Type: questionType, Type: group.Type,
TypeName: group.TypeName, TypeName: group.TypeName,
Question: q.Question, Question: q.Question,
AnswerData: string(answerJSON), AnswerData: string(answerJSON),
@ -93,21 +90,3 @@ func main() {
log.Printf("数据导入完成! 共导入 %d 道题目", totalCount) log.Printf("数据导入完成! 共导入 %d 道题目", totalCount)
} }
// mapQuestionType 映射题型
func mapQuestionType(jsonType string) models.PracticeQuestionType {
switch jsonType {
case "fill-in-blank":
return models.FillInBlank
case "true-false":
return models.TrueFalseType
case "multiple-choice":
return models.MultipleChoiceQ
case "multiple-selection":
return models.MultipleSelection
case "short-answer":
return models.ShortAnswer
default:
return models.PracticeQuestionType(jsonType)
}
}

82
scripts/import_test.go Normal file
View File

@ -0,0 +1,82 @@
package main
import (
"encoding/json"
"os"
"testing"
)
// 测试JSON解析
func TestJSONParsing(t *testing.T) {
// 读取JSON文件
data, err := os.ReadFile("../practice_question_pool.json")
if err != nil {
t.Fatalf("读取JSON文件失败: %v", err)
}
// 解析JSON
var groups []JSONQuestionGroup
if err := json.Unmarshal(data, &groups); err != nil {
t.Fatalf("解析JSON失败: %v", err)
}
t.Logf("成功解析 %d 个题目组", len(groups))
// 检查每个题目组的类型信息
for i, group := range groups {
t.Logf("\n题目组 %d:", i+1)
t.Logf(" Type: %s", group.Type)
t.Logf(" TypeName: %s", group.TypeName)
t.Logf(" 题目数量: %d", len(group.List))
// 检查类型映射
t.Logf(" 原始类型: %s", group.Type)
// 检查是否为空
if group.Type == "" {
t.Errorf(" ❌ 题目组 %d 的 Type 为空!", i+1)
}
if group.TypeName == "" {
t.Errorf(" ❌ 题目组 %d 的 TypeName 为空!", i+1)
}
if len(group.List) == 0 {
t.Errorf(" ❌ 题目组 %d 的题目列表为空!", i+1)
}
}
}
// 测试单个题目组解析
func TestSingleGroupParsing(t *testing.T) {
// 测试第一个题目组填空题type在list之后
jsonStr1 := `{
"list": [{"id": "1", "question": "test", "answers": ["answer1"]}],
"type": "fill-in-blank",
"typeName": "填空题"
}`
var group1 JSONQuestionGroup
if err := json.Unmarshal([]byte(jsonStr1), &group1); err != nil {
t.Fatalf("解析题目组1失败: %v", err)
}
t.Logf("题目组1 - Type: %s, TypeName: %s, 题目数: %d", group1.Type, group1.TypeName, len(group1.List))
// 测试第二个题目组type在list之前
jsonStr2 := `{
"type": "fill-in-blank",
"typeName": "填空题",
"list": [{"id": "1", "question": "test", "answers": ["answer1"]}]
}`
var group2 JSONQuestionGroup
if err := json.Unmarshal([]byte(jsonStr2), &group2); err != nil {
t.Fatalf("解析题目组2失败: %v", err)
}
t.Logf("题目组2 - Type: %s, TypeName: %s, 题目数: %d", group2.Type, group2.TypeName, len(group2.List))
// 验证两种顺序解析结果是否一致
if group1.Type != group2.Type || group1.TypeName != group2.TypeName {
t.Errorf("不同字段顺序导致解析结果不一致!")
} else {
t.Log("✓ JSON字段顺序不影响解析结果")
}
}

View File

@ -0,0 +1,70 @@
import React from 'react'
import { Alert, Typography } from 'antd'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
import type { AnswerResult as AnswerResultType } from '../types/question'
const { Text } = Typography
interface AnswerResultProps {
answerResult: AnswerResultType
selectedAnswer: string | string[]
questionType: string
}
const AnswerResult: React.FC<AnswerResultProps> = ({
answerResult,
selectedAnswer,
questionType,
}) => {
// 格式化答案显示(判断题特殊处理)
const formatAnswer = (answer: string | string[]) => {
const answerStr = Array.isArray(answer) ? answer.join(', ') : answer
if (questionType === 'true-false') {
return answerStr === 'true' ? '正确' : answerStr === 'false' ? '错误' : answerStr
}
return answerStr
}
return (
<Alert
type={answerResult.correct ? 'success' : 'error'}
icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
message={
<div>
<strong>{answerResult.correct ? '回答正确!' : '回答错误'}</strong>
</div>
}
description={
<div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
<Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
{formatAnswer(selectedAnswer)}
</Text>
</div>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#52c41a' }}>
</Text>
<Text strong style={{ color: '#52c41a' }}>
{formatAnswer(
answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '暂无')
)}
</Text>
</div>
{answerResult.explanation && (
<div>
<Text type="secondary"></Text>
<div style={{ marginTop: 4 }}>{answerResult.explanation}</div>
</div>
)}
</div>
}
style={{ marginTop: 20 }}
/>
)
}
export default AnswerResult

View File

@ -0,0 +1,105 @@
import React from 'react'
import { Modal, Button, Space, Typography } from 'antd'
import { TrophyOutlined } from '@ant-design/icons'
const { Title, Text } = Typography
interface CompletionSummaryProps {
visible: boolean
totalQuestions: number
correctCount: number
wrongCount: number
category?: string
onClose: () => void
onRetry: () => void
}
const CompletionSummary: React.FC<CompletionSummaryProps> = ({
visible,
totalQuestions,
correctCount,
wrongCount,
category,
onClose,
onRetry,
}) => {
const accuracy = totalQuestions > 0 ? Math.round((correctCount / totalQuestions) * 100) : 0
return (
<Modal
title={
<div style={{ textAlign: 'center' }}>
<TrophyOutlined style={{ fontSize: 48, color: '#faad14', marginBottom: 16 }} />
<Title level={3} style={{ margin: 0 }}>
</Title>
</div>
}
open={visible}
onCancel={onClose}
footer={[
<Button key="home" type="primary" onClick={onClose}>
</Button>,
<Button key="retry" onClick={onRetry}>
</Button>,
]}
width={500}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={4} style={{ marginBottom: 16 }}>
</Title>
<Text type="secondary">{category || '全部题型'}</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '20px 0' }}>
<div>
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#1890ff' }}>
{totalQuestions}
</div>
<Text type="secondary"></Text>
</div>
<div>
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#52c41a' }}>
{correctCount}
</div>
<Text type="secondary"></Text>
</div>
<div>
<div style={{ fontSize: 36, fontWeight: 'bold', color: '#ff4d4f' }}>
{wrongCount}
</div>
<Text type="secondary"></Text>
</div>
</div>
<div
style={{
padding: 20,
background: '#f0f2f5',
borderRadius: 8,
fontSize: 24,
fontWeight: 'bold',
}}
>
<Text></Text>
<Text
style={{
color: accuracy >= 60 ? '#52c41a' : '#ff4d4f',
fontSize: 32,
}}
>
{accuracy}%
</Text>
</div>
</Space>
</div>
</Modal>
)
}
export default CompletionSummary

View File

@ -0,0 +1,185 @@
import React, { useState } from 'react'
import { Card, Space, Tag, Typography, Radio, Checkbox, Input, Button } from 'antd'
import type { Question, AnswerResult as AnswerResultType } from '../types/question'
import AnswerResult from './AnswerResult'
import styles from '../pages/Question.module.less'
const { TextArea } = Input
const { Title } = Typography
interface QuestionCardProps {
question: Question
selectedAnswer: string | string[]
showResult: boolean
answerResult: AnswerResultType | null
loading: boolean
autoNextLoading: boolean
onAnswerChange: (answer: string | string[]) => void
onSubmit: () => void
onNext: () => void
}
const QuestionCard: React.FC<QuestionCardProps> = ({
question,
selectedAnswer,
showResult,
answerResult,
loading,
autoNextLoading,
onAnswerChange,
onSubmit,
onNext,
}) => {
const [fillAnswers, setFillAnswers] = useState<string[]>([])
// 渲染填空题内容
const renderFillContent = () => {
const content = question.content
const parts = content.split('****')
if (parts.length === 1) {
return <div className={styles.questionContent}>{content}</div>
}
if (fillAnswers.length === 0) {
setFillAnswers(new Array(parts.length - 1).fill(''))
}
return (
<div className={styles.questionContent}>
{parts.map((part, index) => (
<React.Fragment key={index}>
<span>{part}</span>
{index < parts.length - 1 && (
<Input
className={styles.fillInput}
placeholder={`填空${index + 1}`}
value={fillAnswers[index] || ''}
onChange={(e) => {
const newAnswers = [...fillAnswers]
newAnswers[index] = e.target.value
setFillAnswers(newAnswers)
onAnswerChange(newAnswers)
}}
disabled={showResult}
style={{
display: 'inline-block',
width: '120px',
margin: '0 8px',
}}
/>
)}
</React.Fragment>
))}
</div>
)
}
// 渲染题目选项
const renderOptions = () => {
if (question.type === 'fill-in-blank') {
return null
}
if (question.type === 'short-answer') {
return (
<TextArea
placeholder="请输入答案"
value={selectedAnswer as string}
onChange={(e) => onAnswerChange(e.target.value)}
disabled={showResult}
rows={4}
style={{ marginTop: 20 }}
/>
)
}
if (question.type === 'multiple-selection') {
const sortedOptions = [...question.options].sort((a, b) => a.key.localeCompare(b.key))
return (
<Checkbox.Group
value={selectedAnswer as string[]}
onChange={(val) => onAnswerChange(val as string[])}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Checkbox key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{option.key}. {option.value}
</span>
</Checkbox>
))}
</Space>
</Checkbox.Group>
)
}
// 单选题和判断题
const sortedOptions = [...question.options].sort((a, b) => a.key.localeCompare(b.key))
return (
<Radio.Group
value={selectedAnswer as string}
onChange={(e) => onAnswerChange(e.target.value)}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Radio key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{question.type === 'true-false' ? option.value : `${option.key}. ${option.value}`}
</span>
</Radio>
))}
</Space>
</Radio.Group>
)
}
return (
<Card className={styles.questionCard}>
<Space size="small" style={{ marginBottom: 16, alignItems: 'center' }}>
<Title level={5} style={{ margin: 0, display: 'inline' }}>
{question.question_id || question.id}
</Title>
<Tag color="blue">{question.category}</Tag>
</Space>
{question.type === 'fill-in-blank' ? (
renderFillContent()
) : (
<div className={styles.questionContent}>{question.content}</div>
)}
{renderOptions()}
{/* 答案结果 */}
{showResult && answerResult && (
<AnswerResult
answerResult={answerResult}
selectedAnswer={selectedAnswer}
questionType={question.type}
/>
)}
{/* 按钮 */}
<div className={styles.buttonGroup}>
{!showResult ? (
<Button type="primary" size="large" block onClick={onSubmit} loading={loading}>
</Button>
) : (
<Button type="primary" size="large" block onClick={onNext} loading={autoNextLoading}>
{autoNextLoading ? '正在跳转到下一题...' : '下一题'}
</Button>
)}
</div>
</Card>
)
}
export default QuestionCard

View File

@ -0,0 +1,47 @@
import React from 'react'
import { Progress, Space, Typography } from 'antd'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
const { Text } = Typography
interface QuestionProgressProps {
currentIndex: number
totalQuestions: number
correctCount: number
wrongCount: number
}
const QuestionProgress: React.FC<QuestionProgressProps> = ({
currentIndex,
totalQuestions,
correctCount,
wrongCount,
}) => {
if (totalQuestions === 0) return null
return (
<div style={{ marginBottom: 20 }}>
<Progress
percent={Math.round(((currentIndex + 1) / totalQuestions) * 100)}
status="active"
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
format={() => `${currentIndex + 1} / ${totalQuestions}`}
/>
<div style={{ marginTop: 8, textAlign: 'center' }}>
<Space size="large">
<Text>
<CheckOutlined style={{ color: '#52c41a' }} /> {correctCount}
</Text>
<Text>
<CloseOutlined style={{ color: '#ff4d4f' }} /> {wrongCount}
</Text>
</Space>
</div>
</div>
)
}
export default QuestionProgress

View File

@ -0,0 +1,135 @@
# 答题组件说明
本目录包含答题功能的子组件,从原来的 `Question.tsx` 大组件拆分而来。
## 组件列表
### 1. QuestionProgress.tsx
**功能**: 显示答题进度条和统计信息
**Props**:
- `currentIndex`: number - 当前题目索引
- `totalQuestions`: number - 总题目数
- `correctCount`: number - 正确题目数
- `wrongCount`: number - 错误题目数
**职责**:
- 显示当前答题进度(百分比和题号)
- 显示正确和错误的统计数量
- 使用渐变色进度条增强视觉效果
---
### 2. QuestionCard.tsx
**功能**: 显示单个题目的卡片,包含题目内容、选项和答案提交
**Props**:
- `question`: Question - 题目对象
- `selectedAnswer`: string | string[] - 选中的答案
- `showResult`: boolean - 是否显示答题结果
- `answerResult`: AnswerResult | null - 答题结果
- `loading`: boolean - 提交加载状态
- `autoNextLoading`: boolean - 自动下一题加载状态
- `onAnswerChange`: (answer: string | string[]) => void - 答案变更回调
- `onSubmit`: () => void - 提交答案回调
- `onNext`: () => void - 下一题回调
**职责**:
- 根据题目类型渲染不同的答题界面(单选、多选、填空、简答、判断)
- 处理填空题的特殊渲染逻辑
- 显示题目编号和分类标签
- 显示答案结果(使用 AnswerResult 组件)
- 提供提交和下一题按钮
---
### 3. AnswerResult.tsx
**功能**: 显示答题结果的Alert组件
**Props**:
- `answerResult`: AnswerResult - 答题结果对象
- `selectedAnswer`: string | string[] - 用户选择的答案
- `questionType`: string - 题目类型
**职责**:
- 显示正确或错误的提示图标和颜色
- 显示用户答案和正确答案
- 显示答案解析(如果有)
- 特殊处理判断题的答案显示true/false → 正确/错误)
---
### 4. CompletionSummary.tsx
**功能**: 完成所有题目后的统计摘要弹窗
**Props**:
- `visible`: boolean - 弹窗是否可见
- `totalQuestions`: number - 总题目数
- `correctCount`: number - 正确数
- `wrongCount`: number - 错误数
- `category`: string | undefined - 题目类型分类
- `onClose`: () => void - 关闭回调(返回首页)
- `onRetry`: () => void - 重新开始回调
**职责**:
- 显示完成奖杯图标
- 展示本次答题的完整统计数据
- 计算并显示正确率(根据正确率显示不同颜色)
- 提供返回首页和重新开始两个操作
---
## 组件拆分的优势
1. **单一职责**: 每个组件只负责一个特定的功能
2. **可维护性**: 更容易定位和修改问题
3. **可测试性**: 每个组件可以独立测试
4. **可复用性**: 组件可以在其他页面复用
5. **代码清晰**: 主组件 Question.tsx 从 600+ 行缩减到 300 行左右
## 主组件 Question.tsx
**保留职责**:
- 状态管理(题目、答案、进度等)
- 业务逻辑(加载题目、提交答案、保存进度等)
- API 调用
- 组件组合和布局
**文件大小变化**:
- 重构前: ~605 行
- 重构后: ~303 行
- 减少: ~50%
## 使用示例
```tsx
// 在 Question.tsx 中使用
<QuestionProgress
currentIndex={currentIndex}
totalQuestions={allQuestions.length}
correctCount={correctCount}
wrongCount={wrongCount}
/>
<QuestionCard
question={currentQuestion}
selectedAnswer={selectedAnswer}
showResult={showResult}
answerResult={answerResult}
loading={loading}
autoNextLoading={autoNextLoading}
onAnswerChange={setSelectedAnswer}
onSubmit={handleSubmit}
onNext={handleNext}
/>
<CompletionSummary
visible={showSummary}
totalQuestions={allQuestions.length}
correctCount={correctCount}
wrongCount={wrongCount}
category={currentQuestion?.category}
onClose={() => navigate("/")}
onRetry={handleRetry}
/>
```

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Card, List, Button, Typography, Descriptions, Space } from 'antd' import { Card, List, Button, Typography, Descriptions } from 'antd'
import { LeftOutlined } from '@ant-design/icons' import { LeftOutlined } from '@ant-design/icons'
import styles from './About.module.less' import styles from './About.module.less'

View File

@ -17,38 +17,38 @@ import styles from './Home.module.less'
const { Title, Paragraph, Text } = Typography const { Title, Paragraph, Text } = Typography
// 题型配置 // 题型配置 - 使用数据库中的实际类型
const questionTypes = [ const questionTypes = [
{ {
key: 'single', key: 'multiple-choice',
title: '选题', title: '题',
icon: <CheckCircleOutlined />, icon: <CheckCircleOutlined />,
color: '#1677ff', color: '#1677ff',
description: '基础知识考察', description: '基础知识考察',
}, },
{ {
key: 'multiple', key: 'multiple-selection',
title: '多选题', title: '多选题',
icon: <UnorderedListOutlined />, icon: <UnorderedListOutlined />,
color: '#52c41a', color: '#52c41a',
description: '综合能力提升', description: '综合能力提升',
}, },
{ {
key: 'judge', key: 'true-false',
title: '判断题', title: '判断题',
icon: <CheckCircleOutlined />, icon: <CheckCircleOutlined />,
color: '#fa8c16', color: '#fa8c16',
description: '快速判断训练', description: '快速判断训练',
}, },
{ {
key: 'fill', key: 'fill-in-blank',
title: '填空题', title: '填空题',
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
color: '#722ed1', color: '#722ed1',
description: '填空补充练习', description: '填空补充练习',
}, },
{ {
key: 'short', key: 'short-answer',
title: '简答题', title: '简答题',
icon: <EditOutlined />, icon: <EditOutlined />,
color: '#eb2f96', color: '#eb2f96',
@ -110,7 +110,6 @@ const Home: React.FC = () => {
if (res.success && res.data && res.data.length > 0) { if (res.success && res.data && res.data.length > 0) {
// 跳转到答题页面,并传递题型参数 // 跳转到答题页面,并传递题型参数
navigate(`/question?type=${type}`) navigate(`/question?type=${type}`)
message.success(`开始${questionTypes.find(t => t.key === type)?.title}练习`)
} else { } else {
message.warning('该题型暂无题目') message.warning('该题型暂无题目')
} }

View File

@ -1,631 +1,301 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from "react";
import { useSearchParams, useNavigate } from 'react-router-dom' import { useSearchParams, useNavigate } from "react-router-dom";
import { import { Button, message, Typography } from "antd";
Button, import { ArrowLeftOutlined } from "@ant-design/icons";
Card, import type { Question, AnswerResult } from "../types/question";
Space, import * as questionApi from "../api/question";
Radio, import QuestionProgress from "../components/QuestionProgress";
Checkbox, import QuestionCard from "../components/QuestionCard";
Input, import CompletionSummary from "../components/CompletionSummary";
message, import styles from "./Question.module.less";
Modal,
List,
Tag,
Select,
Statistic,
Alert,
Typography,
Row,
Col,
Spin,
} from 'antd'
import {
CheckOutlined,
CloseOutlined,
PieChartOutlined,
UnorderedListOutlined,
FilterOutlined,
ReloadOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons'
import type { Question, AnswerResult } from '../types/question'
import * as questionApi from '../api/question'
import styles from './Question.module.less'
const { TextArea } = Input const { Title } = Typography;
const { Title, Text } = Typography
const QuestionPage: React.FC = () => { const QuestionPage: React.FC = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams();
const navigate = useNavigate() const navigate = useNavigate();
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null) const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
const [selectedAnswer, setSelectedAnswer] = useState<string | string[]>('') const [selectedAnswer, setSelectedAnswer] = useState<string | string[]>("");
const [showResult, setShowResult] = useState(false) const [showResult, setShowResult] = useState(false);
const [answerResult, setAnswerResult] = useState<AnswerResult | null>(null) const [answerResult, setAnswerResult] = useState<AnswerResult | null>(null);
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false);
const [autoNextLoading, setAutoNextLoading] = useState(false) const [autoNextLoading, setAutoNextLoading] = useState(false);
const [allQuestions, setAllQuestions] = useState<Question[]>([]) const [allQuestions, setAllQuestions] = useState<Question[]>([]);
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0);
const [fillAnswers, setFillAnswers] = useState<string[]>([])
// 统计弹窗 // 答题统计
const [statsVisible, setStatsVisible] = useState(false) const [correctCount, setCorrectCount] = useState(0);
const [statistics, setStatistics] = useState({ const [wrongCount, setWrongCount] = useState(0);
total_questions: 0, const [showSummary, setShowSummary] = useState(false);
answered_questions: 0,
correct_answers: 0,
accuracy: 0,
})
// 列表弹窗 // 从localStorage恢复答题进度
const [listVisible, setListVisible] = useState(false) const getStorageKey = () => {
const type = searchParams.get("type");
const mode = searchParams.get("mode");
return `question_progress_${type || mode || "default"}`;
};
// 筛选弹窗 // 保存答题进度
const [filterVisible, setFilterVisible] = useState(false) const saveProgress = (index: number, correct: number, wrong: number) => {
const [filterType, setFilterType] = useState<string | undefined>(undefined) const key = getStorageKey();
const [filterCategory, setFilterCategory] = useState<string | undefined>(undefined) localStorage.setItem(
key,
JSON.stringify({
currentIndex: index,
correctCount: correct,
wrongCount: wrong,
timestamp: Date.now(),
})
);
};
// 恢复答题进度
const loadProgress = () => {
const key = getStorageKey();
const saved = localStorage.getItem(key);
if (saved) {
try {
const progress = JSON.parse(saved);
setCurrentIndex(progress.currentIndex || 0);
setCorrectCount(progress.correctCount || 0);
setWrongCount(progress.wrongCount || 0);
return progress.currentIndex || 0;
} catch (e) {
console.error("恢复进度失败", e);
}
}
return 0;
};
// 加载随机题目 // 加载随机题目
const loadRandomQuestion = async () => { const loadRandomQuestion = async () => {
setLoading(true) setLoading(true);
try { try {
// 检查是否是错题练习模式 // 检查是否是错题练习模式
const mode = searchParams.get('mode') const mode = searchParams.get("mode");
const res = mode === 'wrong' const res =
? await questionApi.getRandomWrongQuestion() mode === "wrong"
: await questionApi.getRandomQuestion() ? 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-selection" ? [] : "");
setFillAnswers([]) setShowResult(false);
setShowResult(false) setAnswerResult(null);
setAnswerResult(null)
} }
} catch (error: any) { } catch (error: any) {
if (error.response?.status === 401) { if (error.response?.status === 401) {
message.error('请先登录') message.error("请先登录");
} else if (error.response?.status === 404) { } else if (error.response?.status === 404) {
message.error('暂无错题') message.error("暂无错题");
} else { } else {
message.error('加载题目失败') message.error("加载题目失败");
} }
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
// 加载题目列表 // 加载题目列表(从第一题开始)
const loadQuestions = async (type?: string, category?: string) => { const loadQuestions = async (type?: string, category?: string) => {
setLoading(true) setLoading(true);
try { try {
const res = await questionApi.getQuestions({ type, category }) const res = await questionApi.getQuestions({ type, category });
if (res.success && res.data) { if (res.success && res.data) {
setAllQuestions(res.data) setAllQuestions(res.data);
// 恢复答题进度
const savedIndex = loadProgress();
const startIndex = savedIndex < res.data.length ? savedIndex : 0;
if (res.data.length > 0) { if (res.data.length > 0) {
setCurrentQuestion(res.data[0]) setCurrentQuestion(res.data[startIndex]);
setCurrentIndex(0) setCurrentIndex(startIndex);
setSelectedAnswer(res.data[0].type === 'multiple' ? [] : '') setSelectedAnswer(
setFillAnswers([]) res.data[startIndex].type === "multiple-selection" ? [] : ""
setShowResult(false) );
setAnswerResult(null) setShowResult(false);
setAnswerResult(null);
} }
} }
} catch (error) { } catch (error) {
message.error('加载题目列表失败') message.error("加载题目列表失败");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
// 加载统计数据
const loadStatistics = async () => {
try {
const res = await questionApi.getStatistics()
if (res.success && res.data) {
setStatistics(res.data)
}
} catch (error) {
message.error('加载统计失败')
}
}
// 提交答案 // 提交答案
const handleSubmit = async () => { const handleSubmit = async () => {
if (!currentQuestion) return if (!currentQuestion) return;
// 检查是否选择了答案 // 检查是否选择了答案
if (currentQuestion.type === 'multiple') { if (currentQuestion.type === "multiple-selection") {
if ((selectedAnswer as string[]).length === 0) { if ((selectedAnswer as string[]).length === 0) {
message.warning('请选择答案') message.warning("请选择答案");
return return;
} }
} else if (currentQuestion.type === 'fill') { } else if (currentQuestion.type === "fill-in-blank") {
if (fillAnswers.length === 0 || fillAnswers.some(a => !a || a.trim() === '')) { const answers = selectedAnswer as string[];
message.warning('请填写所有空格') if (answers.length === 0 || answers.some((a) => !a || a.trim() === "")) {
return message.warning("请填写所有空格");
return;
} }
} else { } else {
if (!selectedAnswer || (typeof selectedAnswer === 'string' && selectedAnswer.trim() === '')) { if (
message.warning('请填写答案') !selectedAnswer ||
return (typeof selectedAnswer === "string" && selectedAnswer.trim() === "")
) {
message.warning("请填写答案");
return;
} }
} }
setLoading(true) setLoading(true);
try { try {
const res = await questionApi.submitAnswer({ const res = await questionApi.submitAnswer({
question_id: currentQuestion.id, question_id: currentQuestion.id,
answer: selectedAnswer, answer: selectedAnswer,
}) });
if (res.success && res.data) { if (res.success && res.data) {
setAnswerResult(res.data) setAnswerResult(res.data);
setShowResult(true) setShowResult(true);
// 更新统计
if (res.data.correct) {
const newCorrect = correctCount + 1;
setCorrectCount(newCorrect);
saveProgress(currentIndex, newCorrect, wrongCount);
} else {
const newWrong = wrongCount + 1;
setWrongCount(newWrong);
saveProgress(currentIndex, correctCount, newWrong);
}
// 如果答案正确1秒后自动进入下一题 // 如果答案正确1秒后自动进入下一题
if (res.data.correct) { if (res.data.correct) {
setAutoNextLoading(true) setAutoNextLoading(true);
setTimeout(() => { setTimeout(() => {
setAutoNextLoading(false) setAutoNextLoading(false);
handleNext() handleNext();
}, 1000) }, 1000);
} }
} }
} catch (error) { } catch (error) {
message.error('提交失败') message.error("提交失败");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
// 下一题 // 下一题
const handleNext = () => { const handleNext = () => {
if (allQuestions.length > 0) { if (allQuestions.length > 0) {
const nextIndex = (currentIndex + 1) % allQuestions.length // 检查是否完成所有题目
setCurrentIndex(nextIndex) if (currentIndex + 1 >= allQuestions.length) {
setCurrentQuestion(allQuestions[nextIndex]) // 显示统计摘要
setSelectedAnswer(allQuestions[nextIndex].type === 'multiple' ? [] : '') setShowSummary(true);
setFillAnswers([]) // 清除进度
setShowResult(false) localStorage.removeItem(getStorageKey());
setAnswerResult(null) return;
} else { }
loadRandomQuestion()
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
setCurrentQuestion(allQuestions[nextIndex]);
setSelectedAnswer(
allQuestions[nextIndex].type === "multiple-selection" ? [] : ""
);
setShowResult(false);
setAnswerResult(null);
// 保存进度
saveProgress(nextIndex, correctCount, wrongCount);
} }
} };
// 选择题目
const handleSelectQuestion = (question: Question, index: number) => {
setCurrentQuestion(question)
setCurrentIndex(index)
setSelectedAnswer(question.type === 'multiple' ? [] : '')
setFillAnswers([])
setShowResult(false)
setAnswerResult(null)
setListVisible(false)
}
// 应用筛选
const handleApplyFilter = () => {
loadQuestions(filterType, filterCategory)
setFilterVisible(false)
}
// 重置进度
const handleReset = async () => {
Modal.confirm({
title: '确定要重置答题进度吗?',
onOk: async () => {
try {
await questionApi.resetProgress()
message.success('重置成功')
loadStatistics()
} catch (error) {
message.error('重置失败')
}
},
})
}
// 初始化 // 初始化
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') const mode = searchParams.get("mode");
// 错题练习模式 // 错题练习模式
if (mode === 'wrong') { if (mode === "wrong") {
loadRandomQuestion() loadRandomQuestion();
return return;
} }
// 普通练习模式 // 普通练习模式 - 从第一题开始
if (typeParam || categoryParam) { loadQuestions(typeParam || undefined, categoryParam || undefined);
loadQuestions(typeParam || undefined, categoryParam || undefined) }, [searchParams]);
} else {
loadRandomQuestion()
loadQuestions()
}
}, [searchParams])
// 获取题型名称 // 重试处理
const getTypeName = (type: string) => { const handleRetry = () => {
const typeMap: Record<string, string> = { setShowSummary(false);
single: '单选题', setCurrentIndex(0);
multiple: '多选题', setCorrectCount(0);
fill: '填空题', setWrongCount(0);
judge: '判断题', localStorage.removeItem(getStorageKey());
short: '简答题', const typeParam = searchParams.get("type");
} const categoryParam = searchParams.get("category");
return typeMap[type] || type loadQuestions(typeParam || undefined, categoryParam || undefined);
} };
// 渲染填空题内容
const renderFillContent = () => {
if (!currentQuestion) return null
const content = currentQuestion.content
const parts = content.split('****')
if (parts.length === 1) {
return <div className={styles.questionContent}>{content}</div>
}
if (fillAnswers.length === 0) {
setFillAnswers(new Array(parts.length - 1).fill(''))
}
return (
<div className={styles.questionContent}>
{parts.map((part, index) => (
<React.Fragment key={index}>
<span>{part}</span>
{index < parts.length - 1 && (
<Input
className={styles.fillInput}
placeholder={`填空${index + 1}`}
value={fillAnswers[index] || ''}
onChange={(e) => {
const newAnswers = [...fillAnswers]
newAnswers[index] = e.target.value
setFillAnswers(newAnswers)
setSelectedAnswer(newAnswers)
}}
disabled={showResult}
style={{
display: 'inline-block',
width: '120px',
margin: '0 8px',
}}
/>
)}
</React.Fragment>
))}
</div>
)
}
// 渲染题目选项
const renderOptions = () => {
if (!currentQuestion) return null
if (currentQuestion.type === 'fill') {
return null
}
if (currentQuestion.type === 'short') {
return (
<TextArea
placeholder="请输入答案"
value={selectedAnswer as string}
onChange={(e) => setSelectedAnswer(e.target.value)}
disabled={showResult}
rows={4}
style={{ marginTop: 20 }}
/>
)
}
if (currentQuestion.type === 'multiple') {
// 按ABCD顺序排序选项
const sortedOptions = [...currentQuestion.options].sort((a, b) =>
a.key.localeCompare(b.key)
)
return (
<Checkbox.Group
value={selectedAnswer as string[]}
onChange={(val) => setSelectedAnswer(val as string[])}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Checkbox key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{option.key}. {option.value}
</span>
</Checkbox>
))}
</Space>
</Checkbox.Group>
)
}
// 按ABCD顺序排序选项
const sortedOptions = [...currentQuestion.options].sort((a, b) =>
a.key.localeCompare(b.key)
)
return (
<Radio.Group
value={selectedAnswer as string}
onChange={(e) => setSelectedAnswer(e.target.value)}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Radio key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{/* 判断题不显示A、B只显示选项内容 */}
{currentQuestion.type === 'judge' ? option.value : `${option.key}. ${option.value}`}
</span>
</Radio>
))}
</Space>
</Radio.Group>
)
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.content}> <div className={styles.content}>
{/* 头部 */} {/* 头部 */}
<div className={styles.header}> <div className={styles.header}>
<Button <Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/")}>
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
>
</Button> </Button>
<Title level={3} className={styles.title}>AnKao </Title> <Title level={3} className={styles.title}>
<Button AnKao
type="primary" </Title>
icon={<PieChartOutlined />}
onClick={() => {
loadStatistics()
setStatsVisible(true)
}}
>
</Button>
</div> </div>
{/* 进度条 */}
<QuestionProgress
currentIndex={currentIndex}
totalQuestions={allQuestions.length}
correctCount={correctCount}
wrongCount={wrongCount}
/>
{/* 题目卡片 */} {/* 题目卡片 */}
<Card className={styles.questionCard}> {currentQuestion && (
{currentQuestion && ( <QuestionCard
<> question={currentQuestion}
<Space size="small" style={{ marginBottom: 16 }}> selectedAnswer={selectedAnswer}
<Tag color="blue">{getTypeName(currentQuestion.type)}</Tag> showResult={showResult}
<Tag color="green">{currentQuestion.category}</Tag> answerResult={answerResult}
</Space> loading={loading}
autoNextLoading={autoNextLoading}
<Title level={5} className={styles.questionNumber}> onAnswerChange={setSelectedAnswer}
{currentQuestion.question_id || currentQuestion.id} onSubmit={handleSubmit}
</Title> onNext={handleNext}
/>
{currentQuestion.type === 'fill' ? renderFillContent() : ( )}
<div className={styles.questionContent}>{currentQuestion.content}</div>
)}
{renderOptions()}
{/* 答案结果 */}
{showResult && answerResult && (
<Alert
type={answerResult.correct ? 'success' : 'error'}
icon={answerResult.correct ? <CheckOutlined /> : <CloseOutlined />}
message={
<div>
<strong>{answerResult.correct ? '回答正确!' : '回答错误'}</strong>
</div>
}
description={
<div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
<Text strong={answerResult.correct} type={answerResult.correct ? undefined : 'danger'}>
{(() => {
const answer = Array.isArray(selectedAnswer)
? selectedAnswer.join(', ')
: selectedAnswer;
// 判断题显示文字而不是 true/false
if (currentQuestion?.type === 'judge') {
return answer === 'true' ? '正确' : answer === 'false' ? '错误' : answer;
}
return answer;
})()}
</Text>
</div>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#52c41a' }}></Text>
<Text strong style={{ color: '#52c41a' }}>
{(() => {
const correctAnswer = answerResult.correct_answer || (answerResult.correct ? selectedAnswer : '');
let displayAnswer = Array.isArray(correctAnswer)
? correctAnswer.join(', ')
: correctAnswer || '暂无';
// 判断题显示文字而不是 true/false
if (currentQuestion?.type === 'judge') {
displayAnswer = displayAnswer === 'true' ? '正确' : displayAnswer === 'false' ? '错误' : displayAnswer;
}
return displayAnswer;
})()}
</Text>
</div>
{answerResult.explanation && (
<div>
<Text type="secondary"></Text>
<div style={{ marginTop: 4 }}>{answerResult.explanation}</div>
</div>
)}
</div>
}
style={{ marginTop: 20 }}
/>
)}
{/* 按钮 */}
<div className={styles.buttonGroup}>
{!showResult ? (
<Button
type="primary"
size="large"
block
onClick={handleSubmit}
loading={loading}
>
</Button>
) : (
<Button
type="primary"
size="large"
block
onClick={handleNext}
loading={autoNextLoading}
>
{autoNextLoading ? '正在跳转到下一题...' : '下一题'}
</Button>
)}
</div>
</>
)}
</Card>
{/* 功能按钮 */}
<div className={styles.actionButtons}>
<Button icon={<ReloadOutlined />} onClick={loadRandomQuestion}>
</Button>
<Button icon={<UnorderedListOutlined />} onClick={() => setListVisible(true)}>
</Button>
<Button icon={<FilterOutlined />} onClick={() => setFilterVisible(true)}>
</Button>
</div>
</div> </div>
{/* 统计弹窗 */} {/* 完成统计摘要 */}
<Modal <CompletionSummary
title="答题统计" visible={showSummary}
open={statsVisible} totalQuestions={allQuestions.length}
onCancel={() => setStatsVisible(false)} correctCount={correctCount}
footer={[ wrongCount={wrongCount}
<Button key="reset" danger onClick={handleReset}> category={currentQuestion?.category}
onClose={() => {
</Button>, setShowSummary(false);
<Button key="close" onClick={() => setStatsVisible(false)}> navigate("/");
}}
</Button>, onRetry={handleRetry}
]} />
>
<Row gutter={16}>
<Col span={12}>
<Statistic title="题库总数" value={statistics.total_questions} />
</Col>
<Col span={12}>
<Statistic title="已答题数" value={statistics.answered_questions} />
</Col>
<Col span={12} style={{ marginTop: 20 }}>
<Statistic title="正确数" value={statistics.correct_answers} />
</Col>
<Col span={12} style={{ marginTop: 20 }}>
<Statistic
title="正确率"
value={statistics.accuracy.toFixed(1)}
suffix="%"
/>
</Col>
</Row>
</Modal>
{/* 题目列表弹窗 */}
<Modal
title="题目列表"
open={listVisible}
onCancel={() => setListVisible(false)}
footer={null}
width={600}
>
<List
dataSource={allQuestions}
renderItem={(q, index) => (
<List.Item
onClick={() => handleSelectQuestion(q, index)}
style={{ cursor: 'pointer' }}
>
<List.Item.Meta
title={`${q.id}. ${q.content}`}
description={
<Space>
<Tag color="blue">{getTypeName(q.type)}</Tag>
<Tag color="green">{q.category}</Tag>
</Space>
}
/>
</List.Item>
)}
style={{ maxHeight: '400px', overflow: 'auto' }}
/>
</Modal>
{/* 筛选弹窗 */}
<Modal
title="筛选题目"
open={filterVisible}
onCancel={() => setFilterVisible(false)}
onOk={handleApplyFilter}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text></Text>
<Select
placeholder="选择题目类型"
value={filterType}
onChange={setFilterType}
style={{ width: '100%', marginTop: 8 }}
allowClear
>
<Select.Option value="single"></Select.Option>
<Select.Option value="multiple"></Select.Option>
<Select.Option value="fill"></Select.Option>
<Select.Option value="judge"></Select.Option>
<Select.Option value="short"></Select.Option>
</Select>
</div>
<div>
<Text></Text>
<Select
placeholder="选择分类"
value={filterCategory}
onChange={setFilterCategory}
style={{ width: '100%', marginTop: 8 }}
allowClear
>
<Select.Option value="Go语言基础">Go语言基础</Select.Option>
<Select.Option value="前端开发"></Select.Option>
<Select.Option value="计算机网络"></Select.Option>
<Select.Option value="计算机基础"></Select.Option>
</Select>
</div>
</Space>
</Modal>
</div> </div>
) );
} };
export default QuestionPage export default QuestionPage;

View File

@ -4,7 +4,6 @@ import { Card, List, Button, Tag, Typography, Space, message, Modal, Empty, Stat
import { import {
BookOutlined, BookOutlined,
CloseCircleOutlined, CloseCircleOutlined,
ReloadOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
PlayCircleOutlined, PlayCircleOutlined,
DeleteOutlined, DeleteOutlined,
@ -96,8 +95,9 @@ const WrongQuestions: React.FC = () => {
// 格式化答案显示 // 格式化答案显示
const formatAnswer = (answer: string | string[], questionType: string) => { const formatAnswer = (answer: string | string[], questionType: string) => {
if (questionType === 'judge') { if (questionType === 'true-false') {
return answer === 'true' || answer === true ? '正确' : '错误' const strAnswer = String(answer)
return strAnswer === 'true' ? '正确' : '错误'
} }
if (Array.isArray(answer)) { if (Array.isArray(answer)) {
return answer.join(', ') return answer.join(', ')
@ -117,18 +117,6 @@ const WrongQuestions: React.FC = () => {
return colorMap[type] || 'default' 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}>
{/* 头部 */} {/* 头部 */}
@ -211,7 +199,7 @@ const WrongQuestions: React.FC = () => {
<Space> <Space>
<Text strong> {item.question.question_id || item.question.id}</Text> <Text strong> {item.question.question_id || item.question.id}</Text>
<Tag color={getTypeColor(item.question.type)}> <Tag color={getTypeColor(item.question.type)}>
{getTypeName(item.question.type)} {item.question.category}
</Tag> </Tag>
<Tag color="error"> {item.wrong_count} </Tag> <Tag color="error"> {item.wrong_count} </Tag>
</Space> </Space>

View File

@ -1,5 +1,5 @@
// 题目类型 // 题目类型 - 使用数据库中的实际类型
export type QuestionType = 'single' | 'multiple' | 'fill' | 'judge' | 'short' export type QuestionType = 'multiple-choice' | 'multiple-selection' | 'fill-in-blank' | 'true-false' | 'short-answer'
// 选项 // 选项
export interface Option { export interface Option {