实现了基于OpenAI的流式题目解析系统,支持答题后查看AI生成的详细解析。 主要功能: - 流式输出:采用SSE (Server-Sent Events) 实现实时流式输出,用户可看到解析逐字生成 - Markdown渲染:使用react-markdown渲染解析内容,支持标题、列表、代码块等格式 - 智能提示词:根据题目类型(选择题/填空题/判断题等)动态调整提示词 - 选择题优化:对选择题提供逐项分析和记忆口诀 - 重新生成:支持重新生成解析,temperature设为0确保输出一致性 - 优化加载:加载指示器显示在内容下方,不遮挡流式输出 技术实现: - 后端:新增ExplainQuestionStream方法支持流式响应 - 前端:使用ReadableStream API接收SSE流式数据 - UI:优化加载状态显示,避免阻塞内容展示 - 清理:删除不再使用的scripts脚本文件 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
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 { 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);
|
||
|
||
// 从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 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);
|
||
try {
|
||
// 检查是否是错题练习模式
|
||
const mode = searchParams.get("mode");
|
||
const res =
|
||
mode === "wrong"
|
||
? await questionApi.getRandomWrongQuestion()
|
||
: await questionApi.getRandomQuestion();
|
||
|
||
if (res.success && res.data) {
|
||
setCurrentQuestion(res.data);
|
||
setSelectedAnswer(res.data.type === "multiple-selection" ? [] : "");
|
||
setShowResult(false);
|
||
setAnswerResult(null);
|
||
}
|
||
} catch (error: any) {
|
||
if (error.response?.status === 401) {
|
||
message.error("请先登录");
|
||
} else if (error.response?.status === 404) {
|
||
message.error("暂无错题");
|
||
} else {
|
||
message.error("加载题目失败");
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 加载题目列表(从第一题开始)
|
||
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);
|
||
|
||
// 更新统计
|
||
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);
|
||
setTimeout(() => {
|
||
setAutoNextLoading(false);
|
||
handleNext();
|
||
}, 1000);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
message.error("提交失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 下一题
|
||
const handleNext = () => {
|
||
if (allQuestions.length > 0) {
|
||
// 检查是否完成所有题目
|
||
if (currentIndex + 1 >= allQuestions.length) {
|
||
// 显示统计摘要
|
||
setShowSummary(true);
|
||
// 清除进度
|
||
localStorage.removeItem(getStorageKey());
|
||
return;
|
||
}
|
||
|
||
const 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' });
|
||
}
|
||
};
|
||
|
||
// 初始化
|
||
useEffect(() => {
|
||
const typeParam = searchParams.get("type");
|
||
const mode = searchParams.get("mode");
|
||
|
||
// 错题练习模式
|
||
if (mode === "wrong") {
|
||
loadRandomQuestion();
|
||
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);
|
||
};
|
||
|
||
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={() => navigate("/")}
|
||
className={styles.backButton}
|
||
type="text"
|
||
>
|
||
返回
|
||
</Button>
|
||
<Title level={3} className={styles.title}>
|
||
AnKao 刷题
|
||
</Title>
|
||
<div className={styles.statsGroup}>
|
||
<div className={styles.statItem}>
|
||
<span className={styles.statLabel}>正确</span>
|
||
<span className={styles.statValue} style={{ color: '#52c41a' }}>{correctCount}</span>
|
||
</div>
|
||
<div className={styles.statItem}>
|
||
<span className={styles.statLabel}>错误</span>
|
||
<span className={styles.statValue} style={{ color: '#ff4d4f' }}>{wrongCount}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 进度条 */}
|
||
<div className={styles.progressWrapper}>
|
||
<QuestionProgress
|
||
currentIndex={currentIndex}
|
||
totalQuestions={allQuestions.length}
|
||
correctCount={correctCount}
|
||
wrongCount={wrongCount}
|
||
/>
|
||
</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}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 完成统计摘要 */}
|
||
<CompletionSummary
|
||
visible={showSummary}
|
||
totalQuestions={allQuestions.length}
|
||
correctCount={correctCount}
|
||
wrongCount={wrongCount}
|
||
category={currentQuestion?.category}
|
||
onClose={() => {
|
||
setShowSummary(false);
|
||
navigate("/");
|
||
}}
|
||
onRetry={handleRetry}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default QuestionPage;
|