优化考试管理页面UI和用户体验

- 重构试卷列表页面布局,提升视觉效果
- 优化试卷卡片样式,添加悬停效果和背景装饰
- 改进移动端响应式设计,增强移动设备用户体验
- 统一返回按钮样式,使用一致的图标和文案
- 调整页面间距和对齐方式,提升整体视觉层次
- 优化代码格式,统一引号使用规范

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yanlongqi 2025-11-18 11:34:09 +08:00
parent 74d692ed2c
commit ebf7c8890a
4 changed files with 445 additions and 237 deletions

View File

@ -12,7 +12,7 @@ import {
message message
} from 'antd' } from 'antd'
import { import {
HomeOutlined LeftOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import * as examApi from '../api/exam' import * as examApi from '../api/exam'
import type { Question } from '../types/question' import type { Question } from '../types/question'
@ -198,11 +198,10 @@ const ExamAnswerView: React.FC = () => {
<Row align="middle" justify="space-between"> <Row align="middle" justify="space-between">
<Col> <Col>
<Button <Button
type="primary" icon={<LeftOutlined />}
icon={<HomeOutlined />}
onClick={() => navigate('/exam/management')} onClick={() => navigate('/exam/management')}
> >
</Button> </Button>
</Col> </Col>
<Col flex="auto" style={{ textAlign: 'center' }}> <Col flex="auto" style={{ textAlign: 'center' }}>

View File

@ -7,17 +7,14 @@
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
margin-bottom: 24px; margin-bottom: 24px;
h2 { h2 {
margin: 0 0 4px 0;
font-size: 24px;
}
.subtitle {
margin: 0; margin: 0;
color: rgba(0, 0, 0, 0.45); font-size: 24px;
flex: 1;
text-align: center;
} }
} }
@ -32,16 +29,22 @@
font-size: 18px; font-size: 18px;
color: #1890ff; color: #1890ff;
} }
span {
line-height: 1.4;
}
} }
.cardContent { .cardContent {
.description { margin-top: 12px;
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.65); .infoRow {
font-size: 14px; display: flex;
gap: 16px;
margin-bottom: 12px;
} }
.statItem { .infoItem {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@ -50,15 +53,97 @@
svg { svg {
color: #1890ff; color: #1890ff;
font-size: 14px;
} }
} }
.stats { .stats {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
display: flex; display: flex;
gap: 8px; 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 { h2 {
font-size: 20px; font-size: 20px;
text-align: center;
} }
button { button {
@ -91,15 +177,49 @@
.cardTitle { .cardTitle {
font-size: 15px; font-size: 15px;
margin-bottom: 8px;
svg {
font-size: 16px;
}
} }
.cardContent { .cardContent {
.description { margin-top: 8px;
.infoRow {
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.infoItem {
font-size: 13px; font-size: 13px;
} }
.statItem { .stats {
font-size: 13px; 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;
} }
} }

View File

@ -10,8 +10,6 @@ import {
List, List,
Tag, Tag,
Modal, Modal,
Row,
Col,
Empty, Empty,
Spin, Spin,
Drawer, Drawer,
@ -27,7 +25,8 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
TrophyOutlined, TrophyOutlined,
HistoryOutlined, HistoryOutlined,
PrinterOutlined PrinterOutlined,
ArrowLeftOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import * as examApi from '../api/exam' import * as examApi from '../api/exam'
import styles from './ExamManagement.module.less' import styles from './ExamManagement.module.less'
@ -177,10 +176,13 @@ const ExamManagement: React.FC = () => {
<div className={styles.container}> <div className={styles.container}>
<Card> <Card>
<div className={styles.header}> <div className={styles.header}>
<div> <Button
<h2></h2> icon={<ArrowLeftOutlined />}
<p className={styles.subtitle}></p> onClick={() => navigate('/')}
</div> >
</Button>
<h2></h2>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
@ -198,11 +200,12 @@ const ExamManagement: React.FC = () => {
/> />
) : ( ) : (
<List <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} dataSource={exams}
renderItem={(exam) => ( renderItem={(exam) => (
<List.Item> <List.Item>
<Card <Card
className={styles.examCard}
hoverable hoverable
actions={[ actions={[
<Button <Button
@ -248,32 +251,28 @@ const ExamManagement: React.FC = () => {
<div className={styles.cardTitle}> <div className={styles.cardTitle}>
<FileTextOutlined /> <FileTextOutlined />
<span>{exam.title}</span> <span>{exam.title}</span>
{exam.has_in_progress_exam && (
<Tag color="processing" style={{ marginLeft: 8 }}></Tag>
)}
</div> </div>
} }
description={ description={
<div className={styles.cardContent}> <div className={styles.cardContent}>
<Row gutter={[16, 16]}> <div className={styles.infoRow}>
<Col span={12}> <div className={styles.infoItem}>
<div className={styles.statItem}> <ClockCircleOutlined />
<ClockCircleOutlined /> <span>{exam.duration} </span>
<span>{exam.duration} </span> </div>
</div> <div className={styles.infoItem}>
</Col> <CheckCircleOutlined />
<Col span={12}> <span> {exam.pass_score} </span>
<div className={styles.statItem}> </div>
<CheckCircleOutlined /> </div>
<span> {exam.pass_score} </span>
</div>
</Col>
</Row>
<div className={styles.stats}> <div className={styles.stats}>
<Tag icon={<TrophyOutlined />} color="gold"> <Tag icon={<TrophyOutlined />} color="gold" className={styles.statTag}>
: {exam.best_score || 0} : {exam.best_score || 0}
</Tag> </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>
</div> </div>
} }

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from "react-router-dom";
import { import {
Card, Card,
Result, Result,
@ -12,137 +12,139 @@ import {
Row, Row,
Col, Col,
Statistic, Statistic,
Divider Divider,
} from 'antd' } from "antd";
import { import {
CheckCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
TrophyOutlined, TrophyOutlined,
ClockCircleOutlined, ClockCircleOutlined,
FileTextOutlined, FileTextOutlined,
HomeOutlined LeftOutlined,
} from '@ant-design/icons' } from "@ant-design/icons";
import * as examApi from '../api/exam' import * as examApi from "../api/exam";
import type { ExamRecordResponse, ExamAnswer } from '../types/exam' import type { ExamRecordResponse, ExamAnswer } from "../types/exam";
import type { Question } from '../types/question' import type { Question } from "../types/question";
import styles from './ExamResultNew.module.less' import styles from "./ExamResultNew.module.less";
const { Text, Paragraph } = Typography const { Text, Paragraph } = Typography;
// 题型名称映射 // 题型名称映射
const TYPE_NAME: Record<string, string> = { const TYPE_NAME: Record<string, string> = {
'fill-in-blank': '填空题', "fill-in-blank": "填空题",
'true-false': '判断题', "true-false": "判断题",
'multiple-choice': '单选题', "multiple-choice": "单选题",
'multiple-selection': '多选题', "multiple-selection": "多选题",
'short-answer': '简答题', "short-answer": "简答题",
'ordinary-essay': '论述题', "ordinary-essay": "论述题",
'management-essay': '论述题', "management-essay": "论述题",
'essay': '论述题' // 合并后的论述题类型 essay: "论述题", // 合并后的论述题类型
} };
// 题型顺序定义 // 题型顺序定义
const TYPE_ORDER: Record<string, number> = { const TYPE_ORDER: Record<string, number> = {
'fill-in-blank': 1, "fill-in-blank": 1,
'true-false': 2, "true-false": 2,
'multiple-choice': 3, "multiple-choice": 3,
'multiple-selection': 4, "multiple-selection": 4,
'short-answer': 5, "short-answer": 5,
'ordinary-essay': 6, "ordinary-essay": 6,
'management-essay': 6, "management-essay": 6,
'essay': 6 // 合并后的论述题顺序 essay: 6, // 合并后的论述题顺序
} };
const ExamResultNew: React.FC = () => { const ExamResultNew: React.FC = () => {
const { recordId } = useParams<{ recordId: string }>() const { recordId } = useParams<{ recordId: string }>();
const navigate = useNavigate() const navigate = useNavigate();
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [data, setData] = useState<ExamRecordResponse | null>(null) const [data, setData] = useState<ExamRecordResponse | null>(null);
const [questions, setQuestions] = useState<Question[]>([]) const [questions, setQuestions] = useState<Question[]>([]);
useEffect(() => { useEffect(() => {
if (!recordId) { if (!recordId) {
message.error('参数错误') message.error("参数错误");
navigate('/exam/management') navigate("/exam/management");
return return;
} }
loadResult() loadResult();
}, [recordId]) }, [recordId]);
const loadResult = async () => { const loadResult = async () => {
setLoading(true) setLoading(true);
try { try {
const recordRes = await examApi.getExamRecord(Number(recordId)) const recordRes = await examApi.getExamRecord(Number(recordId));
if (recordRes.success && recordRes.data) { if (recordRes.success && recordRes.data) {
setData(recordRes.data) setData(recordRes.data);
// 获取试卷详情 // 获取试卷详情
if (recordRes.data.record.exam?.id) { 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) { if (examRes.success && examRes.data) {
setQuestions(examRes.data.questions) setQuestions(examRes.data.questions);
} }
} }
} else { } else {
message.error('加载结果失败') message.error("加载结果失败");
navigate('/exam/management') navigate("/exam/management");
} }
} catch (error: any) { } catch (error: any) {
message.error(error.response?.data?.message || '加载结果失败') message.error(error.response?.data?.message || "加载结果失败");
navigate('/exam/management') navigate("/exam/management");
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
if (loading) { if (loading) {
return ( return (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) );
} }
if (!data) { if (!data) {
return null return null;
} }
const { record, answers } = data const { record, answers } = data;
const isPassed = record.is_passed const isPassed = record.is_passed;
// 总分统一为100分 // 总分统一为100分
const scorePercent = record.score const scorePercent = record.score;
// 构建答案映射 // 构建答案映射
const answerMap = new Map<number, ExamAnswer>() const answerMap = new Map<number, ExamAnswer>();
answers.forEach(ans => { answers.forEach((ans) => {
answerMap.set(ans.question_id, ans) answerMap.set(ans.question_id, ans);
}) });
// 统计正确率 // 统计正确率
const correctCount = answers.filter(a => a.is_correct).length const correctCount = answers.filter((a) => a.is_correct).length;
const totalCount = answers.length const totalCount = answers.length;
const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0 const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0;
// 格式化时间 // 格式化时间
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
const totalSeconds = Math.floor(seconds) // 确保是整数 const totalSeconds = Math.floor(seconds); // 确保是整数
const minutes = Math.floor(totalSeconds / 60) const minutes = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60 const secs = totalSeconds % 60;
return `${minutes}${secs}` return `${minutes}${secs}`;
} };
// 渲染答案详情 // 渲染答案详情
const renderAnswerDetail = (question: Question, answer: ExamAnswer) => { const renderAnswerDetail = (question: Question, answer: ExamAnswer) => {
const isCorrect = answer.is_correct const isCorrect = answer.is_correct;
return ( return (
<div className={styles.answerDetail}> <div className={styles.answerDetail}>
{/* 题目内容 - 填空题特殊处理 */} {/* 题目内容 - 填空题特殊处理 */}
<div className={styles.questionContent}> <div className={styles.questionContent}>
{question.type === 'fill-in-blank' ? ( {question.type === "fill-in-blank" ? (
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}> <Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
{renderFillInBlankQuestion(question.content)} {renderFillInBlankQuestion(question.content)}
</Paragraph> </Paragraph>
@ -154,18 +156,21 @@ const ExamResultNew: React.FC = () => {
</div> </div>
<div className={styles.answerSection}> <div className={styles.answerSection}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <Space direction="vertical" size="middle" style={{ width: "100%" }}>
{/* 用户答案 */} {/* 用户答案 */}
<div className={styles.answerItem}> <div className={styles.answerItem}>
<Space> <Space>
<Text type="secondary"></Text> <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)} {formatAnswer(answer.answer, question.type)}
</Text> </Text>
{isCorrect ? ( {isCorrect ? (
<CheckCircleOutlined style={{ color: '#52c41a' }} /> <CheckCircleOutlined style={{ color: "#52c41a" }} />
) : ( ) : (
<CloseCircleOutlined style={{ color: '#ff4d4f' }} /> <CloseCircleOutlined style={{ color: "#ff4d4f" }} />
)} )}
</Space> </Space>
</div> </div>
@ -174,7 +179,7 @@ const ExamResultNew: React.FC = () => {
<div className={styles.answerItem}> <div className={styles.answerItem}>
<Space> <Space>
<Text type="secondary"></Text> <Text type="secondary"></Text>
<Text strong style={{ color: '#52c41a' }}> <Text strong style={{ color: "#52c41a" }}>
{formatAnswer(answer.correct_answer, question.type)} {formatAnswer(answer.correct_answer, question.type)}
</Text> </Text>
</Space> </Space>
@ -184,7 +189,13 @@ const ExamResultNew: React.FC = () => {
<div className={styles.answerItem}> <div className={styles.answerItem}>
<Space> <Space>
<Text type="secondary"></Text> <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)} {answer.score.toFixed(1)}
</Text> </Text>
</Space> </Space>
@ -192,11 +203,25 @@ const ExamResultNew: React.FC = () => {
{/* AI评分详情 */} {/* AI评分详情 */}
{answer.ai_grading && ( {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 }}> <div style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#1890ff' }}>AI评分详情</Text> <Text strong style={{ color: "#1890ff" }}>
AI评分详情
</Text>
</div> </div>
<Space direction="vertical" size="small" style={{ width: '100%' }}> <Space
direction="vertical"
size="small"
style={{ width: "100%" }}
>
<div> <div>
<Text type="secondary">AI得分</Text> <Text type="secondary">AI得分</Text>
<Text strong>{answer.ai_grading.score} / 100</Text> <Text strong>{answer.ai_grading.score} / 100</Text>
@ -217,79 +242,97 @@ const ExamResultNew: React.FC = () => {
</Space> </Space>
</div> </div>
</div> </div>
) );
} };
// 渲染填空题题目(将 **** 替换为下划线) // 渲染填空题题目(将 **** 替换为下划线)
const renderFillInBlankQuestion = (content: string) => { const renderFillInBlankQuestion = (content: string) => {
const parts = content.split('****') const parts = content.split("****");
return ( return (
<span> <span>
{parts.map((part, i) => ( {parts.map((part, i) => (
<React.Fragment key={i}> <React.Fragment key={i}>
{part} {part}
{i < parts.length - 1 && ( {i < parts.length - 1 && (
<span style={{ <span
display: 'inline-block', style={{
minWidth: '120px', display: "inline-block",
borderBottom: '2px solid #1890ff', minWidth: "120px",
marginLeft: 8, borderBottom: "2px solid #1890ff",
marginRight: 8 marginLeft: 8,
}}> marginRight: 8,
}}
>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</span> </span>
)} )}
</React.Fragment> </React.Fragment>
))} ))}
</span> </span>
) );
} };
// 格式化用户答案 // 格式化用户答案
const formatAnswer = (answer: any, type: string): string => { const formatAnswer = (answer: any, type: string): string => {
if (answer === null || answer === undefined || answer === '') { if (answer === null || answer === undefined || answer === "") {
return '未作答' return "未作答";
} }
if (Array.isArray(answer)) { if (Array.isArray(answer)) {
if (answer.length === 0) return '未作答' if (answer.length === 0) return "未作答";
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、') return answer
.filter((a) => a !== null && a !== undefined && a !== "")
.join("、");
} }
if (type === 'true-false') { if (type === "true-false") {
// 处理判断题:支持字符串和布尔值 // 处理判断题:支持字符串和布尔值
const answerStr = String(answer).toLowerCase() const answerStr = String(answer).toLowerCase();
return answerStr === 'true' ? '正确' : '错误' return answerStr === "true" ? "正确" : "错误";
} }
return String(answer) return String(answer);
} };
// 按题型分组(合并两种论述题) // 按题型分组(合并两种论述题)
const groupedQuestions = questions.reduce((acc, q) => { const groupedQuestions = questions.reduce((acc, q) => {
// 将两种论述题统一为 'essay' // 将两种论述题统一为 '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]) { if (!acc[displayType]) {
acc[displayType] = [] acc[displayType] = [];
} }
acc[displayType].push(q) acc[displayType].push(q);
return acc return acc;
}, {} as Record<string, Question[]>) }, {} as Record<string, Question[]>);
// 计算各题型得分(已在 groupedQuestions 中合并论述题) // 计算各题型得分(已在 groupedQuestions 中合并论述题)
const typeScores = Object.entries(groupedQuestions) const typeScores = Object.entries(groupedQuestions)
.map(([type, qs]) => { .map(([type, qs]) => {
const typeAnswers = qs.map(q => answerMap.get(q.id)).filter(Boolean) as ExamAnswer[] const typeAnswers = qs
const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0) .map((q) => answerMap.get(q.id))
const maxScore = typeAnswers.length * ( .filter(Boolean) as ExamAnswer[];
type === 'fill-in-blank' ? 2.0 : const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0);
type === 'true-false' ? 2.0 : const maxScore =
type === 'multiple-choice' ? 1.0 : typeAnswers.length *
type === 'multiple-selection' ? 2.5 : (type === "fill-in-blank"
type === 'short-answer' ? 10.0 : ? 2.0
(type === 'essay' || type === 'ordinary-essay' || type === 'management-essay') ? 5.0 : 0 : type === "true-false"
) ? 2.0
const correctCount = typeAnswers.filter(ans => ans.is_correct).length : 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 { return {
type, type,
@ -298,21 +341,21 @@ const ExamResultNew: React.FC = () => {
maxScore, maxScore,
correctCount, correctCount,
totalCount: typeAnswers.length, 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 ( return (
<div className={styles.container}> <div className={styles.container}>
{/* 成绩展示 */} {/* 成绩展示 */}
<Result <Result
status={isPassed ? 'success' : 'warning'} status={isPassed ? "success" : "warning"}
title={isPassed ? '恭喜你,考试通过!' : '很遗憾,未通过考试'} title={isPassed ? "恭喜你,考试通过!" : "很遗憾,未通过考试"}
subTitle={ subTitle={
<Space direction="vertical" size="large"> <Space direction="vertical" size="large">
<Text style={{ fontSize: 16 }}> <Text style={{ fontSize: 16 }}>
{record.exam?.title || '模拟考试'} {record.exam?.title || "模拟考试"}
</Text> </Text>
</Space> </Space>
} }
@ -327,7 +370,10 @@ const ExamResultNew: React.FC = () => {
value={scorePercent.toFixed(1)} value={scorePercent.toFixed(1)}
suffix="/ 100" suffix="/ 100"
prefix={<TrophyOutlined />} prefix={<TrophyOutlined />}
valueStyle={{ color: isPassed ? '#52c41a' : '#ff4d4f', fontSize: 32 }} valueStyle={{
color: isPassed ? "#52c41a" : "#ff4d4f",
fontSize: 32,
}}
/> />
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
@ -336,7 +382,7 @@ const ExamResultNew: React.FC = () => {
value={correctRate.toFixed(1)} value={correctRate.toFixed(1)}
suffix="%" suffix="%"
prefix={<CheckCircleOutlined />} prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#1890ff', fontSize: 32 }} valueStyle={{ color: "#1890ff", fontSize: 32 }}
/> />
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
@ -348,14 +394,29 @@ const ExamResultNew: React.FC = () => {
/> />
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: "center" }}>
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 14, marginBottom: 8 }}> <div
style={{
color: "rgba(0, 0, 0, 0.45)",
fontSize: 14,
marginBottom: 8,
}}
>
</div> </div>
<Tag color={isPassed ? 'success' : 'error'} style={{ fontSize: 16, padding: '4px 16px' }}> <Tag
{isPassed ? '已通过' : '未通过'} color={isPassed ? "success" : "error"}
style={{ fontSize: 16, padding: "4px 16px" }}
>
{isPassed ? "已通过" : "未通过"}
</Tag> </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} {record.exam?.pass_score || 60}
</div> </div>
</div> </div>
@ -365,15 +426,21 @@ const ExamResultNew: React.FC = () => {
{/* 各题型得分情况 */} {/* 各题型得分情况 */}
<Card <Card
title={<Text strong style={{ fontSize: 18 }}></Text>} title={
<Text strong style={{ fontSize: 18 }}>
</Text>
}
className={styles.typeScoreCard} className={styles.typeScoreCard}
> >
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{typeScores.map(ts => ( {typeScores.map((ts) => (
<Col xs={24} sm={12} md={8} lg={6} key={ts.type}> <Col xs={24} sm={12} md={8} lg={6} key={ts.type}>
<div className={styles.typeScoreItem}> <div className={styles.typeScoreItem}>
<div className={styles.typeScoreHeader}> <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 }}> <Text type="secondary" style={{ fontSize: 12 }}>
{ts.correctCount}/{ts.totalCount} {ts.correctCount}/{ts.totalCount}
</Text> </Text>
@ -383,12 +450,16 @@ const ExamResultNew: React.FC = () => {
strong strong
style={{ style={{
fontSize: 28, fontSize: 28,
color: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff' color:
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
}} }}
> >
{ts.totalScore.toFixed(1)} {ts.totalScore.toFixed(1)}
</Text> </Text>
<Text type="secondary" style={{ fontSize: 16, marginLeft: 4 }}> <Text
type="secondary"
style={{ fontSize: 16, marginLeft: 4 }}
>
/ {ts.maxScore.toFixed(1)} / {ts.maxScore.toFixed(1)}
</Text> </Text>
</div> </div>
@ -397,7 +468,8 @@ const ExamResultNew: React.FC = () => {
className={styles.typeScoreBar} className={styles.typeScoreBar}
style={{ style={{
width: `${(ts.totalScore / ts.maxScore) * 100}%`, width: `${(ts.totalScore / ts.maxScore) * 100}%`,
background: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff' background:
ts.totalScore === ts.maxScore ? "#52c41a" : "#1890ff",
}} }}
/> />
</div> </div>
@ -408,68 +480,84 @@ const ExamResultNew: React.FC = () => {
</Card> </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) {Object.entries(groupedQuestions)
.sort(([typeA], [typeB]) => { .sort(([typeA], [typeB]) => {
const orderA = TYPE_ORDER[typeA] || 999 const orderA = TYPE_ORDER[typeA] || 999;
const orderB = TYPE_ORDER[typeB] || 999 const orderB = TYPE_ORDER[typeB] || 999;
return orderA - orderB return orderA - orderB;
}) })
.map(([type, qs]) => ( .map(([type, qs]) => (
<div key={type} style={{ marginBottom: 32 }}> <div key={type} style={{ marginBottom: 32 }}>
{/* 题型标题 */} {/* 题型标题 */}
<div style={{ <div
padding: '12px 16px', style={{
background: '#fafafa', padding: "12px 16px",
borderLeft: '4px solid #1890ff', background: "#fafafa",
marginBottom: 16 borderLeft: "4px solid #1890ff",
}}> marginBottom: 16,
<Space> }}
<Text strong style={{ fontSize: 16 }}>{TYPE_NAME[type] || type}</Text> >
<Text type="secondary"> {qs.length} </Text> <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> </Space>
{/* 题型之间的分隔线 */}
<Divider />
</div> </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>
{/* 操作按钮 */} {/* 操作按钮 */}
<Card className={styles.actionsCard}> <Card className={styles.actionsCard}>
<Space size="large"> <Space size="large">
<Button <Button
type="primary"
size="large" size="large"
icon={<HomeOutlined />} icon={<LeftOutlined />}
onClick={() => navigate('/exam/management')} onClick={() => navigate("/exam/management")}
> >
</Button> </Button>
{record.exam?.id && ( {record.exam?.id && (
<Button <Button
@ -477,12 +565,14 @@ const ExamResultNew: React.FC = () => {
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
onClick={async () => { onClick={async () => {
try { try {
const res = await examApi.startExam(record.exam!.id) const res = await examApi.startExam(record.exam!.id);
if (res.success && res.data) { 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) { } catch (error) {
message.error('开始考试失败') message.error("开始考试失败");
} }
}} }}
> >
@ -492,7 +582,7 @@ const ExamResultNew: React.FC = () => {
</Space> </Space>
</Card> </Card>
</div> </div>
) );
} };
export default ExamResultNew export default ExamResultNew;