AnCao/web/src/pages/Question.tsx
yanlongqi 42c54ec90a feat: 错题练习模式添加重新答题功能
- 在QuestionCard组件中添加重新答题按钮
- 仅在错题练习模式(mode=wrong)且答案错误时显示
- 点击后重置当前题目状态,清空答案,允许重新作答
- 添加ReloadOutlined图标提升用户体验

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 22:07:35 +08:00

636 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 [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));
}
};
// 从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, statusMap?: Map<number, boolean | null>) => {
const key = getStorageKey();
const answeredStatusObj: Record<number, boolean | null> = {};
const userAnswersObj: Record<number, string | string[]> = {};
const questionResultsObj: Record<number, AnswerResult> = {};
// 将 Map 转换为普通对象以便 JSON 序列化
const mapToSave = statusMap || answeredStatus;
mapToSave.forEach((value, key) => {
answeredStatusObj[key] = value;
});
userAnswers.forEach((value, key) => {
userAnswersObj[key] = value;
});
questionResults.forEach((value, key) => {
questionResultsObj[key] = value;
});
localStorage.setItem(
key,
JSON.stringify({
currentIndex: index,
correctCount: correct,
wrongCount: wrong,
answeredStatus: answeredStatusObj,
userAnswers: userAnswersObj,
questionResults: questionResultsObj,
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);
// 恢复答题状态
if (progress.answeredStatus) {
const statusMap = new Map<number, boolean | null>();
Object.entries(progress.answeredStatus).forEach(([index, status]) => {
statusMap.set(Number(index), status as boolean | null);
});
setAnsweredStatus(statusMap);
}
// 恢复用户答案
if (progress.userAnswers) {
const answersMap = new Map<number, string | string[]>();
Object.entries(progress.userAnswers).forEach(([index, answer]) => {
answersMap.set(Number(index), answer as string | string[]);
});
setUserAnswers(answersMap);
}
// 恢复答题结果
if (progress.questionResults) {
const resultsMap = new Map<number, AnswerResult>();
Object.entries(progress.questionResults).forEach(([index, result]) => {
resultsMap.set(Number(index), result as AnswerResult);
});
setQuestionResults(resultsMap);
}
return progress.currentIndex || 0;
} catch (e) {
console.error("恢复进度失败", e);
}
}
return 0;
};
// 加载随机错题(使用智能推荐)
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 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("加载题目列表失败");
} 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);
saveProgress(currentIndex, newCorrect, wrongCount, newStatusMap);
} else {
const newWrong = wrongCount + 1;
setWrongCount(newWrong);
saveProgress(currentIndex, correctCount, newWrong, newStatusMap);
}
// 如果答案正确且开启自动跳转,根据设置的延迟时间后自动进入下一题
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");
console.log('[下一题] 当前模式:', mode);
// 错题练习模式:加载下一道推荐错题
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);
// 清除进度
localStorage.removeItem(getStorageKey());
return;
}
// 从未答题目中随机选择一题
const randomIdx = Math.floor(Math.random() * unansweredIndexes.length);
nextIndex = unansweredIndexes[randomIdx];
} else {
// 顺序模式:检查是否完成所有题目
if (currentIndex + 1 >= allQuestions.length) {
// 显示统计摘要
setShowSummary(true);
// 清除进度
localStorage.removeItem(getStorageKey());
return;
}
nextIndex = currentIndex + 1;
}
setCurrentIndex(nextIndex);
setCurrentQuestion(allQuestions[nextIndex]);
setSelectedAnswer(
allQuestions[nextIndex].type === "multiple-selection" ? [] : ""
);
setShowResult(false);
setAnswerResult(null);
// 保存进度
saveProgress(nextIndex, correctCount, wrongCount);
// 滚动到页面顶部
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// 跳转到指定题目
const handleQuestionSelect = (index: number) => {
if (index >= 0 && index < allQuestions.length) {
setCurrentIndex(index);
setCurrentQuestion(allQuestions[index]);
// 检查是否有保存的答案和结果
const savedAnswer = userAnswers.get(index);
const savedResult = questionResults.get(index);
if (savedAnswer !== undefined && savedResult !== undefined) {
// 恢复之前的答案和结果
setSelectedAnswer(savedAnswer);
setAnswerResult(savedResult);
setShowResult(true);
} else {
// 没有答案,重置状态
setSelectedAnswer(
allQuestions[index].type === "multiple-selection" ? [] : ""
);
setShowResult(false);
setAnswerResult(null);
}
// 保存进度
saveProgress(index, correctCount, wrongCount);
// 滚动到页面顶部
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 = () => {
setShowSummary(false);
setCurrentIndex(0);
setCorrectCount(0);
setWrongCount(0);
localStorage.removeItem(getStorageKey());
const typeParam = searchParams.get("type");
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}
/>
)}
</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;