feat: 实现模拟考试功能
## 后端实现 - 添加考试数据模型 (Exam) - 实现考试生成API (/api/exam/generate) - 实现获取考试详情API (/api/exam/:id) - 实现提交考试API (/api/exam/:id/submit) - 支持按题型随机抽取题目 - AI智能评分(简答题和论述题) - 自动计算总分和详细评分 ## 前端实现 - 首页添加"模拟考试"入口 - 考试准备页:显示考试说明,选择在线/打印模式 - 在线答题页:按题型分组显示,支持论述题二选一 - 试卷打印页:A4排版,支持打印试卷/答案 - 成绩报告页:显示总分、详细评分、错题分析 ## 核心特性 - 随机组卷:填空10题、判断10题、单选10题、多选10题、简答2题、论述题2选1 - 智能评分:使用AI评分论述题,给出分数、评语和建议 - 答题进度保存:使用localStorage防止刷新丢失 - 打印优化:A4纸张、黑白打印、合理排版 - 响应式设计:适配移动端、平板和PC端 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
77bd709613
commit
52fff11f07
@ -37,6 +37,7 @@ func InitDB() error {
|
||||
&models.WrongQuestionHistory{}, // 错题历史表
|
||||
&models.WrongQuestionTag{}, // 错题标签表
|
||||
&models.UserAnswerRecord{}, // 用户答题记录表
|
||||
&models.Exam{}, // 考试表
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
|
||||
421
internal/handlers/exam_handler.go
Normal file
421
internal/handlers/exam_handler.go
Normal file
@ -0,0 +1,421 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"ankao/internal/services"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GenerateExam 生成考试
|
||||
func GenerateExam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 使用默认配置
|
||||
config := models.DefaultExamConfig
|
||||
|
||||
// 按题型随机抽取题目
|
||||
questionsByType := make(map[string][]models.PracticeQuestion)
|
||||
questionTypes := []struct {
|
||||
Type string
|
||||
Count int
|
||||
}{
|
||||
{"fill-in-blank", config.FillInBlank},
|
||||
{"true-false", config.TrueFalse},
|
||||
{"multiple-choice", config.MultipleChoice},
|
||||
{"multiple-selection", config.MultipleSelection},
|
||||
{"short-answer", config.ShortAnswer},
|
||||
{"ordinary-essay", config.OrdinaryEssay},
|
||||
{"management-essay", config.ManagementEssay},
|
||||
}
|
||||
|
||||
var allQuestionIDs []uint
|
||||
for _, qt := range questionTypes {
|
||||
var questions []models.PracticeQuestion
|
||||
if err := db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil {
|
||||
log.Printf("查询题目失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查题目数量是否足够
|
||||
if len(questions) < qt.Count {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qt.Type, qt.Count, len(questions)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 随机抽取 (Fisher-Yates 洗牌算法)
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := len(questions) - 1; i > 0; i-- {
|
||||
j := rand.Intn(i + 1)
|
||||
questions[i], questions[j] = questions[j], questions[i]
|
||||
}
|
||||
selectedQuestions := questions[:qt.Count]
|
||||
questionsByType[qt.Type] = selectedQuestions
|
||||
|
||||
// 收集题目ID
|
||||
for _, q := range selectedQuestions {
|
||||
allQuestionIDs = append(allQuestionIDs, q.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 将题目ID列表转为JSON
|
||||
questionIDsJSON, err := json.Marshal(allQuestionIDs)
|
||||
if err != nil {
|
||||
log.Printf("序列化题目ID失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成考试失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建考试记录
|
||||
exam := models.Exam{
|
||||
UserID: userID.(uint),
|
||||
QuestionIDs: string(questionIDsJSON),
|
||||
Status: "draft",
|
||||
}
|
||||
|
||||
if err := db.Create(&exam).Error; err != nil {
|
||||
log.Printf("创建考试记录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建考试失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回考试信息
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"exam_id": exam.ID,
|
||||
"question_ids": allQuestionIDs,
|
||||
"created_at": exam.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetExam 获取考试详情
|
||||
func GetExam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
examIDStr := c.Param("id")
|
||||
examID, err := strconv.ParseUint(examIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析题目ID列表
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil {
|
||||
log.Printf("解析题目ID失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询题目详情
|
||||
var questions []models.PracticeQuestion
|
||||
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
|
||||
log.Printf("查询题目失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 按原始顺序排序题目
|
||||
questionMap := make(map[uint]models.PracticeQuestion)
|
||||
for _, q := range questions {
|
||||
questionMap[q.ID] = q
|
||||
}
|
||||
orderedQuestions := make([]models.PracticeQuestion, 0, len(questionIDs))
|
||||
for _, id := range questionIDs {
|
||||
if q, ok := questionMap[id]; ok {
|
||||
orderedQuestions = append(orderedQuestions, q)
|
||||
}
|
||||
}
|
||||
|
||||
// 是否显示答案
|
||||
showAnswer := c.Query("show_answer") == "true"
|
||||
if !showAnswer {
|
||||
// 不显示答案时,隐藏答案字段
|
||||
for i := range orderedQuestions {
|
||||
orderedQuestions[i].AnswerData = ""
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"exam": exam,
|
||||
"questions": orderedQuestions,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitExam 提交考试
|
||||
func SubmitExam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
examIDStr := c.Param("id")
|
||||
examID, err := strconv.ParseUint(examIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求体
|
||||
var req struct {
|
||||
Answers map[string]interface{} `json:"answers"` // question_id -> answer
|
||||
EssayChoice string `json:"essay_choice"` // 论述题选择: ordinary 或 management
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已提交
|
||||
if exam.Status == "submitted" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已提交"})
|
||||
return
|
||||
}
|
||||
|
||||
// 解析题目ID列表
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil {
|
||||
log.Printf("解析题目ID失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询题目详情
|
||||
var questions []models.PracticeQuestion
|
||||
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
|
||||
log.Printf("查询题目失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 评分
|
||||
totalScore := 0.0
|
||||
scoreConfig := models.DefaultScoreConfig
|
||||
aiService := services.NewAIGradingService()
|
||||
detailedResults := make(map[string]interface{})
|
||||
|
||||
for _, question := range questions {
|
||||
questionIDStr := fmt.Sprintf("%d", question.ID)
|
||||
userAnswerRaw, answered := req.Answers[questionIDStr]
|
||||
if !answered {
|
||||
detailedResults[questionIDStr] = gin.H{
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"message": "未作答",
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 论述题特殊处理:根据用户选择判断是否计分
|
||||
if question.Type == "ordinary-essay" && req.EssayChoice != "ordinary" {
|
||||
detailedResults[questionIDStr] = gin.H{
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"message": "未选择此题",
|
||||
}
|
||||
continue
|
||||
}
|
||||
if question.Type == "management-essay" && req.EssayChoice != "management" {
|
||||
detailedResults[questionIDStr] = gin.H{
|
||||
"correct": false,
|
||||
"score": 0,
|
||||
"message": "未选择此题",
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据题型判断答案
|
||||
var isCorrect bool
|
||||
var score float64
|
||||
var aiGrading *models.AIGrading
|
||||
|
||||
switch question.Type {
|
||||
case "fill-in-blank":
|
||||
// 填空题:比较数组
|
||||
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||
if !ok {
|
||||
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"}
|
||||
continue
|
||||
}
|
||||
var correctAnswers []string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
|
||||
log.Printf("解析填空题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
isCorrect = true
|
||||
for i, ua := range userAnswerArr {
|
||||
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
|
||||
isCorrect = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isCorrect {
|
||||
score = scoreConfig.FillInBlank
|
||||
}
|
||||
|
||||
case "true-false":
|
||||
// 判断题
|
||||
var correctAnswer string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
|
||||
log.Printf("解析判断题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
|
||||
if isCorrect {
|
||||
score = scoreConfig.TrueFalse
|
||||
}
|
||||
|
||||
case "multiple-choice":
|
||||
// 单选题
|
||||
var correctAnswer string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
|
||||
log.Printf("解析单选题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
|
||||
if isCorrect {
|
||||
score = scoreConfig.MultipleChoice
|
||||
}
|
||||
|
||||
case "multiple-selection":
|
||||
// 多选题:比较数组(顺序无关)
|
||||
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||
if !ok {
|
||||
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"}
|
||||
continue
|
||||
}
|
||||
var correctAnswers []string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
|
||||
log.Printf("解析多选题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
userAnswerSet := make(map[string]bool)
|
||||
for _, ua := range userAnswerArr {
|
||||
userAnswerSet[fmt.Sprintf("%v", ua)] = true
|
||||
}
|
||||
isCorrect = len(userAnswerSet) == len(correctAnswers)
|
||||
if isCorrect {
|
||||
for _, ca := range correctAnswers {
|
||||
if !userAnswerSet[ca] {
|
||||
isCorrect = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if isCorrect {
|
||||
score = scoreConfig.MultipleSelection
|
||||
}
|
||||
|
||||
case "short-answer", "ordinary-essay", "management-essay":
|
||||
// 简答题和论述题:使用AI评分
|
||||
var correctAnswer string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
|
||||
log.Printf("解析简答题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
|
||||
aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
|
||||
if err != nil {
|
||||
log.Printf("AI评分失败: %v", err)
|
||||
// AI评分失败时,给一个保守的分数
|
||||
isCorrect = false
|
||||
score = 0
|
||||
} else {
|
||||
isCorrect = aiResult.IsCorrect
|
||||
if question.Type == "short-answer" {
|
||||
// 简答题不计分,仅供参考
|
||||
score = 0
|
||||
} else {
|
||||
// 论述题按AI评分比例计算
|
||||
score = scoreConfig.Essay * (aiResult.Score / 100.0)
|
||||
}
|
||||
aiGrading = &models.AIGrading{
|
||||
Score: aiResult.Score,
|
||||
Feedback: aiResult.Feedback,
|
||||
Suggestion: aiResult.Suggestion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalScore += score
|
||||
detailedResults[questionIDStr] = gin.H{
|
||||
"correct": isCorrect,
|
||||
"score": score,
|
||||
"ai_grading": aiGrading,
|
||||
}
|
||||
}
|
||||
|
||||
// 保存答案和分数
|
||||
answersJSON, err := json.Marshal(req.Answers)
|
||||
if err != nil {
|
||||
log.Printf("序列化答案失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
exam.Answers = string(answersJSON)
|
||||
exam.Score = totalScore
|
||||
exam.Status = "submitted"
|
||||
exam.SubmittedAt = &now
|
||||
|
||||
if err := db.Save(&exam).Error; err != nil {
|
||||
log.Printf("保存考试记录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存考试失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"score": totalScore,
|
||||
"detailed_results": detailedResults,
|
||||
},
|
||||
})
|
||||
}
|
||||
62
internal/models/exam.go
Normal file
62
internal/models/exam.go
Normal file
@ -0,0 +1,62 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Exam 考试记录
|
||||
type Exam struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
UserID uint `json:"user_id"` // 用户ID
|
||||
QuestionIDs string `gorm:"type:text" json:"question_ids"` // 题目ID列表(JSON数组)
|
||||
Answers string `gorm:"type:text" json:"answers"` // 用户答案(JSON对象)
|
||||
Score float64 `json:"score"` // 总分
|
||||
Status string `gorm:"default:'draft'" json:"status"` // 状态: draft/submitted
|
||||
SubmittedAt *time.Time `json:"submitted_at"` // 提交时间
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// ExamQuestionConfig 考试题目配置
|
||||
type ExamQuestionConfig struct {
|
||||
FillInBlank int `json:"fill_in_blank"` // 填空题数量
|
||||
TrueFalse int `json:"true_false"` // 判断题数量
|
||||
MultipleChoice int `json:"multiple_choice"` // 单选题数量
|
||||
MultipleSelection int `json:"multiple_selection"` // 多选题数量
|
||||
ShortAnswer int `json:"short_answer"` // 简答题数量
|
||||
OrdinaryEssay int `json:"ordinary_essay"` // 普通涉密人员论述题数量
|
||||
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
|
||||
}
|
||||
|
||||
// DefaultExamConfig 默认考试配置
|
||||
var DefaultExamConfig = ExamQuestionConfig{
|
||||
FillInBlank: 10, // 填空题10道
|
||||
TrueFalse: 10, // 判断题10道
|
||||
MultipleChoice: 10, // 单选题10道
|
||||
MultipleSelection: 10, // 多选题10道
|
||||
ShortAnswer: 2, // 简答题2道
|
||||
OrdinaryEssay: 1, // 普通论述题1道
|
||||
ManagementEssay: 1, // 管理论述题1道
|
||||
}
|
||||
|
||||
// ExamScoreConfig 考试分值配置
|
||||
type ExamScoreConfig struct {
|
||||
FillInBlank float64 `json:"fill_in_blank"` // 填空题分值
|
||||
TrueFalse float64 `json:"true_false"` // 判断题分值
|
||||
MultipleChoice float64 `json:"multiple_choice"` // 单选题分值
|
||||
MultipleSelection float64 `json:"multiple_selection"` // 多选题分值
|
||||
Essay float64 `json:"essay"` // 论述题分值
|
||||
}
|
||||
|
||||
// DefaultScoreConfig 默认分值配置
|
||||
var DefaultScoreConfig = ExamScoreConfig{
|
||||
FillInBlank: 2.0, // 填空题每题2分 (共20分)
|
||||
TrueFalse: 2.0, // 判断题每题2分 (共20分)
|
||||
MultipleChoice: 1.0, // 单选题每题1分 (共10分)
|
||||
MultipleSelection: 2.5, // 多选题每题2.5分 (共25分)
|
||||
Essay: 25.0, // 论述题25分
|
||||
}
|
||||
5
main.go
5
main.go
@ -70,6 +70,11 @@ func main() {
|
||||
auth.POST("/wrong-question-tags", handlers.CreateWrongQuestionTag) // 创建标签
|
||||
auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签
|
||||
auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签
|
||||
|
||||
// 考试相关API
|
||||
auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试
|
||||
auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情
|
||||
auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试
|
||||
}
|
||||
|
||||
// 题库管理API(需要管理员权限)
|
||||
|
||||
@ -14,6 +14,10 @@ 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'
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
@ -30,6 +34,12 @@ const App: React.FC = () => {
|
||||
<Route path="/wrong-questions" element={<ProtectedRoute><WrongQuestions /></ProtectedRoute>} />
|
||||
<Route path="/question-list" element={<ProtectedRoute><QuestionList /></ProtectedRoute>} />
|
||||
|
||||
{/* 考试相关页面,需要登录保护 */}
|
||||
<Route path="/exam/new" element={<ProtectedRoute><ExamPrepare /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/result" element={<ProtectedRoute><ExamResult /></ProtectedRoute>} />
|
||||
|
||||
{/* 题库管理页面,需要管理员权限 */}
|
||||
<Route path="/question-management" element={
|
||||
<ProtectedRoute>
|
||||
|
||||
25
web/src/api/exam.ts
Normal file
25
web/src/api/exam.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { request } from '../utils/request'
|
||||
import type {
|
||||
GenerateExamResponse,
|
||||
GetExamResponse,
|
||||
SubmitExamRequest,
|
||||
SubmitExamResponse
|
||||
} from '../types/exam'
|
||||
import type { ApiResponse } from '../types/question'
|
||||
|
||||
// 生成考试
|
||||
export const generateExam = () => {
|
||||
return request.post<ApiResponse<GenerateExamResponse>>('/exam/generate')
|
||||
}
|
||||
|
||||
// 获取考试详情
|
||||
export const getExam = (examId: number, showAnswer?: boolean) => {
|
||||
return request.get<ApiResponse<GetExamResponse>>(`/exam/${examId}`, {
|
||||
params: { show_answer: showAnswer },
|
||||
})
|
||||
}
|
||||
|
||||
// 提交考试
|
||||
export const submitExam = (examId: number, data: SubmitExamRequest) => {
|
||||
return request.post<ApiResponse<SubmitExamResponse>>(`/exam/${examId}/submit`, data)
|
||||
}
|
||||
391
web/src/pages/ExamOnline.module.less
Normal file
391
web/src/pages/ExamOnline.module.less
Normal file
@ -0,0 +1,391 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
padding: 0;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
// 固定顶栏
|
||||
.fixedTopBar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(250, 250, 250, 0.85);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.topBarContent {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
color: #007aff;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
|
||||
&:hover {
|
||||
color: #0051d5;
|
||||
background: rgba(0, 122, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #1d1d1f !important;
|
||||
margin: 0 !important;
|
||||
font-weight: 700;
|
||||
font-size: 18px !important;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
// 考试说明卡片
|
||||
.examInfoCard {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 16px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #6e6e73;
|
||||
line-height: 1.8;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 题目组
|
||||
.questionGroup {
|
||||
margin-bottom: 32px;
|
||||
|
||||
:global(.ant-divider) {
|
||||
margin: 24px 0;
|
||||
border-color: #e5e5ea;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 题目卡片
|
||||
.questionCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
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.1);
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
// 论述题选择区域
|
||||
.essaySection {
|
||||
.essayChoiceCard {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.essayRadioGroup {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.essayOptionCard {
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #007aff;
|
||||
background: rgba(0, 122, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
:global(.ant-radio) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.essayContent {
|
||||
margin-top: 12px;
|
||||
margin-left: 24px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #1d1d1f;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交按钮区域
|
||||
.submitSection {
|
||||
margin-top: 48px;
|
||||
margin-bottom: 48px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
min-width: 200px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 122, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// 表单项样式
|
||||
:global {
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-textarea {
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-radio-wrapper,
|
||||
.ant-checkbox-wrapper {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.ant-radio-checked .ant-radio-inner,
|
||||
.ant-checkbox-checked .ant-checkbox-inner {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.topBarContent {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 12px;
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.examInfoCard,
|
||||
.questionCard {
|
||||
border-radius: 8px;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.essaySection {
|
||||
.essayChoiceCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.essayOptionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.essayContent {
|
||||
margin-left: 20px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submitSection {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 平板
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.topBarContent {
|
||||
padding: 14px 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 24px;
|
||||
padding-top: 75px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 1025px) {
|
||||
.topBarContent {
|
||||
padding: 18px 32px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 32px;
|
||||
padding-top: 85px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
.questionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.questionHeader span {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
543
web/src/pages/ExamOnline.tsx
Normal file
543
web/src/pages/ExamOnline.tsx
Normal file
@ -0,0 +1,543 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Button,
|
||||
Typography,
|
||||
message,
|
||||
Spin,
|
||||
Space,
|
||||
Divider,
|
||||
Modal,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import type { GetExamResponse, SubmitExamRequest } from '../types/exam'
|
||||
import styles from './ExamOnline.module.less'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
// 题型顺序映射
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
'fill-in-blank': 1,
|
||||
'true-false': 2,
|
||||
'multiple-choice': 3,
|
||||
'multiple-selection': 4,
|
||||
'short-answer': 5,
|
||||
'ordinary-essay': 6,
|
||||
'management-essay': 6,
|
||||
}
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '填空题',
|
||||
'true-false': '判断题',
|
||||
'multiple-choice': '单选题',
|
||||
'multiple-selection': '多选题',
|
||||
'short-answer': '简答题',
|
||||
'ordinary-essay': '论述题(普通涉密人员)',
|
||||
'management-essay': '论述题(保密管理人员)',
|
||||
}
|
||||
|
||||
const ExamOnline: React.FC = () => {
|
||||
const { examId } = useParams<{ examId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [examData, setExamData] = useState<GetExamResponse | null>(null)
|
||||
const [groupedQuestions, setGroupedQuestions] = useState<
|
||||
Record<string, Question[]>
|
||||
>({})
|
||||
const [essayChoice, setEssayChoice] = useState<'ordinary' | 'management' | null>(null)
|
||||
|
||||
// 加载考试详情
|
||||
useEffect(() => {
|
||||
if (!examId) {
|
||||
message.error('考试ID不存在')
|
||||
navigate('/exam/prepare')
|
||||
return
|
||||
}
|
||||
|
||||
const loadExam = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await examApi.getExam(Number(examId), false)
|
||||
if (res.success && res.data) {
|
||||
setExamData(res.data)
|
||||
// 按题型分组
|
||||
const grouped = groupQuestionsByType(res.data.questions)
|
||||
setGroupedQuestions(grouped)
|
||||
// 恢复答题进度
|
||||
loadProgress(res.data.questions)
|
||||
} else {
|
||||
message.error('加载考试失败')
|
||||
navigate('/exam/prepare')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载考试失败')
|
||||
navigate('/exam/prepare')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExam()
|
||||
}, [examId, navigate])
|
||||
|
||||
// 按题型分组题目
|
||||
const groupQuestionsByType = (questions: Question[]) => {
|
||||
const grouped: Record<string, Question[]> = {}
|
||||
questions.forEach((q) => {
|
||||
if (!grouped[q.type]) {
|
||||
grouped[q.type] = []
|
||||
}
|
||||
grouped[q.type].push(q)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
// 保存答题进度到 localStorage
|
||||
const saveProgress = () => {
|
||||
if (!examId) return
|
||||
const values = form.getFieldsValue()
|
||||
const progress = {
|
||||
answers: values,
|
||||
essayChoice,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(`exam_progress_${examId}`, JSON.stringify(progress))
|
||||
}
|
||||
|
||||
// 从 localStorage 恢复答题进度
|
||||
const loadProgress = (_questions: Question[]) => {
|
||||
if (!examId) return
|
||||
const saved = localStorage.getItem(`exam_progress_${examId}`)
|
||||
if (saved) {
|
||||
try {
|
||||
const progress = JSON.parse(saved)
|
||||
// 恢复表单值
|
||||
if (progress.answers) {
|
||||
form.setFieldsValue(progress.answers)
|
||||
}
|
||||
// 恢复论述题选择
|
||||
if (progress.essayChoice) {
|
||||
setEssayChoice(progress.essayChoice)
|
||||
}
|
||||
message.success('已恢复上次答题进度')
|
||||
} catch (e) {
|
||||
console.error('恢复进度失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听表单变化,自动保存进度
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
saveProgress()
|
||||
}, 5000) // 每5秒自动保存一次
|
||||
return () => clearInterval(timer)
|
||||
}, [examId, essayChoice])
|
||||
|
||||
// 提交考试
|
||||
const handleSubmit = async () => {
|
||||
// 验证论述题选择
|
||||
if (!essayChoice) {
|
||||
message.warning('请选择要作答的论述题')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
try {
|
||||
await form.validateFields()
|
||||
} catch (error) {
|
||||
message.warning('请完成所有题目的作答')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '提交后将无法修改答案,确定要提交吗?',
|
||||
okText: '确定提交',
|
||||
cancelText: '再检查一下',
|
||||
onOk: async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const values = form.getFieldsValue()
|
||||
const answers: Record<string, any> = {}
|
||||
|
||||
// 转换答案格式
|
||||
Object.keys(values).forEach((key) => {
|
||||
const questionId = key.replace('question_', '')
|
||||
answers[questionId] = values[key]
|
||||
})
|
||||
|
||||
const submitData: SubmitExamRequest = {
|
||||
answers,
|
||||
essay_choice: essayChoice!,
|
||||
}
|
||||
|
||||
const res = await examApi.submitExam(Number(examId), submitData)
|
||||
if (res.success) {
|
||||
message.success('提交成功')
|
||||
// 清除进度
|
||||
localStorage.removeItem(`exam_progress_${examId}`)
|
||||
// 跳转到成绩页,传递提交结果
|
||||
navigate(`/exam/${examId}/result`, {
|
||||
state: { submitResult: res.data }
|
||||
})
|
||||
} else {
|
||||
message.error(res.message || '提交失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '提交失败,请稍后重试')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染填空题
|
||||
const renderFillInBlank = (question: Question, index: number) => {
|
||||
// 获取答案数量(如果有 answer 字段)
|
||||
const answerCount = question.answer
|
||||
? Array.isArray(question.answer)
|
||||
? question.answer.length
|
||||
: 1
|
||||
: 1
|
||||
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请填写答案' }]}
|
||||
initialValue={Array(answerCount).fill('')}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{Array.from({ length: answerCount }).map((_, i) => (
|
||||
<Input
|
||||
key={i}
|
||||
placeholder={`第 ${i + 1} 空`}
|
||||
onChange={(e) => {
|
||||
const value = form.getFieldValue(`question_${question.id}`) || []
|
||||
value[i] = e.target.value
|
||||
form.setFieldValue(`question_${question.id}`, value)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染判断题
|
||||
const renderTrueFalse = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
<Radio value="true">正确</Radio>
|
||||
<Radio value="false">错误</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染单选题
|
||||
const renderMultipleChoice = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
{question.options.map((opt) => (
|
||||
<Radio key={opt.key} value={opt.key}>
|
||||
{opt.key}. {opt.value}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染多选题
|
||||
const renderMultipleSelection = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={`question_${question.id}`}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
>
|
||||
<Checkbox.Group>
|
||||
<Space direction="vertical">
|
||||
{question.options.map((opt) => (
|
||||
<Checkbox key={opt.key} value={opt.key}>
|
||||
{opt.key}. {opt.value}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染简答题
|
||||
const renderShortAnswer = (question: Question, index: number) => {
|
||||
return (
|
||||
<Card className={styles.questionCard} key={question.id}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: '13px', marginLeft: '8px' }}>
|
||||
(仅供参考,不计分)
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item name={`question_${question.id}`}>
|
||||
<TextArea rows={4} placeholder="请输入答案" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染论述题
|
||||
const renderEssay = (questions: Question[]) => {
|
||||
const ordinaryEssay = questions.find((q) => q.type === 'ordinary-essay')
|
||||
const managementEssay = questions.find((q) => q.type === 'management-essay')
|
||||
|
||||
if (!ordinaryEssay || !managementEssay) return null
|
||||
|
||||
return (
|
||||
<div className={styles.essaySection}>
|
||||
<Card className={styles.essayChoiceCard}>
|
||||
<Title level={4}>请选择要作答的论述题(二选一)</Title>
|
||||
<Paragraph type="secondary">
|
||||
以下提供两道论述题,请选择其中一道进行作答
|
||||
</Paragraph>
|
||||
<Radio.Group
|
||||
value={essayChoice}
|
||||
onChange={(e) => setEssayChoice(e.target.value)}
|
||||
className={styles.essayRadioGroup}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card
|
||||
className={`${styles.essayOptionCard} ${
|
||||
essayChoice === 'ordinary' ? styles.selected : ''
|
||||
}`}
|
||||
>
|
||||
<Radio value="ordinary">
|
||||
<Text strong>普通涉密人员论述题</Text>
|
||||
</Radio>
|
||||
<div className={styles.essayContent}>
|
||||
<Paragraph>{ordinaryEssay.content}</Paragraph>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`${styles.essayOptionCard} ${
|
||||
essayChoice === 'management' ? styles.selected : ''
|
||||
}`}
|
||||
>
|
||||
<Radio value="management">
|
||||
<Text strong>保密管理人员论述题</Text>
|
||||
</Radio>
|
||||
<div className={styles.essayContent}>
|
||||
<Paragraph>{managementEssay.content}</Paragraph>
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Card>
|
||||
|
||||
{essayChoice && (
|
||||
<Card className={styles.questionCard}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong>
|
||||
{essayChoice === 'ordinary'
|
||||
? '普通涉密人员论述题'
|
||||
: '保密管理人员论述题'}
|
||||
</Text>
|
||||
</div>
|
||||
<Paragraph>{essayChoice === 'ordinary' ? ordinaryEssay.content : managementEssay.content}</Paragraph>
|
||||
<Form.Item
|
||||
name={`question_${essayChoice === 'ordinary' ? ordinaryEssay.id : managementEssay.id}`}
|
||||
rules={[{ required: true, message: '请完成论述题作答' }]}
|
||||
>
|
||||
<TextArea rows={8} placeholder="请输入您的答案(建议300字以上)" showCount />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染题目组
|
||||
const renderQuestionGroup = (type: string, questions: Question[]) => {
|
||||
let startIndex = 0
|
||||
// 计算该题型的起始序号
|
||||
Object.keys(groupedQuestions)
|
||||
.filter((t) => TYPE_ORDER[t] < TYPE_ORDER[type])
|
||||
.forEach((t) => {
|
||||
startIndex += groupedQuestions[t].length
|
||||
})
|
||||
|
||||
return (
|
||||
<div key={type} className={styles.questionGroup}>
|
||||
<Divider orientation="left">
|
||||
<Title level={3}>{TYPE_NAME[type]}</Title>
|
||||
</Divider>
|
||||
{questions.map((question, index) => {
|
||||
switch (type) {
|
||||
case 'fill-in-blank':
|
||||
return renderFillInBlank(question, startIndex + index)
|
||||
case 'true-false':
|
||||
return renderTrueFalse(question, startIndex + index)
|
||||
case 'multiple-choice':
|
||||
return renderMultipleChoice(question, startIndex + index)
|
||||
case 'multiple-selection':
|
||||
return renderMultipleSelection(question, startIndex + index)
|
||||
case 'short-answer':
|
||||
return renderShortAnswer(question, startIndex + index)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
<Text style={{ marginTop: 16 }}>加载考试中...</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!examData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取论述题
|
||||
const essayQuestions = [
|
||||
...(groupedQuestions['ordinary-essay'] || []),
|
||||
...(groupedQuestions['management-essay'] || []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 固定顶栏 */}
|
||||
<div className={styles.fixedTopBar}>
|
||||
<div className={styles.topBarContent}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/exam/prepare')}
|
||||
className={styles.backButton}
|
||||
type="text"
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={3} className={styles.title}>
|
||||
在线答题
|
||||
</Title>
|
||||
<div style={{ width: 80 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<div className={styles.content}>
|
||||
<Card className={styles.examInfoCard}>
|
||||
<Title level={4}>考试说明</Title>
|
||||
<ul>
|
||||
<li>请仔细阅读每道题目,认真作答</li>
|
||||
<li>论述题需要从两道题中选择一道作答</li>
|
||||
<li>简答题仅供参考,不计入总分</li>
|
||||
<li>答题进度会自动保存,可以放心刷新页面</li>
|
||||
<li>提交后将无法修改答案,请仔细检查后再提交</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 按题型顺序渲染题目 */}
|
||||
{Object.keys(groupedQuestions)
|
||||
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
|
||||
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
|
||||
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
|
||||
|
||||
{/* 渲染论述题(二选一) */}
|
||||
{essayQuestions.length > 0 && (
|
||||
<div className={styles.questionGroup}>
|
||||
<Divider orientation="left">
|
||||
<Title level={3}>论述题(二选一)</Title>
|
||||
</Divider>
|
||||
{renderEssay(essayQuestions)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className={styles.submitSection}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
提交考试
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamOnline
|
||||
86
web/src/pages/ExamPrepare.module.less
Normal file
86
web/src/pages/ExamPrepare.module.less
Normal file
@ -0,0 +1,86 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
151
web/src/pages/ExamPrepare.tsx
Normal file
151
web/src/pages/ExamPrepare.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
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
|
||||
514
web/src/pages/ExamPrint.module.less
Normal file
514
web/src/pages/ExamPrint.module.less
Normal file
@ -0,0 +1,514 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 操作按钮区(打印时隐藏)
|
||||
.actionBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid #e5e5ea;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.backButton {
|
||||
color: #007aff;
|
||||
border-color: #007aff;
|
||||
|
||||
&:hover {
|
||||
color: #0051d5;
|
||||
border-color: #0051d5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
// 打印内容区
|
||||
.printContent {
|
||||
max-width: 210mm; // A4纸宽度
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// 试卷头部
|
||||
.paperHeader {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #1d1d1f;
|
||||
|
||||
.paperTitle {
|
||||
margin: 0 0 16px 0 !important;
|
||||
color: #1d1d1f !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 16px;
|
||||
|
||||
.infoItem {
|
||||
font-size: 16px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 考试说明卡片
|
||||
.instructionCard {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d1d6;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.8;
|
||||
|
||||
li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 题目组
|
||||
.questionGroup {
|
||||
margin-bottom: 32px;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #d1d1d6;
|
||||
|
||||
.groupTitle {
|
||||
font-size: 18px;
|
||||
color: #1d1d1f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.groupScore {
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
color: #6e6e73;
|
||||
}
|
||||
}
|
||||
|
||||
.questionsList {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 题目项
|
||||
.questionItem {
|
||||
margin-bottom: 24px;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.questionContent {
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.8;
|
||||
|
||||
span {
|
||||
font-size: 15px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
.optionsList {
|
||||
margin: 12px 0;
|
||||
padding-left: 20px;
|
||||
|
||||
.optionItem {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
.answerArea {
|
||||
margin-top: 12px;
|
||||
padding: 8px 0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.blankLine {
|
||||
margin-bottom: 8px;
|
||||
line-height: 2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.answerLines {
|
||||
margin-top: 8px;
|
||||
|
||||
.answerLine {
|
||||
line-height: 2;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
|
||||
.essayAnswer {
|
||||
:global(.ant-typography) {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打印样式
|
||||
@media print {
|
||||
// 隐藏不需要打印的元素
|
||||
.noPrint,
|
||||
:global(.noPrint) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// A4纸张设置
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
.paperTitle {
|
||||
font-size: 24pt !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
.infoItem {
|
||||
font-size: 12pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instructionCard {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #000;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
ul {
|
||||
li {
|
||||
font-size: 11pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
margin-bottom: 28px;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.groupHeader {
|
||||
.groupTitle {
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.groupScore {
|
||||
font-size: 12pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
margin-bottom: 20px;
|
||||
page-break-inside: avoid;
|
||||
|
||||
.questionContent {
|
||||
span {
|
||||
font-size: 12pt;
|
||||
}
|
||||
}
|
||||
|
||||
.optionsList {
|
||||
.optionItem {
|
||||
font-size: 11pt;
|
||||
}
|
||||
}
|
||||
|
||||
.answerArea {
|
||||
span {
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.blankLine {
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.answerLines {
|
||||
.answerLine {
|
||||
font-size: 11pt;
|
||||
}
|
||||
}
|
||||
|
||||
.essayAnswer {
|
||||
:global(.ant-typography) {
|
||||
font-size: 11pt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 强制分页
|
||||
.pageBreak {
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
// 避免在题目中间分页
|
||||
.questionItem {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
// 黑白打印优化
|
||||
* {
|
||||
color: #000 !important;
|
||||
background: #fff !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
// 保留边框
|
||||
.paperHeader,
|
||||
.instructionCard,
|
||||
.groupHeader {
|
||||
border-color: #000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 移动端
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
padding: 12px 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
.paperTitle {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.infoItem {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instructionCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
ul {
|
||||
li {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
.groupHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
|
||||
.groupTitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.groupScore {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
.questionContent {
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.optionsList {
|
||||
padding-left: 16px;
|
||||
|
||||
.optionItem {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.answerArea {
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.blankLine {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.answerLines {
|
||||
.answerLine {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.essayAnswer {
|
||||
:global(.ant-typography) {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - 平板
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 190mm;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
.paperTitle {
|
||||
font-size: 26px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
.infoItem {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
.groupHeader {
|
||||
.groupTitle {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
.questionContent {
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计 - PC端
|
||||
@media (min-width: 1025px) {
|
||||
.container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.printContent {
|
||||
max-width: 210mm;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.paperHeader {
|
||||
.paperTitle {
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.examInfo {
|
||||
.infoItem {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
475
web/src/pages/ExamPrint.tsx
Normal file
475
web/src/pages/ExamPrint.tsx
Normal file
@ -0,0 +1,475 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Card, Button, Typography, message, Spin, Space } from 'antd'
|
||||
import { ArrowLeftOutlined, PrinterOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import type { GetExamResponse } from '../types/exam'
|
||||
import styles from './ExamPrint.module.less'
|
||||
|
||||
const { Title, Paragraph, Text } = Typography
|
||||
|
||||
// 题型顺序映射
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
'fill-in-blank': 1,
|
||||
'true-false': 2,
|
||||
'multiple-choice': 3,
|
||||
'multiple-selection': 4,
|
||||
'short-answer': 5,
|
||||
'ordinary-essay': 6,
|
||||
'management-essay': 6,
|
||||
}
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '一、填空题',
|
||||
'true-false': '二、判断题',
|
||||
'multiple-choice': '三、单选题',
|
||||
'multiple-selection': '四、多选题',
|
||||
'short-answer': '五、简答题',
|
||||
'ordinary-essay': '六、论述题',
|
||||
'management-essay': '六、论述题',
|
||||
}
|
||||
|
||||
// 题型分值映射
|
||||
const TYPE_SCORE: Record<string, number> = {
|
||||
'fill-in-blank': 2.0,
|
||||
'true-false': 2.0,
|
||||
'multiple-choice': 1.0,
|
||||
'multiple-selection': 2.5,
|
||||
'short-answer': 0, // 不计分
|
||||
'ordinary-essay': 25.0,
|
||||
'management-essay': 25.0,
|
||||
}
|
||||
|
||||
const ExamPrint: React.FC = () => {
|
||||
const { examId } = useParams<{ examId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const showAnswer = searchParams.get('show_answer') === 'true'
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [examData, setExamData] = useState<GetExamResponse | null>(null)
|
||||
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
|
||||
|
||||
// 加载考试详情
|
||||
useEffect(() => {
|
||||
if (!examId) {
|
||||
message.error('考试ID不存在')
|
||||
navigate('/exam/prepare')
|
||||
return
|
||||
}
|
||||
|
||||
const loadExam = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await examApi.getExam(Number(examId), showAnswer)
|
||||
if (res.success && res.data) {
|
||||
setExamData(res.data)
|
||||
// 按题型分组
|
||||
const grouped = groupQuestionsByType(res.data.questions)
|
||||
setGroupedQuestions(grouped)
|
||||
} else {
|
||||
message.error('加载考试失败')
|
||||
navigate('/exam/prepare')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载考试失败')
|
||||
navigate('/exam/prepare')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExam()
|
||||
}, [examId, showAnswer, navigate])
|
||||
|
||||
// 按题型分组题目
|
||||
const groupQuestionsByType = (questions: Question[]) => {
|
||||
const grouped: Record<string, Question[]> = {}
|
||||
questions.forEach((q) => {
|
||||
if (!grouped[q.type]) {
|
||||
grouped[q.type] = []
|
||||
}
|
||||
grouped[q.type].push(q)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
// 打印试卷(不显示答案)
|
||||
const handlePrintPaper = () => {
|
||||
if (showAnswer) {
|
||||
// 重新加载不显示答案的页面
|
||||
window.location.href = `/exam/${examId}/print?show_answer=false`
|
||||
} else {
|
||||
window.print()
|
||||
}
|
||||
}
|
||||
|
||||
// 打印答案(显示答案)
|
||||
const handlePrintAnswer = () => {
|
||||
if (!showAnswer) {
|
||||
// 重新加载显示答案的页面
|
||||
window.location.href = `/exam/${examId}/print?show_answer=true`
|
||||
} else {
|
||||
window.print()
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化答案显示
|
||||
const formatAnswer = (question: Question): string => {
|
||||
if (!question.answer) return ''
|
||||
|
||||
switch (question.type) {
|
||||
case 'fill-in-blank':
|
||||
if (Array.isArray(question.answer)) {
|
||||
return question.answer.join('、')
|
||||
}
|
||||
return String(question.answer)
|
||||
|
||||
case 'true-false':
|
||||
return question.answer === 'true' || question.answer === true ? '正确' : '错误'
|
||||
|
||||
case 'multiple-choice':
|
||||
return String(question.answer)
|
||||
|
||||
case 'multiple-selection':
|
||||
if (Array.isArray(question.answer)) {
|
||||
return question.answer.sort().join('')
|
||||
}
|
||||
return String(question.answer)
|
||||
|
||||
case 'short-answer':
|
||||
case 'ordinary-essay':
|
||||
case 'management-essay':
|
||||
return String(question.answer)
|
||||
|
||||
default:
|
||||
return String(question.answer)
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染填空题
|
||||
const renderFillInBlank = (question: Question, index: number) => {
|
||||
// 获取答案数量
|
||||
const answerCount = question.answer && Array.isArray(question.answer)
|
||||
? question.answer.length
|
||||
: 1
|
||||
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<Text>答案:{formatAnswer(question)}</Text>
|
||||
) : (
|
||||
<>
|
||||
{Array.from({ length: answerCount }).map((_, i) => (
|
||||
<div key={i} className={styles.blankLine}>
|
||||
第 {i + 1} 空:__________________________________________
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染判断题
|
||||
const renderTrueFalse = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<Text>答案:{formatAnswer(question)}</Text>
|
||||
) : (
|
||||
<Text>答案:____</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染单选题
|
||||
const renderMultipleChoice = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.optionsList}>
|
||||
{question.options.map((opt) => (
|
||||
<div key={opt.key} className={styles.optionItem}>
|
||||
{opt.key}. {opt.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<Text>答案:{formatAnswer(question)}</Text>
|
||||
) : (
|
||||
<Text>答案:____</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染多选题
|
||||
const renderMultipleSelection = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.optionsList}>
|
||||
{question.options.map((opt) => (
|
||||
<div key={opt.key} className={styles.optionItem}>
|
||||
{opt.key}. {opt.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<Text>答案:{formatAnswer(question)}</Text>
|
||||
) : (
|
||||
<Text>答案:____</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染简答题
|
||||
const renderShortAnswer = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
{!showAnswer && (
|
||||
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
|
||||
(仅供参考,不计分)
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<div className={styles.essayAnswer}>
|
||||
<Text strong>参考答案:</Text>
|
||||
<Paragraph>{formatAnswer(question)}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.answerLines}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className={styles.answerLine}>
|
||||
_____________________________________________________________________________
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染论述题
|
||||
const renderEssay = (question: Question, index: number) => {
|
||||
return (
|
||||
<div key={question.id} className={styles.questionItem}>
|
||||
<div className={styles.questionContent}>
|
||||
<Text strong>
|
||||
{index + 1}. {question.content}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.answerArea}>
|
||||
{showAnswer ? (
|
||||
<div className={styles.essayAnswer}>
|
||||
<Text strong>参考答案:</Text>
|
||||
<Paragraph>{formatAnswer(question)}</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.answerLines}>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className={styles.answerLine}>
|
||||
_____________________________________________________________________________
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染题目组
|
||||
const renderQuestionGroup = (type: string, questions: Question[]) => {
|
||||
let startIndex = 0
|
||||
// 计算该题型的起始序号
|
||||
Object.keys(groupedQuestions)
|
||||
.filter((t) => TYPE_ORDER[t] < TYPE_ORDER[type])
|
||||
.forEach((t) => {
|
||||
startIndex += groupedQuestions[t].length
|
||||
})
|
||||
|
||||
// 计算该题型总分
|
||||
const totalScore = questions.length * TYPE_SCORE[type]
|
||||
|
||||
return (
|
||||
<div key={type} className={styles.questionGroup}>
|
||||
<div className={styles.groupHeader}>
|
||||
<Text strong className={styles.groupTitle}>
|
||||
{TYPE_NAME[type]}
|
||||
</Text>
|
||||
{TYPE_SCORE[type] > 0 && (
|
||||
<Text type="secondary" className={styles.groupScore}>
|
||||
(共{questions.length}题,每题{TYPE_SCORE[type]}分,共{totalScore}分)
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.questionsList}>
|
||||
{questions.map((question, index) => {
|
||||
switch (type) {
|
||||
case 'fill-in-blank':
|
||||
return renderFillInBlank(question, startIndex + index)
|
||||
case 'true-false':
|
||||
return renderTrueFalse(question, startIndex + index)
|
||||
case 'multiple-choice':
|
||||
return renderMultipleChoice(question, startIndex + index)
|
||||
case 'multiple-selection':
|
||||
return renderMultipleSelection(question, startIndex + index)
|
||||
case 'short-answer':
|
||||
return renderShortAnswer(question, startIndex + index)
|
||||
case 'ordinary-essay':
|
||||
case 'management-essay':
|
||||
return renderEssay(question, startIndex + index)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
<Text style={{ marginTop: 16 }}>加载考试中...</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!examData) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取论述题(合并普通和管理两类)
|
||||
const essayQuestions = [
|
||||
...(groupedQuestions['ordinary-essay'] || []),
|
||||
...(groupedQuestions['management-essay'] || []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 操作按钮区 - 打印时隐藏 */}
|
||||
<div className={`${styles.actionBar} noPrint`}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/exam/new')}
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={handlePrintPaper}
|
||||
>
|
||||
打印试卷
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={handlePrintAnswer}
|
||||
>
|
||||
打印答案
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 打印内容区 */}
|
||||
<div className={styles.printContent}>
|
||||
{/* 试卷头部 */}
|
||||
<div className={styles.paperHeader}>
|
||||
<Title level={2} className={styles.paperTitle}>
|
||||
保密知识模拟考试{showAnswer ? '(答案)' : ''}
|
||||
</Title>
|
||||
<div className={styles.examInfo}>
|
||||
<div className={styles.infoItem}>
|
||||
姓名:__________________
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
日期:__________________
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 考试说明 */}
|
||||
{!showAnswer && (
|
||||
<Card className={styles.instructionCard}>
|
||||
<Title level={4}>考试说明</Title>
|
||||
<ul>
|
||||
<li>本试卷满分100分,考试时间为90分钟</li>
|
||||
<li>请在答题区域内作答,字迹清晰工整</li>
|
||||
<li>简答题仅供参考,不计入总分</li>
|
||||
<li>论述题从以下2道题目中任选1道作答</li>
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 按题型渲染题目 */}
|
||||
{Object.keys(groupedQuestions)
|
||||
.filter((type) => type !== 'ordinary-essay' && type !== 'management-essay')
|
||||
.sort((a, b) => TYPE_ORDER[a] - TYPE_ORDER[b])
|
||||
.map((type) => renderQuestionGroup(type, groupedQuestions[type]))}
|
||||
|
||||
{/* 论述题部分 */}
|
||||
{essayQuestions.length > 0 && (
|
||||
<div className={styles.questionGroup}>
|
||||
<div className={styles.groupHeader}>
|
||||
<Text strong className={styles.groupTitle}>
|
||||
{TYPE_NAME['ordinary-essay']}
|
||||
</Text>
|
||||
<Text type="secondary" className={styles.groupScore}>
|
||||
(以下2道论述题任选1道作答,共25分)
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.questionsList}>
|
||||
{essayQuestions.map((question, index) => renderEssay(question, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamPrint
|
||||
468
web/src/pages/ExamResult.module.less
Normal file
468
web/src/pages/ExamResult.module.less
Normal file
@ -0,0 +1,468 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
590
web/src/pages/ExamResult.tsx
Normal file
590
web/src/pages/ExamResult.tsx
Normal file
@ -0,0 +1,590 @@
|
||||
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
|
||||
@ -547,6 +547,30 @@ const Home: React.FC = () => {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Card
|
||||
hoverable
|
||||
className={styles.quickCard}
|
||||
onClick={() => navigate('/exam/new')}
|
||||
>
|
||||
<Space align="center" size="middle" style={{ width: '100%' }}>
|
||||
<div
|
||||
className={styles.quickIconWrapper}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%)',
|
||||
borderColor: '#ffd591'
|
||||
}}
|
||||
>
|
||||
<FileTextOutlined className={styles.quickIcon} style={{ color: '#fa8c16' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>模拟考试</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>随机组卷,在线答题或打印试卷</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 仅 yanlongqi 用户显示题库管理 */}
|
||||
{userInfo?.username === 'yanlongqi' && (
|
||||
<>
|
||||
|
||||
87
web/src/types/exam.ts
Normal file
87
web/src/types/exam.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { Question } from './question'
|
||||
|
||||
// 考试记录
|
||||
export interface Exam {
|
||||
id: number
|
||||
user_id: number
|
||||
question_ids: string
|
||||
answers: string
|
||||
score: number
|
||||
status: 'draft' | 'submitted'
|
||||
submitted_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 考试题目配置
|
||||
export interface ExamQuestionConfig {
|
||||
fill_in_blank: number // 填空题数量
|
||||
true_false: number // 判断题数量
|
||||
multiple_choice: number // 单选题数量
|
||||
multiple_selection: number // 多选题数量
|
||||
short_answer: number // 简答题数量
|
||||
ordinary_essay: number // 普通涉密人员论述题数量
|
||||
management_essay: number // 保密管理人员论述题数量
|
||||
}
|
||||
|
||||
// 考试分值配置
|
||||
export interface ExamScoreConfig {
|
||||
fill_in_blank: number // 填空题分值
|
||||
true_false: number // 判断题分值
|
||||
multiple_choice: number // 单选题分值
|
||||
multiple_selection: number // 多选题分值
|
||||
essay: number // 论述题分值
|
||||
}
|
||||
|
||||
// 生成考试响应
|
||||
export interface GenerateExamResponse {
|
||||
exam_id: number
|
||||
question_ids: number[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 获取考试响应
|
||||
export interface GetExamResponse {
|
||||
exam: Exam
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
// 提交考试请求
|
||||
export interface SubmitExamRequest {
|
||||
answers: Record<string, any> // question_id -> answer
|
||||
essay_choice: 'ordinary' | 'management' // 论述题选择
|
||||
}
|
||||
|
||||
// 提交考试响应
|
||||
export interface SubmitExamResponse {
|
||||
score: number
|
||||
detailed_results: Record<string, {
|
||||
correct: boolean
|
||||
score: number
|
||||
message?: string
|
||||
ai_grading?: {
|
||||
score: number
|
||||
feedback: string
|
||||
suggestion: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
export const DEFAULT_EXAM_CONFIG: ExamQuestionConfig = {
|
||||
fill_in_blank: 10,
|
||||
true_false: 10,
|
||||
multiple_choice: 10,
|
||||
multiple_selection: 10,
|
||||
short_answer: 2,
|
||||
ordinary_essay: 1,
|
||||
management_essay: 1,
|
||||
}
|
||||
|
||||
export const DEFAULT_SCORE_CONFIG: ExamScoreConfig = {
|
||||
fill_in_blank: 2.0,
|
||||
true_false: 2.0,
|
||||
multiple_choice: 1.0,
|
||||
multiple_selection: 2.5,
|
||||
essay: 25.0,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user