From 52fff11f07b494ab84b60807bc7ba6b56abe50c7 Mon Sep 17 00:00:00 2001 From: yanlongqi Date: Sat, 8 Nov 2025 20:45:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E8=80=83=E8=AF=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 后端实现 - 添加考试数据模型 (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 --- internal/database/database.go | 1 + internal/handlers/exam_handler.go | 421 ++++++++++++++++++ internal/models/exam.go | 62 +++ main.go | 5 + web/src/App.tsx | 10 + web/src/api/exam.ts | 25 ++ web/src/pages/ExamOnline.module.less | 391 +++++++++++++++++ web/src/pages/ExamOnline.tsx | 543 ++++++++++++++++++++++++ web/src/pages/ExamPrepare.module.less | 86 ++++ web/src/pages/ExamPrepare.tsx | 151 +++++++ web/src/pages/ExamPrint.module.less | 514 ++++++++++++++++++++++ web/src/pages/ExamPrint.tsx | 475 +++++++++++++++++++++ web/src/pages/ExamResult.module.less | 468 ++++++++++++++++++++ web/src/pages/ExamResult.tsx | 590 ++++++++++++++++++++++++++ web/src/pages/Home.tsx | 24 ++ web/src/types/exam.ts | 87 ++++ 16 files changed, 3853 insertions(+) create mode 100644 internal/handlers/exam_handler.go create mode 100644 internal/models/exam.go create mode 100644 web/src/api/exam.ts create mode 100644 web/src/pages/ExamOnline.module.less create mode 100644 web/src/pages/ExamOnline.tsx create mode 100644 web/src/pages/ExamPrepare.module.less create mode 100644 web/src/pages/ExamPrepare.tsx create mode 100644 web/src/pages/ExamPrint.module.less create mode 100644 web/src/pages/ExamPrint.tsx create mode 100644 web/src/pages/ExamResult.module.less create mode 100644 web/src/pages/ExamResult.tsx create mode 100644 web/src/types/exam.ts diff --git a/internal/database/database.go b/internal/database/database.go index de945de..e92b84e 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -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) diff --git a/internal/handlers/exam_handler.go b/internal/handlers/exam_handler.go new file mode 100644 index 0000000..a9ed86c --- /dev/null +++ b/internal/handlers/exam_handler.go @@ -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, + }, + }) +} diff --git a/internal/models/exam.go b/internal/models/exam.go new file mode 100644 index 0000000..dd3b87f --- /dev/null +++ b/internal/models/exam.go @@ -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分 +} diff --git a/main.go b/main.go index fc31ef6..fe213b8 100644 --- a/main.go +++ b/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(需要管理员权限) diff --git a/web/src/App.tsx b/web/src/App.tsx index 8be9703..59f5ce3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () => { } /> } /> + {/* 考试相关页面,需要登录保护 */} + } /> + } /> + } /> + } /> + {/* 题库管理页面,需要管理员权限 */} diff --git a/web/src/api/exam.ts b/web/src/api/exam.ts new file mode 100644 index 0000000..5006af7 --- /dev/null +++ b/web/src/api/exam.ts @@ -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>('/exam/generate') +} + +// 获取考试详情 +export const getExam = (examId: number, showAnswer?: boolean) => { + return request.get>(`/exam/${examId}`, { + params: { show_answer: showAnswer }, + }) +} + +// 提交考试 +export const submitExam = (examId: number, data: SubmitExamRequest) => { + return request.post>(`/exam/${examId}/submit`, data) +} diff --git a/web/src/pages/ExamOnline.module.less b/web/src/pages/ExamOnline.module.less new file mode 100644 index 0000000..43942b2 --- /dev/null +++ b/web/src/pages/ExamOnline.module.less @@ -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; + } +} diff --git a/web/src/pages/ExamOnline.tsx b/web/src/pages/ExamOnline.tsx new file mode 100644 index 0000000..89c2ba9 --- /dev/null +++ b/web/src/pages/ExamOnline.tsx @@ -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 = { + '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 = { + '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(null) + const [groupedQuestions, setGroupedQuestions] = useState< + Record + >({}) + 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 = {} + 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: , + content: '提交后将无法修改答案,确定要提交吗?', + okText: '确定提交', + cancelText: '再检查一下', + onOk: async () => { + setSubmitting(true) + try { + const values = form.getFieldsValue() + const answers: Record = {} + + // 转换答案格式 + 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 ( + +
+ + {index + 1}. {question.content} + +
+ + + {Array.from({ length: answerCount }).map((_, i) => ( + { + const value = form.getFieldValue(`question_${question.id}`) || [] + value[i] = e.target.value + form.setFieldValue(`question_${question.id}`, value) + }} + /> + ))} + + +
+ ) + } + + // 渲染判断题 + const renderTrueFalse = (question: Question, index: number) => { + return ( + +
+ + {index + 1}. {question.content} + +
+ + + + 正确 + 错误 + + + +
+ ) + } + + // 渲染单选题 + const renderMultipleChoice = (question: Question, index: number) => { + return ( + +
+ + {index + 1}. {question.content} + +
+ + + + {question.options.map((opt) => ( + + {opt.key}. {opt.value} + + ))} + + + +
+ ) + } + + // 渲染多选题 + const renderMultipleSelection = (question: Question, index: number) => { + return ( + +
+ + {index + 1}. {question.content} + +
+ + + + {question.options.map((opt) => ( + + {opt.key}. {opt.value} + + ))} + + + +
+ ) + } + + // 渲染简答题 + const renderShortAnswer = (question: Question, index: number) => { + return ( + +
+ + {index + 1}. {question.content} + + + (仅供参考,不计分) + +
+ +