实现了基于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>
197 lines
5.6 KiB
TypeScript
197 lines
5.6 KiB
TypeScript
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
|