feat: 实现完整的考试系统
实现了包含试卷管理、考试答题、AI智能阅卷的完整考试流程。 **后端新增功能**: - 试卷管理: 创建试卷、获取试卷列表和详情 - 考试流程: 开始考试、提交答案、查询结果 - AI阅卷: 异步阅卷系统,支持简答题和论述题AI评分 - 实时答题: 题目级别的答案保存和加载 - 数据模型: ExamRecord(考试记录)、ExamUserAnswer(用户答案) **前端新增页面**: - 考试管理页面: 试卷列表展示,支持开始/继续考试 - 答题页面: 左侧题目列表、右侧答题区,支持实时保存 - 成绩查看页面: 展示详细评分结果和AI评语 **技术亮点**: - 按题型固定分值配置(总分100分) - 异步阅卷机制,提交后立即返回 - 答案实时保存,支持断点续答 - AI评分集成,智能评判主观题 - 响应式设计,适配移动端和PC端 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f8ce822436
commit
4c06a8acd5
5
go.mod
5
go.mod
@ -10,6 +10,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
@ -19,8 +20,10 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
@ -48,4 +51,6 @@ require (
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/datatypes v1.2.7 // indirect
|
||||
gorm.io/driver/mysql v1.5.6 // indirect
|
||||
)
|
||||
|
||||
12
go.sum
12
go.sum
@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
@ -23,6 +25,9 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
@ -30,6 +35,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@ -106,7 +113,12 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
||||
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
|
||||
gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8=
|
||||
gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@ -37,7 +37,9 @@ func InitDB() error {
|
||||
&models.WrongQuestionHistory{}, // 错题历史表
|
||||
&models.WrongQuestionTag{}, // 错题标签表
|
||||
&models.UserAnswerRecord{}, // 用户答题记录表
|
||||
&models.Exam{}, // 考试表
|
||||
&models.Exam{}, // 考试表(试卷)
|
||||
&models.ExamRecord{}, // 考试记录表
|
||||
&models.ExamUserAnswer{}, // 用户答案表
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate database: %w", err)
|
||||
|
||||
257
internal/handlers/exam_grading.go
Normal file
257
internal/handlers/exam_grading.go
Normal file
@ -0,0 +1,257 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"ankao/internal/services"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// ReGradeExam 公开的重新阅卷函数,可被外部调用
|
||||
func ReGradeExam(recordID uint, examID uint, userID uint) {
|
||||
gradeExam(recordID, examID, userID)
|
||||
}
|
||||
|
||||
// gradeExam 异步阅卷函数
|
||||
func gradeExam(recordID uint, examID uint, userID uint) {
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ?", recordID).First(&record).Error; err != nil {
|
||||
log.Printf("查询考试记录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询试卷
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
|
||||
log.Printf("查询试卷失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 从 ExamUserAnswer 表读取所有答案
|
||||
var userAnswers []models.ExamUserAnswer
|
||||
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
|
||||
log.Printf("查询用户答案失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 map 格式方便查找
|
||||
answersMap := make(map[uint]interface{})
|
||||
for _, ua := range userAnswers {
|
||||
var answer interface{}
|
||||
if err := json.Unmarshal(ua.Answer, &answer); err != nil {
|
||||
log.Printf("解析答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
answersMap[ua.QuestionID] = answer
|
||||
}
|
||||
|
||||
// 解析题目ID列表
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
|
||||
log.Printf("解析题目ID失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 查询题目详情
|
||||
var questions []models.PracticeQuestion
|
||||
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
|
||||
log.Printf("查询题目失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用固定的题型分值映射
|
||||
scoreMap := map[string]float64{
|
||||
"fill-in-blank": 2.0,
|
||||
"true-false": 2.0,
|
||||
"multiple-choice": 1.0,
|
||||
"multiple-selection": 2.5,
|
||||
"short-answer": 10.0,
|
||||
"ordinary-essay": 5.0,
|
||||
"management-essay": 5.0,
|
||||
}
|
||||
|
||||
// 评分
|
||||
totalScore := 0.0
|
||||
aiService := services.NewAIGradingService()
|
||||
|
||||
for _, question := range questions {
|
||||
userAnswerRaw, answered := answersMap[question.ID]
|
||||
|
||||
if !answered {
|
||||
// 更新数据库中的 ExamUserAnswer 记录为未作答
|
||||
var userAnswer models.ExamUserAnswer
|
||||
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
|
||||
if result.Error == nil {
|
||||
updates := map[string]interface{}{
|
||||
"is_correct": false,
|
||||
"score": 0.0,
|
||||
}
|
||||
db.Model(&userAnswer).Updates(updates)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据题型判断答案
|
||||
var isCorrect bool
|
||||
var score float64
|
||||
var aiGrading *models.AIGrading
|
||||
|
||||
switch question.Type {
|
||||
case "fill-in-blank":
|
||||
// 填空题:比较数组
|
||||
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||
if !ok {
|
||||
isCorrect = false
|
||||
score = 0
|
||||
// 更新数据库
|
||||
var userAnswer models.ExamUserAnswer
|
||||
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
|
||||
db.Model(&userAnswer).Updates(map[string]interface{}{
|
||||
"is_correct": false,
|
||||
"score": 0.0,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
var correctAnswers []string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
|
||||
log.Printf("解析填空题答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
isCorrect = len(userAnswerArr) == len(correctAnswers)
|
||||
if isCorrect {
|
||||
for i, ua := range userAnswerArr {
|
||||
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
|
||||
isCorrect = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if isCorrect {
|
||||
score = scoreMap["fill-in-blank"]
|
||||
}
|
||||
|
||||
case "true-false":
|
||||
// 判断题 - AnswerData 直接存储 "true" 或 "false" 字符串
|
||||
correctAnswer := question.AnswerData
|
||||
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
|
||||
if isCorrect {
|
||||
score = scoreMap["true-false"]
|
||||
}
|
||||
|
||||
case "multiple-choice":
|
||||
correctAnswer := question.AnswerData
|
||||
isCorrect = fmt.Sprintf("\"%v\"", userAnswerRaw) == correctAnswer
|
||||
if isCorrect {
|
||||
score = scoreMap["multiple-choice"]
|
||||
}
|
||||
case "multiple-selection":
|
||||
// 多选题:比较数组(顺序无关)
|
||||
userAnswerArr, ok := userAnswerRaw.([]interface{})
|
||||
if !ok {
|
||||
isCorrect = false
|
||||
score = 0
|
||||
// 更新数据库
|
||||
var userAnswer models.ExamUserAnswer
|
||||
if result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer); result.Error == nil {
|
||||
db.Model(&userAnswer).Updates(map[string]interface{}{
|
||||
"is_correct": false,
|
||||
"score": 0.0,
|
||||
})
|
||||
}
|
||||
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 = scoreMap["multiple-selection"]
|
||||
}
|
||||
|
||||
case "short-answer", "ordinary-essay", "management-essay":
|
||||
// 简答题和论述题:使用AI评分
|
||||
// AnswerData 直接存储答案文本
|
||||
correctAnswer := question.AnswerData
|
||||
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
|
||||
aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
|
||||
if err != nil {
|
||||
log.Printf("AI评分失败: %v", err)
|
||||
isCorrect = false
|
||||
score = 0
|
||||
} else {
|
||||
isCorrect = aiResult.IsCorrect
|
||||
// 按AI评分比例计算
|
||||
var questionScore float64
|
||||
if question.Type == "short-answer" {
|
||||
questionScore = scoreMap["short-answer"]
|
||||
} else if question.Type == "ordinary-essay" {
|
||||
questionScore = scoreMap["ordinary-essay"]
|
||||
} else if question.Type == "management-essay" {
|
||||
questionScore = scoreMap["management-essay"]
|
||||
}
|
||||
score = questionScore * (aiResult.Score / 100.0)
|
||||
aiGrading = &models.AIGrading{
|
||||
Score: aiResult.Score,
|
||||
Feedback: aiResult.Feedback,
|
||||
Suggestion: aiResult.Suggestion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalScore += score
|
||||
|
||||
// 更新数据库中的 ExamUserAnswer 记录
|
||||
var userAnswer models.ExamUserAnswer
|
||||
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, question.ID).First(&userAnswer)
|
||||
if result.Error == nil {
|
||||
// 序列化 AI 评分数据
|
||||
var aiGradingJSON datatypes.JSON
|
||||
if aiGrading != nil {
|
||||
aiGradingData, _ := json.Marshal(aiGrading)
|
||||
aiGradingJSON = datatypes.JSON(aiGradingData)
|
||||
}
|
||||
|
||||
// 更新评分结果
|
||||
updates := map[string]interface{}{
|
||||
"is_correct": isCorrect,
|
||||
"score": score,
|
||||
"ai_grading_data": aiGradingJSON,
|
||||
}
|
||||
db.Model(&userAnswer).Updates(updates)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存分数和状态到考试记录
|
||||
record.Score = totalScore
|
||||
record.Status = "graded"
|
||||
record.IsPassed = totalScore >= float64(exam.PassScore)
|
||||
|
||||
if err := db.Save(&record).Error; err != nil {
|
||||
log.Printf("保存考试记录失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("阅卷完成: 考试记录ID=%d, 总分=%.2f, 是否通过=%v", recordID, totalScore, record.IsPassed)
|
||||
}
|
||||
@ -3,7 +3,6 @@ package handlers
|
||||
import (
|
||||
"ankao/internal/database"
|
||||
"ankao/internal/models"
|
||||
"ankao/internal/services"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@ -13,50 +12,73 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GenerateExam 生成考试
|
||||
func GenerateExam(c *gin.Context) {
|
||||
// CreateExamRequest 创建试卷请求
|
||||
type CreateExamRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateExam 创建试卷
|
||||
func CreateExam(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 req CreateExamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询用户信息,获取用户类型
|
||||
var user models.User
|
||||
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
log.Printf("查询用户失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询用户信息失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 根据用户类型决定论述题类型
|
||||
essayType := "ordinary-essay" // 默认普通涉密人员论述题
|
||||
if user.UserType == "management-person" {
|
||||
essayType = "management-essay" // 保密管理人员论述题
|
||||
}
|
||||
|
||||
// 使用固定的题型配置(总分100分)
|
||||
questionTypes := []models.QuestionTypeConfig{
|
||||
{Type: "fill-in-blank", Count: 10, Score: 2.0}, // 20分
|
||||
{Type: "true-false", Count: 10, Score: 2.0}, // 20分
|
||||
{Type: "multiple-choice", Count: 10, Score: 1.0}, // 10分
|
||||
{Type: "multiple-selection", Count: 10, Score: 2.5}, // 25分
|
||||
{Type: "short-answer", Count: 2, Score: 10.0}, // 20分
|
||||
{Type: essayType, Count: 1, Score: 5.0}, // 5分(根据用户类型选择论述题)
|
||||
}
|
||||
|
||||
// 按题型配置随机抽取题目
|
||||
var allQuestionIDs []uint
|
||||
for _, qt := range questionTypes {
|
||||
totalScore := 0.0
|
||||
|
||||
for _, qtConfig := range questionTypes {
|
||||
var questions []models.PracticeQuestion
|
||||
if err := db.Where("type = ?", qt.Type).Find(&questions).Error; err != nil {
|
||||
query := db.Where("type = ?", qtConfig.Type)
|
||||
|
||||
if err := query.Find(&questions).Error; err != nil {
|
||||
log.Printf("查询题目失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查题目数量是否足够
|
||||
if len(questions) < qt.Count {
|
||||
if len(questions) < qtConfig.Count {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qt.Type, qt.Count, len(questions)),
|
||||
"message": fmt.Sprintf("题型 %s 题目数量不足,需要 %d 道,实际 %d 道", qtConfig.Type, qtConfig.Count, len(questions)),
|
||||
})
|
||||
return
|
||||
}
|
||||
@ -67,82 +89,167 @@ func GenerateExam(c *gin.Context) {
|
||||
j := rand.Intn(i + 1)
|
||||
questions[i], questions[j] = questions[j], questions[i]
|
||||
}
|
||||
selectedQuestions := questions[:qt.Count]
|
||||
questionsByType[qt.Type] = selectedQuestions
|
||||
selectedQuestions := questions[:qtConfig.Count]
|
||||
|
||||
// 收集题目ID
|
||||
for _, q := range selectedQuestions {
|
||||
allQuestionIDs = append(allQuestionIDs, q.ID)
|
||||
}
|
||||
|
||||
// 计算总分
|
||||
totalScore += float64(qtConfig.Count) * qtConfig.Score
|
||||
}
|
||||
|
||||
// 将题目ID列表转为JSON
|
||||
// 随机打乱题目ID顺序
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := len(allQuestionIDs) - 1; i > 0; i-- {
|
||||
j := rand.Intn(i + 1)
|
||||
allQuestionIDs[i], allQuestionIDs[j] = allQuestionIDs[j], allQuestionIDs[i]
|
||||
}
|
||||
|
||||
// 序列化题目ID
|
||||
questionIDsJSON, err := json.Marshal(allQuestionIDs)
|
||||
if err != nil {
|
||||
log.Printf("序列化题目ID失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成考试失败"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成试卷失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建考试记录
|
||||
// 创建试卷
|
||||
exam := models.Exam{
|
||||
UserID: userID.(uint),
|
||||
QuestionIDs: string(questionIDsJSON),
|
||||
Status: "draft",
|
||||
Title: req.Title,
|
||||
TotalScore: int(totalScore), // 总分100分
|
||||
Duration: 60, // 固定60分钟
|
||||
PassScore: 80, // 固定80分及格
|
||||
QuestionIDs: questionIDsJSON,
|
||||
Status: "active",
|
||||
}
|
||||
|
||||
if err := db.Create(&exam).Error; err != nil {
|
||||
log.Printf("创建考试记录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建考试失败"})
|
||||
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,
|
||||
"id": exam.ID,
|
||||
"title": exam.Title,
|
||||
"total_score": exam.TotalScore,
|
||||
"duration": exam.Duration,
|
||||
"pass_score": exam.PassScore,
|
||||
"question_count": len(allQuestionIDs),
|
||||
"created_at": exam.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetExam 获取考试详情
|
||||
func GetExam(c *gin.Context) {
|
||||
// GetExamList 获取试卷列表
|
||||
func GetExamList(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询用户创建的试卷
|
||||
var exams []models.Exam
|
||||
if err := db.Where("user_id = ? AND status = ?", userID, "active").
|
||||
Order("created_at DESC").
|
||||
Find(&exams).Error; err != nil {
|
||||
log.Printf("查询试卷列表失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询试卷列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 为每个试卷计算题目数量和获取考试记录统计
|
||||
type ExamWithStats struct {
|
||||
models.Exam
|
||||
QuestionCount int `json:"question_count"`
|
||||
AttemptCount int `json:"attempt_count"` // 考试次数
|
||||
BestScore float64 `json:"best_score"` // 最高分
|
||||
HasInProgressExam bool `json:"has_in_progress_exam"` // 是否有进行中的考试
|
||||
InProgressRecordID uint `json:"in_progress_record_id,omitempty"` // 进行中的考试记录ID
|
||||
}
|
||||
|
||||
result := make([]ExamWithStats, 0, len(exams))
|
||||
for _, exam := range exams {
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err == nil {
|
||||
stats := ExamWithStats{
|
||||
Exam: exam,
|
||||
QuestionCount: len(questionIDs),
|
||||
}
|
||||
|
||||
// 查询该试卷的考试记录统计
|
||||
var count int64
|
||||
db.Model(&models.ExamRecord{}).Where("exam_id = ? AND user_id = ?", exam.ID, userID).Count(&count)
|
||||
stats.AttemptCount = int(count)
|
||||
|
||||
// 查询最高分
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("exam_id = ? AND user_id = ?", exam.ID, userID).
|
||||
Order("score DESC").
|
||||
First(&record).Error; err == nil {
|
||||
stats.BestScore = record.Score
|
||||
}
|
||||
|
||||
// 查询是否有进行中的考试(status为in_progress)
|
||||
var inProgressRecord models.ExamRecord
|
||||
if err := db.Where("exam_id = ? AND user_id = ? AND status = ?", exam.ID, userID, "in_progress").
|
||||
Order("created_at DESC").
|
||||
First(&inProgressRecord).Error; err == nil {
|
||||
stats.HasInProgressExam = true
|
||||
stats.InProgressRecordID = inProgressRecord.ID
|
||||
}
|
||||
|
||||
result = append(result, stats)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamDetail 获取试卷详情
|
||||
func GetExamDetail(c *gin.Context) {
|
||||
_, 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"})
|
||||
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": "考试不存在"})
|
||||
if err := db.Where("id = ?", examID).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 {
|
||||
if err := json.Unmarshal(exam.QuestionIDs, &questionIDs); err != nil {
|
||||
log.Printf("解析题目ID失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析试卷数据失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 是否显示答案
|
||||
showAnswer := c.Query("show_answer") == "true"
|
||||
|
||||
// 查询题目详情
|
||||
var questions []models.PracticeQuestion
|
||||
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
|
||||
@ -151,7 +258,7 @@ func GetExam(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 按原始顺序排序题目并转换为DTO
|
||||
// 按原始顺序排序题目并转换为DTO(不显示答案)
|
||||
questionMap := make(map[uint]models.PracticeQuestion)
|
||||
for _, q := range questions {
|
||||
questionMap[q.ID] = q
|
||||
@ -160,10 +267,7 @@ func GetExam(c *gin.Context) {
|
||||
for _, id := range questionIDs {
|
||||
if q, ok := questionMap[id]; ok {
|
||||
dto := convertToDTO(q)
|
||||
// 是否显示答案
|
||||
if !showAnswer {
|
||||
dto.Answer = nil
|
||||
}
|
||||
dto.Answer = nil // 不显示答案
|
||||
orderedDTOs = append(orderedDTOs, dto)
|
||||
}
|
||||
}
|
||||
@ -177,8 +281,8 @@ func GetExam(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitExam 提交考试
|
||||
func SubmitExam(c *gin.Context) {
|
||||
// StartExam 开始考试(创建考试记录)
|
||||
func StartExam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
@ -188,14 +292,63 @@ func SubmitExam(c *gin.Context) {
|
||||
examIDStr := c.Param("id")
|
||||
examID, err := strconv.ParseUint(examIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询试卷
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建考试记录
|
||||
now := time.Now()
|
||||
record := models.ExamRecord{
|
||||
ExamID: uint(examID),
|
||||
UserID: userID.(uint),
|
||||
StartTime: &now,
|
||||
TotalScore: exam.TotalScore,
|
||||
Status: "in_progress",
|
||||
}
|
||||
|
||||
if err := db.Create(&record).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{
|
||||
"record_id": record.ID,
|
||||
"start_time": record.StartTime,
|
||||
"duration": exam.Duration,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitExam 提交试卷答案
|
||||
func SubmitExam(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
recordIDStr := c.Param("record_id")
|
||||
recordID, err := strconv.ParseUint(recordIDStr, 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
|
||||
Answers map[string]interface{} `json:"answers"` // question_id -> answer (可选)
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
|
||||
@ -205,27 +358,106 @@ func SubmitExam(c *gin.Context) {
|
||||
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": "考试不存在"})
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已提交
|
||||
if exam.Status == "submitted" {
|
||||
if record.Status == "submitted" || record.Status == "graded" {
|
||||
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": "解析考试数据失败"})
|
||||
// 查询试卷
|
||||
var exam models.Exam
|
||||
if err := db.Where("id = ?", record.ExamID).First(&exam).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新考试记录状态为已提交
|
||||
now := time.Now()
|
||||
record.Status = "submitted"
|
||||
record.SubmitTime = &now
|
||||
|
||||
// 计算用时(秒)
|
||||
if record.StartTime != nil {
|
||||
duration := now.Sub(*record.StartTime)
|
||||
record.TimeSpent = int(duration.Seconds())
|
||||
|
||||
// 确保用时不为负数(容错处理)
|
||||
if record.TimeSpent < 0 {
|
||||
log.Printf("警告: 计算出负的用时,开始时间=%v, 结束时间=%v", *record.StartTime, now)
|
||||
record.TimeSpent = 0
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Save(&record).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,
|
||||
"message": "提交成功,正在阅卷中...",
|
||||
"data": gin.H{
|
||||
"record_id": record.ID,
|
||||
"status": "submitted",
|
||||
"time_spent": record.TimeSpent,
|
||||
"total_score": exam.TotalScore,
|
||||
},
|
||||
})
|
||||
|
||||
// 异步执行阅卷(从 exam_user_answers 表读取答案)
|
||||
go gradeExam(uint(recordID), exam.ID, userID.(uint))
|
||||
}
|
||||
|
||||
// GetExamRecord 获取考试记录详情
|
||||
func GetExamRecord(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
recordIDStr := c.Param("record_id")
|
||||
recordID, err := strconv.ParseUint(recordIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ? AND user_id = ?", recordID, userID).
|
||||
Preload("Exam").
|
||||
First(&record).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从 exam_user_answers 表读取所有答案
|
||||
var userAnswers []models.ExamUserAnswer
|
||||
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
|
||||
log.Printf("查询用户答案失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询所有题目以获取正确答案
|
||||
var questionIDs []uint
|
||||
if err := json.Unmarshal(record.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)
|
||||
@ -233,188 +465,305 @@ func SubmitExam(c *gin.Context) {
|
||||
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": "未作答",
|
||||
// 构建题目映射
|
||||
questionMap := make(map[uint]models.PracticeQuestion)
|
||||
for _, q := range questions {
|
||||
questionMap[q.ID] = q
|
||||
}
|
||||
|
||||
// 构建 ExamAnswer 列表
|
||||
examAnswers := make([]models.ExamAnswer, 0, len(userAnswers))
|
||||
for _, ua := range userAnswers {
|
||||
// 解析用户答案
|
||||
var userAnswer interface{}
|
||||
if err := json.Unmarshal(ua.Answer, &userAnswer); err != nil {
|
||||
log.Printf("解析用户答案失败: %v", err)
|
||||
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": "未选择此题",
|
||||
}
|
||||
// 获取题目并解析正确答案
|
||||
question, ok := questionMap[ua.QuestionID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据题型判断答案
|
||||
var isCorrect bool
|
||||
var score float64
|
||||
var aiGrading *models.AIGrading
|
||||
|
||||
var correctAnswerRaw interface{}
|
||||
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
|
||||
case "fill-in-blank", "multiple-selection", "multiple-choice":
|
||||
// 数组类型:需要 JSON 解析(单选题也是数组格式A)
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(question.AnswerData), &arr); err != nil {
|
||||
// 尝试解析为单个字符串(兼容旧数据格式)
|
||||
var singleStr string
|
||||
if err2 := json.Unmarshal([]byte(question.AnswerData), &singleStr); err2 == nil {
|
||||
// 成功解析为字符串,单选题直接使用,其他类型转为数组
|
||||
if question.Type == "multiple-choice" {
|
||||
correctAnswerRaw = singleStr
|
||||
} else {
|
||||
isCorrect = aiResult.IsCorrect
|
||||
if question.Type == "short-answer" {
|
||||
// 简答题不计分,仅供参考
|
||||
score = 0
|
||||
correctAnswerRaw = []string{singleStr}
|
||||
|
||||
}
|
||||
} else {
|
||||
// 论述题按AI评分比例计算
|
||||
score = scoreConfig.Essay * (aiResult.Score / 100.0)
|
||||
correctAnswerRaw = "解析失败"
|
||||
}
|
||||
aiGrading = &models.AIGrading{
|
||||
Score: aiResult.Score,
|
||||
Feedback: aiResult.Feedback,
|
||||
Suggestion: aiResult.Suggestion,
|
||||
} else {
|
||||
// 单选题只取第一个元素
|
||||
if question.Type == "multiple-choice" && len(arr) > 0 {
|
||||
correctAnswerRaw = arr[0]
|
||||
} else {
|
||||
correctAnswerRaw = arr
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 字符串类型:直接使用 AnswerData(true-false, short-answer, essay)
|
||||
correctAnswerRaw = question.AnswerData
|
||||
}
|
||||
|
||||
// 构建答案对象
|
||||
examAnswer := models.ExamAnswer{
|
||||
QuestionID: ua.QuestionID,
|
||||
Answer: userAnswer,
|
||||
CorrectAnswer: correctAnswerRaw,
|
||||
IsCorrect: ua.IsCorrect != nil && *ua.IsCorrect,
|
||||
Score: ua.Score,
|
||||
}
|
||||
|
||||
// 添加 AI 评分信息
|
||||
if len(ua.AIGradingData) > 0 {
|
||||
var aiGrading models.AIGrading
|
||||
if err := json.Unmarshal(ua.AIGradingData, &aiGrading); err == nil {
|
||||
examAnswer.AIGrading = &aiGrading
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
examAnswers = append(examAnswers, examAnswer)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"score": totalScore,
|
||||
"detailed_results": detailedResults,
|
||||
"record": record,
|
||||
"answers": examAnswers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamRecordList 获取考试记录列表
|
||||
func GetExamRecordList(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
examIDStr := c.Query("exam_id")
|
||||
|
||||
db := database.GetDB()
|
||||
query := db.Where("user_id = ?", userID)
|
||||
|
||||
// 如果指定了试卷ID,只查询该试卷的记录
|
||||
if examIDStr != "" {
|
||||
examID, err := strconv.ParseUint(examIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的试卷ID"})
|
||||
return
|
||||
}
|
||||
query = query.Where("exam_id = ?", examID)
|
||||
}
|
||||
|
||||
var records []models.ExamRecord
|
||||
if err := query.Preload("Exam").
|
||||
Order("created_at DESC").
|
||||
Find(&records).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": records,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExam 删除试卷
|
||||
func DeleteExam(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
|
||||
}
|
||||
|
||||
// 软删除
|
||||
if err := db.Delete(&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,
|
||||
"message": "删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// SaveExamProgressRequest 保存考试进度请求
|
||||
type SaveExamProgressRequest struct {
|
||||
QuestionID uint `json:"question_id"` // 题目ID
|
||||
Answer interface{} `json:"answer"` // 答案数据
|
||||
}
|
||||
|
||||
// SaveExamProgress 保存单题答案
|
||||
func SaveExamProgress(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
recordIDStr := c.Param("record_id")
|
||||
recordID, err := strconv.ParseUint(recordIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req SaveExamProgressRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查考试状态
|
||||
if record.Status != "in_progress" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已结束,无法保存答案"})
|
||||
return
|
||||
}
|
||||
|
||||
// 序列化答案数据
|
||||
answerJSON, err := json.Marshal(req.Answer)
|
||||
if err != nil {
|
||||
log.Printf("序列化答案失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存失败"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 查找是否已存在该题的答案
|
||||
var userAnswer models.ExamUserAnswer
|
||||
result := db.Where("exam_record_id = ? AND question_id = ?", recordID, req.QuestionID).First(&userAnswer)
|
||||
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
// 不存在,创建新记录
|
||||
userAnswer = models.ExamUserAnswer{
|
||||
ExamRecordID: uint(recordID),
|
||||
QuestionID: req.QuestionID,
|
||||
UserID: userID.(uint),
|
||||
Answer: answerJSON,
|
||||
AnsweredAt: &now,
|
||||
LastModifiedAt: now,
|
||||
}
|
||||
if err := db.Create(&userAnswer).Error; err != nil {
|
||||
log.Printf("创建答案记录失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"})
|
||||
return
|
||||
}
|
||||
} else if result.Error != nil {
|
||||
log.Printf("查询答案记录失败: %v", result.Error)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"})
|
||||
return
|
||||
} else {
|
||||
// 已存在,更新答案
|
||||
updates := map[string]interface{}{
|
||||
"answer": answerJSON,
|
||||
"last_modified_at": now,
|
||||
}
|
||||
if err := db.Model(&userAnswer).Updates(updates).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,
|
||||
"message": "保存成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetExamUserAnswers 获取用户在考试中的所有答案
|
||||
func GetExamUserAnswers(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
recordIDStr := c.Param("record_id")
|
||||
recordID, err := strconv.ParseUint(recordIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试记录ID"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
// 查询考试记录,确保用户有权限
|
||||
var record models.ExamRecord
|
||||
if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询所有已保存的答案
|
||||
var userAnswers []models.ExamUserAnswer
|
||||
if err := db.Where("exam_record_id = ?", recordID).Find(&userAnswers).Error; err != nil {
|
||||
log.Printf("查询用户答案失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询答案失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为前端需要的格式: { question_id: answer }
|
||||
answers := make(map[string]interface{})
|
||||
for _, ua := range userAnswers {
|
||||
var answer interface{}
|
||||
if err := json.Unmarshal(ua.Answer, &answer); err != nil {
|
||||
log.Printf("解析答案失败: %v", err)
|
||||
continue
|
||||
}
|
||||
// 使用 q_<question_id> 格式作为key,与前端表单字段名保持一致
|
||||
fieldName := fmt.Sprintf("q_%d", ua.QuestionID)
|
||||
answers[fieldName] = answer
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": answers,
|
||||
})
|
||||
}
|
||||
|
||||
@ -3,22 +3,90 @@ package models
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Exam 考试记录
|
||||
// Exam 试卷模型
|
||||
type Exam struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
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"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID
|
||||
Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题
|
||||
TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分
|
||||
Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟)
|
||||
PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
|
||||
QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active'" json:"status"` // 状态: active, archived
|
||||
}
|
||||
|
||||
// ExamRecord 考试记录
|
||||
type ExamRecord 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:"-"`
|
||||
ExamID uint `gorm:"not null;index" json:"exam_id"` // 试卷ID
|
||||
UserID uint `gorm:"not null;index" json:"user_id"` // 考生ID
|
||||
StartTime *time.Time `json:"start_time"` // 开始时间
|
||||
SubmitTime *time.Time `json:"submit_time"` // 提交时间
|
||||
TimeSpent int `json:"time_spent"` // 实际用时(秒)
|
||||
Score float64 `gorm:"type:decimal(5,2)" json:"score"` // 得分
|
||||
TotalScore int `json:"total_score"` // 总分
|
||||
Status string `gorm:"type:varchar(20);not null;default:'in_progress'" json:"status"` // 状态: in_progress, submitted, graded
|
||||
IsPassed bool `json:"is_passed"` // 是否通过
|
||||
|
||||
// 关联
|
||||
Exam *Exam `gorm:"foreignKey:ExamID" json:"exam,omitempty"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// ExamUserAnswer 用户答案表(记录每道题的答案)
|
||||
type ExamUserAnswer 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:"-"`
|
||||
ExamRecordID uint `gorm:"not null;index:idx_record_question" json:"exam_record_id"` // 考试记录ID
|
||||
QuestionID uint `gorm:"not null;index:idx_record_question" json:"question_id"` // 题目ID
|
||||
UserID uint `gorm:"not null;index" json:"user_id"` // 用户ID
|
||||
Answer datatypes.JSON `gorm:"type:json" json:"answer"` // 用户答案 (JSON格式,支持各种题型)
|
||||
IsCorrect *bool `json:"is_correct,omitempty"` // 是否正确(提交后评分)
|
||||
Score float64 `gorm:"type:decimal(5,2);default:0" json:"score"` // 得分
|
||||
AIGradingData datatypes.JSON `gorm:"type:json" json:"ai_grading_data,omitempty"` // AI评分数据
|
||||
AnsweredAt *time.Time `json:"answered_at"` // 答题时间
|
||||
LastModifiedAt time.Time `json:"last_modified_at"` // 最后修改时间
|
||||
|
||||
// 关联
|
||||
ExamRecord *ExamRecord `gorm:"foreignKey:ExamRecordID" json:"-"`
|
||||
Question *PracticeQuestion `gorm:"foreignKey:QuestionID" json:"-"`
|
||||
}
|
||||
|
||||
// ExamConfig 试卷配置结构
|
||||
type ExamConfig struct {
|
||||
QuestionTypes []QuestionTypeConfig `json:"question_types"` // 题型配置
|
||||
Categories []string `json:"categories"` // 题目分类筛选
|
||||
Difficulty []string `json:"difficulty"` // 难度筛选
|
||||
RandomOrder bool `json:"random_order"` // 是否随机顺序
|
||||
}
|
||||
|
||||
// QuestionTypeConfig 题型配置
|
||||
type QuestionTypeConfig struct {
|
||||
Type string `json:"type"` // 题目类型
|
||||
Count int `json:"count"` // 题目数量
|
||||
Score float64 `json:"score"` // 每题分数
|
||||
}
|
||||
|
||||
// ExamAnswer 考试答案结构
|
||||
type ExamAnswer struct {
|
||||
QuestionID uint `json:"question_id"`
|
||||
Answer interface{} `json:"answer"` // 用户答案
|
||||
CorrectAnswer interface{} `json:"correct_answer"` // 正确答案
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
Score float64 `json:"score"`
|
||||
AIGrading *AIGrading `json:"ai_grading,omitempty"`
|
||||
}
|
||||
|
||||
// ExamQuestionConfig 考试题目配置
|
||||
@ -32,6 +100,7 @@ type ExamQuestionConfig struct {
|
||||
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
|
||||
}
|
||||
|
||||
|
||||
// DefaultExamConfig 默认考试配置
|
||||
var DefaultExamConfig = ExamQuestionConfig{
|
||||
FillInBlank: 10, // 填空题10道
|
||||
|
||||
15
main.go
15
main.go
@ -71,10 +71,17 @@ func main() {
|
||||
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
|
||||
auth.POST("/exams", handlers.CreateExam) // 创建试卷
|
||||
auth.GET("/exams", handlers.GetExamList) // 获取试卷列表
|
||||
auth.GET("/exams/:id", handlers.GetExamDetail) // 获取试卷详情
|
||||
auth.POST("/exams/:id/start", handlers.StartExam) // 开始考试
|
||||
auth.POST("/exam-records/:record_id/submit", handlers.SubmitExam) // 提交试卷答案
|
||||
auth.GET("/exam-records/:record_id", handlers.GetExamRecord) // 获取考试记录详情
|
||||
auth.GET("/exam-records", handlers.GetExamRecordList) // 获取考试记录列表
|
||||
auth.DELETE("/exams/:id", handlers.DeleteExam) // 删除试卷
|
||||
auth.POST("/exam-records/:record_id/progress", handlers.SaveExamProgress) // 保存考试进度
|
||||
auth.GET("/exam-records/:record_id/answers", handlers.GetExamUserAnswers) // 获取用户答案
|
||||
}
|
||||
|
||||
// 题库管理API(需要管理员权限)
|
||||
|
||||
@ -18,6 +18,9 @@ import ExamPrepare from './pages/ExamPrepare'
|
||||
import ExamOnline from './pages/ExamOnline'
|
||||
import ExamPrint from './pages/ExamPrint'
|
||||
import ExamResult from './pages/ExamResult'
|
||||
import ExamManagement from './pages/ExamManagement'
|
||||
import ExamTaking from './pages/ExamTaking'
|
||||
import ExamResultNew from './pages/ExamResultNew'
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
@ -36,9 +39,12 @@ const App: React.FC = () => {
|
||||
|
||||
{/* 考试相关页面,需要登录保护 */}
|
||||
<Route path="/exam/new" element={<ProtectedRoute><ExamPrepare /></ProtectedRoute>} />
|
||||
<Route path="/exam/management" element={<ProtectedRoute><ExamManagement /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/taking/:recordId" element={<ProtectedRoute><ExamTaking /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
|
||||
<Route path="/exam/:examId/result" element={<ProtectedRoute><ExamResult /></ProtectedRoute>} />
|
||||
<Route path="/exam/result/:recordId" element={<ProtectedRoute><ExamResultNew /></ProtectedRoute>} />
|
||||
|
||||
{/* 题库管理页面,需要管理员权限 */}
|
||||
<Route path="/question-management" element={
|
||||
|
||||
@ -3,10 +3,71 @@ import type {
|
||||
GenerateExamResponse,
|
||||
GetExamResponse,
|
||||
SubmitExamRequest,
|
||||
SubmitExamResponse
|
||||
SubmitExamResponse,
|
||||
CreateExamRequest,
|
||||
CreateExamResponse,
|
||||
ExamListResponse,
|
||||
ExamDetailResponse,
|
||||
StartExamResponse,
|
||||
ExamRecordResponse,
|
||||
ExamRecordListResponse
|
||||
} from '../types/exam'
|
||||
import type { ApiResponse } from '../types/question'
|
||||
|
||||
// 创建试卷
|
||||
export const createExam = (data: CreateExamRequest) => {
|
||||
return request.post<ApiResponse<CreateExamResponse>>('/exams', data)
|
||||
}
|
||||
|
||||
// 获取试卷列表
|
||||
export const getExamList = () => {
|
||||
return request.get<ApiResponse<ExamListResponse>>('/exams')
|
||||
}
|
||||
|
||||
// 获取试卷详情
|
||||
export const getExamDetail = (examId: number) => {
|
||||
return request.get<ApiResponse<ExamDetailResponse>>(`/exams/${examId}`)
|
||||
}
|
||||
|
||||
// 开始考试
|
||||
export const startExam = (examId: number) => {
|
||||
return request.post<ApiResponse<StartExamResponse>>(`/exams/${examId}/start`)
|
||||
}
|
||||
|
||||
// 提交试卷答案
|
||||
export const submitExamAnswer = (recordId: number, data: SubmitExamRequest) => {
|
||||
return request.post<ApiResponse<SubmitExamResponse>>(`/exam-records/${recordId}/submit`, data)
|
||||
}
|
||||
|
||||
// 获取考试记录详情
|
||||
export const getExamRecord = (recordId: number) => {
|
||||
return request.get<ApiResponse<ExamRecordResponse>>(`/exam-records/${recordId}`)
|
||||
}
|
||||
|
||||
// 获取考试记录列表
|
||||
export const getExamRecordList = (examId?: number) => {
|
||||
return request.get<ApiResponse<ExamRecordListResponse>>('/exam-records', {
|
||||
params: examId ? { exam_id: examId } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// 删除试卷
|
||||
export const deleteExam = (examId: number) => {
|
||||
return request.delete<ApiResponse<void>>(`/exams/${examId}`)
|
||||
}
|
||||
|
||||
// 保存考试进度(单题答案)
|
||||
export const saveExamProgress = (recordId: number, data: { question_id: number; answer: any }) => {
|
||||
return request.post<ApiResponse<void>>(`/exam-records/${recordId}/progress`, data)
|
||||
}
|
||||
|
||||
// 获取用户答案
|
||||
export const getExamUserAnswers = (recordId: number) => {
|
||||
return request.get<ApiResponse<Record<string, any>>>(`/exam-records/${recordId}/answers`)
|
||||
}
|
||||
|
||||
// === 兼容旧版API ===
|
||||
|
||||
// 生成考试
|
||||
export const generateExam = () => {
|
||||
return request.post<ApiResponse<GenerateExamResponse>>('/exam/generate')
|
||||
|
||||
105
web/src/pages/ExamManagement.module.less
Normal file
105
web/src/pages/ExamManagement.module.less
Normal file
@ -0,0 +1,105 @@
|
||||
.container {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.description {
|
||||
margin-bottom: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
|
||||
svg {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin: 24px 0 16px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.statItem {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
398
web/src/pages/ExamManagement.tsx
Normal file
398
web/src/pages/ExamManagement.tsx
Normal file
@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
List,
|
||||
Tag,
|
||||
Modal,
|
||||
Row,
|
||||
Col,
|
||||
Empty,
|
||||
Spin,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Divider
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
TrophyOutlined,
|
||||
HistoryOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import styles from './ExamManagement.module.less'
|
||||
|
||||
interface ExamListItem {
|
||||
id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number
|
||||
pass_score: number
|
||||
question_count: number
|
||||
attempt_count: number
|
||||
best_score: number
|
||||
has_in_progress_exam: boolean
|
||||
in_progress_record_id?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const ExamManagement: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exams, setExams] = useState<ExamListItem[]>([])
|
||||
const [loadingExams, setLoadingExams] = useState(false)
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false)
|
||||
const [recordsDrawerVisible, setRecordsDrawerVisible] = useState(false)
|
||||
const [, setCurrentExamId] = useState<number | null>(null)
|
||||
const [examRecords, setExamRecords] = useState<any[]>([])
|
||||
const [loadingRecords, setLoadingRecords] = useState(false)
|
||||
|
||||
// 加载试卷列表
|
||||
const loadExams = async () => {
|
||||
setLoadingExams(true)
|
||||
try {
|
||||
const res = await examApi.getExamList()
|
||||
if (res.success) {
|
||||
setExams(res.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载试卷列表失败')
|
||||
} finally {
|
||||
setLoadingExams(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadExams()
|
||||
}, [])
|
||||
|
||||
// 创建试卷
|
||||
const handleCreateExam = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = {
|
||||
title: values.title,
|
||||
duration: 60, // 默认60分钟
|
||||
question_types: [] // 空配置,后端会使用默认值
|
||||
}
|
||||
|
||||
const res = await examApi.createExam(params)
|
||||
if (res.success) {
|
||||
message.success('试卷创建成功')
|
||||
setCreateModalVisible(false)
|
||||
form.resetFields()
|
||||
loadExams()
|
||||
} 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 handleDeleteExam = async (examId: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除试卷后将无法恢复,是否确认删除?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await examApi.deleteExam(examId)
|
||||
if (res.success) {
|
||||
message.success('删除成功')
|
||||
loadExams()
|
||||
} else {
|
||||
message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 开始考试
|
||||
const handleStartExam = async (examId: number, hasInProgressExam: boolean, inProgressRecordId?: number) => {
|
||||
try {
|
||||
if (hasInProgressExam && inProgressRecordId) {
|
||||
// 有未完成的考试,直接跳转继续答题
|
||||
navigate(`/exam/${examId}/taking/${inProgressRecordId}`)
|
||||
} else {
|
||||
// 没有未完成的考试,调用开始考试API创建新记录
|
||||
const res = await examApi.startExam(examId)
|
||||
if (res.success && res.data) {
|
||||
navigate(`/exam/${examId}/taking/${res.data.record_id}`)
|
||||
} else {
|
||||
message.error(res.message || '开始考试失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('开始考试失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看考试记录
|
||||
const handleViewRecords = async (examId: number) => {
|
||||
setCurrentExamId(examId)
|
||||
setRecordsDrawerVisible(true)
|
||||
setLoadingRecords(true)
|
||||
try {
|
||||
const res = await examApi.getExamRecordList(examId)
|
||||
if (res.success && res.data) {
|
||||
setExamRecords(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载考试记录失败')
|
||||
} finally {
|
||||
setLoadingRecords(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看记录详情
|
||||
const handleViewRecordDetail = (recordId: number) => {
|
||||
navigate(`/exam/result/${recordId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Card>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h2>模拟考试</h2>
|
||||
<p className={styles.subtitle}>创建和管理模拟试卷</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
>
|
||||
创建试卷
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loadingExams}>
|
||||
{exams.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无试卷,点击上方按钮创建"
|
||||
style={{ marginTop: 40 }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
grid={{ gutter: 16, xs: 1, sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }}
|
||||
dataSource={exams}
|
||||
renderItem={(exam) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
hoverable
|
||||
actions={[
|
||||
<Button
|
||||
type="link"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => handleStartExam(exam.id, exam.has_in_progress_exam, exam.in_progress_record_id)}
|
||||
>
|
||||
{exam.has_in_progress_exam ? '继续考试' : '开始考试'}
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => handleViewRecords(exam.id)}
|
||||
>
|
||||
考试记录
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteExam(exam.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div className={styles.cardTitle}>
|
||||
<FileTextOutlined />
|
||||
<span>{exam.title}</span>
|
||||
{exam.has_in_progress_exam && (
|
||||
<Tag color="processing" style={{ marginLeft: 8 }}>进行中</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div className={styles.cardContent}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<div className={styles.statItem}>
|
||||
<ClockCircleOutlined />
|
||||
<span>{exam.duration} 分钟</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div className={styles.statItem}>
|
||||
<CheckCircleOutlined />
|
||||
<span>及格 {exam.pass_score} 分</span>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className={styles.stats}>
|
||||
<Tag icon={<TrophyOutlined />} color="gold">
|
||||
最高分: {exam.best_score || 0}
|
||||
</Tag>
|
||||
<Tag color="blue">已考 {exam.attempt_count} 次</Tag>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* 考试记录抽屉 */}
|
||||
<Drawer
|
||||
title="考试记录"
|
||||
placement="right"
|
||||
width={600}
|
||||
open={recordsDrawerVisible}
|
||||
onClose={() => setRecordsDrawerVisible(false)}
|
||||
>
|
||||
<Spin spinning={loadingRecords}>
|
||||
{examRecords.length === 0 ? (
|
||||
<Empty description="暂无考试记录" />
|
||||
) : (
|
||||
<List
|
||||
dataSource={examRecords}
|
||||
renderItem={(record: any) => (
|
||||
<Card
|
||||
key={record.id}
|
||||
style={{ marginBottom: 16 }}
|
||||
size="small"
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="状态">
|
||||
{record.status === 'in_progress' && <Tag color="processing">进行中</Tag>}
|
||||
{record.status === 'submitted' && <Tag color="warning">已提交</Tag>}
|
||||
{record.status === 'graded' && (
|
||||
<Tag color={record.is_passed ? 'success' : 'error'}>
|
||||
{record.is_passed ? '已通过' : '未通过'}
|
||||
</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="分数">
|
||||
{record.status === 'in_progress' ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: record.is_passed ? '#52c41a' : '#ff4d4f' }}>
|
||||
{record.score} / {record.total_score}
|
||||
</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">
|
||||
{record.start_time ? new Date(record.start_time).toLocaleString() : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">
|
||||
{record.submit_time ? new Date(record.submit_time).toLocaleString() : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用时">
|
||||
{record.time_spent ? `${Math.floor(record.time_spent / 60)} 分 ${record.time_spent % 60} 秒` : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Space>
|
||||
{record.status === 'in_progress' && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => {
|
||||
setRecordsDrawerVisible(false)
|
||||
navigate(`/exam/${record.exam_id}/taking/${record.id}`)
|
||||
}}
|
||||
>
|
||||
继续答题
|
||||
</Button>
|
||||
)}
|
||||
{record.status !== 'in_progress' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleViewRecordDetail(record.id)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Drawer>
|
||||
|
||||
{/* 创建试卷模态框 */}
|
||||
<Modal
|
||||
title="创建试卷"
|
||||
open={createModalVisible}
|
||||
onCancel={() => {
|
||||
setCreateModalVisible(false)
|
||||
form.resetFields()
|
||||
}}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateExam}
|
||||
>
|
||||
<Form.Item
|
||||
label="试卷标题"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入试卷标题' }]}
|
||||
>
|
||||
<Input placeholder="例如:保密知识测试卷(一)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
创建试卷
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
setCreateModalVisible(false)
|
||||
form.resetFields()
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamManagement
|
||||
221
web/src/pages/ExamResultNew.module.less
Normal file
221
web/src/pages/ExamResultNew.module.less
Normal file
@ -0,0 +1,221 @@
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
:global(.ant-result) {
|
||||
padding: 40px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.passMark {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreCard {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.typeScoreItem {
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f0f5ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
.typeScoreHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.typeScoreContent {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.typeScoreProgress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e8e8e8;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
.typeScoreBar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.questionItem {
|
||||
.questionNumber {
|
||||
display: inline-block;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.answerDetail {
|
||||
.questionContent {
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.answerSection {
|
||||
padding-left: 12px;
|
||||
|
||||
.answerItem {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.correct {
|
||||
color: #52c41a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
color: #ff4d4f;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.aiGrading {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionsCard {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
|
||||
:global(.ant-result) {
|
||||
padding: 24px 16px;
|
||||
|
||||
:global(.ant-result-title) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
:global(.ant-col) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:global(.ant-statistic) {
|
||||
:global(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.ant-statistic-content) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreCard {
|
||||
.typeScoreItem {
|
||||
padding: 12px;
|
||||
|
||||
.typeScoreHeader {
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreContent {
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
&:first-child {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
&:last-child {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typeScoreProgress {
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
.questionItem {
|
||||
.answerSection {
|
||||
padding-left: 0;
|
||||
|
||||
.answerItem {
|
||||
flex-wrap: wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionsCard {
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
498
web/src/pages/ExamResultNew.tsx
Normal file
498
web/src/pages/ExamResultNew.tsx
Normal file
@ -0,0 +1,498 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Card,
|
||||
Result,
|
||||
Button,
|
||||
Typography,
|
||||
Tag,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Divider
|
||||
} from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
TrophyOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileTextOutlined,
|
||||
HomeOutlined
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { ExamRecordResponse, ExamAnswer } from '../types/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import styles from './ExamResultNew.module.less'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, string> = {
|
||||
'fill-in-blank': '填空题',
|
||||
'true-false': '判断题',
|
||||
'multiple-choice': '单选题',
|
||||
'multiple-selection': '多选题',
|
||||
'short-answer': '简答题',
|
||||
'ordinary-essay': '论述题',
|
||||
'management-essay': '论述题',
|
||||
'essay': '论述题' // 合并后的论述题类型
|
||||
}
|
||||
|
||||
// 题型顺序定义
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
'fill-in-blank': 1,
|
||||
'true-false': 2,
|
||||
'multiple-choice': 3,
|
||||
'multiple-selection': 4,
|
||||
'short-answer': 5,
|
||||
'ordinary-essay': 6,
|
||||
'management-essay': 6,
|
||||
'essay': 6 // 合并后的论述题顺序
|
||||
}
|
||||
|
||||
const ExamResultNew: React.FC = () => {
|
||||
const { recordId } = useParams<{ recordId: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<ExamRecordResponse | null>(null)
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordId) {
|
||||
message.error('参数错误')
|
||||
navigate('/exam/management')
|
||||
return
|
||||
}
|
||||
|
||||
loadResult()
|
||||
}, [recordId])
|
||||
|
||||
const loadResult = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const recordRes = await examApi.getExamRecord(Number(recordId))
|
||||
|
||||
if (recordRes.success && recordRes.data) {
|
||||
setData(recordRes.data)
|
||||
|
||||
// 获取试卷详情
|
||||
if (recordRes.data.record.exam?.id) {
|
||||
const examRes = await examApi.getExamDetail(recordRes.data.record.exam.id)
|
||||
if (examRes.success && examRes.data) {
|
||||
setQuestions(examRes.data.questions)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error('加载结果失败')
|
||||
navigate('/exam/management')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载结果失败')
|
||||
navigate('/exam/management')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { record, answers } = data
|
||||
const isPassed = record.is_passed
|
||||
// 总分统一为100分
|
||||
const scorePercent = record.score
|
||||
|
||||
// 构建答案映射
|
||||
const answerMap = new Map<number, ExamAnswer>()
|
||||
answers.forEach(ans => {
|
||||
answerMap.set(ans.question_id, ans)
|
||||
})
|
||||
|
||||
// 统计正确率
|
||||
const correctCount = answers.filter(a => a.is_correct).length
|
||||
const totalCount = answers.length
|
||||
const correctRate = totalCount > 0 ? (correctCount / totalCount) * 100 : 0
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number) => {
|
||||
const totalSeconds = Math.floor(seconds) // 确保是整数
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const secs = totalSeconds % 60
|
||||
return `${minutes}分${secs}秒`
|
||||
}
|
||||
|
||||
// 渲染答案详情
|
||||
const renderAnswerDetail = (question: Question, answer: ExamAnswer) => {
|
||||
const isCorrect = answer.is_correct
|
||||
|
||||
return (
|
||||
<div className={styles.answerDetail}>
|
||||
{/* 题目内容 - 填空题特殊处理 */}
|
||||
<div className={styles.questionContent}>
|
||||
{question.type === 'fill-in-blank' ? (
|
||||
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{renderFillInBlankQuestion(question.content)}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph strong style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{question.content}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.answerSection}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{/* 用户答案 */}
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">你的答案:</Text>
|
||||
<Text strong className={isCorrect ? styles.correct : styles.incorrect}>
|
||||
{formatAnswer(answer.answer, question.type)}
|
||||
</Text>
|
||||
{isCorrect ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 正确答案 */}
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">正确答案:</Text>
|
||||
<Text strong style={{ color: '#52c41a' }}>
|
||||
{formatAnswer(answer.correct_answer, question.type)}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 得分 */}
|
||||
<div className={styles.answerItem}>
|
||||
<Space>
|
||||
<Text type="secondary">得分:</Text>
|
||||
<Text strong style={{ color: isCorrect ? '#52c41a' : '#ff4d4f', fontSize: 16 }}>
|
||||
{answer.score.toFixed(1)} 分
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* AI评分详情 */}
|
||||
{answer.ai_grading && (
|
||||
<div className={styles.aiGrading} style={{ marginTop: 12, padding: 16, background: '#f0f5ff', borderRadius: 8 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ color: '#1890ff' }}>AI评分详情:</Text>
|
||||
</div>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text type="secondary">AI得分:</Text>
|
||||
<Text strong>{answer.ai_grading.score} / 100</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">评语:</Text>
|
||||
<Text>{answer.ai_grading.feedback}</Text>
|
||||
</div>
|
||||
{answer.ai_grading.suggestion && (
|
||||
<div>
|
||||
<Text type="secondary">改进建议:</Text>
|
||||
<Text>{answer.ai_grading.suggestion}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染填空题题目(将 **** 替换为下划线)
|
||||
const renderFillInBlankQuestion = (content: string) => {
|
||||
const parts = content.split('****')
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{part}
|
||||
{i < parts.length - 1 && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
minWidth: '120px',
|
||||
borderBottom: '2px solid #1890ff',
|
||||
marginLeft: 8,
|
||||
marginRight: 8
|
||||
}}>
|
||||
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 格式化用户答案
|
||||
const formatAnswer = (answer: any, type: string): string => {
|
||||
if (answer === null || answer === undefined || answer === '') {
|
||||
return '未作答'
|
||||
}
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
if (answer.length === 0) return '未作答'
|
||||
return answer.filter(a => a !== null && a !== undefined && a !== '').join('、')
|
||||
}
|
||||
|
||||
if (type === 'true-false') {
|
||||
// 处理判断题:支持字符串和布尔值
|
||||
const answerStr = String(answer).toLowerCase()
|
||||
return answerStr === 'true' ? '正确' : '错误'
|
||||
}
|
||||
|
||||
return String(answer)
|
||||
}
|
||||
|
||||
// 按题型分组(合并两种论述题)
|
||||
const groupedQuestions = questions.reduce((acc, q) => {
|
||||
// 将两种论述题统一为 'essay'
|
||||
const displayType = (q.type === 'ordinary-essay' || q.type === 'management-essay') ? 'essay' : q.type
|
||||
if (!acc[displayType]) {
|
||||
acc[displayType] = []
|
||||
}
|
||||
acc[displayType].push(q)
|
||||
return acc
|
||||
}, {} as Record<string, Question[]>)
|
||||
|
||||
// 计算各题型得分(已在 groupedQuestions 中合并论述题)
|
||||
const typeScores = Object.entries(groupedQuestions)
|
||||
.map(([type, qs]) => {
|
||||
const typeAnswers = qs.map(q => answerMap.get(q.id)).filter(Boolean) as ExamAnswer[]
|
||||
const totalScore = typeAnswers.reduce((sum, ans) => sum + ans.score, 0)
|
||||
const maxScore = typeAnswers.length * (
|
||||
type === 'fill-in-blank' ? 2.0 :
|
||||
type === 'true-false' ? 2.0 :
|
||||
type === 'multiple-choice' ? 1.0 :
|
||||
type === 'multiple-selection' ? 2.5 :
|
||||
type === 'short-answer' ? 10.0 :
|
||||
(type === 'essay' || type === 'ordinary-essay' || type === 'management-essay') ? 5.0 : 0
|
||||
)
|
||||
const correctCount = typeAnswers.filter(ans => ans.is_correct).length
|
||||
|
||||
return {
|
||||
type,
|
||||
typeName: TYPE_NAME[type] || type,
|
||||
totalScore,
|
||||
maxScore,
|
||||
correctCount,
|
||||
totalCount: typeAnswers.length,
|
||||
order: TYPE_ORDER[type] || 999
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 成绩展示 */}
|
||||
<Result
|
||||
status={isPassed ? 'success' : 'warning'}
|
||||
title={isPassed ? '恭喜你,考试通过!' : '很遗憾,未通过考试'}
|
||||
subTitle={
|
||||
<Space direction="vertical" size="large">
|
||||
<Text style={{ fontSize: 16 }}>
|
||||
{record.exam?.title || '模拟考试'}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 成绩统计 */}
|
||||
<Card className={styles.statsCard}>
|
||||
<Row gutter={[32, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="总分"
|
||||
value={scorePercent.toFixed(1)}
|
||||
suffix="/ 100"
|
||||
prefix={<TrophyOutlined />}
|
||||
valueStyle={{ color: isPassed ? '#52c41a' : '#ff4d4f', fontSize: 32 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="正确率"
|
||||
value={correctRate.toFixed(1)}
|
||||
suffix="%"
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#1890ff', fontSize: 32 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic
|
||||
title="用时"
|
||||
value={formatTime(record.time_spent)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
valueStyle={{ fontSize: 32 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ color: 'rgba(0, 0, 0, 0.45)', fontSize: 14, marginBottom: 8 }}>
|
||||
考试状态
|
||||
</div>
|
||||
<Tag color={isPassed ? 'success' : 'error'} style={{ fontSize: 16, padding: '4px 16px' }}>
|
||||
{isPassed ? '已通过' : '未通过'}
|
||||
</Tag>
|
||||
<div style={{ marginTop: 8, color: 'rgba(0, 0, 0, 0.45)', fontSize: 12 }}>
|
||||
及格分数:{record.exam?.pass_score || 60} 分
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 各题型得分情况 */}
|
||||
<Card
|
||||
title={<Text strong style={{ fontSize: 18 }}>题型得分统计</Text>}
|
||||
className={styles.typeScoreCard}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{typeScores.map(ts => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={ts.type}>
|
||||
<div className={styles.typeScoreItem}>
|
||||
<div className={styles.typeScoreHeader}>
|
||||
<Text strong style={{ fontSize: 16 }}>{ts.typeName}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
({ts.correctCount}/{ts.totalCount}题正确)
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.typeScoreContent}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff'
|
||||
}}
|
||||
>
|
||||
{ts.totalScore.toFixed(1)}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 16, marginLeft: 4 }}>
|
||||
/ {ts.maxScore.toFixed(1)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.typeScoreProgress}>
|
||||
<div
|
||||
className={styles.typeScoreBar}
|
||||
style={{
|
||||
width: `${(ts.totalScore / ts.maxScore) * 100}%`,
|
||||
background: ts.totalScore === ts.maxScore ? '#52c41a' : '#1890ff'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 答题详情 - 直接展示,不使用折叠 */}
|
||||
<Card title={<Text strong style={{ fontSize: 18 }}>答题详情</Text>} className={styles.detailCard}>
|
||||
{Object.entries(groupedQuestions)
|
||||
.sort(([typeA], [typeB]) => {
|
||||
const orderA = TYPE_ORDER[typeA] || 999
|
||||
const orderB = TYPE_ORDER[typeB] || 999
|
||||
return orderA - orderB
|
||||
})
|
||||
.map(([type, qs]) => (
|
||||
<div key={type} style={{ marginBottom: 32 }}>
|
||||
{/* 题型标题 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#fafafa',
|
||||
borderLeft: '4px solid #1890ff',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<Space>
|
||||
<Text strong style={{ fontSize: 16 }}>{TYPE_NAME[type] || type}</Text>
|
||||
<Text type="secondary">(共 {qs.length} 题)</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 题目列表 */}
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{qs.map((q, idx) => {
|
||||
const ans = answerMap.get(q.id)
|
||||
if (!ans) return null
|
||||
return (
|
||||
<Card
|
||||
key={q.id}
|
||||
size="small"
|
||||
className={styles.questionCard}
|
||||
style={{
|
||||
borderLeft: ans.is_correct ? '4px solid #52c41a' : '4px solid #ff4d4f',
|
||||
background: ans.is_correct ? '#f6ffed' : '#fff2f0'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color="blue">第 {idx + 1} 题</Tag>
|
||||
</div>
|
||||
{renderAnswerDetail(q, ans)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
|
||||
{/* 题型之间的分隔线 */}
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Card className={styles.actionsCard}>
|
||||
<Space size="large">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<HomeOutlined />}
|
||||
onClick={() => navigate('/exam/management')}
|
||||
>
|
||||
返回试卷列表
|
||||
</Button>
|
||||
{record.exam?.id && (
|
||||
<Button
|
||||
size="large"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await examApi.startExam(record.exam!.id)
|
||||
if (res.success && res.data) {
|
||||
navigate(`/exam/${record.exam!.id}/taking/${res.data.record_id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('开始考试失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
再考一次
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamResultNew
|
||||
400
web/src/pages/ExamTaking.module.less
Normal file
400
web/src/pages/ExamTaking.module.less
Normal file
@ -0,0 +1,400 @@
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
> div:first-child {
|
||||
:global(.ant-typography) {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
:global(.ant-statistic-title) {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
:global(.ant-statistic-content) {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
:global(.ant-divider-vertical) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
|
||||
.statLabel {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progressInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
color: #1f2937;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.questionContainer {
|
||||
min-height: 400px;
|
||||
padding: 24px 0;
|
||||
|
||||
.questionHeader {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:global(.ant-tag) {
|
||||
padding: 4px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-form-item-label > label) {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:global(.ant-input),
|
||||
:global(.ant-input-textarea) {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-radio-wrapper),
|
||||
:global(.ant-checkbox-wrapper) {
|
||||
font-size: 15px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-form-item-label > label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉样式
|
||||
.drawerContent {
|
||||
.questionTypeSection {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.typeHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
:global(.ant-typography) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.questionGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
.questionItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: #1890ff;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.answered:not(.current) {
|
||||
border-color: #52c41a;
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 16px 0 0 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.legendBox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.current {
|
||||
border-color: #1890ff;
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
&.answered {
|
||||
border-color: #52c41a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
&.unanswered {
|
||||
border-color: #d9d9d9;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-typography) {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
.headerContent {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
:global(.ant-divider) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.ant-statistic) {
|
||||
:global(.ant-statistic-title) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.ant-statistic-content) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.progressInfo {
|
||||
:global(.ant-typography) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
.questionContainer {
|
||||
min-height: 300px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
padding: 16px 0;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.questionGroup {
|
||||
.groupHeader {
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-form-item-label > label {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 20px 0;
|
||||
|
||||
:global(.ant-space) {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉响应式
|
||||
.drawerContent {
|
||||
.questionGrid {
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 8px;
|
||||
|
||||
.questionItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
728
web/src/pages/ExamTaking.tsx
Normal file
728
web/src/pages/ExamTaking.tsx
Normal file
@ -0,0 +1,728 @@
|
||||
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,
|
||||
Statistic,
|
||||
FloatButton,
|
||||
Drawer,
|
||||
Tag
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import * as examApi from '../api/exam'
|
||||
import type { Question } from '../types/question'
|
||||
import type { ExamDetailResponse } from '../types/exam'
|
||||
import styles from './ExamTaking.module.less'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { TextArea } = Input
|
||||
const { Countdown } = Statistic
|
||||
|
||||
// 题型名称映射
|
||||
const TYPE_NAME: Record<string, 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
|
||||
}
|
||||
|
||||
const ExamTaking: React.FC = () => {
|
||||
const { examId, recordId } = useParams<{ examId: string; recordId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [examData, setExamData] = useState<ExamDetailResponse | null>(null)
|
||||
const [groupedQuestions, setGroupedQuestions] = useState<Record<string, Question[]>>({})
|
||||
const [answeredCount, setAnsweredCount] = useState(0)
|
||||
const [endTime, setEndTime] = useState<number>(0)
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
|
||||
// 加载考试详情
|
||||
useEffect(() => {
|
||||
if (!examId || !recordId) {
|
||||
message.error('参数错误')
|
||||
navigate('/exam/management')
|
||||
return
|
||||
}
|
||||
|
||||
const loadExam = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await examApi.getExamDetail(Number(examId))
|
||||
if (res.success && res.data) {
|
||||
// 获取用户信息,判断用户类型
|
||||
const userStr = localStorage.getItem('user')
|
||||
const user = userStr ? JSON.parse(userStr) : null
|
||||
const userType = user?.user_type // 'ordinary-person' 或 'management-person'
|
||||
|
||||
// 过滤题目:根据用户类型只保留对应的论述题
|
||||
let filteredQuestions = res.data.questions.filter(q => {
|
||||
if (q.type === 'ordinary-essay') {
|
||||
return userType === 'ordinary-person'
|
||||
}
|
||||
if (q.type === 'management-essay') {
|
||||
return userType === 'management-person'
|
||||
}
|
||||
return true // 其他题目全部保留
|
||||
})
|
||||
|
||||
// 按照题型顺序排序题目
|
||||
filteredQuestions.sort((a, b) => {
|
||||
const orderA = TYPE_ORDER[a.type] || 999
|
||||
const orderB = TYPE_ORDER[b.type] || 999
|
||||
return orderA - orderB
|
||||
})
|
||||
|
||||
setExamData({
|
||||
...res.data,
|
||||
questions: filteredQuestions
|
||||
})
|
||||
|
||||
// 按题型分组
|
||||
const grouped = groupQuestionsByType(filteredQuestions)
|
||||
setGroupedQuestions(grouped)
|
||||
|
||||
// 检查是否有保存的剩余时间(暂停状态)
|
||||
const savedProgress = localStorage.getItem(`exam_progress_${recordId}`)
|
||||
if (savedProgress) {
|
||||
try {
|
||||
const progress = JSON.parse(savedProgress)
|
||||
if (progress.remainingTime) {
|
||||
// 恢复暂停时的剩余时间
|
||||
setEndTime(Date.now() + progress.remainingTime)
|
||||
} else {
|
||||
// 没有暂停记录,使用完整考试时长
|
||||
const duration = res.data.exam.duration * 60 * 1000
|
||||
setEndTime(Date.now() + duration)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析进度失败', e)
|
||||
const duration = res.data.exam.duration * 60 * 1000
|
||||
setEndTime(Date.now() + duration)
|
||||
}
|
||||
} else {
|
||||
// 首次进入,使用完整考试时长
|
||||
const duration = res.data.exam.duration * 60 * 1000
|
||||
setEndTime(Date.now() + duration)
|
||||
}
|
||||
|
||||
// 恢复答题进度(先从服务器,再从localStorage)
|
||||
await loadProgressFromServer()
|
||||
} else {
|
||||
message.error('加载考试失败')
|
||||
navigate('/exam/management')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '加载考试失败')
|
||||
navigate('/exam/management')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExam()
|
||||
}, [examId, recordId, navigate])
|
||||
|
||||
// 定时保存进度(每30秒)
|
||||
useEffect(() => {
|
||||
if (!recordId) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
saveCurrentQuestionToServer()
|
||||
}, 30000) // 30秒
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [recordId, currentQuestionIndex, examData]) // 依赖当前题目索引
|
||||
|
||||
// 按题型分组题目
|
||||
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 saveCurrentQuestionToServer = async () => {
|
||||
if (!recordId || !examData || currentQuestionIndex < 0) return
|
||||
|
||||
try {
|
||||
const currentQuestion = examData.questions[currentQuestionIndex]
|
||||
const fieldName = `q_${currentQuestion.id}`
|
||||
const answer = form.getFieldValue(fieldName)
|
||||
|
||||
// 只有当答案不为空时才保存
|
||||
if (answer !== undefined && answer !== null && answer !== '') {
|
||||
await examApi.saveExamProgress(Number(recordId), {
|
||||
question_id: currentQuestion.id,
|
||||
answer: answer
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存答案失败:', error)
|
||||
// 静默失败,不打扰用户
|
||||
}
|
||||
}
|
||||
|
||||
// 保存答题进度(仅localStorage,用于快速保存)
|
||||
const saveProgress = () => {
|
||||
if (!recordId) return
|
||||
const values = form.getFieldsValue()
|
||||
const remaining = endTime - Date.now()
|
||||
const progress = {
|
||||
answers: values,
|
||||
remainingTime: remaining > 0 ? remaining : 0,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
localStorage.setItem(`exam_progress_${recordId}`, JSON.stringify(progress))
|
||||
}
|
||||
|
||||
// 从服务器恢复答题进度
|
||||
const loadProgressFromServer = async () => {
|
||||
if (!recordId) return
|
||||
|
||||
try {
|
||||
// 1. 先尝试从服务器加载答案
|
||||
const res = await examApi.getExamUserAnswers(Number(recordId))
|
||||
if (res.success && res.data && Object.keys(res.data).length > 0) {
|
||||
form.setFieldsValue(res.data)
|
||||
updateAnsweredCount(res.data)
|
||||
message.success('已恢复服务器保存的答题进度')
|
||||
return
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('从服务器恢复进度失败:', error)
|
||||
}
|
||||
|
||||
// 2. 如果服务器没有数据,尝试从localStorage恢复
|
||||
const saved = localStorage.getItem(`exam_progress_${recordId}`)
|
||||
if (saved) {
|
||||
try {
|
||||
const progress = JSON.parse(saved)
|
||||
if (progress.answers) {
|
||||
form.setFieldsValue(progress.answers)
|
||||
updateAnsweredCount(progress.answers)
|
||||
message.success('已恢复本地保存的答题进度')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('恢复本地进度失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新已答题目数量
|
||||
const updateAnsweredCount = (values: any) => {
|
||||
let count = 0
|
||||
Object.values(values).forEach((val: any) => {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
if (Array.isArray(val) && val.length > 0) {
|
||||
count++
|
||||
} else if (!Array.isArray(val)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
})
|
||||
setAnsweredCount(count)
|
||||
}
|
||||
|
||||
// 监听表单变化
|
||||
const handleFormChange = () => {
|
||||
const values = form.getFieldsValue()
|
||||
updateAnsweredCount(values)
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
// 提交考试
|
||||
const handleSubmit = async () => {
|
||||
const totalQuestions = examData?.questions.length || 0
|
||||
const unanswered = totalQuestions - answeredCount
|
||||
|
||||
// 先保存当前题目答案
|
||||
await saveCurrentQuestionToServer()
|
||||
|
||||
if (unanswered > 0) {
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: `还有 ${unanswered} 道题未作答,确认提交吗?`,
|
||||
okText: '确认提交',
|
||||
cancelText: '继续答题',
|
||||
onOk: () => submitExam()
|
||||
})
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
icon: <CheckCircleOutlined />,
|
||||
content: '已完成所有题目,确认提交吗?',
|
||||
okText: '确认提交',
|
||||
cancelText: '检查答案',
|
||||
onOk: () => submitExam()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提交答案并触发阅卷
|
||||
const submitExam = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 直接提交,后端会从数据库读取答案并阅卷
|
||||
const res = await examApi.submitExamAnswer(Number(recordId), {})
|
||||
if (res.success) {
|
||||
message.success('提交成功,正在阅卷...')
|
||||
// 清除进度
|
||||
localStorage.removeItem(`exam_progress_${recordId}`)
|
||||
// 跳转到试卷列表页面
|
||||
navigate('/exam/management')
|
||||
} else {
|
||||
message.error(res.message || '提交失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '提交失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间到自动提交
|
||||
const handleTimeFinish = async () => {
|
||||
message.warning('考试时间已到,系统将自动提交')
|
||||
await saveCurrentQuestionToServer() // 先保存当前答案
|
||||
submitExam()
|
||||
}
|
||||
|
||||
// 上一题
|
||||
const handlePrevQuestion = async () => {
|
||||
if (currentQuestionIndex > 0) {
|
||||
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
|
||||
setCurrentQuestionIndex(currentQuestionIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 下一题
|
||||
const handleNextQuestion = async () => {
|
||||
if (examData && currentQuestionIndex < examData.questions.length - 1) {
|
||||
await saveCurrentQuestionToServer() // 保存当前题目答案到服务器
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定题目
|
||||
const handleJumpToQuestion = (index: number) => {
|
||||
setCurrentQuestionIndex(index)
|
||||
setDrawerVisible(false)
|
||||
}
|
||||
|
||||
// 检查题目是否已答
|
||||
const isQuestionAnswered = (question: Question): boolean => {
|
||||
const fieldName = `q_${question.id}`
|
||||
const value = form.getFieldValue(fieldName)
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// 处理多选题和填空题
|
||||
return value.length > 0 && value.some(v => v !== undefined && v !== null && v !== '')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 渲染题目
|
||||
const renderQuestion = (question: Question) => {
|
||||
const fieldName = `q_${question.id}`
|
||||
|
||||
switch (question.type) {
|
||||
case 'fill-in-blank':
|
||||
// 将题目按 **** 分割,在占位符位置插入输入框
|
||||
const parts = question.content.split('****')
|
||||
const blankCount = parts.length - 1 // 填空数量 = 分割后的部分数 - 1
|
||||
|
||||
if (blankCount === 0) {
|
||||
// 如果没有 ****,显示警告并提供一个文本框
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
label={question.content}
|
||||
required={false}
|
||||
>
|
||||
<div style={{ marginBottom: 12, padding: '12px', background: '#fff7e6', border: '1px solid #ffd591', borderRadius: '4px' }}>
|
||||
<Text type="warning" style={{ fontSize: 14 }}>
|
||||
提示:题目格式错误,缺少 **** 占位符,请联系管理员修正。
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Item
|
||||
name={[fieldName, 0]}
|
||||
rules={[{ required: true, message: '请填写答案' }]}
|
||||
noStyle
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请在此处填写答案"
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderBottom: '2px solid #1890ff',
|
||||
borderRadius: 0,
|
||||
padding: '4px 0',
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
required={false}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: '#1f2937', lineHeight: 2, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<span>{part}</span>
|
||||
{i < blankCount && (
|
||||
<Form.Item
|
||||
name={[fieldName, i]}
|
||||
rules={[{ required: true, message: '请填写' }]}
|
||||
style={{ display: 'inline-block', margin: '0 8px 0 8px' }}
|
||||
>
|
||||
<Input
|
||||
placeholder={`填空 ${i + 1}`}
|
||||
style={{
|
||||
width: 180,
|
||||
border: 'none',
|
||||
borderBottom: '2px solid #1890ff',
|
||||
borderRadius: 0,
|
||||
padding: '4px 0',
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
case 'true-false':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
required={false}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Space direction="vertical">
|
||||
<Radio value="true">正确</Radio>
|
||||
<Radio value="false">错误</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
case 'multiple-choice':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
required={false}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
|
||||
case 'multiple-selection':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请选择答案' }]}
|
||||
required={false}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
|
||||
case 'short-answer':
|
||||
case 'ordinary-essay':
|
||||
case 'management-essay':
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.id}
|
||||
name={fieldName}
|
||||
label={question.content}
|
||||
rules={[{ required: true, message: '请作答' }]}
|
||||
required={false}
|
||||
>
|
||||
<TextArea rows={6} placeholder="请输入你的答案" />
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!examData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalQuestions = examData.questions.length
|
||||
const currentQuestion = examData.questions[currentQuestionIndex]
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* 顶部信息栏 */}
|
||||
<Card className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<div>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '退出将保存当前答题进度,确认退出吗?',
|
||||
onOk: () => {
|
||||
saveProgress()
|
||||
navigate('/exam/management')
|
||||
}
|
||||
})
|
||||
}}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
退出考试
|
||||
</Button>
|
||||
<Title level={3}>{examData.exam.title}</Title>
|
||||
<Text type="secondary">
|
||||
题目 {currentQuestionIndex + 1}/{totalQuestions}
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.statCard}>
|
||||
<Text type="secondary" className={styles.statLabel}>剩余时间</Text>
|
||||
<div className={styles.statValue}>
|
||||
<ClockCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Countdown
|
||||
value={endTime}
|
||||
format="mm:ss"
|
||||
onFinish={handleTimeFinish}
|
||||
style={{ fontSize: 24, fontWeight: 600, color: '#1f2937', lineHeight: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider type="vertical" style={{ height: 60 }} />
|
||||
<div className={styles.statCard}>
|
||||
<Text type="secondary" className={styles.statLabel}>答题进度</Text>
|
||||
<div className={styles.statValue}>
|
||||
<CheckCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Text strong style={{ fontSize: 24, lineHeight: 1 }}>
|
||||
{answeredCount}/{totalQuestions}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 答题区域 - 单题显示 */}
|
||||
<Card className={styles.content}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={handleFormChange}
|
||||
>
|
||||
{currentQuestion && (
|
||||
<div className={styles.questionContainer}>
|
||||
<div className={styles.questionHeader}>
|
||||
<Text strong style={{ fontSize: 16, marginRight: 8 }}>
|
||||
第{(() => {
|
||||
const typeQuestions = groupedQuestions[currentQuestion.type] || []
|
||||
const typeIndex = typeQuestions.findIndex(q => q.id === currentQuestion.id)
|
||||
return typeIndex + 1
|
||||
})()}题
|
||||
</Text>
|
||||
<Tag color="blue">{TYPE_NAME[currentQuestion.type] || currentQuestion.type}</Tag>
|
||||
</div>
|
||||
{renderQuestion(currentQuestion)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导航按钮 */}
|
||||
<div className={styles.navigation}>
|
||||
<Space size="large">
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePrevQuestion}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
>
|
||||
上一题
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
{currentQuestionIndex + 1} / {totalQuestions}
|
||||
</Text>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNextQuestion}
|
||||
disabled={currentQuestionIndex === totalQuestions - 1}
|
||||
>
|
||||
下一题
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
>
|
||||
提交答卷
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* 悬浮球 */}
|
||||
<FloatButton
|
||||
icon={<UnorderedListOutlined />}
|
||||
type="primary"
|
||||
style={{ right: 24, bottom: 24 }}
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
badge={{ count: answeredCount, overflowCount: 999 }}
|
||||
/>
|
||||
|
||||
{/* 答题情况抽屉 */}
|
||||
<Drawer
|
||||
title="答题情况"
|
||||
placement="right"
|
||||
width={500}
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
>
|
||||
<div className={styles.drawerContent}>
|
||||
{/* 按题型分组显示 */}
|
||||
{Object.entries(groupedQuestions).sort(([typeA], [typeB]) => {
|
||||
const orderA = TYPE_ORDER[typeA] || 999
|
||||
const orderB = TYPE_ORDER[typeB] || 999
|
||||
return orderA - orderB
|
||||
}).map(([type, questions]) => {
|
||||
return (
|
||||
<div key={type} className={styles.questionTypeSection}>
|
||||
<div className={styles.typeHeader}>
|
||||
<Text strong>{TYPE_NAME[type] || type}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {questions.length} 题
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.questionGrid}>
|
||||
{questions.map((q, idx) => {
|
||||
const globalIndex = examData.questions.findIndex(eq => eq.id === q.id)
|
||||
const isAnswered = isQuestionAnswered(q)
|
||||
const isCurrent = globalIndex === currentQuestionIndex
|
||||
return (
|
||||
<div
|
||||
key={q.id}
|
||||
className={`${styles.questionItem} ${isCurrent ? styles.current : ''} ${isAnswered ? styles.answered : ''}`}
|
||||
onClick={() => handleJumpToQuestion(globalIndex)}
|
||||
title={`第 ${idx + 1} 题`}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={`${styles.legendBox} ${styles.current}`}></div>
|
||||
<Text>当前题目</Text>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={`${styles.legendBox} ${styles.answered}`}></div>
|
||||
<Text>已答题目</Text>
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={`${styles.legendBox} ${styles.unanswered}`}></div>
|
||||
<Text>未答题目</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExamTaking
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Typography, message, Space, Avatar, Button, Modal, Form, Radio, Alert, Input, Switch, InputNumber, Dropdown, Row, Col, Card, Tag } from 'antd'
|
||||
import { Typography, message, Space, Avatar, Button, Modal, Form, Radio, Alert, Input, Switch, InputNumber, Dropdown, Row, Col, Card } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
import {
|
||||
FileTextOutlined,
|
||||
@ -580,7 +580,7 @@ const Home: React.FC = () => {
|
||||
<Card
|
||||
hoverable
|
||||
className={styles.quickCard}
|
||||
onClick={() => navigate('/exam/new')}
|
||||
onClick={() => navigate('/exam/management')}
|
||||
>
|
||||
<Space align="center" size="middle" style={{ width: '100%' }}>
|
||||
<div
|
||||
@ -593,10 +593,8 @@ const Home: React.FC = () => {
|
||||
<FileTextOutlined className={styles.quickIcon} style={{ color: '#fa8c16' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
模拟考试 <Tag color="orange">待测试</Tag>
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>随机组卷,在线答题或打印试卷</Paragraph>
|
||||
<Title level={5} style={{ margin: 0 }}>模拟考试</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>创建试卷,随机组卷在线答题</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@ -1,5 +1,149 @@
|
||||
import { Question } from './question'
|
||||
|
||||
// ========== 新版数据结构 ==========
|
||||
|
||||
// 题型配置
|
||||
export interface QuestionTypeConfig {
|
||||
type: string
|
||||
count: number
|
||||
score: number
|
||||
}
|
||||
|
||||
// 试卷配置
|
||||
export interface ExamConfig {
|
||||
question_types: QuestionTypeConfig[]
|
||||
categories?: string[]
|
||||
random_order: boolean
|
||||
}
|
||||
|
||||
// 试卷模型
|
||||
export interface ExamModel {
|
||||
id: number
|
||||
user_id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number // 分钟
|
||||
pass_score: number
|
||||
question_ids: number[]
|
||||
status: 'active' | 'archived'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// AI评分结果
|
||||
export interface AIGrading {
|
||||
score: number
|
||||
feedback: string
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
// 考试答案
|
||||
export interface ExamAnswer {
|
||||
question_id: number
|
||||
answer: any
|
||||
correct_answer: any
|
||||
is_correct: boolean
|
||||
score: number
|
||||
ai_grading?: AIGrading
|
||||
}
|
||||
|
||||
// 考试记录
|
||||
export interface ExamRecord {
|
||||
id: number
|
||||
exam_id: number
|
||||
user_id: number
|
||||
start_time?: string
|
||||
submit_time?: string
|
||||
time_spent: number // 秒
|
||||
score: number
|
||||
total_score: number
|
||||
answers: ExamAnswer[]
|
||||
status: 'in_progress' | 'submitted' | 'graded'
|
||||
is_passed: boolean
|
||||
exam?: ExamModel
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 创建试卷请求
|
||||
export interface CreateExamRequest {
|
||||
title: string
|
||||
duration: number
|
||||
pass_score?: number
|
||||
question_types: QuestionTypeConfig[]
|
||||
categories?: string[]
|
||||
random_order?: boolean
|
||||
}
|
||||
|
||||
// 创建试卷响应
|
||||
export interface CreateExamResponse {
|
||||
id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number
|
||||
pass_score: number
|
||||
question_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 试卷列表响应
|
||||
export type ExamListResponse = Array<{
|
||||
id: number
|
||||
title: string
|
||||
total_score: number
|
||||
duration: number
|
||||
pass_score: number
|
||||
question_count: number
|
||||
attempt_count: number
|
||||
best_score: number
|
||||
has_in_progress_exam: boolean
|
||||
in_progress_record_id?: number
|
||||
created_at: string
|
||||
}>
|
||||
|
||||
// 试卷详情响应
|
||||
export interface ExamDetailResponse {
|
||||
exam: ExamModel
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
// 开始考试响应
|
||||
export interface StartExamResponse {
|
||||
record_id: number
|
||||
start_time: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
// 提交试卷响应
|
||||
export interface SubmitExamResponse {
|
||||
score: number
|
||||
total_score: number
|
||||
is_passed: boolean
|
||||
time_spent: number
|
||||
answers: ExamAnswer[]
|
||||
detailed_results: Record<string, {
|
||||
correct: boolean
|
||||
score: number
|
||||
message?: string
|
||||
ai_grading?: {
|
||||
score: number
|
||||
feedback: string
|
||||
suggestion: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
// 考试记录响应
|
||||
export interface ExamRecordResponse {
|
||||
record: ExamRecord
|
||||
answers: ExamAnswer[]
|
||||
}
|
||||
|
||||
// 考试记录列表响应
|
||||
export type ExamRecordListResponse = ExamRecord[]
|
||||
|
||||
// ========== 旧版数据结构(兼容) ==========
|
||||
|
||||
// 考试记录
|
||||
export interface Exam {
|
||||
id: number
|
||||
@ -48,12 +192,12 @@ export interface GetExamResponse {
|
||||
|
||||
// 提交考试请求
|
||||
export interface SubmitExamRequest {
|
||||
answers: Record<string, any> // question_id -> answer
|
||||
essay_choice: 'ordinary' | 'management' // 论述题选择
|
||||
answers?: Record<string, any> // question_id -> answer (可选,后端会从数据库读取)
|
||||
essay_choice?: 'ordinary' | 'management' // 论述题选择
|
||||
}
|
||||
|
||||
// 提交考试响应
|
||||
export interface SubmitExamResponse {
|
||||
// 提交考试响应(旧版)
|
||||
export interface SubmitExamResponseOld {
|
||||
score: number
|
||||
detailed_results: Record<string, {
|
||||
correct: boolean
|
||||
@ -85,3 +229,4 @@ export const DEFAULT_SCORE_CONFIG: ExamScoreConfig = {
|
||||
multiple_selection: 2.5,
|
||||
essay: 25.0,
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user