重大改进: - 练习进度模型优化:从"每题一条记录"改为"每用户每类型一条记录",提升性能和数据管理 - 完全基于后端数据库恢复答题进度,移除 localStorage 依赖,提高可靠性 - AI评分结果持久化:在答题记录中保存AI评分、评语和建议,支持历史查看 后端改进: - 新增 /api/practice/progress 接口获取练习进度(支持按类型筛选) - 新增 /api/practice/progress 接口清除练习进度(支持按类型清除) - PracticeProgress 模型重构:添加 current_question_id 和 user_answer_records 字段 - UserAnswerRecord 模型增强:添加 ai_score、ai_feedback、ai_suggestion 字段 - 提交答案时自动保存AI评分到数据库 前端优化: - 答题进度完全从后端加载,移除 localStorage 备份逻辑 - 修复判断题答案格式转换问题(boolean -> string) - 优化随机模式:首次答题时随机选择起始题目 - 改进答题历史显示:显示答题序号和历史答案标识 - 已答题目切换时保持答案和结果显示状态 - 清除进度时支持按类型清除(而非清空所有) 技术优化: - 统一索引策略:从 idx_user_question 改为 idx_user_type - JSON 字段类型从 jsonp 改为 jsonb(PostgreSQL 性能优化) - 增加详细的日志记录,便于调试和追踪 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
712 lines
25 KiB
TypeScript
712 lines
25 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||
import { Button, message, Typography, Switch, InputNumber, Modal, Space } from "antd";
|
||
import { ArrowLeftOutlined, SettingOutlined } from "@ant-design/icons";
|
||
import type { Question, AnswerResult } from "../types/question";
|
||
import * as questionApi from "../api/question";
|
||
import QuestionCard from "../components/QuestionCard";
|
||
import CompletionSummary from "../components/CompletionSummary";
|
||
import QuestionDrawer from "../components/QuestionDrawer";
|
||
import QuestionFloatButton from "../components/QuestionFloatButton";
|
||
import styles from "./Question.module.less";
|
||
|
||
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 [correctCount, setCorrectCount] = useState(0);
|
||
const [wrongCount, setWrongCount] = useState(0);
|
||
const [showSummary, setShowSummary] = useState(false);
|
||
|
||
// 题目导航抽屉
|
||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||
const [answeredStatus, setAnsweredStatus] = useState<Map<number, boolean | null>>(new Map());
|
||
|
||
// 存储每道题的用户答案和答题结果
|
||
const [userAnswers, setUserAnswers] = useState<Map<number, string | string[]>>(new Map());
|
||
const [questionResults, setQuestionResults] = useState<Map<number, AnswerResult>>(new Map());
|
||
|
||
// 存储每道题的答题序号(第几次答题)
|
||
const [answerSequences, setAnswerSequences] = useState<Map<number, number>>(new Map());
|
||
|
||
// 设置弹窗
|
||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||
|
||
// 自动跳转开关(默认开启)
|
||
const [autoNext, setAutoNext] = useState(() => {
|
||
const saved = localStorage.getItem('autoNextEnabled');
|
||
return saved !== null ? saved === 'true' : true;
|
||
});
|
||
|
||
// 自动跳转延迟时间(秒,默认2秒)
|
||
const [autoNextDelay, setAutoNextDelay] = useState(() => {
|
||
const saved = localStorage.getItem('autoNextDelay');
|
||
return saved !== null ? parseInt(saved, 10) : 2;
|
||
});
|
||
|
||
// 随机题目开关(默认关闭)
|
||
const [randomMode, setRandomMode] = useState(() => {
|
||
const saved = localStorage.getItem('randomModeEnabled');
|
||
return saved !== null ? saved === 'true' : false;
|
||
});
|
||
|
||
// 切换自动跳转开关
|
||
const toggleAutoNext = () => {
|
||
const newValue = !autoNext;
|
||
setAutoNext(newValue);
|
||
localStorage.setItem('autoNextEnabled', String(newValue));
|
||
};
|
||
|
||
// 切换随机模式开关
|
||
const toggleRandomMode = () => {
|
||
const newValue = !randomMode;
|
||
setRandomMode(newValue);
|
||
localStorage.setItem('randomModeEnabled', String(newValue));
|
||
};
|
||
|
||
// 修改自动跳转延迟时间
|
||
const handleDelayChange = (value: number | null) => {
|
||
if (value !== null && value >= 1 && value <= 10) {
|
||
setAutoNextDelay(value);
|
||
localStorage.setItem('autoNextDelay', String(value));
|
||
}
|
||
};
|
||
|
||
// 恢复答题进度(完全从后端数据库加载)
|
||
const loadProgress = async (questions: Question[], type: string) => {
|
||
console.log('[进度加载] 开始加载进度 (type:', type, ', questions:', questions.length, ')');
|
||
|
||
if (!type || questions.length === 0) {
|
||
console.log('[进度加载] 参数无效,跳过加载');
|
||
return { index: 0, hasAnswer: false };
|
||
}
|
||
|
||
try {
|
||
// 从后端加载该类型的进度数据
|
||
console.log('[进度加载] 调用 API: GET /api/practice/progress?type=' + type);
|
||
const res = await questionApi.getPracticeProgress(type);
|
||
console.log('[进度加载] API 响应:', res);
|
||
|
||
if (res.success && res.data && res.data.length > 0) {
|
||
// 取第一条记录(每个type只有一条进度记录)
|
||
const progressData = res.data[0];
|
||
console.log('[进度加载] 进度数据:', progressData);
|
||
|
||
// 创建 question_id 到索引的映射
|
||
const questionIdToIndex = new Map<number, number>();
|
||
questions.forEach((q, idx) => {
|
||
questionIdToIndex.set(q.id, idx);
|
||
});
|
||
|
||
// 根据 current_question_id 定位到题目索引
|
||
const currentIndex = questionIdToIndex.get(progressData.current_question_id);
|
||
console.log('[进度加载] current_question_id:', progressData.current_question_id, ', 对应索引:', currentIndex);
|
||
|
||
// 解析已答题目的状态
|
||
const statusMap = new Map<number, boolean | null>();
|
||
const answersMap = new Map<number, string | string[]>();
|
||
const sequencesMap = new Map<number, number>();
|
||
const resultsMap = new Map<number, AnswerResult>();
|
||
|
||
let correct = 0;
|
||
let wrong = 0;
|
||
|
||
// 遍历所有已答题目
|
||
progressData.answered_questions.forEach((item) => {
|
||
const index = questionIdToIndex.get(item.question_id);
|
||
if (index !== undefined) {
|
||
statusMap.set(index, item.is_correct);
|
||
|
||
// 对于判断题,需要将布尔值转换为字符串(Radio.Group 需要字符串类型)
|
||
let userAnswer = item.user_answer;
|
||
if (questions[index].type === 'true-false' && typeof userAnswer === 'boolean') {
|
||
userAnswer = userAnswer ? 'true' : 'false';
|
||
console.log('[进度加载] 判断题答案格式转换: boolean', item.user_answer, '-> string', userAnswer);
|
||
}
|
||
answersMap.set(index, userAnswer);
|
||
|
||
// 构造答题结果对象
|
||
const result: AnswerResult = {
|
||
correct: item.is_correct,
|
||
user_answer: userAnswer,
|
||
correct_answer: item.correct_answer,
|
||
};
|
||
|
||
// 如果有 AI 评分信息,也加入结果中
|
||
if (item.ai_score !== undefined && item.ai_feedback !== undefined) {
|
||
result.ai_grading = {
|
||
score: item.ai_score,
|
||
feedback: item.ai_feedback,
|
||
suggestion: item.ai_suggestion || '',
|
||
};
|
||
console.log('[进度加载] 恢复AI评分: score=', item.ai_score, ', feedback=', item.ai_feedback);
|
||
}
|
||
|
||
resultsMap.set(index, result);
|
||
|
||
// 计算答题序号(查询该题目的总答题次数)
|
||
// 注意:这里暂时用1,后续可以优化为查询历史记录数
|
||
sequencesMap.set(index, 1);
|
||
|
||
if (item.is_correct) {
|
||
correct++;
|
||
} else {
|
||
wrong++;
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('[进度加载] 已答题目数:', progressData.answered_questions.length, ', 正确:', correct, ', 错误:', wrong);
|
||
|
||
setAnsweredStatus(statusMap);
|
||
setUserAnswers(answersMap);
|
||
setAnswerSequences(sequencesMap);
|
||
setQuestionResults(resultsMap);
|
||
setCorrectCount(correct);
|
||
setWrongCount(wrong);
|
||
|
||
// 如果找到 current_question_id 对应的索引,返回它及其答案
|
||
if (currentIndex !== undefined) {
|
||
console.log('[进度加载] 定位到题目索引:', currentIndex);
|
||
const hasAnswer = answersMap.has(currentIndex);
|
||
return {
|
||
index: currentIndex,
|
||
hasAnswer: hasAnswer,
|
||
savedAnswer: answersMap.get(currentIndex),
|
||
savedResult: resultsMap.get(currentIndex)
|
||
};
|
||
}
|
||
} else {
|
||
console.log('[进度加载] 没有进度数据');
|
||
}
|
||
} catch (error) {
|
||
console.error('[进度加载] 加载失败:', error);
|
||
}
|
||
|
||
// 如果后端没有数据或加载失败,返回0(从第一题开始)
|
||
console.log('[进度加载] 返回默认索引: 0');
|
||
return { index: 0, hasAnswer: false };
|
||
};
|
||
|
||
// 加载随机错题(使用智能推荐)
|
||
const loadRandomWrongQuestion = async () => {
|
||
console.log('[错题练习] 开始加载下一道错题...');
|
||
console.log('[错题练习] 当前题目ID:', currentQuestion?.id);
|
||
setLoading(true);
|
||
try {
|
||
// 传递当前题目ID,避免重复推荐
|
||
const res = await questionApi.getRecommendedWrongQuestions(1, currentQuestion?.id);
|
||
console.log('[错题练习] API响应:', res);
|
||
|
||
if (res.success && res.data && res.data.length > 0) {
|
||
// 获取推荐的错题,然后加载对应的题目详情
|
||
const wrongQuestion = res.data[0];
|
||
console.log('[错题练习] 推荐的错题:', wrongQuestion);
|
||
const questionRes = await questionApi.getQuestionById(wrongQuestion.question_id);
|
||
console.log('[错题练习] 题目详情:', questionRes);
|
||
|
||
if (questionRes.success && questionRes.data) {
|
||
console.log('[错题练习] 设置新题目:', questionRes.data.question_id);
|
||
setCurrentQuestion(questionRes.data);
|
||
setSelectedAnswer(questionRes.data.type === "multiple-selection" ? [] : "");
|
||
setShowResult(false);
|
||
setAnswerResult(null);
|
||
} else {
|
||
console.error('[错题练习] 加载题目详情失败');
|
||
message.error("加载题目详情失败");
|
||
}
|
||
} else {
|
||
console.warn('[错题练习] 暂无推荐错题');
|
||
message.warning("暂无需要复习的错题");
|
||
}
|
||
} catch (error: any) {
|
||
console.error('[错题练习] 加载失败:', error);
|
||
if (error.response?.status === 401) {
|
||
message.error("请先登录");
|
||
} else if (error.response?.status === 404) {
|
||
message.error("暂无错题");
|
||
} else {
|
||
message.error("加载题目失败");
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
console.log('[错题练习] 加载完成');
|
||
}
|
||
};
|
||
|
||
// 加载题目列表(从第一题开始)
|
||
const loadQuestions = async (type?: string) => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await questionApi.getQuestions({ type });
|
||
if (res.success && res.data) {
|
||
setAllQuestions(res.data);
|
||
|
||
// 恢复答题进度(传入题目列表和类型)
|
||
const progressResult = type ? await loadProgress(res.data, type) : { index: 0, hasAnswer: false };
|
||
let startIndex = progressResult.index < res.data.length ? progressResult.index : 0;
|
||
|
||
// 如果是随机模式且没有进度数据(全新开始),随机选择起始题目
|
||
if (randomMode && !progressResult.hasAnswer && startIndex === 0 && res.data.length > 0) {
|
||
startIndex = Math.floor(Math.random() * res.data.length);
|
||
console.log('[题目加载] 随机模式:随机选择起始题目 (index:', startIndex, ')');
|
||
}
|
||
|
||
if (res.data.length > 0) {
|
||
setCurrentQuestion(res.data[startIndex]);
|
||
setCurrentIndex(startIndex);
|
||
|
||
// 如果这道题已答,恢复答案和结果
|
||
if (progressResult.hasAnswer && progressResult.savedAnswer !== undefined && progressResult.savedResult !== undefined) {
|
||
console.log('[题目加载] 恢复已答题目的答案和结果 (index:', startIndex, ')');
|
||
setSelectedAnswer(progressResult.savedAnswer);
|
||
setAnswerResult(progressResult.savedResult);
|
||
setShowResult(true);
|
||
} else {
|
||
// 如果未答,重置状态
|
||
console.log('[题目加载] 题目未答,重置状态 (index:', startIndex, ')');
|
||
setSelectedAnswer(
|
||
res.data[startIndex].type === "multiple-selection" ? [] : ""
|
||
);
|
||
setShowResult(false);
|
||
setAnswerResult(null);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
message.error("加载题目列表失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 提交答案
|
||
const handleSubmit = async () => {
|
||
if (!currentQuestion) return;
|
||
|
||
// 检查是否选择了答案
|
||
if (currentQuestion.type === "multiple-selection") {
|
||
if ((selectedAnswer as string[]).length === 0) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
// 处理判断题答案:将字符串 "true"/"false" 转换为布尔值
|
||
let answerToSubmit: string | string[] | boolean = selectedAnswer;
|
||
if (currentQuestion.type === "true-false" && typeof selectedAnswer === "string") {
|
||
answerToSubmit = selectedAnswer === "true";
|
||
}
|
||
|
||
const res = await questionApi.submitAnswer({
|
||
question_id: currentQuestion.id,
|
||
answer: answerToSubmit,
|
||
});
|
||
|
||
if (res.success && res.data) {
|
||
setAnswerResult(res.data);
|
||
setShowResult(true);
|
||
|
||
// 保存用户答案和结果到 Map
|
||
const newAnswersMap = new Map(userAnswers);
|
||
newAnswersMap.set(currentIndex, selectedAnswer);
|
||
setUserAnswers(newAnswersMap);
|
||
|
||
const newResultsMap = new Map(questionResults);
|
||
newResultsMap.set(currentIndex, res.data);
|
||
setQuestionResults(newResultsMap);
|
||
|
||
// 更新答题状态
|
||
const newStatusMap = new Map(answeredStatus);
|
||
newStatusMap.set(currentIndex, res.data.correct);
|
||
setAnsweredStatus(newStatusMap);
|
||
|
||
// 更新统计
|
||
if (res.data.correct) {
|
||
const newCorrect = correctCount + 1;
|
||
setCorrectCount(newCorrect);
|
||
} else {
|
||
const newWrong = wrongCount + 1;
|
||
setWrongCount(newWrong);
|
||
}
|
||
|
||
// 注意:进度已由后端的 /api/practice/submit 接口自动保存到 practice_progress 表
|
||
|
||
// 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题
|
||
if (res.data.correct && autoNext) {
|
||
setAutoNextLoading(true);
|
||
setTimeout(() => {
|
||
setAutoNextLoading(false);
|
||
handleNext();
|
||
}, autoNextDelay * 1000); // 将秒转换为毫秒
|
||
}
|
||
}
|
||
} catch (error) {
|
||
message.error("提交失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 下一题
|
||
const handleNext = () => {
|
||
const mode = searchParams.get("mode");
|
||
const typeParam = searchParams.get("type");
|
||
console.log('[下一题] 当前模式:', mode, ', 题目类型:', typeParam);
|
||
|
||
// 错题练习模式:加载下一道推荐错题
|
||
if (mode === "wrong") {
|
||
console.log('[下一题] 错题练习模式,加载下一道错题');
|
||
loadRandomWrongQuestion();
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
return;
|
||
}
|
||
|
||
// 普通模式:从题库数组中选择
|
||
if (allQuestions.length > 0) {
|
||
let nextIndex: number;
|
||
|
||
// 随机模式:从题库中随机选择一题
|
||
if (randomMode) {
|
||
// 获取所有未答题目的索引(排除当前题目)
|
||
const unansweredIndexes: number[] = [];
|
||
for (let i = 0; i < allQuestions.length; i++) {
|
||
// 排除当前题目索引,避免选到同一题
|
||
if (i !== currentIndex && !answeredStatus.has(i)) {
|
||
unansweredIndexes.push(i);
|
||
}
|
||
}
|
||
|
||
// 如果没有未答题目,显示总结页面
|
||
if (unansweredIndexes.length === 0) {
|
||
setShowSummary(true);
|
||
// 清除后端进度(只清除当前类型)
|
||
questionApi.clearPracticeProgress(typeParam || undefined).catch(err => console.error('清除进度失败:', err));
|
||
return;
|
||
}
|
||
|
||
// 从未答题目中随机选择一题
|
||
const randomIdx = Math.floor(Math.random() * unansweredIndexes.length);
|
||
nextIndex = unansweredIndexes[randomIdx];
|
||
} else {
|
||
// 顺序模式:检查是否完成所有题目
|
||
if (currentIndex + 1 >= allQuestions.length) {
|
||
// 显示统计摘要
|
||
setShowSummary(true);
|
||
// 清除后端进度(只清除当前类型)
|
||
questionApi.clearPracticeProgress(typeParam || undefined).catch(err => console.error('清除进度失败:', err));
|
||
return;
|
||
}
|
||
nextIndex = currentIndex + 1;
|
||
}
|
||
|
||
setCurrentIndex(nextIndex);
|
||
setCurrentQuestion(allQuestions[nextIndex]);
|
||
|
||
// 检查这道题是否已答
|
||
const savedAnswer = userAnswers.get(nextIndex);
|
||
const savedResult = questionResults.get(nextIndex);
|
||
|
||
if (savedAnswer !== undefined && savedResult !== undefined) {
|
||
// 如果已答,恢复答案和结果
|
||
console.log('[下一题] 题目已答,恢复答案和结果 (index:', nextIndex, ')');
|
||
setSelectedAnswer(savedAnswer);
|
||
setAnswerResult(savedResult);
|
||
setShowResult(true);
|
||
} else {
|
||
// 如果未答,重置状态
|
||
console.log('[下一题] 题目未答,重置状态 (index:', nextIndex, ')');
|
||
setSelectedAnswer(
|
||
allQuestions[nextIndex].type === "multiple-selection" ? [] : ""
|
||
);
|
||
setShowResult(false);
|
||
setAnswerResult(null);
|
||
}
|
||
|
||
// 进度已由后端自动保存,无需手动保存
|
||
|
||
// 滚动到页面顶部
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
};
|
||
|
||
// 跳转到指定题目
|
||
const handleQuestionSelect = (index: number) => {
|
||
if (index >= 0 && index < allQuestions.length) {
|
||
const question = allQuestions[index];
|
||
|
||
// 检查是否有保存的答案和结果
|
||
const savedAnswer = userAnswers.get(index);
|
||
const savedResult = questionResults.get(index);
|
||
|
||
// 先更新题目索引和题目内容
|
||
setCurrentIndex(index);
|
||
setCurrentQuestion(question);
|
||
|
||
if (savedAnswer !== undefined && savedResult !== undefined) {
|
||
// 恢复之前的答案和结果
|
||
setSelectedAnswer(savedAnswer);
|
||
setAnswerResult(savedResult);
|
||
setShowResult(true);
|
||
} else {
|
||
// 没有答案,重置状态
|
||
setSelectedAnswer(
|
||
question.type === "multiple-selection" ? [] : ""
|
||
);
|
||
setShowResult(false);
|
||
setAnswerResult(null);
|
||
}
|
||
|
||
// 进度已由后端自动保存,无需手动保存
|
||
|
||
// 滚动到页面顶部
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
};
|
||
|
||
// 初始化
|
||
useEffect(() => {
|
||
const typeParam = searchParams.get("type");
|
||
const mode = searchParams.get("mode");
|
||
|
||
// 错题练习模式
|
||
if (mode === "wrong") {
|
||
loadRandomWrongQuestion();
|
||
return;
|
||
}
|
||
|
||
// 普通练习模式 - 从第一题开始
|
||
loadQuestions(typeParam || undefined);
|
||
}, [searchParams]);
|
||
|
||
// 重试处理
|
||
const handleRetry = async () => {
|
||
setShowSummary(false);
|
||
setCurrentIndex(0);
|
||
setCorrectCount(0);
|
||
setWrongCount(0);
|
||
|
||
const typeParam = searchParams.get("type");
|
||
|
||
// 清除后端进度(只清除当前类型)
|
||
try {
|
||
await questionApi.clearPracticeProgress(typeParam || undefined);
|
||
console.log('[重试] 已清除类型', typeParam, '的进度');
|
||
} catch (error) {
|
||
console.error('清除后端进度失败:', error);
|
||
}
|
||
|
||
loadQuestions(typeParam || undefined);
|
||
};
|
||
|
||
// 重新答题(错题练习模式)
|
||
const handleRetryQuestion = () => {
|
||
// 重置当前题目的答题状态
|
||
setShowResult(false);
|
||
setAnswerResult(null);
|
||
|
||
// 重置答案
|
||
if (currentQuestion) {
|
||
setSelectedAnswer(
|
||
currentQuestion.type === "multiple-selection" ? [] : ""
|
||
);
|
||
}
|
||
|
||
// 滚动到页面顶部
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
};
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
{/* 固定顶栏:包含导航和进度 */}
|
||
<div className={styles.fixedTopBar}>
|
||
<div className={styles.topBarContent}>
|
||
<div className={styles.topBarCard}>
|
||
{/* 头部导航 */}
|
||
<div className={styles.header}>
|
||
<Button
|
||
icon={<ArrowLeftOutlined />}
|
||
onClick={() => {
|
||
const mode = searchParams.get("mode");
|
||
// 错题练习模式返回错题本页面,否则返回首页
|
||
navigate(mode === "wrong" ? "/wrong-questions" : "/");
|
||
}}
|
||
className={styles.backButton}
|
||
type="text"
|
||
>
|
||
返回
|
||
</Button>
|
||
<Title level={3} className={styles.title}>
|
||
AnKao 刷题
|
||
</Title>
|
||
{/* 设置按钮 */}
|
||
<Button
|
||
type="text"
|
||
icon={<SettingOutlined />}
|
||
onClick={() => setSettingsVisible(true)}
|
||
className={styles.settingsButton}
|
||
>
|
||
设置
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主内容区 */}
|
||
<div className={styles.content}>
|
||
{/* 题目卡片 */}
|
||
{currentQuestion && (
|
||
<QuestionCard
|
||
question={currentQuestion}
|
||
selectedAnswer={selectedAnswer}
|
||
showResult={showResult}
|
||
answerResult={answerResult}
|
||
loading={loading}
|
||
autoNextLoading={autoNextLoading}
|
||
onAnswerChange={setSelectedAnswer}
|
||
onSubmit={handleSubmit}
|
||
onNext={handleNext}
|
||
onRetry={handleRetryQuestion}
|
||
mode={searchParams.get("mode") || undefined}
|
||
answerSequence={answerSequences.get(currentIndex)}
|
||
hasHistory={userAnswers.has(currentIndex)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 完成统计摘要 */}
|
||
<CompletionSummary
|
||
visible={showSummary}
|
||
totalQuestions={allQuestions.length}
|
||
correctCount={correctCount}
|
||
wrongCount={wrongCount}
|
||
category={currentQuestion?.category}
|
||
onClose={() => {
|
||
setShowSummary(false);
|
||
navigate("/");
|
||
}}
|
||
onRetry={handleRetry}
|
||
/>
|
||
|
||
{/* 题目导航抽屉 */}
|
||
<QuestionDrawer
|
||
visible={drawerVisible}
|
||
onClose={() => setDrawerVisible(false)}
|
||
questions={allQuestions}
|
||
currentIndex={currentIndex}
|
||
onQuestionSelect={handleQuestionSelect}
|
||
answeredStatus={answeredStatus}
|
||
/>
|
||
|
||
{/* 悬浮球 - 题目导航 */}
|
||
{allQuestions.length > 0 && (
|
||
<QuestionFloatButton
|
||
currentIndex={currentIndex}
|
||
totalQuestions={allQuestions.length}
|
||
correctCount={correctCount}
|
||
wrongCount={wrongCount}
|
||
onClick={() => setDrawerVisible(true)}
|
||
/>
|
||
)}
|
||
|
||
{/* 设置弹窗 */}
|
||
<Modal
|
||
title="答题设置"
|
||
open={settingsVisible}
|
||
onCancel={() => setSettingsVisible(false)}
|
||
footer={null}
|
||
width={400}
|
||
>
|
||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||
<div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<span style={{ fontSize: 15, fontWeight: 500 }}>自动下一题</span>
|
||
<span style={{ marginLeft: 8, fontSize: 13, color: '#8c8c8c' }}>
|
||
答对题目后自动跳转到下一题
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||
<Switch
|
||
checked={autoNext}
|
||
onChange={toggleAutoNext}
|
||
/>
|
||
<span style={{ fontSize: 14, color: autoNext ? '#52c41a' : '#8c8c8c' }}>
|
||
{autoNext ? '已开启' : '已关闭'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{autoNext && (
|
||
<div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<span style={{ fontSize: 15, fontWeight: 500 }}>延迟时间</span>
|
||
<span style={{ marginLeft: 8, fontSize: 13, color: '#8c8c8c' }}>
|
||
设置答对后等待多久跳转到下一题
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||
<InputNumber
|
||
min={1}
|
||
max={10}
|
||
value={autoNextDelay}
|
||
onChange={handleDelayChange}
|
||
style={{ width: 80 }}
|
||
/>
|
||
<span style={{ fontSize: 14 }}>秒后自动跳转</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<div style={{ marginBottom: 12 }}>
|
||
<span style={{ fontSize: 15, fontWeight: 500 }}>随机题目</span>
|
||
<span style={{ marginLeft: 8, fontSize: 13, color: '#8c8c8c' }}>
|
||
开启后点击下一题时随机跳转
|
||
</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingLeft: 8 }}>
|
||
<Switch
|
||
checked={randomMode}
|
||
onChange={toggleRandomMode}
|
||
/>
|
||
<span style={{ fontSize: 14, color: randomMode ? '#52c41a' : '#8c8c8c' }}>
|
||
{randomMode ? '已开启' : '已关闭'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</Space>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default QuestionPage;
|