AnCao/web/src/pages/Question.tsx
yanlongqi 59364700bc feat: 练习进度系统重构和AI评分持久化
重大改进:
- 练习进度模型优化:从"每题一条记录"改为"每用户每类型一条记录",提升性能和数据管理
- 完全基于后端数据库恢复答题进度,移除 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>
2025-11-13 05:51:48 +08:00

712 lines
25 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 [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;