优化模拟考试答案查看页面的显示效果
主要改进: 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:
parent
48730369e5
commit
c4a8b28abe
@ -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)
|
||||
// 根据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 获取考试记录详情
|
||||
|
||||
405
internal/services/baidu_ai_grading.go
Normal file
405
internal/services/baidu_ai_grading.go
Normal 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
|
||||
}
|
||||
@ -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={
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
378
web/src/pages/ExamAnswerView.module.less
Normal file
378
web/src/pages/ExamAnswerView.module.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
317
web/src/pages/ExamAnswerView.tsx
Normal file
317
web/src/pages/ExamAnswerView.tsx
Normal 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
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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}
|
||||
>
|
||||
返回
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user