## 后端实现 - 添加考试数据模型 (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>
476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
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>本试卷满分100分,考试时间为90分钟</li>
|
||
<li>请在答题区域内作答,字迹清晰工整</li>
|
||
<li>简答题仅供参考,不计入总分</li>
|
||
<li>论述题从以下2道题目中任选1道作答</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}>
|
||
(以下2道论述题任选1道作答,共25分)
|
||
</Text>
|
||
</div>
|
||
<div className={styles.questionsList}>
|
||
{essayQuestions.map((question, index) => renderEssay(question, index))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ExamPrint
|