AnCao/web/src/pages/Question.tsx
yanlongqi 24d098ae92 添加AI流式题目解析功能
实现了基于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>
2025-11-05 16:04:07 +08:00

334 lines
10 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 } 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;