优化模拟考试答案查看页面的显示效果

主要改进:
1. 新增ExamAnswerView页面和样式文件
2. 优化填空题间距,减少过大的垂直边距
3. 紧凑化题型之间的间距,提升页面密度
4. 去掉题型标题的背景色和左侧竖线
5. 为题型标题添加汉字序号(一、二、三等)
6. 去掉选择题表格的边框,简化界面
7. 解决打印时显示"试卷答案"标题的问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
燕陇琪 2025-11-18 01:23:10 +08:00
parent 48730369e5
commit c4a8b28abe
13 changed files with 1142 additions and 1314 deletions

View File

@ -117,7 +117,7 @@ func CreateExam(c *gin.Context) {
// 创建试卷
exam := models.Exam{
UserID: userID.(uint),
UserID: uint(userID.(int64)),
Title: req.Title,
TotalScore: int(totalScore), // 总分100分
Duration: 60, // 固定60分钟
@ -233,6 +233,9 @@ func GetExamDetail(c *gin.Context) {
return
}
// 检查是否需要显示答案
showAnswer := c.Query("show_answer") == "true"
db := database.GetDB()
// 查询试卷
@ -258,7 +261,7 @@ func GetExamDetail(c *gin.Context) {
return
}
// 按原始顺序排序题目并转换为DTO(不显示答案)
// 按原始顺序排序题目并转换为DTO
questionMap := make(map[int64]models.PracticeQuestion)
for _, q := range questions {
questionMap[q.ID] = q
@ -267,7 +270,10 @@ func GetExamDetail(c *gin.Context) {
for _, id := range questionIDs {
if q, ok := questionMap[id]; ok {
dto := convertToDTO(q)
dto.Answer = nil // 不显示答案
// 根据showAnswer参数决定是否显示答案
if !showAnswer {
dto.Answer = nil // 不显示答案
}
orderedDTOs = append(orderedDTOs, dto)
}
}
@ -309,7 +315,7 @@ func StartExam(c *gin.Context) {
now := time.Now()
record := models.ExamRecord{
ExamID: uint(examID),
UserID: userID.(uint),
UserID: uint(userID.(int64)),
StartTime: &now,
TotalScore: exam.TotalScore,
Status: "in_progress",
@ -413,7 +419,7 @@ func SubmitExam(c *gin.Context) {
})
// 异步执行阅卷(从 exam_user_answers 表读取答案)
go gradeExam(uint(recordID), exam.ID, userID.(uint))
go gradeExam(uint(recordID), exam.ID, uint(userID.(int64)))
}
// GetExamRecord 获取考试记录详情

View File

@ -0,0 +1,405 @@
package services
import (
"ankao/pkg/config"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/baidubce/app-builder/go/appbuilder"
)
// BaiduAIGradingService 百度云AI评分服务
type BaiduAIGradingService struct {
client *appbuilder.AppBuilderClient
config *config.AIConfig
conversationID string // 会话ID,用于保持上下文
}
// 全局单例和锁
var (
globalBaiduService *BaiduAIGradingService
serviceMutex sync.Mutex
serviceInitialized bool
)
// NewBaiduAIGradingService 创建百度云AI评分服务实例(使用单例模式)
func NewBaiduAIGradingService() (*BaiduAIGradingService, error) {
serviceMutex.Lock()
defer serviceMutex.Unlock()
// 如果已经初始化过,直接返回
if serviceInitialized && globalBaiduService != nil {
return globalBaiduService, nil
}
cfg := config.GetAIConfig()
// 设置百度云AppBuilder Token
clientConfig, err := appbuilder.NewSDKConfig("", cfg.APIKey)
if err != nil {
return nil, fmt.Errorf("创建SDK配置失败: %w", err)
}
// 创建AppBuilder客户端
client, err := appbuilder.NewAppBuilderClient(cfg.BaiduAppID, clientConfig)
if err != nil {
return nil, fmt.Errorf("创建AppBuilder客户端失败: %w", err)
}
// 创建会话
conversationID, err := client.CreateConversation()
if err != nil {
return nil, fmt.Errorf("创建会话失败: %w", err)
}
log.Printf("百度云AI服务初始化成功会话ID: %s", conversationID)
globalBaiduService = &BaiduAIGradingService{
client: client,
config: cfg,
conversationID: conversationID,
}
serviceInitialized = true
return globalBaiduService, nil
}
// GradeEssay 对论述题进行AI评分(不需要标准答案)
func (s *BaiduAIGradingService) GradeEssay(question, userAnswer string) (*AIGradingResult, error) {
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请对以下论述题进行评分
题目:%s
学生答案:%s
评分依据:
请依据以下保密法律法规和管理制度进行分析和评分:
1. 中华人民共和国保守国家秘密法
2. 中华人民共和国保守国家秘密法实施条例
3. 保密工作管理制度2025.9.9
4. 软件开发管理制度
5. 涉密信息系统集成资质保密标准
6. 涉密信息系统集成资质管理办法
评分标准(论述题没有固定标准答案,请根据答题质量和法规符合度评分):
1. 论点是否明确,是否符合保密法规要求(30)
2. 内容是否充实,论据是否引用相关法规条文(30)
3. 逻辑是否严密,分析是否符合保密工作实际(25)
4. 语言表达是否准确专业(15)
评分等级:
- 85-100:论述优秀,论点明确论据充分符合法规要求分析专业
- 70-84:论述良好,基本要素齐全,符合保密工作要求
- 60-69:论述基本合格,要点基本涵盖但不够深入
- 40-59:论述不够完整,缺乏法规支撑或逻辑性较差
- 0-39:论述严重缺失或完全离题
判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
评分要求:
1. 给出一个0-100的精确分数
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
3. 生成一个专业的参考答案(reference_answer,150-300,必须引用相关法规条文)
4. 给出评分依据(scoring_rationale,说明依据了哪些法规和条文,80-150)
5. 给出简短的评语(feedback,说明得分情况,不超过80字)
6. 给出具体的改进建议(suggestion,如果分数在90分以上可以简短,否则必须指出具体改进方向,不超过80字)
请按照以下JSON格式返回结果:
{
"score": 75,
"is_correct": true,
"reference_answer": "根据《中华人民共和国保守国家秘密法》第XX条...",
"scoring_rationale": "依据《保密法》第XX条、《保密法实施条例》第XX条...",
"feedback": "论述较为完整,论点明确,但论据不够充分,缺少具体法规引用",
"suggestion": "建议补充《保密法》相关条文,加强论点之间的逻辑联系"
}
注意:
1. 只返回JSON格式的结果,不要有其他内容
2. 参考答案必须专业准确,体现保密法规要求
3. 评分依据必须具体引用法规条文`, question, userAnswer)
// 调用百度云AI
answer, err := s.runAppBuilder(prompt)
if err != nil {
return nil, err
}
// 解析返回结果
var result AIGradingResult
if err := parseAIResponse(answer, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// GradeShortAnswer 对简答题进行AI评分
func (s *BaiduAIGradingService) GradeShortAnswer(question, standardAnswer, userAnswer string) (*AIGradingResult, error) {
prompt := fmt.Sprintf(`你是一位专业的保密领域阅卷老师,请严格按照标准答案对以下简答题进行评分
题目:%s
标准答案:%s
学生答案:%s
评分依据:
请依据以下保密法律法规和管理制度进行分析和评分:
1. 中华人民共和国保守国家秘密法
2. 中华人民共和国保守国家秘密法实施条例
3. 保密工作管理制度2025.9.9
4. 软件开发管理制度
5. 涉密信息系统集成资质保密标准
6. 涉密信息系统集成资质管理办法
评分标准(请严格遵守):
1. 必须与标准答案进行逐项对比
2. 答案要点完全覆盖标准答案且表述准确符合法规要求的,给85-100
3. 答案要点基本覆盖但有缺漏或表述不够准确的,给60-84
4. 答案要点缺失较多或有明显错误的,给40-59
5. 答案完全错误或离题的,给0-39
6. 判断标准:60分及以上为正确(is_correct: true),否则为错误(is_correct: false)
评分要求:
1. 给出一个0-100的精确分数
2. 判断答案是否正确(is_correct: 60分及以上为true,否则为false)
3. 给出评分依据(scoring_rationale,说明依据了哪些法规和标准答案的哪些要点,80-150)
4. 给出简短的评语(feedback,说明得分和失分原因,不超过80字)
5. 给出具体的改进建议(suggestion,如果答案满分可以为空,否则必须指出具体改进方向,不超过80字)
请按照以下JSON格式返回结果:
{
"score": 85,
"is_correct": true,
"scoring_rationale": "依据《保密法》第XX条和标准答案要点分析...",
"feedback": "答案覆盖了主要要点,但XXX部分描述不够准确",
"suggestion": "建议补充XXX内容,并完善XXX的描述"
}
注意:
1. 只返回JSON格式的结果,不要有其他内容
2. 必须严格对照标准答案评分,不要过于宽松
3. 评分依据必须说明符合或违反了哪些法规要求`, question, standardAnswer, userAnswer)
// 调用百度云AI
answer, err := s.runAppBuilder(prompt)
if err != nil {
return nil, err
}
// 解析返回结果
var result AIGradingResult
if err := parseAIResponse(answer, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// ExplainQuestionStream 生成题目解析(流式输出)
func (s *BaiduAIGradingService) ExplainQuestionStream(writer http.ResponseWriter, question, standardAnswer, questionType string) error {
prompt := fmt.Sprintf(`你是一位严谨专业的保密领域专家老师,请对以下题目进行详细解析
题目类型:%s
题目内容:%s
标准答案:%s
**重要依据**:请基于以下保密法律法规和管理制度进行专业分析:
1. 中华人民共和国保守国家秘密法
2. 中华人民共和国保守国家秘密法实施条例
3. 保密工作管理制度2025.9.9
4. 软件开发管理制度
5. 涉密信息系统集成资质保密标准
6. 涉密信息系统集成资质管理办法
请提供一个详细的解析,要求:
1. **必须基于保密法规**:解析时必须引用相关法规条文,说明依据哪些具体法律法规
2. **必须实事求是**:只基于题目内容标准答案和实际法规进行解析
3. **不要胡编乱造**:如果某些信息不确定或题目没有提供,请如实说明,不要编造法规条文
解析内容要求:
- **知识点**:说明题目考查的核心知识点,指出涉及哪些保密法规
- **法规依据**:明确引用相关法律法规的具体条文(:保密法第X条保密法实施条例第X条等)
- **解题思路**:提供清晰的解题步骤和方法,结合保密工作实际
%s
示例输出格式:
## 知识点
本题考查的是[知识点名称],涉及XX法规第XX条...
## 法规依据
- 中华人民共和国保守国家秘密法第XX条规定:...
- 保密工作管理制度2025.9.9第X章第X节:...
## 解题思路
1. 首先根据XX法规第XX条,我们可以判断...
2. 然后结合保密工作实际,分析...
%s
## 总结
%s
**重要提醒**:请务必在解析中引用具体的法规条文,不要空泛地提及法规名称如果不确定具体条文编号,可以说明法规的精神和要求
请使用markdown格式输出解析内容`,
questionType,
question,
standardAnswer,
// 根据题目类型添加特定要求
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return `- **选项分析**:对于选择题,必须逐项分析每个选项的对错及原因,并说明依据哪些法规
- **记忆口诀**:如果适用,提供便于记忆的口诀或技巧`
}
return "- **答案解析**:详细说明为什么这个答案是正确的,并引用相关法规依据"
}(),
// 根据题目类型添加示例格式
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return `## 选项分析
- **A选项**:[分析该选项],根据XX法规第XX条...
- **B选项**:[分析该选项],根据XX法规第XX条...
- **C选项**:[分析该选项],根据XX法规第XX条...
- **D选项**:[分析该选项],根据XX法规第XX条...
## 正确答案
正确答案是... 因为根据XX法规第XX条规定...`
}
return `## 答案解析
正确答案是... 根据XX法规第XX条的规定...`
}(),
// 根据题目类型添加总结要求
func() string {
if questionType == "single-selection" || questionType == "multiple-selection" {
return "对于选择题,可以提供记忆口诀或关键要点总结,并总结涉及的主要法规要求"
}
return "总结本题的关键要点、涉及的主要法规要求和在保密工作中的实际应用"
}(),
)
// 调用百度云AI(流式)
return s.runAppBuilderStream(writer, prompt)
}
// ExplainQuestion 生成题目解析
func (s *BaiduAIGradingService) ExplainQuestion(question, standardAnswer, questionType string) (*AIExplanationResult, error) {
prompt := fmt.Sprintf(`你是一位经验丰富的老师,请对以下题目进行详细解析
题目类型:%s
题目内容:%s
标准答案:%s
请提供一个详细的解析,包括:
1. 题目考查的知识点
2. 解题思路和方法
3. 为什么选择这个答案
4. 相关的重要概念或注意事项
请按照以下JSON格式返回结果:
{
"explanation": "这道题考查的是...(200字以内的详细解析)"
}
注意:只返回JSON格式的结果,不要有其他内容`, questionType, question, standardAnswer)
// 调用百度云AI
answer, err := s.runAppBuilder(prompt)
if err != nil {
return nil, err
}
// 解析返回结果
var result AIExplanationResult
if err := parseAIResponse(answer, &result); err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
}
return &result, nil
}
// runAppBuilder 调用百度云AppBuilder(非流式)
func (s *BaiduAIGradingService) runAppBuilder(query string) (string, error) {
startTime := time.Now()
log.Printf("[百度云AI] 开始调用会话ID: %s", s.conversationID)
// 调用AppBuilder Run方法 - 注意:即使stream=false,依然需要迭代读取
iterator, err := s.client.Run(s.conversationID, query, nil, false)
if err != nil {
return "", fmt.Errorf("调用AppBuilder失败: %w", err)
}
// 收集所有返回内容
var fullAnswer strings.Builder
// 只读取一次 - 非流式模式下SDK应该一次性返回完整结果
answer, err := iterator.Next()
if err != nil {
if errors.Is(err, io.EOF) {
return "", fmt.Errorf("AI未返回任何内容")
}
return "", fmt.Errorf("读取AppBuilder响应失败: %w", err)
}
if answer != nil && answer.Answer != "" {
fullAnswer.WriteString(answer.Answer)
}
result := fullAnswer.String()
if result == "" {
return "", fmt.Errorf("AI未返回任何内容")
}
elapsed := time.Since(startTime)
log.Printf("[百度云AI] 调用完成,耗时: %v, 返回长度: %d", elapsed, len(result))
return result, nil
}
// runAppBuilderStream 调用百度云AppBuilder(流式)
func (s *BaiduAIGradingService) runAppBuilderStream(writer http.ResponseWriter, query string) error {
// 调用AppBuilder Run方法(流式)
iterator, err := s.client.Run(s.conversationID, query, nil, true)
if err != nil {
return fmt.Errorf("创建流式请求失败: %w", err)
}
flusher, ok := writer.(http.Flusher)
if !ok {
return fmt.Errorf("响应写入器不支持Flush")
}
// 读取流式响应并发送给客户端 - 参考百度云SDK示例代码的方式
var answer *appbuilder.AppBuilderClientAnswer
for answer, err = iterator.Next(); err == nil; answer, err = iterator.Next() {
// 发送增量内容
if answer != nil && answer.Answer != "" {
// 使用SSE格式发送
fmt.Fprintf(writer, "data: %s\n\n", answer.Answer)
flusher.Flush()
}
}
// 检查是否因为EOF之外的错误退出 - 使用errors.Is更安全
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("接收流式响应失败: %w", err)
}
// 流结束
fmt.Fprintf(writer, "data: [DONE]\n\n")
flusher.Flush()
return nil
}

View File

@ -14,13 +14,12 @@ import QuestionManagement from './pages/QuestionManagement'
import QuestionList from './pages/QuestionList'
import UserManagement from './pages/UserManagement'
import UserDetail from './pages/UserDetail'
import ExamPrepare from './pages/ExamPrepare'
import ExamOnline from './pages/ExamOnline'
import ExamPrint from './pages/ExamPrint'
import ExamResult from './pages/ExamResult'
import ExamManagement from './pages/ExamManagement'
import ExamTaking from './pages/ExamTaking'
import ExamResultNew from './pages/ExamResultNew'
import ExamAnswerView from './pages/ExamAnswerView'
const App: React.FC = () => {
return (
@ -38,13 +37,12 @@ const App: React.FC = () => {
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
{/* 考试相关页面,需要登录保护 */}
<Route path="/exam/new" element={<ProtectedRoute><ExamPrepare /></ProtectedRoute>} />
<Route path="/exam/management" element={<ProtectedRoute><ExamManagement /></ProtectedRoute>} />
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
<Route path="/exam/:examId/taking/:recordId" element={<ProtectedRoute><ExamTaking /></ProtectedRoute>} />
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
<Route path="/exam/:examId/result" element={<ProtectedRoute><ExamResult /></ProtectedRoute>} />
<Route path="/exam/result/:recordId" element={<ProtectedRoute><ExamResultNew /></ProtectedRoute>} />
<Route path="/exam/:examId/answer" element={<ProtectedRoute><ExamAnswerView /></ProtectedRoute>} />
{/* 题库管理页面,需要管理员权限 */}
<Route path="/question-management" element={

View File

@ -75,7 +75,7 @@ export const generateExam = () => {
// 获取考试详情
export const getExam = (examId: number, showAnswer?: boolean) => {
return request.get<ApiResponse<GetExamResponse>>(`/exam/${examId}`, {
return request.get<ApiResponse<GetExamResponse>>(`/exams/${examId}`, {
params: { show_answer: showAnswer },
})
}

View File

@ -0,0 +1,378 @@
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-size: 14px; // 设置基础字体大小
@media print {
padding: 0;
font-size: 12px;
margin: 0;
}
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
}
.statsCard {
margin-bottom: 24px;
}
.detailCard {
margin-bottom: 24px;
// 适合打印的样式
@media print {
box-shadow: none;
border: 1px solid #000;
}
}
.actionsCard {
text-align: center;
@media print {
display: none;
}
}
.questionCard {
margin-bottom: 16px;
@media print {
box-shadow: none;
border: 1px solid #ccc;
page-break-inside: avoid;
}
}
.answerDetail {
padding: 16px 0;
}
.questionContent {
margin-bottom: 16px;
}
.answerSection {
background: #f8f9fa;
padding: 16px;
border-radius: 4px;
@media print {
background: none;
border: 1px solid #ccc;
}
}
.answerItem {
margin-bottom: 12px;
}
.correct {
color: #52c41a;
}
.incorrect {
color: #ff4d4f;
}
.typeScoreCard {
margin-bottom: 24px;
}
.typeScoreItem {
text-align: center;
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
.typeScoreHeader {
margin-bottom: 8px;
}
.typeScoreContent {
margin-bottom: 8px;
}
.typeScoreProgress {
height: 6px;
background: #f0f0f0;
border-radius: 3px;
overflow: hidden;
}
.typeScoreBar {
height: 100%;
border-radius: 3px;
}
// 打印样式优化
@media print {
.container {
max-width: 100%;
padding: 0;
margin: 0;
}
body {
font-size: 12pt;
line-height: 1.4;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
p {
page-break-inside: avoid;
}
.no-print {
display: none !important;
}
// 优化打印时的卡片显示
.detailCard {
box-shadow: none;
border: none;
margin-bottom: 0;
@media print {
// 打印时移除卡片头部的内边距
.ant-card-head {
padding: 0;
min-height: 0;
margin-bottom: 0;
}
// 移除卡片内容区域的内边距
.ant-card-body {
padding: 0;
margin: 0;
}
// 避免在卡片前后分页
page-break-inside: avoid;
page-break-after: avoid;
}
}
// 打印时优化Card显示
.detailCard {
@media print {
.ant-card-body {
padding: 0;
}
}
}
// 确保题型标题在打印时不会被分页打断
.typeTitle {
page-break-after: avoid;
page-break-inside: avoid;
font-size: 16px;
margin-bottom: 8px;
@media print {
font-size: 14px;
margin-bottom: 4px;
}
}
// 优化打印时的分隔线
.ant-divider {
display: none;
}
// 打印时隐藏分隔线容器的外边距
.ant-divider-horizontal {
margin: 16px 0;
@media print {
margin: 0;
display: none;
}
}
// 专门用于隐藏打印时分隔线的类
.noPrintDivider {
@media print {
display: none;
margin: 0;
}
}
// 隐藏打印按钮所在的卡片
.statsCard {
box-shadow: none;
border: none;
margin-bottom: 0;
@media print {
margin-bottom: 0;
padding: 0;
display: none;
}
// 隐藏返回按钮和打印按钮
button {
display: none !important;
}
}
// 防止最后一页出现空白页
.fillBlankContainer,
.fillBlankItem,
.optionsTable {
page-break-inside: avoid;
page-break-after: avoid;
}
// 移除页面底部可能的空白
.container:last-child {
page-break-after: auto;
}
}
// 填空题容器样式
.fillBlankContainer {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 14px;
@media print {
gap: 2px;
font-size: 12px;
margin: 0;
padding: 0;
}
}
// 填空题项目样式
.fillBlankItem {
page-break-inside: avoid;
padding: 4px 0;
@media print {
page-break-inside: avoid;
padding: 2px 0;
margin: 0;
}
}
// 表格样式用于选择题
.optionsTable {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
table-layout: fixed; // 固定表格布局,确保列宽一致
th, td {
border: none;
padding: 8px 4px;
text-align: center;
vertical-align: top;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
@media print {
th, td {
border: none;
padding: 6px 2px;
}
th {
background-color: #eee;
}
// 打印时增加分页控制
tr {
page-break-inside: avoid;
}
}
}
// 选择题答案显示
.choiceAnswer {
font-weight: bold;
color: #1890ff;
margin-top: 8px;
}
// 题号和答案的显示样式
.answerDetail {
text-align: left;
padding: 4px 0;
font-size: 14px; // 调整字体大小以适应打印
line-height: 1.4;
word-wrap: break-word; // 自动换行
word-break: break-all;
@media print {
font-size: 12px; // 打印时使用稍小的字体
padding: 2px 0;
line-height: 1.3;
}
}
// 表格样式优化
.optionsTable {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
table-layout: fixed; // 固定表格布局,确保列宽一致
font-size: 14px;
margin: 0;
th, td {
border: none;
padding: 12px 8px;
text-align: center;
vertical-align: top;
}
th {
background-color: #f5f5f5;
font-weight: bold;
}
@media print {
font-size: 12px;
margin: 0;
padding: 0;
th, td {
border: none;
padding: 8px 4px;
margin: 0;
}
th {
background-color: #eee;
}
// 打印时增加分页控制
tr {
page-break-inside: avoid;
}
// 避免表格后产生空白页
tbody:last-child {
page-break-after: auto;
}
}
}

View File

@ -0,0 +1,317 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Card,
Button,
Typography,
Tag,
Space,
Spin,
Row,
Col,
Divider,
message
} from 'antd'
import {
FileTextOutlined,
HomeOutlined,
CheckCircleOutlined,
CloseCircleOutlined
} 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 './ExamAnswerView.module.less'
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': '论述题' // 合并后的论述题类型
}
// 题型顺序定义
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 // 合并后的论述题顺序
}
const ExamAnswerView: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [questions, setQuestions] = useState<Question[]>([])
// 处理打印功能
const handlePrint = () => {
// 设置打印标题
document.title = `${examData?.exam?.title || '试卷答案'}_打印版`
// 触发打印
window.print()
// 打印完成后恢复标题
setTimeout(() => {
document.title = 'AnKao - 智能考试系统'
}, 1000)
}
useEffect(() => {
if (!examId) {
message.error('参数错误')
navigate('/exam/management')
return
}
loadExamData()
}, [examId])
const loadExamData = async () => {
setLoading(true)
try {
// 获取带答案的试卷详情
const res = await examApi.getExam(Number(examId), true)
if (res.success && res.data) {
setExamData(res.data)
setQuestions(res.data.questions)
} else {
message.error('加载试卷失败')
navigate('/exam/management')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载试卷失败')
navigate('/exam/management')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return null
}
// 格式化答案显示
const formatAnswer = (answer: any, type: string): string => {
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 (type === 'true-false') {
// 处理判断题:支持字符串和布尔值
const answerStr = String(answer).toLowerCase()
return answerStr === 'true' ? '正确' : '错误'
}
return String(answer)
}
// 渲染答案详情
const renderAnswerDetail = (question: Question, index: number, type: string) => {
// 格式化答案显示
const formatAnswer = (answer: any, type: string): string => {
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 (type === 'true-false') {
// 处理判断题:支持字符串和布尔值
const answerStr = String(answer).toLowerCase()
return answerStr === 'true' ? '正确' : '错误'
}
return String(answer)
}
// 特殊处理填空题,按要求格式显示
if (question.type === 'fill-in-blank') {
const answers = Array.isArray(question.answer) ? question.answer : [question.answer];
// 过滤掉空答案并转换为字符串
const validAnswers = answers
.filter(answer => answer !== null && answer !== undefined && answer !== '')
.map(answer => String(answer));
// 如果没有有效答案,显示提示
if (validAnswers.length === 0) {
return (
<div className={styles.answerDetail}>
{index + 1}.
</div>
);
}
// 使用逗号分隔显示答案
return (
<div className={styles.answerDetail}>
{index + 1}. {validAnswers.join('')}
</div>
);
}
// 特殊处理判断题显示对号或X
if (question.type === 'true-false') {
const answerStr = String(question.answer).toLowerCase();
const isCorrect = answerStr === 'true';
return (
<div className={styles.answerDetail}>
{index + 1}. {isCorrect ? '√' : '×'}
</div>
);
}
// 其他题型显示题号和答案
return (
<div className={styles.answerDetail}>
{index + 1}. {formatAnswer(question.answer, question.type)}
</div>
);
}
// 汉字数字映射
const chineseNumbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
// 按题型分组(合并两种论述题)
const groupedQuestions = questions.reduce((acc, q) => {
// 将两种论述题统一为 'essay'
const displayType = (q.type === 'ordinary-essay' || q.type === 'management-essay') ? 'essay' : q.type
if (!acc[displayType]) {
acc[displayType] = []
}
acc[displayType].push(q)
return acc
}, {} as Record<string, Question[]>)
return (
<div className={styles.container}>
{/* 试卷标题和返回按钮 */}
<Card className={styles.statsCard}>
<Row align="middle" justify="space-between">
<Col>
<Button
type="primary"
icon={<HomeOutlined />}
onClick={() => navigate('/exam/management')}
>
</Button>
</Col>
<Col flex="auto" style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>
{examData.exam?.title || '试卷答案'}
</Text>
</Col>
<Col>
<Button
type="primary"
onClick={handlePrint}
>
</Button>
</Col>
</Row>
</Card>
{/* 答题详情 - 使用表格展示 */}
<Card className={styles.detailCard}>
{Object.entries(groupedQuestions)
.sort(([typeA], [typeB]) => {
const orderA = TYPE_ORDER[typeA] || 999
const orderB = TYPE_ORDER[typeB] || 999
return orderA - orderB
})
.map(([type, qs], typeIndex) => (
<div key={type} style={{ marginBottom: 16 }}>
{/* 题型标题 */}
<div className={styles.typeTitle} style={{
padding: '8px 12px',
marginBottom: 8
}}>
<Space>
<Text strong style={{ fontSize: 16 }}>
{chineseNumbers[typeIndex] || typeIndex + 1}{TYPE_NAME[type] || type}
</Text>
<Text type="secondary"> {qs.length} </Text>
</Space>
</div>
{/* 填空题、简答题和论述题特殊处理:每行一个答案,不使用表格 */}
{type === 'fill-in-blank' || type === 'short-answer' || type === 'essay' || type === 'ordinary-essay' || type === 'management-essay' ? (
<div className={styles.fillBlankContainer}>
{qs.map((q, index) => (
<div key={q.id} className={styles.fillBlankItem}>
{renderAnswerDetail(q, index, type)}
</div>
))}
</div>
) : (
/* 其他题型使用表格显示答案每行5列确保题号和答案分行显示 */
<table className={styles.optionsTable}>
<tbody>
{/* 每5个题目为一行确保更好的打印效果 */}
{Array.from({ length: Math.ceil(qs.length / 5) }).map((_, rowIndex) => (
<tr key={rowIndex}>
{qs.slice(rowIndex * 5, (rowIndex + 1) * 5).map((q, colIndex) => {
const globalIndex = rowIndex * 5 + colIndex;
return (
<td key={q.id} style={{ width: '20%' }}>
{renderAnswerDetail(q, globalIndex, type)}
</td>
);
})}
{/* 如果最后一行不足5列用空单元格填充 */}
{qs.length - rowIndex * 5 < 5 &&
Array.from({ length: 5 - (qs.length - rowIndex * 5) }).map((_, emptyIndex) => (
<td key={`empty-${emptyIndex}`} style={{ width: '20%' }}></td>
))
}
</tr>
))}
</tbody>
</table>
)}
{/* 题型之间的分隔线 */}
<Divider className={styles.noPrintDivider} />
</div>
))}
</Card>
</div>
)
}
export default ExamAnswerView

View File

@ -218,6 +218,13 @@ const ExamManagement: React.FC = () => {
>
</Button>,
<Button
type="link"
icon={<FileTextOutlined />}
onClick={() => navigate(`/exam/${exam.id}/answer`)}
>
</Button>,
<Button
type="link"
danger
@ -344,6 +351,18 @@ const ExamManagement: React.FC = () => {
</Button>
)}
{record.status === 'graded' && (
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => {
setRecordsDrawerVisible(false)
navigate(`/exam/result/${record.id}`)
}}
>
</Button>
)}
</Space>
</Card>
)}

View File

@ -66,7 +66,7 @@ const ExamOnline: React.FC = () => {
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/prepare')
navigate('/exam/management')
return
}
@ -83,11 +83,11 @@ const ExamOnline: React.FC = () => {
loadProgress(res.data.questions)
} else {
message.error('加载考试失败')
navigate('/exam/prepare')
navigate('/exam/management')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/prepare')
navigate('/exam/management')
} finally {
setLoading(false)
}
@ -195,7 +195,7 @@ const ExamOnline: React.FC = () => {
// 清除进度
localStorage.removeItem(`exam_progress_${examId}`)
// 跳转到成绩页,传递提交结果
navigate(`/exam/${examId}/result`, {
navigate(`/exam/result/${res.data.record_id}`, {
state: { submitResult: res.data }
})
} else {
@ -478,7 +478,7 @@ const ExamOnline: React.FC = () => {
<div className={styles.topBarContent}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/exam/prepare')}
onClick={() => navigate('/exam/management')}
className={styles.backButton}
type="text"
>

View File

@ -1,86 +0,0 @@
.container {
min-height: calc(100vh - 64px);
padding: 16px;
background: #f0f2f5;
@media (min-width: 768px) {
padding: 24px;
}
}
.header {
margin-bottom: 16px;
padding: 12px 16px;
@media (min-width: 768px) {
margin-bottom: 24px;
padding: 16px 24px;
}
}
.backButton {
border: none;
padding: 4px 15px;
}
.content {
max-width: 900px;
margin: 0 auto;
}
.titleSection {
text-align: center;
margin-bottom: 32px;
.titleIcon {
font-size: 48px;
color: #fa8c16;
margin-bottom: 16px;
}
h2 {
margin-bottom: 8px;
}
}
.infoCard {
margin-bottom: 24px;
}
.tipCard {
margin-bottom: 32px;
ul {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 8px;
color: #595959;
}
}
}
.actionButtons {
display: flex;
justify-content: center;
width: 100%;
@media (max-width: 576px) {
flex-direction: column;
button {
width: 100%;
}
}
}
.primaryButton {
background: #fa8c16;
border-color: #fa8c16;
&:hover {
background: #ff9c2e;
border-color: #ff9c2e;
}
}

View File

@ -1,151 +0,0 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Button, Typography, Descriptions, Space, message } from 'antd'
import {
ArrowLeftOutlined,
FileTextOutlined,
PrinterOutlined,
EditOutlined,
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import { DEFAULT_EXAM_CONFIG, DEFAULT_SCORE_CONFIG } from '../types/exam'
import styles from './ExamPrepare.module.less'
const { Title, Paragraph } = Typography
const ExamPrepare: React.FC = () => {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
// 生成在线考试
const handleStartOnlineExam = async () => {
setLoading(true)
try {
const res = await examApi.generateExam()
if (res.success && res.data) {
const examId = res.data.exam_id
message.success('考试已生成')
navigate(`/exam/${examId}/online`)
} else {
message.error(res.message || '生成考试失败')
}
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('生成考试失败,请稍后重试')
}
} finally {
setLoading(false)
}
}
// 生成试卷打印
const handlePrintExam = async () => {
setLoading(true)
try {
const res = await examApi.generateExam()
if (res.success && res.data) {
const examId = res.data.exam_id
message.success('试卷已生成')
navigate(`/exam/${examId}/print`)
} else {
message.error(res.message || '生成试卷失败')
}
} catch (error: any) {
if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('生成试卷失败,请稍后重试')
}
} finally {
setLoading(false)
}
}
return (
<div className={styles.container}>
<Card className={styles.header}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
className={styles.backButton}
>
</Button>
</Card>
<Card className={styles.content}>
<div className={styles.titleSection}>
<FileTextOutlined className={styles.titleIcon} />
<Title level={2}></Title>
<Paragraph type="secondary">
线
</Paragraph>
</div>
<Card title="考试说明" className={styles.infoCard}>
<Descriptions column={1} bordered>
<Descriptions.Item label="填空题">
{DEFAULT_EXAM_CONFIG.fill_in_blank} {DEFAULT_SCORE_CONFIG.fill_in_blank} {DEFAULT_EXAM_CONFIG.fill_in_blank * DEFAULT_SCORE_CONFIG.fill_in_blank}
</Descriptions.Item>
<Descriptions.Item label="判断题">
{DEFAULT_EXAM_CONFIG.true_false} {DEFAULT_SCORE_CONFIG.true_false} {DEFAULT_EXAM_CONFIG.true_false * DEFAULT_SCORE_CONFIG.true_false}
</Descriptions.Item>
<Descriptions.Item label="单选题">
{DEFAULT_EXAM_CONFIG.multiple_choice} {DEFAULT_SCORE_CONFIG.multiple_choice} {DEFAULT_EXAM_CONFIG.multiple_choice * DEFAULT_SCORE_CONFIG.multiple_choice}
</Descriptions.Item>
<Descriptions.Item label="多选题">
{DEFAULT_EXAM_CONFIG.multiple_selection} {DEFAULT_SCORE_CONFIG.multiple_selection} {DEFAULT_EXAM_CONFIG.multiple_selection * DEFAULT_SCORE_CONFIG.multiple_selection}
</Descriptions.Item>
<Descriptions.Item label="简答题">
{DEFAULT_EXAM_CONFIG.short_answer}
</Descriptions.Item>
<Descriptions.Item label="论述题">
2 1 {DEFAULT_SCORE_CONFIG.essay}
<br />
<span style={{ fontSize: '13px', color: '#8c8c8c' }}>
1 1
</span>
</Descriptions.Item>
<Descriptions.Item label="总分">100 </Descriptions.Item>
<Descriptions.Item label="建议时间">120 </Descriptions.Item>
</Descriptions>
</Card>
<Card title="温馨提示" className={styles.tipCard}>
<ul>
<li>线</li>
<li></li>
<li>使 AI </li>
<li></li>
<li></li>
</ul>
</Card>
<Space size="large" className={styles.actionButtons}>
<Button
type="primary"
size="large"
icon={<EditOutlined />}
onClick={handleStartOnlineExam}
loading={loading}
className={styles.primaryButton}
>
线
</Button>
<Button
size="large"
icon={<PrinterOutlined />}
onClick={handlePrintExam}
loading={loading}
>
</Button>
</Space>
</Card>
</div>
)
}
export default ExamPrepare

View File

@ -56,7 +56,7 @@ const ExamPrint: React.FC = () => {
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/prepare')
navigate('/exam/management')
return
}
@ -71,11 +71,11 @@ const ExamPrint: React.FC = () => {
setGroupedQuestions(grouped)
} else {
message.error('加载考试失败')
navigate('/exam/prepare')
navigate('/exam/management')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载考试失败')
navigate('/exam/prepare')
navigate('/exam/management')
} finally {
setLoading(false)
}
@ -392,7 +392,7 @@ const ExamPrint: React.FC = () => {
<div className={`${styles.actionBar} noPrint`}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/exam/new')}
onClick={() => navigate('/exam/management')}
className={styles.backButton}
>

View File

@ -1,468 +0,0 @@
.container {
min-height: 100vh;
padding: 24px 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
@media (min-width: 768px) {
padding: 40px 24px;
}
}
// 加载状态
.loadingContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
// 成绩大卡片
.scoreCard {
max-width: 800px;
margin: 0 auto 24px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: none;
overflow: hidden;
animation: slideDown 0.5s ease-out;
:global(.ant-card-body) {
padding: 40px 32px;
@media (max-width: 768px) {
padding: 24px 20px;
}
}
}
.scoreHeader {
text-align: center;
margin-bottom: 32px;
.trophyIcon {
font-size: 72px;
margin-bottom: 16px;
animation: bounce 1s ease-in-out infinite;
&.passed {
color: #52c41a;
}
&.failed {
color: #ff4d4f;
}
}
.scoreTitle {
margin: 0 !important;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.scoreContent {
text-align: center;
margin-bottom: 32px;
:global(.ant-statistic) {
display: inline-block;
}
:global(.ant-statistic-title) {
margin-bottom: 8px;
color: #595959;
}
}
.scoreBadge {
margin-top: 16px;
text-align: center;
}
.progressSection {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
// 信息卡片
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
max-width: 800px;
margin: 0 auto 24px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border: none;
:global(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
border-radius: 12px 12px 0 0;
:global(.ant-card-head-title) {
font-weight: 600;
font-size: 16px;
}
}
:global(.ant-card-body) {
padding: 24px;
@media (max-width: 768px) {
padding: 16px;
}
}
}
// 题型得分卡片
.typeCard {
text-align: center;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
:global(.ant-card-body) {
padding: 20px 16px;
}
:global(.ant-statistic-title) {
font-size: 14px;
margin-bottom: 8px;
color: #595959;
}
}
.typeInfo {
margin-top: 8px;
font-size: 12px;
}
// 错题列表
.wrongQuestionsCollapse {
background: transparent;
border: none;
:global(.ant-collapse-item) {
margin-bottom: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
background: #ffffff;
&:last-child {
margin-bottom: 0;
}
}
:global(.ant-collapse-header) {
background: #fafafa;
padding: 16px 20px;
font-size: 15px;
border-radius: 8px !important;
&:hover {
background: #f5f5f5;
}
}
:global(.ant-collapse-content) {
border-top: 1px solid #f0f0f0;
}
:global(.ant-collapse-content-box) {
padding: 20px;
}
}
.wrongQuestionHeader {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.wrongIcon {
color: #ff4d4f;
font-size: 18px;
}
@media (max-width: 576px) {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
.wrongQuestionContent {
:global(.ant-typography) {
margin-bottom: 16px;
}
.options {
margin: 16px 0;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.option {
padding: 8px 0;
color: #595959;
line-height: 1.6;
&:not(:last-child) {
border-bottom: 1px solid #f0f0f0;
}
}
}
.answerBlock {
padding: 16px;
background: #fafafa;
border-radius: 8px;
.answerContent {
margin-top: 8px;
}
.wrongAnswer {
color: #ff4d4f;
font-weight: 600;
font-size: 15px;
}
.correctAnswer {
color: #52c41a;
font-weight: 600;
font-size: 15px;
}
}
// AI评分块
.aiGradingBlock {
margin-top: 24px;
padding: 20px;
background: linear-gradient(135deg, #fef3e7 0%, #fef9f3 100%);
border-radius: 8px;
border: 1px solid #ffd591;
:global(.ant-divider) {
margin: 16px 0;
border-color: #ffd591;
}
:global(.ant-divider-inner-text) {
font-weight: 600;
color: #fa8c16;
}
:global(.ant-typography) {
margin-bottom: 8px;
}
}
.messageBlock {
margin-top: 16px;
padding: 12px 16px;
background: #f0f0f0;
border-radius: 6px;
}
// 操作按钮区域
.actionCard {
:global(.ant-card-body) {
padding: 32px 24px;
text-align: center;
}
}
.actionButtons {
display: flex;
justify-content: center;
width: 100%;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
:global(.ant-btn) {
width: 100%;
}
}
:global(.ant-btn-lg) {
height: 48px;
font-size: 16px;
font-weight: 500;
border-radius: 24px;
min-width: 180px;
@media (max-width: 768px) {
min-width: auto;
}
}
:global(.ant-btn-primary) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
&:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5);
transform: translateY(-2px);
}
}
:global(.ant-btn:not(.ant-btn-primary)) {
&:hover {
border-color: #667eea;
color: #667eea;
}
}
}
// 动画
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
// 响应式优化
@media (max-width: 768px) {
.container {
padding: 16px 12px;
}
.scoreCard {
border-radius: 12px;
:global(.ant-card-body) {
padding: 24px 16px;
}
.scoreHeader {
margin-bottom: 24px;
.trophyIcon {
font-size: 56px;
}
.scoreTitle {
font-size: 24px !important;
}
}
.scoreContent {
:global(.ant-statistic-content-value) {
font-size: 48px !important;
}
}
.progressSection {
margin-top: 24px;
padding-top: 20px;
}
}
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
border-radius: 8px;
}
.wrongQuestionsCollapse {
:global(.ant-collapse-header) {
padding: 12px 16px;
font-size: 14px;
}
:global(.ant-collapse-content-box) {
padding: 16px;
}
}
.answerBlock {
padding: 12px;
font-size: 14px;
}
.aiGradingBlock {
padding: 16px;
font-size: 14px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.container {
padding: 32px 24px;
}
.scoreCard,
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
max-width: 900px;
}
}
@media (min-width: 1025px) {
.container {
padding: 48px 32px;
}
.scoreCard,
.infoCard,
.detailCard,
.wrongQuestionsCard,
.actionCard {
max-width: 1000px;
}
.scoreCard {
:global(.ant-card-body) {
padding: 48px 40px;
}
}
.scoreHeader {
.trophyIcon {
font-size: 80px;
}
}
.actionCard {
:global(.ant-card-body) {
padding: 40px 32px;
}
}
}

View File

@ -1,590 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import {
Card,
Button,
Typography,
Descriptions,
Statistic,
Progress,
Space,
Collapse,
Tag,
Spin,
message,
Row,
Col,
Divider,
} from 'antd'
import {
TrophyOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
FileTextOutlined,
RedoOutlined,
HomeOutlined,
WarningOutlined,
} from '@ant-design/icons'
import * as examApi from '../api/exam'
import type { GetExamResponse, SubmitExamResponse } from '../types/exam'
import type { Question, QuestionType } from '../types/question'
import styles from './ExamResult.module.less'
const { Title, Paragraph, Text } = Typography
const { Panel } = Collapse
// 题型名称映射
const TYPE_NAME: Record<QuestionType, string> = {
'fill-in-blank': '填空题',
'true-false': '判断题',
'multiple-choice': '单选题',
'multiple-selection': '多选题',
'short-answer': '简答题',
'ordinary-essay': '论述题(普通)',
'management-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,
}
interface DetailedResult {
correct: boolean
score: number
message?: string
ai_grading?: {
score: number
feedback: string
suggestion: string
}
}
const ExamResult: React.FC = () => {
const { examId } = useParams<{ examId: string }>()
const navigate = useNavigate()
const location = useLocation()
const [loading, setLoading] = useState(false)
const [examData, setExamData] = useState<GetExamResponse | null>(null)
const [submitResult, setSubmitResult] = useState<SubmitExamResponse | null>(null)
// 从导航 state 获取提交结果,或从 API 加载
useEffect(() => {
if (!examId) {
message.error('考试ID不存在')
navigate('/exam/new')
return
}
// 如果从提交页面跳转过来,会带有 submitResult
const stateResult = location.state?.submitResult as SubmitExamResponse | undefined
if (stateResult) {
setSubmitResult(stateResult)
}
// 加载考试详情(包含题目信息)
const loadExamData = async () => {
setLoading(true)
try {
// showAnswer=true 可以获取正确答案
const res = await examApi.getExam(Number(examId), true)
if (res.success && res.data) {
setExamData(res.data)
// 如果没有从 state 获取到结果,提示用户
if (!stateResult) {
// 检查考试是否已提交
if (res.data.exam.status !== 'submitted') {
message.warning('考试尚未提交,请先完成考试')
navigate(`/exam/${examId}/online`)
return
}
// 由于详细结果仅在提交时返回直接访问URL无法获取详细评分
// 我们只显示总分,建议用户查看试卷答案
message.info('详细评分仅在提交考试时显示,您可以查看试卷答案')
}
} else {
message.error('加载考试详情失败')
navigate('/exam/new')
}
} catch (error: any) {
message.error(error.response?.data?.message || '加载失败')
navigate('/exam/new')
} finally {
setLoading(false)
}
}
loadExamData()
}, [examId, navigate, location.state])
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 格式化答案显示
const formatAnswer = (answer: any): string => {
if (answer === null || answer === undefined) return '-'
if (Array.isArray(answer)) {
return answer.join(', ')
}
if (typeof answer === 'boolean') {
return answer ? '正确' : '错误'
}
return String(answer)
}
// 计算统计数据
const calculateStats = () => {
if (!submitResult || !examData) {
return {
totalQuestions: 0,
correctCount: 0,
wrongCount: 0,
accuracy: 0,
typeScores: {},
wrongQuestions: [],
}
}
const results = submitResult.detailed_results
let correctCount = 0
let wrongCount = 0
const typeScores: Record<string, { correct: number; total: number; score: number }> = {}
const wrongQuestions: Array<{ question: Question; result: DetailedResult }> = []
examData.questions.forEach((question) => {
const result = results[String(question.id)]
if (!result) return
const typeName = TYPE_NAME[question.type]
if (!typeScores[typeName]) {
typeScores[typeName] = { correct: 0, total: 0, score: 0 }
}
typeScores[typeName].total += 1
typeScores[typeName].score += result.score
if (result.correct) {
correctCount += 1
typeScores[typeName].correct += 1
} else {
wrongCount += 1
wrongQuestions.push({ question, result })
}
})
const accuracy = examData.questions.length > 0
? (correctCount / examData.questions.length) * 100
: 0
return {
totalQuestions: examData.questions.length,
correctCount,
wrongCount,
accuracy,
typeScores,
wrongQuestions,
}
}
// 渲染错题详情
const renderWrongQuestion = (question: Question, result: DetailedResult, index: number) => {
// 解析用户答案和正确答案
let userAnswer = '-'
let correctAnswer = '-'
try {
const answers = JSON.parse(examData!.exam.answers)
const userAnswerData = answers.answers?.[String(question.id)]
userAnswer = formatAnswer(userAnswerData)
// 从 question.answer 获取正确答案
correctAnswer = formatAnswer(question.answer)
} catch (e) {
console.error('解析答案失败', e)
}
return (
<Panel
header={
<div className={styles.wrongQuestionHeader}>
<Space>
<CloseCircleOutlined className={styles.wrongIcon} />
<Text strong>{index + 1}. {TYPE_NAME[question.type]}</Text>
</Space>
<Tag color="red">-{result.score} </Tag>
</div>
}
key={question.id}
>
<div className={styles.wrongQuestionContent}>
<Paragraph strong>{question.content}</Paragraph>
{/* 选项(如果有) */}
{question.options && question.options.length > 0 && (
<div className={styles.options}>
{question.options.map((opt) => (
<div key={opt.key} className={styles.option}>
{opt.key}. {opt.value}
</div>
))}
</div>
)}
<Divider />
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<div className={styles.answerBlock}>
<Text type="secondary"></Text>
<div className={styles.answerContent}>
<Text className={styles.wrongAnswer}>{userAnswer}</Text>
</div>
</div>
</Col>
<Col xs={24} md={12}>
<div className={styles.answerBlock}>
<Text type="secondary"></Text>
<div className={styles.answerContent}>
<Text className={styles.correctAnswer}>{correctAnswer}</Text>
</div>
</div>
</Col>
</Row>
{/* AI评分反馈 */}
{result.ai_grading && (
<div className={styles.aiGradingBlock}>
<Divider>AI </Divider>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text strong></Text>
<Text type="warning" style={{ fontSize: '16px' }}>
{result.ai_grading.score}
</Text>
</div>
{result.ai_grading.feedback && (
<div>
<Text strong></Text>
<Paragraph>{result.ai_grading.feedback}</Paragraph>
</div>
)}
{result.ai_grading.suggestion && (
<div>
<Text strong></Text>
<Paragraph type="secondary">{result.ai_grading.suggestion}</Paragraph>
</div>
)}
</Space>
</div>
)}
{/* 其他提示信息 */}
{result.message && !result.ai_grading && (
<div className={styles.messageBlock}>
<Text type="secondary">{result.message}</Text>
</div>
)}
</div>
</Panel>
)
}
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" />
<Text style={{ marginTop: 16 }}>...</Text>
</div>
)
}
if (!examData) {
return (
<div className={styles.loadingContainer}>
<WarningOutlined style={{ fontSize: 48, color: '#faad14', marginBottom: 16 }} />
<Text></Text>
<Button type="primary" onClick={() => navigate('/exam/new')} style={{ marginTop: 16 }}>
</Button>
</div>
)
}
// 如果没有详细结果,只显示总分
if (!submitResult) {
const score = examData.exam.score
const isPassed = score >= 60
return (
<div className={styles.container}>
{/* 简化的成绩卡片 */}
<Card className={styles.scoreCard}>
<div className={styles.scoreHeader}>
<TrophyOutlined className={`${styles.trophyIcon} ${isPassed ? styles.passed : styles.failed}`} />
<Title level={2} className={styles.scoreTitle}>
{isPassed ? '恭喜通过!' : '继续加油!'}
</Title>
</div>
<div className={styles.scoreContent}>
<Statistic
title={<span style={{ fontSize: '18px' }}></span>}
value={score}
suffix="/ 100"
valueStyle={{
color: isPassed ? '#52c41a' : '#ff4d4f',
fontSize: '64px',
fontWeight: 700,
}}
/>
<div className={styles.scoreBadge}>
{isPassed ? (
<Tag color="success" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CheckCircleOutlined />
</Tag>
) : (
<Tag color="error" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CloseCircleOutlined />
</Tag>
)}
</div>
</div>
</Card>
{/* 考试信息 */}
<Card title="考试信息" className={styles.infoCard}>
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
<Descriptions.Item label="创建时间">
{formatTime(examData.exam.created_at)}
</Descriptions.Item>
<Descriptions.Item label="提交时间">
{formatTime(examData.exam.submitted_at)}
</Descriptions.Item>
<Descriptions.Item label="题目总数">
{examData.questions.length}
</Descriptions.Item>
<Descriptions.Item label="总分">
<Text style={{ fontWeight: 600, color: isPassed ? '#52c41a' : '#ff4d4f' }}>
{score}
</Text>
</Descriptions.Item>
</Descriptions>
<Paragraph type="secondary" style={{ marginTop: 16, marginBottom: 0 }}>
</Paragraph>
</Card>
{/* 操作按钮 */}
<Card className={styles.actionCard}>
<Space size="large" wrap className={styles.actionButtons}>
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate(`/exam/${examId}/print?show_answer=true`)}
>
</Button>
<Button
size="large"
icon={<RedoOutlined />}
onClick={() => navigate('/exam/new')}
>
</Button>
<Button
size="large"
icon={<HomeOutlined />}
onClick={() => navigate('/')}
>
</Button>
</Space>
</Card>
</div>
)
}
const stats = calculateStats()
const isPassed = submitResult.score >= 60
return (
<div className={styles.container}>
{/* 成绩大卡片 */}
<Card className={styles.scoreCard}>
<div className={styles.scoreHeader}>
<TrophyOutlined className={`${styles.trophyIcon} ${isPassed ? styles.passed : styles.failed}`} />
<Title level={2} className={styles.scoreTitle}>
{isPassed ? '恭喜通过!' : '继续加油!'}
</Title>
</div>
<div className={styles.scoreContent}>
<Statistic
title={<span style={{ fontSize: '18px' }}></span>}
value={submitResult.score}
suffix="/ 100"
valueStyle={{
color: isPassed ? '#52c41a' : '#ff4d4f',
fontSize: '64px',
fontWeight: 700,
}}
/>
<div className={styles.scoreBadge}>
{isPassed ? (
<Tag color="success" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CheckCircleOutlined />
</Tag>
) : (
<Tag color="error" style={{ fontSize: '16px', padding: '8px 16px' }}>
<CloseCircleOutlined />
</Tag>
)}
</div>
</div>
{/* 正确率进度条 */}
<div className={styles.progressSection}>
<Text strong style={{ fontSize: '16px', marginBottom: 8, display: 'block' }}>
</Text>
<Progress
percent={Math.round(stats.accuracy)}
strokeColor={{
'0%': isPassed ? '#87d068' : '#ff7875',
'100%': isPassed ? '#52c41a' : '#ff4d4f',
}}
strokeWidth={12}
format={(percent) => (
<span style={{ fontSize: '16px', fontWeight: 600 }}>
{percent}%
</span>
)}
/>
</div>
</Card>
{/* 考试信息 */}
<Card title="考试信息" className={styles.infoCard}>
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
<Descriptions.Item label="创建时间">
{formatTime(examData.exam.created_at)}
</Descriptions.Item>
<Descriptions.Item label="提交时间">
{formatTime(examData.exam.submitted_at)}
</Descriptions.Item>
<Descriptions.Item label="题目总数">
{stats.totalQuestions}
</Descriptions.Item>
<Descriptions.Item label="答对题数">
<Text style={{ color: '#52c41a', fontWeight: 600 }}>
{stats.correctCount}
</Text>
</Descriptions.Item>
<Descriptions.Item label="答错题数">
<Text style={{ color: '#ff4d4f', fontWeight: 600 }}>
{stats.wrongCount}
</Text>
</Descriptions.Item>
<Descriptions.Item label="正确率">
<Text style={{ fontWeight: 600 }}>
{stats.accuracy.toFixed(1)}%
</Text>
</Descriptions.Item>
</Descriptions>
</Card>
{/* 各题型得分明细 */}
<Card title="各题型得分明细" className={styles.detailCard}>
<Row gutter={[16, 16]}>
{Object.entries(stats.typeScores).map(([typeName, data]) => (
<Col xs={12} sm={8} md={6} key={typeName}>
<Card className={styles.typeCard}>
<Statistic
title={typeName}
value={data.score}
suffix="分"
valueStyle={{ fontSize: '24px', fontWeight: 600 }}
/>
<div className={styles.typeInfo}>
<Text type="secondary">
{data.correct}/{data.total}
</Text>
</div>
</Card>
</Col>
))}
</Row>
</Card>
{/* 错题列表 */}
{stats.wrongQuestions.length > 0 && (
<Card
title={
<Space>
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
<span>{stats.wrongQuestions.length} </span>
</Space>
}
className={styles.wrongQuestionsCard}
>
<Collapse accordion className={styles.wrongQuestionsCollapse}>
{stats.wrongQuestions
.sort((a, b) => TYPE_ORDER[a.question.type] - TYPE_ORDER[b.question.type])
.map(({ question, result }, index) =>
renderWrongQuestion(question, result, index)
)}
</Collapse>
</Card>
)}
{/* 操作按钮 */}
<Card className={styles.actionCard}>
<Space size="large" wrap className={styles.actionButtons}>
<Button
type="primary"
size="large"
icon={<FileTextOutlined />}
onClick={() => navigate(`/exam/${examId}/print?show_answer=true`)}
>
</Button>
<Button
size="large"
icon={<RedoOutlined />}
onClick={() => navigate('/exam/new')}
>
</Button>
<Button
size="large"
icon={<HomeOutlined />}
onClick={() => navigate('/')}
>
</Button>
</Space>
</Card>
</div>
)
}
export default ExamResult