AnCao/web/src/pages/ExamPrint.tsx
yanlongqi 52fff11f07 feat: 实现模拟考试功能
## 后端实现
- 添加考试数据模型 (Exam)
- 实现考试生成API (/api/exam/generate)
- 实现获取考试详情API (/api/exam/:id)
- 实现提交考试API (/api/exam/:id/submit)
- 支持按题型随机抽取题目
- AI智能评分(简答题和论述题)
- 自动计算总分和详细评分

## 前端实现
- 首页添加"模拟考试"入口
- 考试准备页:显示考试说明,选择在线/打印模式
- 在线答题页:按题型分组显示,支持论述题二选一
- 试卷打印页:A4排版,支持打印试卷/答案
- 成绩报告页:显示总分、详细评分、错题分析

## 核心特性
- 随机组卷:填空10题、判断10题、单选10题、多选10题、简答2题、论述题2选1
- 智能评分:使用AI评分论述题,给出分数、评语和建议
- 答题进度保存:使用localStorage防止刷新丢失
- 打印优化:A4纸张、黑白打印、合理排版
- 响应式设计:适配移动端、平板和PC端

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 20:45:30 +08:00

476 lines
14 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 { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Card, Button, Typography, message, Spin, Space } from 'antd'
import { ArrowLeftOutlined, PrinterOutlined, FileTextOutlined } from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { Question } from '../types/question'
import type { GetExamResponse } from '../types/exam'
import styles from './ExamPrint.module.less'
const { Title, Paragraph, Text } = Typography
// 题型顺序映射
const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1,
'true-false': 2,
'multiple-choice': 3,
'multiple-selection': 4,
'short-answer': 5,
'ordinary-essay': 6,
'management-essay': 6,
}
// 题型名称映射
const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '一、填空题',
'true-false': '二、判断题',
'multiple-choice': '三、单选题',
'multiple-selection': '四、多选题',
'short-answer': '五、简答题',
'ordinary-essay': '六、论述题',
'management-essay': '六、论述题',
}
// 题型分值映射
const TYPE_SCORE: Record<string, number> = {
'fill-in-blank': 2.0,
'true-false': 2.0,
'multiple-choice': 1.0,
'multiple-selection': 2.5,
'short-answer': 0, // 不计分
'ordinary-essay': 25.0,
'management-essay': 25.0,
}
const ExamPrint: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const showAnswer = searchParams.get('show_answer') === 'true'
const [loading, setLoading] = useState(false)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
// 加载考试详情
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/prepare')
return
}
const loadExam = async () => {
setLoading(true)
try {
const res = await examApi.getExam(Number(examId), showAnswer)
if (res.success && res.data) {
setExamData(res.data)
// 按题型分组
const grouped = groupQuestionsByType(res.data.questions)
setGroupedQuestions(grouped)
} else {
message.error('加载考试失败')
navigate('/exam/prepare')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/prepare')
} finally {
setLoading(false)
}
}
loadExam()
}, [examId, showAnswer, navigate])
// 按题型分组题目
const groupQuestionsByType = (questions: Question[]) => {
const grouped: Record<string, Question[]> = {}
questions.forEach((q) => {
if (!grouped[q.type]) {
grouped[q.type] = []
}
grouped[q.type].push(q)
})
return grouped
}
// 打印试卷(不显示答案)
const handlePrintPaper = () => {
if (showAnswer) {
// 重新加载不显示答案的页面
window.location.href = `/exam/${examId}/print?show_answer=false`
} else {
window.print()
}
}
// 打印答案(显示答案)
const handlePrintAnswer = () => {
if (!showAnswer) {
// 重新加载显示答案的页面
window.location.href = `/exam/${examId}/print?show_answer=true`
} else {
window.print()
}
}
// 格式化答案显示
const formatAnswer = (question: Question): string => {
if (!question.answer) return ''
switch (question.type) {
case 'fill-in-blank':
if (Array.isArray(question.answer)) {
return question.answer.join('、')
}
return String(question.answer)
case 'true-false':
return question.answer === 'true' || question.answer === true ? '正确' : '错误'
case 'multiple-choice':
return String(question.answer)
case 'multiple-selection':
if (Array.isArray(question.answer)) {
return question.answer.sort().join('')
}
return String(question.answer)
case 'short-answer':
case 'ordinary-essay':
case 'management-essay':
return String(question.answer)
default:
return String(question.answer)
}
}
// 渲染填空题
const renderFillInBlank = (question: Question, index: number) => {
// 获取答案数量
const answerCount = question.answer && Array.isArray(question.answer)
? question.answer.length
: 1
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<>
{Array.from({ length: answerCount }).map((_, i) => (
<div key={i} className={styles.blankLine}>
{i + 1} __________________________________________
</div>
))}
</>
)}
</div>
</div>
)
}
// 渲染判断题
const renderTrueFalse = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<Text>____</Text>
)}
</div>
</div>
)
}
// 渲染单选题
const renderMultipleChoice = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.optionsList}>
{question.options.map((opt) => (
<div key={opt.key} className={styles.optionItem}>
{opt.key}. {opt.value}
</div>
))}
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<Text>____</Text>
)}
</div>
</div>
)
}
// 渲染多选题
const renderMultipleSelection = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.optionsList}>
{question.options.map((opt) => (
<div key={opt.key} className={styles.optionItem}>
{opt.key}. {opt.value}
</div>
))}
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<Text>{formatAnswer(question)}</Text>
) : (
<Text>____</Text>
)}
</div>
</div>
)
}
// 渲染简答题
const renderShortAnswer = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
{!showAnswer && (
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
</Text>
)}
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<div className={styles.essayAnswer}>
<Text strong></Text>
<Paragraph>{formatAnswer(question)}</Paragraph>
</div>
) : (
<div className={styles.answerLines}>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className={styles.answerLine}>
_____________________________________________________________________________
</div>
))}
</div>
)}
</div>
</div>
)
}
// 渲染论述题
const renderEssay = (question: Question, index: number) => {
return (
<div key={question.id} className={styles.questionItem}>
<div className={styles.questionContent}>
<Text strong>
{index + 1}. {question.content}
</Text>
</div>
<div className={styles.answerArea}>
{showAnswer ? (
<div className={styles.essayAnswer}>
<Text strong></Text>
<Paragraph>{formatAnswer(question)}</Paragraph>
</div>
) : (
<div className={styles.answerLines}>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className={styles.answerLine}>
_____________________________________________________________________________
</div>
))}
</div>
)}
</div>
</div>
)
}
// 渲染题目组
const renderQuestionGroup = (type: string, questions: Question[]) => {
let startIndex = 0
// 计算该题型的起始序号
Object.keys(groupedQuestions)
.filter((t) => TYPE_ORDER[t] < TYPE_ORDER[type])
.forEach((t) => {
startIndex += groupedQuestions[t].length
})
// 计算该题型总分
const totalScore = questions.length * TYPE_SCORE[type]
return (
<div key={type} className={styles.questionGroup}>
<div className={styles.groupHeader}>
<Text strong className={styles.groupTitle}>
{TYPE_NAME[type]}
</Text>
{TYPE_SCORE[type] > 0 && (
<Text type="secondary" className={styles.groupScore}>
{questions.length}{TYPE_SCORE[type]}{totalScore}
</Text>
)}
</div>
<div className={styles.questionsList}>
{questions.map((question, index) => {
switch (type) {
case 'fill-in-blank':
return renderFillInBlank(question, startIndex + index)
case 'true-false':
return renderTrueFalse(question, startIndex + index)
case 'multiple-choice':
return renderMultipleChoice(question, startIndex + index)
case 'multiple-selection':
return renderMultipleSelection(question, startIndex + index)
case 'short-answer':
return renderShortAnswer(question, startIndex + index)
case 'ordinary-essay':
case 'management-essay':
return renderEssay(question, startIndex + index)
default:
return null
}
})}
</div>
</div>
)
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return null
}
// 获取论述题(合并普通和管理两类)
const essayQuestions = [
...(groupedQuestions['ordinary-essay'] || []),
...(groupedQuestions['management-essay'] || []),
]
return (
<div className={styles.container}>
{/* 操作按钮区 - 打印时隐藏 */}
<div className={`${styles.actionBar} noPrint`}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/exam/new')}
className={styles.backButton}
>
</Button>
<Space>
<Button
type="default"
icon={<FileTextOutlined />}
onClick={handlePrintPaper}
>
</Button>
<Button
type="primary"
icon={<PrinterOutlined />}
onClick={handlePrintAnswer}
>
</Button>
</Space>
</div>
{/* 打印内容区 */}
<div className={styles.printContent}>
{/* 试卷头部 */}
<div className={styles.paperHeader}>
<Title level={2} className={styles.paperTitle}>
{showAnswer ? '(答案)' : ''}
</Title>
<div className={styles.examInfo}>
<div className={styles.infoItem}>
__________________
</div>
<div className={styles.infoItem}>
__________________
</div>
</div>
</div>
{/* 考试说明 */}
{!showAnswer && (
<Card className={styles.instructionCard}>
<Title level={4}></Title>
<ul>
<li>10090</li>
<li></li>
<li></li>
<li>21</li>
</ul>
</Card>
)}
{/* 按题型渲染题目 */}
{Object.keys(groupedQuestions)
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
{/* 论述题部分 */}
{essayQuestions.length > 0 && (
<div className={styles.questionGroup}>
<div className={styles.groupHeader}>
<Text strong className={styles.groupTitle}>
{TYPE_NAME['ordinary-essay']}
</Text>
<Text type="secondary" className={styles.groupScore}>
2125
</Text>
</div>
<div className={styles.questionsList}>
{essayQuestions.map((question, index) => renderEssay(question, index))}
</div>
</div>
)}
</div>
</div>
)
}
export default ExamPrint