重构答题系统:组件拆分、进度跟踪、完成统计
主要改动: 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:
parent
6082ca0bf3
commit
de8480a328
@ -32,10 +32,9 @@ func GetPracticeQuestions(c *gin.Context) {
|
||||
|
||||
query := db.Model(&models.PracticeQuestion{})
|
||||
|
||||
// 根据题型过滤 - 将前端类型映射到后端类型
|
||||
// 根据题型过滤 - 直接使用前端传来的type
|
||||
if typeParam != "" {
|
||||
backendType := mapFrontendToBackendType(typeParam)
|
||||
query = query.Where("type = ?", backendType)
|
||||
query = query.Where("type = ?", typeParam)
|
||||
}
|
||||
|
||||
// 根据分类过滤
|
||||
@ -112,8 +111,7 @@ func GetRandomPracticeQuestion(c *gin.Context) {
|
||||
|
||||
query := db.Model(&models.PracticeQuestion{})
|
||||
if typeParam != "" {
|
||||
backendType := mapFrontendToBackendType(typeParam)
|
||||
query = query.Where("type = ?", backendType)
|
||||
query = query.Where("type = ?", typeParam)
|
||||
}
|
||||
|
||||
// 使用PostgreSQL的随机排序
|
||||
@ -220,23 +218,23 @@ func SubmitPracticeAnswer(c *gin.Context) {
|
||||
func GetPracticeQuestionTypes(c *gin.Context) {
|
||||
types := []gin.H{
|
||||
{
|
||||
"type": models.FillInBlank,
|
||||
"type": "fill-in-blank",
|
||||
"type_name": "填空题",
|
||||
},
|
||||
{
|
||||
"type": models.TrueFalseType,
|
||||
"type": "true-false",
|
||||
"type_name": "判断题",
|
||||
},
|
||||
{
|
||||
"type": models.MultipleChoiceQ,
|
||||
"type": "multiple-choice",
|
||||
"type_name": "选择题",
|
||||
},
|
||||
{
|
||||
"type": models.MultipleSelection,
|
||||
"type": "multiple-selection",
|
||||
"type_name": "多选题",
|
||||
},
|
||||
{
|
||||
"type": models.ShortAnswer,
|
||||
"type": "short-answer",
|
||||
"type_name": "简答题",
|
||||
},
|
||||
}
|
||||
@ -248,21 +246,21 @@ func GetPracticeQuestionTypes(c *gin.Context) {
|
||||
}
|
||||
|
||||
// checkPracticeAnswer 检查练习答案是否正确
|
||||
func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, correctAnswer interface{}) bool {
|
||||
func checkPracticeAnswer(questionType string, userAnswer, correctAnswer interface{}) bool {
|
||||
switch questionType {
|
||||
case models.TrueFalseType:
|
||||
case "true-false":
|
||||
// 判断题: boolean 比较
|
||||
userBool, ok1 := userAnswer.(bool)
|
||||
correctBool, ok2 := correctAnswer.(bool)
|
||||
return ok1 && ok2 && userBool == correctBool
|
||||
|
||||
case models.MultipleChoiceQ:
|
||||
case "multiple-choice":
|
||||
// 单选题: 字符串比较
|
||||
userStr, ok1 := userAnswer.(string)
|
||||
correctStr, ok2 := correctAnswer.(string)
|
||||
return ok1 && ok2 && userStr == correctStr
|
||||
|
||||
case models.MultipleSelection:
|
||||
case "multiple-selection":
|
||||
// 多选题: 数组比较
|
||||
userArr, ok1 := toStringArray(userAnswer)
|
||||
correctArr, ok2 := toStringArray(correctAnswer)
|
||||
@ -282,7 +280,7 @@ func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, c
|
||||
}
|
||||
return true
|
||||
|
||||
case models.FillInBlank:
|
||||
case "fill-in-blank":
|
||||
// 填空题: 数组比较
|
||||
userArr, ok1 := toStringArray(userAnswer)
|
||||
correctArr, ok2 := toStringArray(correctAnswer)
|
||||
@ -298,7 +296,7 @@ func checkPracticeAnswer(questionType models.PracticeQuestionType, userAnswer, c
|
||||
}
|
||||
return true
|
||||
|
||||
case models.ShortAnswer:
|
||||
case "short-answer":
|
||||
// 简答题: 字符串比较(简单实现,实际可能需要更复杂的判断)
|
||||
userStr, ok1 := userAnswer.(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
|
||||
func convertToDTO(question models.PracticeQuestion) models.PracticeQuestionDTO {
|
||||
dto := models.PracticeQuestionDTO{
|
||||
ID: question.ID,
|
||||
QuestionID: question.QuestionID, // 添加题目编号
|
||||
Type: mapBackendToFrontendType(question.Type),
|
||||
QuestionID: question.QuestionID,
|
||||
Type: question.Type, // 直接使用数据库中的type,不做映射
|
||||
Content: question.Question,
|
||||
Category: question.TypeName,
|
||||
Category: question.TypeName, // 使用typeName作为分类显示
|
||||
Options: []models.Option{},
|
||||
}
|
||||
|
||||
// 判断题自动生成选项
|
||||
if question.Type == models.TrueFalseType {
|
||||
if question.Type == "true-false" {
|
||||
dto.Options = []models.Option{
|
||||
{Key: "true", Value: "正确"},
|
||||
{Key: "false", Value: "错误"},
|
||||
|
||||
@ -49,8 +49,8 @@ func GetWrongQuestions(c *gin.Context) {
|
||||
// 转换为DTO
|
||||
dtos := make([]models.WrongQuestionDTO, 0, len(wrongQuestions))
|
||||
for _, wq := range wrongQuestions {
|
||||
// 题型筛选
|
||||
if questionType != "" && mapBackendToFrontendType(wq.PracticeQuestion.Type) != questionType {
|
||||
// 题型筛选 - 直接比较type字段
|
||||
if questionType != "" && wq.PracticeQuestion.Type != questionType {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -116,9 +116,8 @@ func GetWrongQuestionStats(c *gin.Context) {
|
||||
stats.NotMastered++
|
||||
}
|
||||
|
||||
// 统计题型
|
||||
frontendType := mapBackendToFrontendType(wq.PracticeQuestion.Type)
|
||||
stats.TypeStats[frontendType]++
|
||||
// 统计题型 - 直接使用type字段
|
||||
stats.TypeStats[wq.PracticeQuestion.Type]++
|
||||
|
||||
// 统计分类
|
||||
stats.CategoryStats[wq.PracticeQuestion.TypeName]++
|
||||
|
||||
@ -2,22 +2,11 @@ package models
|
||||
|
||||
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 练习题目模型
|
||||
type PracticeQuestion struct {
|
||||
gorm.Model
|
||||
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"` // 题型名称(中文)
|
||||
Question string `gorm:"type:text;not null" json:"question"` // 题目内容
|
||||
AnswerData string `gorm:"type:text;not null" json:"-"` // 答案数据(JSON格式存储)
|
||||
|
||||
26
scripts/check_db.go
Normal file
26
scripts/check_db.go
Normal 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)
|
||||
}
|
||||
}
|
||||
28
scripts/clear_questions.go
Normal file
28
scripts/clear_questions.go
Normal 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)
|
||||
}
|
||||
@ -69,13 +69,10 @@ func main() {
|
||||
optionsJSON = string(optJSON)
|
||||
}
|
||||
|
||||
// 处理题型映射
|
||||
questionType := mapQuestionType(group.Type)
|
||||
|
||||
// 创建题目记录
|
||||
// 创建题目记录 - 直接使用group.Type,不做类型映射
|
||||
question := models.PracticeQuestion{
|
||||
QuestionID: q.ID,
|
||||
Type: questionType,
|
||||
Type: group.Type,
|
||||
TypeName: group.TypeName,
|
||||
Question: q.Question,
|
||||
AnswerData: string(answerJSON),
|
||||
@ -93,21 +90,3 @@ func main() {
|
||||
|
||||
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
82
scripts/import_test.go
Normal 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字段顺序不影响解析结果")
|
||||
}
|
||||
}
|
||||
70
web/src/components/AnswerResult.tsx
Normal file
70
web/src/components/AnswerResult.tsx
Normal 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
|
||||
105
web/src/components/CompletionSummary.tsx
Normal file
105
web/src/components/CompletionSummary.tsx
Normal 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
|
||||
185
web/src/components/QuestionCard.tsx
Normal file
185
web/src/components/QuestionCard.tsx
Normal 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
|
||||
47
web/src/components/QuestionProgress.tsx
Normal file
47
web/src/components/QuestionProgress.tsx
Normal 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
|
||||
135
web/src/components/README.md
Normal file
135
web/src/components/README.md
Normal 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}
|
||||
/>
|
||||
```
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
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 styles from './About.module.less'
|
||||
|
||||
|
||||
@ -17,38 +17,38 @@ import styles from './Home.module.less'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
// 题型配置
|
||||
// 题型配置 - 使用数据库中的实际类型
|
||||
const questionTypes = [
|
||||
{
|
||||
key: 'single',
|
||||
title: '单选题',
|
||||
key: 'multiple-choice',
|
||||
title: '选择题',
|
||||
icon: <CheckCircleOutlined />,
|
||||
color: '#1677ff',
|
||||
description: '基础知识考察',
|
||||
},
|
||||
{
|
||||
key: 'multiple',
|
||||
key: 'multiple-selection',
|
||||
title: '多选题',
|
||||
icon: <UnorderedListOutlined />,
|
||||
color: '#52c41a',
|
||||
description: '综合能力提升',
|
||||
},
|
||||
{
|
||||
key: 'judge',
|
||||
key: 'true-false',
|
||||
title: '判断题',
|
||||
icon: <CheckCircleOutlined />,
|
||||
color: '#fa8c16',
|
||||
description: '快速判断训练',
|
||||
},
|
||||
{
|
||||
key: 'fill',
|
||||
key: 'fill-in-blank',
|
||||
title: '填空题',
|
||||
icon: <FileTextOutlined />,
|
||||
color: '#722ed1',
|
||||
description: '填空补充练习',
|
||||
},
|
||||
{
|
||||
key: 'short',
|
||||
key: 'short-answer',
|
||||
title: '简答题',
|
||||
icon: <EditOutlined />,
|
||||
color: '#eb2f96',
|
||||
@ -110,7 +110,6 @@ const Home: React.FC = () => {
|
||||
if (res.success && res.data && res.data.length > 0) {
|
||||
// 跳转到答题页面,并传递题型参数
|
||||
navigate(`/question?type=${type}`)
|
||||
message.success(`开始${questionTypes.find(t => t.key === type)?.title}练习`)
|
||||
} else {
|
||||
message.warning('该题型暂无题目')
|
||||
}
|
||||
|
||||
@ -1,631 +1,301 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Space,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Input,
|
||||
message,
|
||||
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'
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { Button, message, Typography } from "antd";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import type { Question, AnswerResult } from "../types/question";
|
||||
import * as questionApi from "../api/question";
|
||||
import QuestionProgress from "../components/QuestionProgress";
|
||||
import QuestionCard from "../components/QuestionCard";
|
||||
import CompletionSummary from "../components/CompletionSummary";
|
||||
import styles from "./Question.module.less";
|
||||
|
||||
const { TextArea } = Input
|
||||
const { Title, Text } = Typography
|
||||
const { Title } = Typography;
|
||||
|
||||
const QuestionPage: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null)
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string | string[]>('')
|
||||
const [showResult, setShowResult] = useState(false)
|
||||
const [answerResult, setAnswerResult] = useState<AnswerResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [autoNextLoading, setAutoNextLoading] = useState(false)
|
||||
const [allQuestions, setAllQuestions] = useState<Question[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [fillAnswers, setFillAnswers] = useState<string[]>([])
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string | string[]>("");
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [answerResult, setAnswerResult] = useState<AnswerResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoNextLoading, setAutoNextLoading] = useState(false);
|
||||
const [allQuestions, setAllQuestions] = useState<Question[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// 统计弹窗
|
||||
const [statsVisible, setStatsVisible] = useState(false)
|
||||
const [statistics, setStatistics] = useState({
|
||||
total_questions: 0,
|
||||
answered_questions: 0,
|
||||
correct_answers: 0,
|
||||
accuracy: 0,
|
||||
// 答题统计
|
||||
const [correctCount, setCorrectCount] = useState(0);
|
||||
const [wrongCount, setWrongCount] = useState(0);
|
||||
const [showSummary, setShowSummary] = useState(false);
|
||||
|
||||
// 从localStorage恢复答题进度
|
||||
const getStorageKey = () => {
|
||||
const type = searchParams.get("type");
|
||||
const mode = searchParams.get("mode");
|
||||
return `question_progress_${type || mode || "default"}`;
|
||||
};
|
||||
|
||||
// 保存答题进度
|
||||
const saveProgress = (index: number, correct: number, wrong: number) => {
|
||||
const key = getStorageKey();
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
currentIndex: index,
|
||||
correctCount: correct,
|
||||
wrongCount: wrong,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 列表弹窗
|
||||
const [listVisible, setListVisible] = useState(false)
|
||||
|
||||
// 筛选弹窗
|
||||
const [filterVisible, setFilterVisible] = useState(false)
|
||||
const [filterType, setFilterType] = useState<string | undefined>(undefined)
|
||||
const [filterCategory, setFilterCategory] = useState<string | undefined>(undefined)
|
||||
// 恢复答题进度
|
||||
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 () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
// 检查是否是错题练习模式
|
||||
const mode = searchParams.get('mode')
|
||||
const res = mode === 'wrong'
|
||||
const mode = searchParams.get("mode");
|
||||
const res =
|
||||
mode === "wrong"
|
||||
? await questionApi.getRandomWrongQuestion()
|
||||
: await questionApi.getRandomQuestion()
|
||||
: await questionApi.getRandomQuestion();
|
||||
|
||||
if (res.success && res.data) {
|
||||
setCurrentQuestion(res.data)
|
||||
setSelectedAnswer(res.data.type === 'multiple' ? [] : '')
|
||||
setFillAnswers([])
|
||||
setShowResult(false)
|
||||
setAnswerResult(null)
|
||||
setCurrentQuestion(res.data);
|
||||
setSelectedAnswer(res.data.type === "multiple-selection" ? [] : "");
|
||||
setShowResult(false);
|
||||
setAnswerResult(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 401) {
|
||||
message.error('请先登录')
|
||||
message.error("请先登录");
|
||||
} else if (error.response?.status === 404) {
|
||||
message.error('暂无错题')
|
||||
message.error("暂无错题");
|
||||
} else {
|
||||
message.error('加载题目失败')
|
||||
message.error("加载题目失败");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载题目列表
|
||||
// 加载题目列表(从第一题开始)
|
||||
const loadQuestions = async (type?: string, category?: string) => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await questionApi.getQuestions({ type, category })
|
||||
const res = await questionApi.getQuestions({ type, category });
|
||||
if (res.success && res.data) {
|
||||
setAllQuestions(res.data)
|
||||
if (res.data.length > 0) {
|
||||
setCurrentQuestion(res.data[0])
|
||||
setCurrentIndex(0)
|
||||
setSelectedAnswer(res.data[0].type === 'multiple' ? [] : '')
|
||||
setFillAnswers([])
|
||||
setShowResult(false)
|
||||
setAnswerResult(null)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载题目列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
setAllQuestions(res.data);
|
||||
|
||||
// 加载统计数据
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
const res = await questionApi.getStatistics()
|
||||
if (res.success && res.data) {
|
||||
setStatistics(res.data)
|
||||
// 恢复答题进度
|
||||
const savedIndex = loadProgress();
|
||||
const startIndex = savedIndex < res.data.length ? savedIndex : 0;
|
||||
|
||||
if (res.data.length > 0) {
|
||||
setCurrentQuestion(res.data[startIndex]);
|
||||
setCurrentIndex(startIndex);
|
||||
setSelectedAnswer(
|
||||
res.data[startIndex].type === "multiple-selection" ? [] : ""
|
||||
);
|
||||
setShowResult(false);
|
||||
setAnswerResult(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载统计失败')
|
||||
}
|
||||
message.error("加载题目列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交答案
|
||||
const handleSubmit = async () => {
|
||||
if (!currentQuestion) return
|
||||
if (!currentQuestion) return;
|
||||
|
||||
// 检查是否选择了答案
|
||||
if (currentQuestion.type === 'multiple') {
|
||||
if (currentQuestion.type === "multiple-selection") {
|
||||
if ((selectedAnswer as string[]).length === 0) {
|
||||
message.warning('请选择答案')
|
||||
return
|
||||
message.warning("请选择答案");
|
||||
return;
|
||||
}
|
||||
} else if (currentQuestion.type === 'fill') {
|
||||
if (fillAnswers.length === 0 || fillAnswers.some(a => !a || a.trim() === '')) {
|
||||
message.warning('请填写所有空格')
|
||||
return
|
||||
} else if (currentQuestion.type === "fill-in-blank") {
|
||||
const answers = selectedAnswer as string[];
|
||||
if (answers.length === 0 || answers.some((a) => !a || a.trim() === "")) {
|
||||
message.warning("请填写所有空格");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!selectedAnswer || (typeof selectedAnswer === 'string' && selectedAnswer.trim() === '')) {
|
||||
message.warning('请填写答案')
|
||||
return
|
||||
if (
|
||||
!selectedAnswer ||
|
||||
(typeof selectedAnswer === "string" && selectedAnswer.trim() === "")
|
||||
) {
|
||||
message.warning("请填写答案");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await questionApi.submitAnswer({
|
||||
question_id: currentQuestion.id,
|
||||
answer: selectedAnswer,
|
||||
})
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
setAnswerResult(res.data)
|
||||
setShowResult(true)
|
||||
setAnswerResult(res.data);
|
||||
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秒后自动进入下一题
|
||||
if (res.data.correct) {
|
||||
setAutoNextLoading(true)
|
||||
setAutoNextLoading(true);
|
||||
setTimeout(() => {
|
||||
setAutoNextLoading(false)
|
||||
handleNext()
|
||||
}, 1000)
|
||||
setAutoNextLoading(false);
|
||||
handleNext();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('提交失败')
|
||||
message.error("提交失败");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 下一题
|
||||
const handleNext = () => {
|
||||
if (allQuestions.length > 0) {
|
||||
const nextIndex = (currentIndex + 1) % allQuestions.length
|
||||
setCurrentIndex(nextIndex)
|
||||
setCurrentQuestion(allQuestions[nextIndex])
|
||||
setSelectedAnswer(allQuestions[nextIndex].type === 'multiple' ? [] : '')
|
||||
setFillAnswers([])
|
||||
setShowResult(false)
|
||||
setAnswerResult(null)
|
||||
} else {
|
||||
loadRandomQuestion()
|
||||
}
|
||||
// 检查是否完成所有题目
|
||||
if (currentIndex + 1 >= allQuestions.length) {
|
||||
// 显示统计摘要
|
||||
setShowSummary(true);
|
||||
// 清除进度
|
||||
localStorage.removeItem(getStorageKey());
|
||||
return;
|
||||
}
|
||||
|
||||
// 选择题目
|
||||
const handleSelectQuestion = (question: Question, index: number) => {
|
||||
setCurrentQuestion(question)
|
||||
setCurrentIndex(index)
|
||||
setSelectedAnswer(question.type === 'multiple' ? [] : '')
|
||||
setFillAnswers([])
|
||||
setShowResult(false)
|
||||
setAnswerResult(null)
|
||||
setListVisible(false)
|
||||
}
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
setCurrentQuestion(allQuestions[nextIndex]);
|
||||
setSelectedAnswer(
|
||||
allQuestions[nextIndex].type === "multiple-selection" ? [] : ""
|
||||
);
|
||||
setShowResult(false);
|
||||
setAnswerResult(null);
|
||||
|
||||
// 应用筛选
|
||||
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('重置失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
// 保存进度
|
||||
saveProgress(nextIndex, correctCount, wrongCount);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
const typeParam = searchParams.get('type')
|
||||
const categoryParam = searchParams.get('category')
|
||||
const mode = searchParams.get('mode')
|
||||
const typeParam = searchParams.get("type");
|
||||
const categoryParam = searchParams.get("category");
|
||||
const mode = searchParams.get("mode");
|
||||
|
||||
// 错题练习模式
|
||||
if (mode === 'wrong') {
|
||||
loadRandomQuestion()
|
||||
return
|
||||
if (mode === "wrong") {
|
||||
loadRandomQuestion();
|
||||
return;
|
||||
}
|
||||
|
||||
// 普通练习模式
|
||||
if (typeParam || categoryParam) {
|
||||
loadQuestions(typeParam || undefined, categoryParam || undefined)
|
||||
} else {
|
||||
loadRandomQuestion()
|
||||
loadQuestions()
|
||||
}
|
||||
}, [searchParams])
|
||||
// 普通练习模式 - 从第一题开始
|
||||
loadQuestions(typeParam || undefined, categoryParam || undefined);
|
||||
}, [searchParams]);
|
||||
|
||||
// 获取题型名称
|
||||
const getTypeName = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
single: '单选题',
|
||||
multiple: '多选题',
|
||||
fill: '填空题',
|
||||
judge: '判断题',
|
||||
short: '简答题',
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
// 渲染填空题内容
|
||||
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>
|
||||
)
|
||||
}
|
||||
// 重试处理
|
||||
const handleRetry = () => {
|
||||
setShowSummary(false);
|
||||
setCurrentIndex(0);
|
||||
setCorrectCount(0);
|
||||
setWrongCount(0);
|
||||
localStorage.removeItem(getStorageKey());
|
||||
const typeParam = searchParams.get("type");
|
||||
const categoryParam = searchParams.get("category");
|
||||
loadQuestions(typeParam || undefined, categoryParam || undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
{/* 头部 */}
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate("/")}>
|
||||
返回首页
|
||||
</Button>
|
||||
<Title level={3} className={styles.title}>AnKao 刷题</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PieChartOutlined />}
|
||||
onClick={() => {
|
||||
loadStatistics()
|
||||
setStatsVisible(true)
|
||||
}}
|
||||
>
|
||||
统计
|
||||
</Button>
|
||||
<Title level={3} className={styles.title}>
|
||||
AnKao 刷题
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<QuestionProgress
|
||||
currentIndex={currentIndex}
|
||||
totalQuestions={allQuestions.length}
|
||||
correctCount={correctCount}
|
||||
wrongCount={wrongCount}
|
||||
/>
|
||||
|
||||
{/* 题目卡片 */}
|
||||
<Card className={styles.questionCard}>
|
||||
{currentQuestion && (
|
||||
<>
|
||||
<Space size="small" style={{ marginBottom: 16 }}>
|
||||
<Tag color="blue">{getTypeName(currentQuestion.type)}</Tag>
|
||||
<Tag color="green">{currentQuestion.category}</Tag>
|
||||
</Space>
|
||||
|
||||
<Title level={5} className={styles.questionNumber}>
|
||||
第 {currentQuestion.question_id || currentQuestion.id} 题
|
||||
</Title>
|
||||
|
||||
{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}
|
||||
<QuestionCard
|
||||
question={currentQuestion}
|
||||
selectedAnswer={selectedAnswer}
|
||||
showResult={showResult}
|
||||
answerResult={answerResult}
|
||||
loading={loading}
|
||||
>
|
||||
提交答案
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleNext}
|
||||
loading={autoNextLoading}
|
||||
>
|
||||
{autoNextLoading ? '正在跳转到下一题...' : '下一题'}
|
||||
</Button>
|
||||
autoNextLoading={autoNextLoading}
|
||||
onAnswerChange={setSelectedAnswer}
|
||||
onSubmit={handleSubmit}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 统计弹窗 */}
|
||||
<Modal
|
||||
title="答题统计"
|
||||
open={statsVisible}
|
||||
onCancel={() => setStatsVisible(false)}
|
||||
footer={[
|
||||
<Button key="reset" danger onClick={handleReset}>
|
||||
重置进度
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setStatsVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<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="%"
|
||||
{/* 完成统计摘要 */}
|
||||
<CompletionSummary
|
||||
visible={showSummary}
|
||||
totalQuestions={allQuestions.length}
|
||||
correctCount={correctCount}
|
||||
wrongCount={wrongCount}
|
||||
category={currentQuestion?.category}
|
||||
onClose={() => {
|
||||
setShowSummary(false);
|
||||
navigate("/");
|
||||
}}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionPage
|
||||
export default QuestionPage;
|
||||
|
||||
@ -4,7 +4,6 @@ import { Card, List, Button, Tag, Typography, Space, message, Modal, Empty, Stat
|
||||
import {
|
||||
BookOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
ArrowLeftOutlined,
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
@ -96,8 +95,9 @@ const WrongQuestions: React.FC = () => {
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = (answer: string | string[], questionType: string) => {
|
||||
if (questionType === 'judge') {
|
||||
return answer === 'true' || answer === true ? '正确' : '错误'
|
||||
if (questionType === 'true-false') {
|
||||
const strAnswer = String(answer)
|
||||
return strAnswer === 'true' ? '正确' : '错误'
|
||||
}
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.join(', ')
|
||||
@ -117,18 +117,6 @@ const WrongQuestions: React.FC = () => {
|
||||
return colorMap[type] || 'default'
|
||||
}
|
||||
|
||||
// 获取题型名称
|
||||
const getTypeName = (type: string) => {
|
||||
const nameMap: Record<string, string> = {
|
||||
single: '单选题',
|
||||
multiple: '多选题',
|
||||
fill: '填空题',
|
||||
judge: '判断题',
|
||||
short: '简答题',
|
||||
}
|
||||
return nameMap[type] || type
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 头部 */}
|
||||
@ -211,7 +199,7 @@ const WrongQuestions: React.FC = () => {
|
||||
<Space>
|
||||
<Text strong>题目 {item.question.question_id || item.question.id}</Text>
|
||||
<Tag color={getTypeColor(item.question.type)}>
|
||||
{getTypeName(item.question.type)}
|
||||
{item.question.category}
|
||||
</Tag>
|
||||
<Tag color="error">错误 {item.wrong_count} 次</Tag>
|
||||
</Space>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user