AnCao/web/src/components/QuestionCard.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

197 lines
5.6 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 { Space, Tag, Typography, Radio, Checkbox, Input, Button } from 'antd'
import type { Question, AnswerResult as AnswerResultType } from '../types/question'
import AnswerResult from './AnswerResult'
import styles from '../pages/Question.module.less'
const { TextArea } = Input
const { Title } = Typography
interface QuestionCardProps {
question: Question
selectedAnswer: string | string[]
showResult: boolean
answerResult: AnswerResultType | null
loading: boolean
autoNextLoading: boolean
onAnswerChange: (answer: string | string[]) => void
onSubmit: () => void
onNext: () => void
}
const QuestionCard: React.FC<QuestionCardProps> = ({
question,
selectedAnswer,
showResult,
answerResult,
loading,
autoNextLoading,
onAnswerChange,
onSubmit,
onNext,
}) => {
const [fillAnswers, setFillAnswers] = useState<string[]>([])
// 当题目ID变化时重置填空题答案
useEffect(() => {
if (question.type === 'fill-in-blank') {
setFillAnswers([])
}
}, [question.id, question.type])
// 渲染填空题内容
const renderFillContent = () => {
const content = question.content
const parts = content.split('****')
if (parts.length === 1) {
return <div className={styles.questionContent}>{content}</div>
}
if (fillAnswers.length === 0) {
setFillAnswers(new Array(parts.length - 1).fill(''))
}
return (
<div className={styles.questionContent}>
{parts.map((part, index) => (
<React.Fragment key={index}>
<span>{part}</span>
{index < parts.length - 1 && (
<Input
className={styles.fillInput}
placeholder={`填空${index + 1}`}
value={fillAnswers[index] || ''}
onChange={(e) => {
const newAnswers = [...fillAnswers]
newAnswers[index] = e.target.value
setFillAnswers(newAnswers)
onAnswerChange(newAnswers)
}}
disabled={showResult}
style={{
display: 'inline-block',
width: '120px',
margin: '0 8px',
}}
/>
)}
</React.Fragment>
))}
</div>
)
}
// 渲染题目选项
const renderOptions = () => {
if (question.type === 'fill-in-blank') {
return null
}
if (question.type === 'short-answer') {
return (
<TextArea
placeholder="请输入答案"
value={selectedAnswer as string}
onChange={(e) => onAnswerChange(e.target.value)}
disabled={showResult}
rows={4}
style={{ marginTop: 20 }}
/>
)
}
if (question.type === 'multiple-selection') {
const sortedOptions = [...question.options].sort((a, b) => a.key.localeCompare(b.key))
return (
<Checkbox.Group
value={selectedAnswer as string[]}
onChange={(val) => onAnswerChange(val as string[])}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Checkbox key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{option.key}. {option.value}
</span>
</Checkbox>
))}
</Space>
</Checkbox.Group>
)
}
// 单选题和判断题
// 判断题不排序,保持后端返回的顺序(正确在前,错误在后)
const sortedOptions = question.type === 'true-false'
? question.options
: [...question.options].sort((a, b) => a.key.localeCompare(b.key))
return (
<Radio.Group
value={selectedAnswer as string}
onChange={(e) => onAnswerChange(e.target.value)}
disabled={showResult}
style={{ width: '100%', marginTop: 20 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{sortedOptions.map((option) => (
<Radio key={option.key} value={option.key}>
<span style={{ fontSize: 16 }}>
{question.type === 'true-false' ? option.value : `${option.key}. ${option.value}`}
</span>
</Radio>
))}
</Space>
</Radio.Group>
)
}
return (
<div className={styles.questionCard}>
<Space size="small" style={{ marginBottom: 16, alignItems: 'center' }}>
<Title level={5} style={{ margin: 0, display: 'inline' }}>
{question.question_id}
</Title>
<Tag color="blue">{question.category}</Tag>
</Space>
{question.type === 'fill-in-blank' ? (
renderFillContent()
) : (
<div className={styles.questionContent}>{question.content}</div>
)}
{renderOptions()}
{/* 答案结果 */}
{showResult && answerResult && (
<AnswerResult
answerResult={answerResult}
selectedAnswer={selectedAnswer}
questionType={question.type}
questionId={question.id}
/>
)}
{/* 按钮 */}
<div className={styles.buttonGroup}>
{!showResult ? (
<Button type="primary" size="large" block onClick={onSubmit} loading={loading}>
</Button>
) : (
<Button type="primary" size="large" block onClick={onNext} loading={autoNextLoading}>
{autoNextLoading ? '正在跳转到下一题...' : '下一题'}
</Button>
)}
</div>
</div>
)
}
export default QuestionCard