AnCao/web/src/pages/ExamPrint.tsx
yanlongqi 4f95514af8 添加打印试卷页面的职位填写横线
- 在姓名后面添加职位的填写横线:职位:________________
- 调整试卷头部布局,从三栏改为四栏(日期、姓名、职位、成绩)
- 使用flex布局确保各元素合理分布和对齐

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 22:38:36 +08:00

485 lines
15 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 } 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' }}>
&nbsp;
</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' }}>
&nbsp;
</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>10060</li>
<li>8</li>
<li>921</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}>
219
</Text>
</div>
<div className={styles.questionsList}>
{essayQuestions.map((question, index) => renderEssay(question, index))}
</div>
</div>
)}
</div>
</div>
)
}
export default ExamPrint