新增功能: 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>
284 lines
8.1 KiB
TypeScript
284 lines
8.1 KiB
TypeScript
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
|