AnCao/web/src/pages/QuestionList.tsx
yanlongqi ea051e9380 添加AI评分系统和题目列表功能
新增功能:
1. AI智能评分系统
   - 集成OpenAI兼容API进行简答题评分
   - 提供分数、评语和改进建议
   - 支持自定义AI服务配置(BaseURL、APIKey、Model)

2. 题目列表页面
   - 展示所有题目和答案
   - Tab标签页形式的题型筛选(选择题、多选题、判断题、填空题、简答题)
   - 关键词搜索功能(支持题目内容和编号搜索)
   - 填空题特殊渲染:****显示为下划线
   - 判断题不显示选项,界面更简洁

3. UI优化
   - 答题结果组件重构,支持AI评分显示
   - 首页新增"题目列表"快速入口
   - 响应式设计,适配移动端和PC端

技术改进:
- 添加AI评分服务层(internal/services/ai_grading.go)
- 扩展题目模型支持AI评分结果
- 更新配置管理支持AI服务配置
- 添加AI评分测试脚本和文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 13:36:30 +08:00

284 lines
8.1 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, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Card,
Tabs,
List,
Tag,
Typography,
Space,
Divider,
Button,
Spin,
message,
Input,
} from 'antd'
import {
BookOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
EditOutlined,
FileTextOutlined,
UnorderedListOutlined,
SearchOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons'
import * as questionApi from '../api/question'
import type { Question } from '../types/question'
import styles from './QuestionList.module.less'
const { Title, Text, Paragraph } = Typography
// 题型配置
const questionTypeConfig: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
'multiple-choice': { label: '选择题', icon: <CheckCircleOutlined />, color: '#1677ff' },
'multiple-selection': { label: '多选题', icon: <UnorderedListOutlined />, color: '#52c41a' },
'true-false': { label: '判断题', icon: <CheckCircleOutlined />, color: '#fa8c16' },
'fill-in-blank': { label: '填空题', icon: <FileTextOutlined />, color: '#722ed1' },
'short-answer': { label: '简答题', icon: <EditOutlined />, color: '#eb2f96' },
}
const QuestionList: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [questions, setQuestions] = useState<Question[]>([])
const [filteredQuestions, setFilteredQuestions] = useState<Question[]>([])
const [selectedType, setSelectedType] = useState<string>('all')
const [searchKeyword, setSearchKeyword] = useState<string>('')
// 加载题目列表
const loadQuestions = async () => {
setLoading(true)
try {
const res = await questionApi.getQuestions({})
if (res.success && res.data) {
setQuestions(res.data)
setFilteredQuestions(res.data)
}
} catch (error) {
message.error('加载题目列表失败')
console.error('加载题目失败:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadQuestions()
}, [])
// 筛选题目
useEffect(() => {
let filtered = questions
// 按题型筛选
if (selectedType !== 'all') {
filtered = filtered.filter(q => q.type === selectedType)
}
// 按关键词搜索
if (searchKeyword.trim()) {
const keyword = searchKeyword.trim().toLowerCase()
filtered = filtered.filter(q =>
q.content.toLowerCase().includes(keyword) ||
q.question_id.toLowerCase().includes(keyword)
)
}
setFilteredQuestions(filtered)
}, [selectedType, searchKeyword, questions])
// 渲染填空题内容,将****替换为下划线
const renderFillInBlankContent = (content: string): React.ReactNode => {
// 将 **** 替换为下划线样式
const parts = content.split('****')
return (
<>
{parts.map((part, index) => (
<React.Fragment key={index}>
{part}
{index < parts.length - 1 && (
<span className={styles.blank}>______</span>
)}
</React.Fragment>
))}
</>
)
}
// 格式化答案显示
const formatAnswer = (question: Question): string => {
const { type, answer, options } = question
switch (type) {
case 'true-false':
return answer === true ? '正确' : '错误'
case 'multiple-choice':
const option = options?.find(opt => opt.key === answer)
return option ? `${answer}. ${option.value}` : String(answer)
case 'multiple-selection':
if (Array.isArray(answer)) {
return answer.map(key => {
const opt = options?.find(o => o.key === key)
return opt ? `${key}. ${opt.value}` : key
}).join('; ')
}
return String(answer)
case 'fill-in-blank':
if (Array.isArray(answer)) {
return answer.map((a, i) => `${i + 1}: ${a}`).join('; ')
}
return String(answer)
case 'short-answer':
return String(answer)
default:
return String(answer)
}
}
// 渲染选项
const renderOptions = (question: Question) => {
// 判断题不显示选项
if (question.type === 'true-false') {
return null
}
if (!question.options || question.options.length === 0) {
return null
}
return (
<div className={styles.options}>
{question.options.map(opt => (
<div key={opt.key} className={styles.option}>
<Tag color="blue">{opt.key}</Tag>
<Text>{opt.value}</Text>
</div>
))}
</div>
)
}
return (
<div className={styles.container}>
{/* 头部 */}
<div className={styles.header}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
className={styles.backButton}
>
</Button>
<Title level={3} className={styles.title}>
<BookOutlined />
</Title>
</div>
{/* 筛选栏 */}
<Card className={styles.filterCard}>
<Input
placeholder="搜索题目内容或题目编号"
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
allowClear
size="large"
className={styles.searchInput}
/>
</Card>
{/* 题型选项卡 */}
<Card className={styles.tabsCard}>
<Tabs
activeKey={selectedType}
onChange={setSelectedType}
items={[
{
key: 'all',
label: (
<span>
<BookOutlined />
</span>
),
},
...Object.entries(questionTypeConfig).map(([type, config]) => ({
key: type,
label: (
<span>
{config.icon} {config.label}
</span>
),
})),
]}
/>
<Divider style={{ margin: '12px 0 0 0' }} />
<Text type="secondary">
{filteredQuestions.length}
</Text>
</Card>
{/* 题目列表 */}
<Spin spinning={loading}>
<List
dataSource={filteredQuestions}
renderItem={(question, index) => {
const typeConfig = questionTypeConfig[question.type]
return (
<Card key={question.id} className={styles.questionCard}>
{/* 题目头部 */}
<div className={styles.questionHeader}>
<Space size="small">
<Tag color={typeConfig?.color || 'default'}>
{typeConfig?.icon} {typeConfig?.label || question.type}
</Tag>
<Text type="secondary" className={styles.questionId}>
#{question.question_id}
</Text>
</Space>
<Text type="secondary" className={styles.questionNumber}>
{index + 1}
</Text>
</div>
{/* 题目内容 */}
<div className={styles.questionContent}>
<Paragraph className={styles.contentText}>
{question.type === 'fill-in-blank'
? renderFillInBlankContent(question.content)
: question.content
}
</Paragraph>
</div>
{/* 选项 */}
{renderOptions(question)}
{/* 答案 */}
<div className={styles.answerSection}>
<Space size="small">
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Text strong></Text>
</Space>
<Paragraph className={styles.answerText}>
{formatAnswer(question)}
</Paragraph>
</div>
</Card>
)
}}
locale={{ emptyText: '暂无题目' }}
/>
</Spin>
</div>
)
}
export default QuestionList