- 在姓名后面添加职位的填写横线:职位:________________ - 调整试卷头部布局,从三栏改为四栏(日期、姓名、职位、成绩) - 使用flex布局确保各元素合理分布和对齐 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||
import { Card, Button, Typography, message, Spin } from 'antd'
|
||
import { ArrowLeftOutlined, 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 formatDate = (date: Date): string => {
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
// 题型顺序映射
|
||
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': 4.5,
|
||
'management-essay': 4.5,
|
||
}
|
||
|
||
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/management')
|
||
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/management')
|
||
}
|
||
} catch (error: any) {
|
||
message.error(error.response?.data?.message || '加载考试失败')
|
||
navigate('/exam/management')
|
||
} 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 handlePrint = () => {
|
||
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 answers = question.answer && Array.isArray(question.answer)
|
||
? question.answer
|
||
: question.answer
|
||
? [String(question.answer)]
|
||
: []
|
||
|
||
// 计算下划线字符数量
|
||
const calculateUnderscoreCount = (blankIndex: number, totalBlanks: number) => {
|
||
// 优先使用 answer_lengths 字段(在 show_answer=false 时也会返回)
|
||
if (question.answer_lengths && question.answer_lengths[blankIndex] !== undefined) {
|
||
const answerLength = question.answer_lengths[blankIndex]
|
||
// 最少8个下划线字符
|
||
return Math.max(answerLength, 8)
|
||
}
|
||
|
||
// 如果有实际的答案数据,使用答案长度
|
||
if (answers[blankIndex]) {
|
||
const answerText = String(answers[blankIndex])
|
||
// 最少8个下划线字符
|
||
return Math.max(answerText.length, 8)
|
||
}
|
||
|
||
// 如果没有任何答案数据,使用默认策略
|
||
if (totalBlanks === 1) {
|
||
return 8 // 单个填空:8个下划线字符
|
||
} else {
|
||
// 多个填空:8-12个下划线字符循环
|
||
const counts = [8, 10, 12, 9, 11]
|
||
return counts[blankIndex % counts.length]
|
||
}
|
||
}
|
||
|
||
// 处理题目内容,将 **** 替换为下划线字符
|
||
const renderQuestionContent = (content: string) => {
|
||
if (!content) return content
|
||
|
||
let processedContent = content
|
||
let blankIndex = 0
|
||
|
||
// 先计算出总共有多少个填空
|
||
const totalBlanks = (content.match(/\*{4,}/g) || []).length
|
||
|
||
// 将所有的 **** 替换为下划线字符
|
||
processedContent = processedContent.replace(/\*{4,}/g, () => {
|
||
const underscoreCount = calculateUnderscoreCount(blankIndex, totalBlanks)
|
||
blankIndex++
|
||
return '_'.repeat(underscoreCount)
|
||
})
|
||
|
||
return processedContent
|
||
}
|
||
|
||
return (
|
||
<div key={question.id} className={styles.questionItem}>
|
||
<div className={styles.questionContent}>
|
||
<Text>
|
||
{index + 1}.{' '}
|
||
</Text>
|
||
<span
|
||
dangerouslySetInnerHTML={{
|
||
__html: renderQuestionContent(question.content || '')
|
||
}}
|
||
/>
|
||
</div>
|
||
{showAnswer && answers.length > 0 && (
|
||
<div className={styles.answerArea}>
|
||
<Text>参考答案:{formatAnswer(question)}</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 渲染判断题
|
||
const renderTrueFalse = (question: Question, index: number) => {
|
||
return (
|
||
<div key={question.id} className={styles.questionItem}>
|
||
<div className={styles.questionContent}>
|
||
<Text>
|
||
{index + 1}. {question.content}
|
||
</Text>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 渲染单选题
|
||
const renderMultipleChoice = (question: Question, index: number) => {
|
||
return (
|
||
<div key={question.id} className={styles.questionItem}>
|
||
<div className={styles.questionContent}>
|
||
<Text>
|
||
{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>
|
||
)
|
||
}
|
||
|
||
// 渲染多选题
|
||
const renderMultipleSelection = (question: Question, index: number) => {
|
||
return (
|
||
<div key={question.id} className={styles.questionItem}>
|
||
<div className={styles.questionContent}>
|
||
<Text>
|
||
{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>
|
||
)
|
||
}
|
||
|
||
// 渲染简答题
|
||
const renderShortAnswer = (question: Question, index: number) => {
|
||
return (
|
||
<div key={question.id} className={styles.questionItem}>
|
||
<div className={styles.questionContent}>
|
||
<Text>
|
||
{index + 1}. {question.content}
|
||
</Text>
|
||
</div>
|
||
<div className={styles.answerArea}>
|
||
{showAnswer ? (
|
||
<div className={styles.essayAnswer}>
|
||
<Text>参考答案:</Text>
|
||
<Paragraph>{formatAnswer(question)}</Paragraph>
|
||
</div>
|
||
) : (
|
||
<div className={styles.answerLines}>
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<div key={i} className={styles.answerLine} style={{ height: '30px' }}>
|
||
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 渲染论述题
|
||
const renderEssay = (question: Question, index: number) => {
|
||
const getUserTypeHint = () => {
|
||
if (question.type === 'ordinary-essay') {
|
||
return '(普通涉密人员作答)'
|
||
} else if (question.type === 'management-essay') {
|
||
return '(保密管理人员作答)'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
return (
|
||
<div key={question.id} className={styles.questionItem}>
|
||
<div className={styles.questionContent}>
|
||
<Text>
|
||
{index + 1}. {question.content}
|
||
</Text>
|
||
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
|
||
{getUserTypeHint()}
|
||
</Text>
|
||
</div>
|
||
<div className={styles.answerArea}>
|
||
{showAnswer ? (
|
||
<div className={styles.essayAnswer}>
|
||
<Text>参考答案:</Text>
|
||
<Paragraph>{formatAnswer(question)}</Paragraph>
|
||
</div>
|
||
) : (
|
||
<div className={styles.answerLines}>
|
||
{Array.from({ length: 10 }).map((_, i) => (
|
||
<div key={i} className={styles.answerLine} style={{ height: '35px' }}>
|
||
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 渲染题目组
|
||
const renderQuestionGroup = (type: string, questions: Question[]) => {
|
||
// 每个题型分类都从1开始编号
|
||
const startIndex = 0
|
||
|
||
// 计算该题型总分
|
||
const totalScore = questions.length * TYPE_SCORE[type]
|
||
|
||
return (
|
||
<div key={type} className={styles.questionGroup}>
|
||
<div className={styles.groupHeader}>
|
||
<Text 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/management')}
|
||
className={styles.backButton}
|
||
>
|
||
返回
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<FileTextOutlined />}
|
||
onClick={handlePrint}
|
||
>
|
||
打印试卷
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 打印内容区 */}
|
||
<div className={styles.printContent}>
|
||
{/* 试卷头部 */}
|
||
<div className={styles.paperHeader}>
|
||
<Title level={2} className={styles.paperTitle}>
|
||
保密知识模拟考试{showAnswer ? '(答案)' : ''}
|
||
</Title>
|
||
<div style={{ fontFamily: 'SimSun, 宋体, serif', fontSize: '9pt' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', width: '100%' }}>
|
||
<span style={{ flex: '0 0 auto', textAlign: 'left' }}>日期:{formatDate(new Date())}</span>
|
||
<span style={{ flex: '0 0 auto', textAlign: 'center' }}>姓名:________________</span>
|
||
<span style={{ flex: '0 0 auto', textAlign: 'center' }}>职位:________________</span>
|
||
<span style={{ flex: '0 0 auto', textAlign: 'right' }}>成绩:________________</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 考试说明 */}
|
||
{!showAnswer && (
|
||
<Card className={styles.instructionCard}>
|
||
<Title level={4}>考试说明</Title>
|
||
<ul>
|
||
<li>本试卷满分100分,考试时间为60分钟</li>
|
||
<li>简答题每题8分,请在答题区域内作答,字迹清晰工整</li>
|
||
<li>论述题每题9分,从以下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 className={styles.groupTitle}>
|
||
{TYPE_NAME['ordinary-essay']}
|
||
</Text>
|
||
<Text type="secondary" className={styles.groupScore}>
|
||
(以下2道论述题任选1道作答,共9分)
|
||
</Text>
|
||
</div>
|
||
<div className={styles.questionsList}>
|
||
{essayQuestions.map((question, index) => renderEssay(question, index))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ExamPrint
|