优化考试管理页面UI和用户体验
- 重构试卷列表页面布局,提升视觉效果 - 优化试卷卡片样式,添加悬停效果和背景装饰 - 改进移动端响应式设计,增强移动设备用户体验 - 统一返回按钮样式,使用一致的图标和文案 - 调整页面间距和对齐方式,提升整体视觉层次 - 优化代码格式,统一引号使用规范 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
74d692ed2c
commit
ebf7c8890a
@ -12,7 +12,7 @@ import {
|
||||
message
|
||||
} from 'antd'
|
||||
import {
|
||||
HomeOutlined
|
||||
LeftOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
@ -198,11 +198,10 @@ const ExamAnswerView: React.FC = () => {
|
||||
<Row align="middle" justify="space-between">
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<HomeOutlined />}
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => navigate('/exam/management')}
|
||||
>
|
||||
返回试卷列表
|
||||
返回
|
||||
</Button>
|
||||
</Col>
|
||||
<Col flex="auto" style={{ textAlign: 'center' }}>
|
||||
|
||||
@ -7,17 +7,14 @@
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 24px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,16 +29,22 @@
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.description {
|
||||
margin-bottom: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
.infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@ -50,15 +53,97 @@
|
||||
|
||||
svg {
|
||||
color: #1890ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.statTag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 试卷卡片样式优化
|
||||
.examCard {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// 右侧背景图片
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 35px;
|
||||
right: 15px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b8d4f1' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'%3E%3C/path%3E%3Cpolyline points='14,2 14,8 20,8'%3E%3C/polyline%3E%3Cline x1='16' y1='13' x2='8' y2='13'%3E%3C/line%3E%3Cline x1='16' y1='17' x2='8' y2='17'%3E%3C/line%3E%3Cpolyline points='10,9 9,9 8,9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
opacity: 0.12;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片操作按钮样式优化
|
||||
:global(.ant-card-actions) {
|
||||
background: #fafafa;
|
||||
|
||||
li {
|
||||
margin: 0 !important;
|
||||
|
||||
button {
|
||||
height: auto;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(24, 144, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
button.ant-btn-link {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: rgba(255, 77, 79, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,6 +167,7 @@
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
@ -91,15 +177,49 @@
|
||||
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
svg {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.description {
|
||||
margin-top: 8px;
|
||||
|
||||
.infoRow {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
font-size: 13px;
|
||||
.stats {
|
||||
margin-top: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.statTag {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
// 移动端卡片操作按钮优化
|
||||
:global(.ant-card-actions) {
|
||||
li {
|
||||
button {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 移动端样式调整
|
||||
.examCard::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,8 +10,6 @@ import {
|
||||
List,
|
||||
Tag,
|
||||
Modal,
|
||||
Row,
|
||||
Col,
|
||||
Empty,
|
||||
Spin,
|
||||
Drawer,
|
||||
@ -27,7 +25,8 @@ import {
|
||||
CheckCircleOutlined,
|
||||
TrophyOutlined,
|
||||
HistoryOutlined,
|
||||
PrinterOutlined
|
||||
PrinterOutlined,
|
||||
ArrowLeftOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import styles from './ExamManagement.module.less'
|
||||
@ -177,10 +176,13 @@ const ExamManagement: React.FC = () => {
|
||||
<div className={styles.container}>
|
||||
<Card>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h2>模拟考试</h2>
|
||||
<p className={styles.subtitle}>创建和管理模拟试卷</p>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<h2>模拟考试</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
@ -198,11 +200,12 @@ const ExamManagement: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
|
||||
grid={{ gutter: 16, xs: 1, sm: 1, md: 1, lg: 2, xl: 2, xxl: 2 }}
|
||||
dataSource={exams}
|
||||
renderItem={(exam) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
className={styles.examCard}
|
||||
hoverable
|
||||
actions={[
|
||||
<Button
|
||||
@ -248,32 +251,28 @@ const ExamManagement: React.FC = () => {
|
||||
<div className={styles.cardTitle}>
|
||||
<FileTextOutlined />
|
||||
<span>{exam.title}</span>
|
||||
{exam.has_in_progress_exam && (
|
||||
<Tag color="processing" style={{ marginLeft: 8 }}>进行中</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className={styles.cardContent}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<div className={styles.statItem}>
|
||||
<ClockCircleOutlined />
|
||||
<span>{exam.duration} 分钟</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div className={styles.statItem}>
|
||||
<CheckCircleOutlined />
|
||||
<span>及格 {exam.pass_score} 分</span>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className={styles.infoRow}>
|
||||
<div className={styles.infoItem}>
|
||||
<ClockCircleOutlined />
|
||||
<span>{exam.duration} 分钟</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<CheckCircleOutlined />
|
||||
<span>及格 {exam.pass_score} 分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<Tag icon={<TrophyOutlined />} color="gold">
|
||||
<Tag icon={<TrophyOutlined />} color="gold" className={styles.statTag}>
|
||||
最高分: {exam.best_score || 0}
|
||||
</Tag>
|
||||
<Tag color="blue">已考 {exam.attempt_count} 次</Tag>
|
||||
<Tag color="blue" className={styles.statTag}>已考 {exam.attempt_count} 次</Tag>
|
||||
{exam.has_in_progress_exam && (
|
||||
<Tag color="processing" className={styles.statTag}>进行中</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
Result,
|
||||
@ -12,137 +12,139 @@ import {
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Divider
|
||||
} from 'antd'
|
||||
Divider,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
TrophyOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileTextOutlined,
|
||||
HomeOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { ExamRecordResponse, ExamAnswer } from '../types/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import styles from './ExamResultNew.module.less'
|
||||
LeftOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import * as examApi from "../api/exam";
|
||||
import type { ExamRecordResponse, ExamAnswer } from "../types/exam";
|
||||
import type { Question } from "../types/question";
|
||||
import styles from "./ExamResultNew.module.less";
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '填空题',
|
||||
'true-false': '判断题',
|
||||
'multiple-choice': '单选题',
|
||||
'multiple-selection': '多选题',
|
||||
'short-answer': '简答题',
|
||||
'ordinary-essay': '论述题',
|
||||
'management-essay': '论述题',
|
||||
'essay': '论述题' // 合并后的论述题类型
|
||||
}
|
||||
"fill-in-blank": "填空题",
|
||||
"true-false": "判断题",
|
||||
"multiple-choice": "单选题",
|
||||
"multiple-selection": "多选题",
|
||||
"short-answer": "简答题",
|
||||
"ordinary-essay": "论述题",
|
||||
"management-essay": "论述题",
|
||||
essay: "论述题", // 合并后的论述题类型
|
||||
};
|
||||
|
||||
// 题型顺序定义
|
||||
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,
|
||||
'essay': 6 // 合并后的论述题顺序
|
||||
}
|
||||
"fill-in-blank": 1,
|
||||
"true-false": 2,
|
||||
"multiple-choice": 3,
|
||||
"multiple-selection": 4,
|
||||
"short-answer": 5,
|
||||
"ordinary-essay": 6,
|
||||
"management-essay": 6,
|
||||
essay: 6, // 合并后的论述题顺序
|
||||
};
|
||||
|
||||
const ExamResultNew: React.FC = () => {
|
||||
const { recordId } = useParams<{ recordId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { recordId } = useParams<{ recordId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<ExamRecordResponse | null>(null)
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<ExamRecordResponse | null>(null);
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordId) {
|
||||
message.error('参数错误')
|
||||
navigate('/exam/management')
|
||||
return
|
||||
message.error("参数错误");
|
||||
navigate("/exam/management");
|
||||
return;
|
||||
}
|
||||
|
||||
loadResult()
|
||||
}, [recordId])
|
||||
loadResult();
|
||||
}, [recordId]);
|
||||
|
||||
const loadResult = async () => {
|
||||
setLoading(true)
|
||||
setLoading(true);
|
||||
try {
|
||||
const recordRes = await examApi.getExamRecord(Number(recordId))
|
||||
const recordRes = await examApi.getExamRecord(Number(recordId));
|
||||
|
||||
if (recordRes.success && recordRes.data) {
|
||||
setData(recordRes.data)
|
||||
setData(recordRes.data);
|
||||
|
||||
// 获取试卷详情
|
||||
if (recordRes.data.record.exam?.id) {
|
||||
const examRes = await examApi.getExamDetail(recordRes.data.record.exam.id)
|
||||
const examRes = await examApi.getExamDetail(
|
||||
recordRes.data.record.exam.id
|
||||
);
|
||||
if (examRes.success && examRes.data) {
|
||||
setQuestions(examRes.data.questions)
|
||||
setQuestions(examRes.data.questions);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error('加载结果失败')
|
||||
navigate('/exam/management')
|
||||
message.error("加载结果失败");
|
||||
navigate("/exam/management");
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载结果失败')
|
||||
navigate('/exam/management')
|
||||
message.error(error.response?.data?.message || "加载结果失败");
|
||||
navigate("/exam/management");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const { record, answers } = data
|
||||
const isPassed = record.is_passed
|
||||
const { record, answers } = data;
|
||||
const isPassed = record.is_passed;
|
||||
// 总分统一为100分
|
||||
const scorePercent = record.score
|
||||
const scorePercent = record.score;
|
||||
|
||||
// 构建答案映射
|
||||
const answerMap = new Map<number, ExamAnswer>()
|
||||
answers.forEach(ans => {
|
||||
answerMap.set(ans.question_id, ans)
|
||||
})
|
||||
const answerMap = new Map<number, ExamAnswer>();
|
||||
answers.forEach((ans) => {
|
||||
answerMap.set(ans.question_id, ans);
|
||||
});
|
||||
|
||||
// 统计正确率
|
||||
const correctCount = answers.filter(a => a.is_correct).length
|
||||
const totalCount = answers.length
|
||||
const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0
|
||||
const correctCount = answers.filter((a) => a.is_correct).length;
|
||||
const totalCount = answers.length;
|
||||
const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0;
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number) => {
|
||||
const totalSeconds = Math.floor(seconds) // 确保是整数
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const secs = totalSeconds % 60
|
||||
return `${minutes}分${secs}秒`
|
||||
}
|
||||
const totalSeconds = Math.floor(seconds); // 确保是整数
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
return `${minutes}分${secs}秒`;
|
||||
};
|
||||
|
||||
// 渲染答案详情
|
||||
const renderAnswerDetail = (question: Question, answer: ExamAnswer) => {
|
||||
const isCorrect = answer.is_correct
|
||||
const isCorrect = answer.is_correct;
|
||||
|
||||
return (
|
||||
<div className={styles.answerDetail}>
|
||||
{/* 题目内容 - 填空题特殊处理 */}
|
||||
<div className={styles.questionContent}>
|
||||
{question.type === 'fill-in-blank' ? (
|
||||
{question.type === "fill-in-blank" ? (
|
||||
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{renderFillInBlankQuestion(question.content)}
|
||||
</Paragraph>
|
||||
@ -154,18 +156,21 @@ const ExamResultNew: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className={styles.answerSection}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{/* 用户答案 */}
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">你的答案:</Text>
|
||||
<Text strong className={isCorrect ? styles.correct : styles.incorrect}>
|
||||
<Text
|
||||
strong
|
||||
className={isCorrect ? styles.correct : styles.incorrect}
|
||||
>
|
||||
{formatAnswer(answer.answer, question.type)}
|
||||
</Text>
|
||||
{isCorrect ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
@ -174,7 +179,7 @@ const ExamResultNew: React.FC = () => {
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">正确答案:</Text>
|
||||
<Text strong style={{ color: '#52c41a' }}>
|
||||
<Text strong style={{ color: "#52c41a" }}>
|
||||
{formatAnswer(answer.correct_answer, question.type)}
|
||||
</Text>
|
||||
</Space>
|
||||
@ -184,7 +189,13 @@ const ExamResultNew: React.FC = () => {
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">得分:</Text>
|
||||
<Text strong style={{ color: isCorrect ? '#52c41a' : '#ff4d4f', fontSize: 16 }}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
color: isCorrect ? "#52c41a" : "#ff4d4f",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{answer.score.toFixed(1)} 分
|
||||
</Text>
|
||||
</Space>
|
||||
@ -192,11 +203,25 @@ const ExamResultNew: React.FC = () => {
|
||||
|
||||
{/* AI评分详情 */}
|
||||
{answer.ai_grading && (
|
||||
<div className={styles.aiGrading} style={{ marginTop: 12, padding: 16, background: '#f0f5ff', borderRadius: 8 }}>
|
||||
<div
|
||||
className={styles.aiGrading}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 16,
|
||||
background: "#f0f5ff",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: '#1890ff' }}>AI评分详情:</Text>
|
||||
<Text strong style={{ color: "#1890ff" }}>
|
||||
AI评分详情:
|
||||
</Text>
|
||||
</div>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="small"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<div>
|
||||
<Text type="secondary">AI得分:</Text>
|
||||
<Text strong>{answer.ai_grading.score} / 100</Text>
|
||||
@ -217,79 +242,97 @@ const ExamResultNew: React.FC = () => {
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染填空题题目(将 **** 替换为下划线)
|
||||
const renderFillInBlankQuestion = (content: string) => {
|
||||
const parts = content.split('****')
|
||||
const parts = content.split("****");
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{part}
|
||||
{i < parts.length - 1 && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
minWidth: '120px',
|
||||
borderBottom: '2px solid #1890ff',
|
||||
marginLeft: 8,
|
||||
marginRight: 8
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
minWidth: "120px",
|
||||
borderBottom: "2px solid #1890ff",
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
}}
|
||||
>
|
||||
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化用户答案
|
||||
const formatAnswer = (answer: any, type: string): string => {
|
||||
if (answer === null || answer === undefined || answer === '') {
|
||||
return '未作答'
|
||||
if (answer === null || answer === undefined || answer === "") {
|
||||
return "未作答";
|
||||
}
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
if (answer.length === 0) return '未作答'
|
||||
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、')
|
||||
if (answer.length === 0) return "未作答";
|
||||
return answer
|
||||
.filter((a) => a !== null && a !== undefined && a !== "")
|
||||
.join("、");
|
||||
}
|
||||
|
||||
if (type === 'true-false') {
|
||||
if (type === "true-false") {
|
||||
// 处理判断题:支持字符串和布尔值
|
||||
const answerStr = String(answer).toLowerCase()
|
||||
return answerStr === 'true' ? '正确' : '错误'
|
||||
const answerStr = String(answer).toLowerCase();
|
||||
return answerStr === "true" ? "正确" : "错误";
|
||||
}
|
||||
|
||||
return String(answer)
|
||||
}
|
||||
return String(answer);
|
||||
};
|
||||
|
||||
// 按题型分组(合并两种论述题)
|
||||
const groupedQuestions = questions.reduce((acc, q) => {
|
||||
// 将两种论述题统一为 'essay'
|
||||
const displayType = (q.type === 'ordinary-essay' || q.type === 'management-essay') ? 'essay' : q.type
|
||||
const displayType =
|
||||
q.type === "ordinary-essay" || q.type === "management-essay"
|
||||
? "essay"
|
||||
: q.type;
|
||||
if (!acc[displayType]) {
|
||||
acc[displayType] = []
|
||||
acc[displayType] = [];
|
||||
}
|
||||
acc[displayType].push(q)
|
||||
return acc
|
||||
}, {} as Record<string, Question[]>)
|
||||
acc[displayType].push(q);
|
||||
return acc;
|
||||
}, {} as Record<string, Question[]>);
|
||||
|
||||
// 计算各题型得分(已在 groupedQuestions 中合并论述题)
|
||||
const typeScores = Object.entries(groupedQuestions)
|
||||
.map(([type, qs]) => {
|
||||
const typeAnswers = qs.map(q => answerMap.get(q.id)).filter(Boolean) as ExamAnswer[]
|
||||
const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0)
|
||||
const maxScore = typeAnswers.length * (
|
||||
type === 'fill-in-blank' ? 2.0 :
|
||||
type === 'true-false' ? 2.0 :
|
||||
type === 'multiple-choice' ? 1.0 :
|
||||
type === 'multiple-selection' ? 2.5 :
|
||||
type === 'short-answer' ? 10.0 :
|
||||
(type === 'essay' || type === 'ordinary-essay' || type === 'management-essay') ? 5.0 : 0
|
||||
)
|
||||
const correctCount = typeAnswers.filter(ans => ans.is_correct).length
|
||||
const typeAnswers = qs
|
||||
.map((q) => answerMap.get(q.id))
|
||||
.filter(Boolean) as ExamAnswer[];
|
||||
const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0);
|
||||
const maxScore =
|
||||
typeAnswers.length *
|
||||
(type === "fill-in-blank"
|
||||
? 2.0
|
||||
: type === "true-false"
|
||||
? 2.0
|
||||
: type === "multiple-choice"
|
||||
? 1.0
|
||||
: type === "multiple-selection"
|
||||
? 2.5
|
||||
: type === "short-answer"
|
||||
? 10.0
|
||||
: type === "essay" ||
|
||||
type === "ordinary-essay" ||
|
||||
type === "management-essay"
|
||||
? 5.0
|
||||
: 0);
|
||||
const correctCount = typeAnswers.filter((ans) => ans.is_correct).length;
|
||||
|
||||
return {
|
||||
type,
|
||||
@ -298,21 +341,21 @@ const ExamResultNew: React.FC = () => {
|
||||
maxScore,
|
||||
correctCount,
|
||||
totalCount: typeAnswers.length,
|
||||
order: TYPE_ORDER[type] || 999
|
||||
}
|
||||
order: TYPE_ORDER[type] || 999,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 成绩展示 */}
|
||||
<Result
|
||||
status={isPassed ? 'success' : 'warning'}
|
||||
title={isPassed ? '恭喜你,考试通过!' : '很遗憾,未通过考试'}
|
||||
status={isPassed ? "success" : "warning"}
|
||||
title={isPassed ? "恭喜你,考试通过!" : "很遗憾,未通过考试"}
|
||||
subTitle={
|
||||
<Space direction="vertical" size="large">
|
||||
<Text style={{ fontSize: 16 }}>
|
||||
{record.exam?.title || '模拟考试'}
|
||||
{record.exam?.title || "模拟考试"}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
@ -327,7 +370,10 @@ const ExamResultNew: React.FC = () => {
|
||||
value={scorePercent.toFixed(1)}
|
||||
suffix="/ 100"
|
||||
prefix={<TrophyOutlined />}
|
||||
valueStyle={{ color: isPassed ? '#52c41a' : '#ff4d4f', fontSize: 32 }}
|
||||
valueStyle={{
|
||||
color: isPassed ? "#52c41a" : "#ff4d4f",
|
||||
fontSize: 32,
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
@ -336,7 +382,7 @@ const ExamResultNew: React.FC = () => {
|
||||
value={correctRate.toFixed(1)}
|
||||
suffix="%"
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#1890ff', fontSize: 32 }}
|
||||
valueStyle={{ color: "#1890ff", fontSize: 32 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
@ -348,14 +394,29 @@ const ExamResultNew: React.FC = () => {
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 14, marginBottom: 8 }}>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
color: "rgba(0, 0, 0, 0.45)",
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
考试状态
|
||||
</div>
|
||||
<Tag color={isPassed ? 'success' : 'error'} style={{ fontSize: 16, padding: '4px 16px' }}>
|
||||
{isPassed ? '已通过' : '未通过'}
|
||||
<Tag
|
||||
color={isPassed ? "success" : "error"}
|
||||
style={{ fontSize: 16, padding: "4px 16px" }}
|
||||
>
|
||||
{isPassed ? "已通过" : "未通过"}
|
||||
</Tag>
|
||||
<div style={{ marginTop: 8, color: 'rgba(0, 0, 0, 0.45)', fontSize: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
color: "rgba(0, 0, 0, 0.45)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
及格分数:{record.exam?.pass_score || 60} 分
|
||||
</div>
|
||||
</div>
|
||||
@ -365,15 +426,21 @@ const ExamResultNew: React.FC = () => {
|
||||
|
||||
{/* 各题型得分情况 */}
|
||||
<Card
|
||||
title={<Text strong style={{ fontSize: 18 }}>题型得分统计</Text>}
|
||||
title={
|
||||
<Text strong style={{ fontSize: 18 }}>
|
||||
题型得分统计
|
||||
</Text>
|
||||
}
|
||||
className={styles.typeScoreCard}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{typeScores.map(ts => (
|
||||
{typeScores.map((ts) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={ts.type}>
|
||||
<div className={styles.typeScoreItem}>
|
||||
<div className={styles.typeScoreHeader}>
|
||||
<Text strong style={{ fontSize: 16 }}>{ts.typeName}</Text>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{ts.typeName}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
({ts.correctCount}/{ts.totalCount}题正确)
|
||||
</Text>
|
||||
@ -383,12 +450,16 @@ const ExamResultNew: React.FC = () => {
|
||||
strong
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff'
|
||||
color:
|
||||
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
|
||||
}}
|
||||
>
|
||||
{ts.totalScore.toFixed(1)}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 16, marginLeft: 4 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 16, marginLeft: 4 }}
|
||||
>
|
||||
/ {ts.maxScore.toFixed(1)}
|
||||
</Text>
|
||||
</div>
|
||||
@ -397,7 +468,8 @@ const ExamResultNew: React.FC = () => {
|
||||
className={styles.typeScoreBar}
|
||||
style={{
|
||||
width: `${(ts.totalScore / ts.maxScore) * 100}%`,
|
||||
background: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff'
|
||||
background:
|
||||
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -408,68 +480,84 @@ const ExamResultNew: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* 答题详情 - 直接展示,不使用折叠 */}
|
||||
<Card title={<Text strong style={{ fontSize: 18 }}>答题详情</Text>} className={styles.detailCard}>
|
||||
<Card
|
||||
title={
|
||||
<Text strong style={{ fontSize: 18 }}>
|
||||
答题详情
|
||||
</Text>
|
||||
}
|
||||
className={styles.detailCard}
|
||||
>
|
||||
{Object.entries(groupedQuestions)
|
||||
.sort(([typeA], [typeB]) => {
|
||||
const orderA = TYPE_ORDER[typeA] || 999
|
||||
const orderB = TYPE_ORDER[typeB] || 999
|
||||
return orderA - orderB
|
||||
const orderA = TYPE_ORDER[typeA] || 999;
|
||||
const orderB = TYPE_ORDER[typeB] || 999;
|
||||
return orderA - orderB;
|
||||
})
|
||||
.map(([type, qs]) => (
|
||||
<div key={type} style={{ marginBottom: 32 }}>
|
||||
{/* 题型标题 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#fafafa',
|
||||
borderLeft: '4px solid #1890ff',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: 16 }}>{TYPE_NAME[type] || type}</Text>
|
||||
<Text type="secondary">(共 {qs.length} 题)</Text>
|
||||
<div key={type} style={{ marginBottom: 32 }}>
|
||||
{/* 题型标题 */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "#fafafa",
|
||||
borderLeft: "4px solid #1890ff",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{TYPE_NAME[type] || type}
|
||||
</Text>
|
||||
<Text type="secondary">(共 {qs.length} 题)</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 题目列表 */}
|
||||
<Space
|
||||
direction="vertical"
|
||||
size="large"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{qs.map((q, idx) => {
|
||||
const ans = answerMap.get(q.id);
|
||||
if (!ans) return null;
|
||||
return (
|
||||
<Card
|
||||
key={q.id}
|
||||
size="small"
|
||||
className={styles.questionCard}
|
||||
style={{
|
||||
borderLeft: ans.is_correct
|
||||
? "4px solid #52c41a"
|
||||
: "4px solid #ff4d4f",
|
||||
background: ans.is_correct ? "#f6ffed" : "#fff2f0",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color="blue">第 {idx + 1} 题</Tag>
|
||||
</div>
|
||||
{renderAnswerDetail(q, ans)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
|
||||
{/* 题型之间的分隔线 */}
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
{/* 题目列表 */}
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{qs.map((q, idx) => {
|
||||
const ans = answerMap.get(q.id)
|
||||
if (!ans) return null
|
||||
return (
|
||||
<Card
|
||||
key={q.id}
|
||||
size="small"
|
||||
className={styles.questionCard}
|
||||
style={{
|
||||
borderLeft: ans.is_correct ? '4px solid #52c41a' : '4px solid #ff4d4f',
|
||||
background: ans.is_correct ? '#f6ffed' : '#fff2f0'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color="blue">第 {idx + 1} 题</Tag>
|
||||
</div>
|
||||
{renderAnswerDetail(q, ans)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
|
||||
{/* 题型之间的分隔线 */}
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Card className={styles.actionsCard}>
|
||||
<Space size="large">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<HomeOutlined />}
|
||||
onClick={() => navigate('/exam/management')}
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => navigate("/exam/management")}
|
||||
>
|
||||
返回试卷列表
|
||||
返回
|
||||
</Button>
|
||||
{record.exam?.id && (
|
||||
<Button
|
||||
@ -477,12 +565,14 @@ const ExamResultNew: React.FC = () => {
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await examApi.startExam(record.exam!.id)
|
||||
const res = await examApi.startExam(record.exam!.id);
|
||||
if (res.success && res.data) {
|
||||
navigate(`/exam/${record.exam!.id}/taking/${res.data.record_id}`)
|
||||
navigate(
|
||||
`/exam/${record.exam!.id}/taking/${res.data.record_id}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('开始考试失败')
|
||||
message.error("开始考试失败");
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -492,7 +582,7 @@ const ExamResultNew: React.FC = () => {
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamResultNew
|
||||
export default ExamResultNew;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user