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:
燕陇琪 2025-11-11 03:55:24 +08:00
parent f8ce822436
commit 4c06a8acd5
17 changed files with 3516 additions and 255 deletions

5
go.mod
View File

@ -10,6 +10,7 @@ require (
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-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-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // 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/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // 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
View File

@ -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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= 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/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 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= 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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 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 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@ -37,7 +37,9 @@ func InitDB() error {
&models.WrongQuestionHistory{}, // 错题历史表 &models.WrongQuestionHistory{}, // 错题历史表
&models.WrongQuestionTag{}, // 错题标签表 &models.WrongQuestionTag{}, // 错题标签表
&models.UserAnswerRecord{}, // 用户答题记录表 &models.UserAnswerRecord{}, // 用户答题记录表
&models.Exam{}, // 考试表 &models.Exam{}, // 考试表(试卷)
&models.ExamRecord{}, // 考试记录表
&models.ExamUserAnswer{}, // 用户答案表
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to migrate database: %w", err) return fmt.Errorf("failed to migrate database: %w", err)

View 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)
}

View File

@ -3,7 +3,6 @@ package handlers
import ( import (
"ankao/internal/database" "ankao/internal/database"
"ankao/internal/models" "ankao/internal/models"
"ankao/internal/services"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@ -13,50 +12,73 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
// GenerateExam 生成考试 // CreateExamRequest 创建试卷请求
func GenerateExam(c *gin.Context) { type CreateExamRequest struct {
Title string `json:"title" binding:"required"`
}
// CreateExam 创建试卷
func CreateExam(c *gin.Context) {
userID, exists := c.Get("user_id") userID, exists := c.Get("user_id")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return return
} }
db := database.GetDB() var req CreateExamRequest
if err := c.ShouldBindJSON(&req); err != nil {
// 使用默认配置 c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据: " + err.Error()})
config := models.DefaultExamConfig return
// 按题型随机抽取题目
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},
} }
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 var allQuestionIDs []uint
for _, qt := range questionTypes { totalScore := 0.0
for _, qtConfig := range questionTypes {
var questions []models.PracticeQuestion 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) log.Printf("查询题目失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询题目失败"})
return return
} }
// 检查题目数量是否足够 // 检查题目数量是否足够
if len(questions) < qt.Count { if len(questions) < qtConfig.Count {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "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 return
} }
@ -67,82 +89,167 @@ func GenerateExam(c *gin.Context) {
j := rand.Intn(i + 1) j := rand.Intn(i + 1)
questions[i], questions[j] = questions[j], questions[i] questions[i], questions[j] = questions[j], questions[i]
} }
selectedQuestions := questions[:qt.Count] selectedQuestions := questions[:qtConfig.Count]
questionsByType[qt.Type] = selectedQuestions
// 收集题目ID // 收集题目ID
for _, q := range selectedQuestions { for _, q := range selectedQuestions {
allQuestionIDs = append(allQuestionIDs, q.ID) 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) questionIDsJSON, err := json.Marshal(allQuestionIDs)
if err != nil { if err != nil {
log.Printf("序列化题目ID失败: %v", err) log.Printf("序列化题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成考试失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "生成失败"})
return return
} }
// 创建考试记录 // 创建试卷
exam := models.Exam{ exam := models.Exam{
UserID: userID.(uint), UserID: userID.(uint),
QuestionIDs: string(questionIDsJSON), Title: req.Title,
Status: "draft", TotalScore: int(totalScore), // 总分100分
Duration: 60, // 固定60分钟
PassScore: 80, // 固定80分及格
QuestionIDs: questionIDsJSON,
Status: "active",
} }
if err := db.Create(&exam).Error; err != nil { if err := db.Create(&exam).Error; err != nil {
log.Printf("创建考试记录失败: %v", err) log.Printf("创建试卷失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建试失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建失败"})
return return
} }
// 返回试信息 // 返回信息
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": gin.H{ "data": gin.H{
"exam_id": exam.ID, "id": exam.ID,
"question_ids": allQuestionIDs, "title": exam.Title,
"created_at": exam.CreatedAt, "total_score": exam.TotalScore,
"duration": exam.Duration,
"pass_score": exam.PassScore,
"question_count": len(allQuestionIDs),
"created_at": exam.CreatedAt,
}, },
}) })
} }
// GetExam 获取考试详情 // GetExamList 获取试卷列表
func GetExam(c *gin.Context) { func GetExamList(c *gin.Context) {
userID, exists := c.Get("user_id") userID, exists := c.Get("user_id")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return 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") examIDStr := c.Param("id")
examID, err := strconv.ParseUint(examIDStr, 10, 32) examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的考试ID"}) c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的ID"})
return return
} }
db := database.GetDB() db := database.GetDB()
// 查询考试记录 // 查询试卷
var exam models.Exam var exam models.Exam
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil { if err := db.Where("id = ?", examID).First(&exam).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"}) c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "不存在"})
return return
} }
// 解析题目ID列表 // 解析题目ID列表
var questionIDs []uint 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) log.Printf("解析题目ID失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析考试数据失败"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "解析数据失败"})
return return
} }
// 是否显示答案
showAnswer := c.Query("show_answer") == "true"
// 查询题目详情 // 查询题目详情
var questions []models.PracticeQuestion var questions []models.PracticeQuestion
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil { if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
@ -151,7 +258,7 @@ func GetExam(c *gin.Context) {
return return
} }
// 按原始顺序排序题目并转换为DTO // 按原始顺序排序题目并转换为DTO(不显示答案)
questionMap := make(map[uint]models.PracticeQuestion) questionMap := make(map[uint]models.PracticeQuestion)
for _, q := range questions { for _, q := range questions {
questionMap[q.ID] = q questionMap[q.ID] = q
@ -160,10 +267,7 @@ func GetExam(c *gin.Context) {
for _, id := range questionIDs { for _, id := range questionIDs {
if q, ok := questionMap[id]; ok { if q, ok := questionMap[id]; ok {
dto := convertToDTO(q) dto := convertToDTO(q)
// 是否显示答案 dto.Answer = nil // 不显示答案
if !showAnswer {
dto.Answer = nil
}
orderedDTOs = append(orderedDTOs, dto) orderedDTOs = append(orderedDTOs, dto)
} }
} }
@ -177,8 +281,8 @@ func GetExam(c *gin.Context) {
}) })
} }
// SubmitExam 提交考试 // StartExam 开始考试(创建考试记录)
func SubmitExam(c *gin.Context) { func StartExam(c *gin.Context) {
userID, exists := c.Get("user_id") userID, exists := c.Get("user_id")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"}) c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
@ -188,14 +292,63 @@ func SubmitExam(c *gin.Context) {
examIDStr := c.Param("id") examIDStr := c.Param("id")
examID, err := strconv.ParseUint(examIDStr, 10, 32) examID, err := strconv.ParseUint(examIDStr, 10, 32)
if err != nil { 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 return
} }
// 解析请求体 // 解析请求体
var req struct { var req struct {
Answers map[string]interface{} `json:"answers"` // question_id -> answer Answers map[string]interface{} `json:"answers"` // question_id -> answer (可选)
EssayChoice string `json:"essay_choice"` // 论述题选择: ordinary 或 management
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"}) c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "无效的请求数据"})
@ -205,27 +358,106 @@ func SubmitExam(c *gin.Context) {
db := database.GetDB() db := database.GetDB()
// 查询考试记录 // 查询考试记录
var exam models.Exam var record models.ExamRecord
if err := db.Where("id = ? AND user_id = ?", examID, userID).First(&exam).Error; err != nil { if err := db.Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试不存在"}) c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "考试记录不存在"})
return return
} }
// 检查是否已提交 // 检查是否已提交
if exam.Status == "submitted" { if record.Status == "submitted" || record.Status == "graded" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已提交"}) c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "考试已提交"})
return return
} }
// 解析题目ID列表 // 查询试卷
var questionIDs []uint var exam models.Exam
if err := json.Unmarshal([]byte(exam.QuestionIDs), &questionIDs); err != nil { if err := db.Where("id = ?", record.ExamID).First(&exam).Error; err != nil {
log.Printf("解析题目ID失败: %v", err) c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "试卷不存在"})
c.JSON(http.StatusInternalServerError, 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 return
} }
// 查询题目详情
var questions []models.PracticeQuestion var questions []models.PracticeQuestion
if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil { if err := db.Where("id IN ?", questionIDs).Find(&questions).Error; err != nil {
log.Printf("查询题目失败: %v", err) log.Printf("查询题目失败: %v", err)
@ -233,188 +465,305 @@ func SubmitExam(c *gin.Context) {
return return
} }
// 评分 // 构建题目映射
totalScore := 0.0 questionMap := make(map[uint]models.PracticeQuestion)
scoreConfig := models.DefaultScoreConfig for _, q := range questions {
aiService := services.NewAIGradingService() questionMap[q.ID] = q
detailedResults := make(map[string]interface{}) }
for _, question := range questions { // 构建 ExamAnswer 列表
questionIDStr := fmt.Sprintf("%d", question.ID) examAnswers := make([]models.ExamAnswer, 0, len(userAnswers))
userAnswerRaw, answered := req.Answers[questionIDStr] for _, ua := range userAnswers {
if !answered { // 解析用户答案
detailedResults[questionIDStr] = gin.H{ var userAnswer interface{}
"correct": false, if err := json.Unmarshal(ua.Answer, &userAnswer); err != nil {
"score": 0, log.Printf("解析用户答案失败: %v", err)
"message": "未作答",
}
continue continue
} }
// 论述题特殊处理:根据用户选择判断是否计分 // 获取题目并解析正确答案
if question.Type == "ordinary-essay" && req.EssayChoice != "ordinary" { question, ok := questionMap[ua.QuestionID]
detailedResults[questionIDStr] = gin.H{ if !ok {
"correct": false,
"score": 0,
"message": "未选择此题",
}
continue
}
if question.Type == "management-essay" && req.EssayChoice != "management" {
detailedResults[questionIDStr] = gin.H{
"correct": false,
"score": 0,
"message": "未选择此题",
}
continue continue
} }
// 根据题型判断答案 var correctAnswerRaw interface{}
var isCorrect bool
var score float64
var aiGrading *models.AIGrading
switch question.Type { switch question.Type {
case "fill-in-blank": case "fill-in-blank", "multiple-selection", "multiple-choice":
// 填空题:比较数组 // 数组类型:需要 JSON 解析单选题也是数组格式A
userAnswerArr, ok := userAnswerRaw.([]interface{}) var arr []string
if !ok { if err := json.Unmarshal([]byte(question.AnswerData), &arr); err != nil {
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"} // 尝试解析为单个字符串(兼容旧数据格式)
continue var singleStr string
} if err2 := json.Unmarshal([]byte(question.AnswerData), &singleStr); err2 == nil {
var correctAnswers []string // 成功解析为字符串,单选题直接使用,其他类型转为数组
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil { if question.Type == "multiple-choice" {
log.Printf("解析填空题答案失败: %v", err) correctAnswerRaw = singleStr
continue } else {
} correctAnswerRaw = []string{singleStr}
isCorrect = true
for i, ua := range userAnswerArr {
if i >= len(correctAnswers) || fmt.Sprintf("%v", ua) != correctAnswers[i] {
isCorrect = false
break
}
}
if isCorrect {
score = scoreConfig.FillInBlank
}
case "true-false":
// 判断题
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析判断题答案失败: %v", err)
continue
}
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreConfig.TrueFalse
}
case "multiple-choice":
// 单选题
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析单选题答案失败: %v", err)
continue
}
isCorrect = fmt.Sprintf("%v", userAnswerRaw) == correctAnswer
if isCorrect {
score = scoreConfig.MultipleChoice
}
case "multiple-selection":
// 多选题:比较数组(顺序无关)
userAnswerArr, ok := userAnswerRaw.([]interface{})
if !ok {
detailedResults[questionIDStr] = gin.H{"correct": false, "score": 0, "message": "答案格式错误"}
continue
}
var correctAnswers []string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswers); err != nil {
log.Printf("解析多选题答案失败: %v", err)
continue
}
userAnswerSet := make(map[string]bool)
for _, ua := range userAnswerArr {
userAnswerSet[fmt.Sprintf("%v", ua)] = true
}
isCorrect = len(userAnswerSet) == len(correctAnswers)
if isCorrect {
for _, ca := range correctAnswers {
if !userAnswerSet[ca] {
isCorrect = false
break
} }
}
}
if isCorrect {
score = scoreConfig.MultipleSelection
}
case "short-answer", "ordinary-essay", "management-essay":
// 简答题和论述题使用AI评分
var correctAnswer string
if err := json.Unmarshal([]byte(question.AnswerData), &correctAnswer); err != nil {
log.Printf("解析简答题答案失败: %v", err)
continue
}
userAnswerStr := fmt.Sprintf("%v", userAnswerRaw)
aiResult, err := aiService.GradeShortAnswer(question.Question, correctAnswer, userAnswerStr)
if err != nil {
log.Printf("AI评分失败: %v", err)
// AI评分失败时给一个保守的分数
isCorrect = false
score = 0
} else {
isCorrect = aiResult.IsCorrect
if question.Type == "short-answer" {
// 简答题不计分,仅供参考
score = 0
} else { } else {
// 论述题按AI评分比例计算 correctAnswerRaw = "解析失败"
score = scoreConfig.Essay * (aiResult.Score / 100.0)
} }
aiGrading = &models.AIGrading{ } else {
Score: aiResult.Score, // 单选题只取第一个元素
Feedback: aiResult.Feedback, if question.Type == "multiple-choice" && len(arr) > 0 {
Suggestion: aiResult.Suggestion, correctAnswerRaw = arr[0]
} else {
correctAnswerRaw = arr
} }
} }
default:
// 字符串类型:直接使用 AnswerDatatrue-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 examAnswers = append(examAnswers, examAnswer)
detailedResults[questionIDStr] = gin.H{
"correct": isCorrect,
"score": score,
"ai_grading": aiGrading,
}
}
// 保存答案和分数
answersJSON, err := json.Marshal(req.Answers)
if err != nil {
log.Printf("序列化答案失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存答案失败"})
return
}
now := time.Now()
exam.Answers = string(answersJSON)
exam.Score = totalScore
exam.Status = "submitted"
exam.SubmittedAt = &now
if err := db.Save(&exam).Error; err != nil {
log.Printf("保存考试记录失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "保存考试失败"})
return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"data": gin.H{ "data": gin.H{
"score": totalScore, "record": record,
"detailed_results": detailedResults, "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,
})
}

View File

@ -3,22 +3,90 @@ package models
import ( import (
"time" "time"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Exam 考试记录 // Exam 试卷模型
type Exam struct { type Exam struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UserID uint `json:"user_id"` // 用户ID UserID uint `gorm:"not null;index" json:"user_id"` // 创建者ID
QuestionIDs string `gorm:"type:text" json:"question_ids"` // 题目ID列表(JSON数组) Title string `gorm:"type:varchar(200);default:''" json:"title"` // 试卷标题
Answers string `gorm:"type:text" json:"answers"` // 用户答案(JSON对象) TotalScore int `gorm:"not null;default:100" json:"total_score"` // 总分
Score float64 `json:"score"` // 总分 Duration int `gorm:"not null;default:60" json:"duration"` // 考试时长(分钟)
Status string `gorm:"default:'draft'" json:"status"` // 状态: draft/submitted PassScore int `gorm:"not null;default:60" json:"pass_score"` // 及格分数
SubmittedAt *time.Time `json:"submitted_at"` // 提交时间 QuestionIDs datatypes.JSON `gorm:"type:json" json:"question_ids"` // 题目ID列表 (JSON数组)
User User `gorm:"foreignKey:UserID" json:"user,omitempty"` 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 考试题目配置 // ExamQuestionConfig 考试题目配置
@ -32,6 +100,7 @@ type ExamQuestionConfig struct {
ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量 ManagementEssay int `json:"management_essay"` // 保密管理人员论述题数量
} }
// DefaultExamConfig 默认考试配置 // DefaultExamConfig 默认考试配置
var DefaultExamConfig = ExamQuestionConfig{ var DefaultExamConfig = ExamQuestionConfig{
FillInBlank: 10, // 填空题10道 FillInBlank: 10, // 填空题10道

15
main.go
View File

@ -71,10 +71,17 @@ func main() {
auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签 auth.PUT("/wrong-question-tags/:id", handlers.UpdateWrongQuestionTag) // 更新标签
auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签 auth.DELETE("/wrong-question-tags/:id", handlers.DeleteWrongQuestionTag) // 删除标签
// 考试相关API // 模拟考试相关API
auth.POST("/exam/generate", handlers.GenerateExam) // 生成考试 auth.POST("/exams", handlers.CreateExam) // 创建试卷
auth.GET("/exam/:id", handlers.GetExam) // 获取考试详情 auth.GET("/exams", handlers.GetExamList) // 获取试卷列表
auth.POST("/exam/:id/submit", handlers.SubmitExam) // 提交考试 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需要管理员权限 // 题库管理API需要管理员权限

View File

@ -18,6 +18,9 @@ import ExamPrepare from './pages/ExamPrepare'
import ExamOnline from './pages/ExamOnline' import ExamOnline from './pages/ExamOnline'
import ExamPrint from './pages/ExamPrint' import ExamPrint from './pages/ExamPrint'
import ExamResult from './pages/ExamResult' import ExamResult from './pages/ExamResult'
import ExamManagement from './pages/ExamManagement'
import ExamTaking from './pages/ExamTaking'
import ExamResultNew from './pages/ExamResultNew'
const App: React.FC = () => { const App: React.FC = () => {
return ( return (
@ -36,9 +39,12 @@ const App: React.FC = () => {
{/* 考试相关页面,需要登录保护 */} {/* 考试相关页面,需要登录保护 */}
<Route path="/exam/new" element={<ProtectedRoute><ExamPrepare /></ProtectedRoute>} /> <Route path="/exam/new" element={<ProtectedRoute><ExamPrepare /></ProtectedRoute>} />
<Route path="/exam/management" element={<ProtectedRoute><ExamManagement /></ProtectedRoute>} />
<Route path="/exam/:examId/online" element={<ProtectedRoute><ExamOnline /></ProtectedRoute>} /> <Route path="/exam/:examId/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/print" element={<ProtectedRoute><ExamPrint /></ProtectedRoute>} />
<Route path="/exam/:examId/result" element={<ProtectedRoute><ExamResult /></ProtectedRoute>} /> <Route path="/exam/:examId/result" element={<ProtectedRoute><ExamResult /></ProtectedRoute>} />
<Route path="/exam/result/:recordId" element={<ProtectedRoute><ExamResultNew /></ProtectedRoute>} />
{/* 题库管理页面,需要管理员权限 */} {/* 题库管理页面,需要管理员权限 */}
<Route path="/question-management" element={ <Route path="/question-management" element={

View File

@ -3,10 +3,71 @@ import type {
GenerateExamResponse, GenerateExamResponse,
GetExamResponse, GetExamResponse,
SubmitExamRequest, SubmitExamRequest,
SubmitExamResponse SubmitExamResponse,
CreateExamRequest,
CreateExamResponse,
ExamListResponse,
ExamDetailResponse,
StartExamResponse,
ExamRecordResponse,
ExamRecordListResponse
} from '../types/exam' } from '../types/exam'
import type { ApiResponse } from '../types/question' 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 = () => { export const generateExam = () => {
return request.post<ApiResponse<GenerateExamResponse>>('/exam/generate') return request.post<ApiResponse<GenerateExamResponse>>('/exam/generate')

View 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;
}
}
}

View 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

View 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%;
}
}
}
}

View 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
}}>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</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

View 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;
}
}
}
}

View 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

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' 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 type { MenuProps } from 'antd'
import { import {
FileTextOutlined, FileTextOutlined,
@ -580,7 +580,7 @@ const Home: React.FC = () => {
<Card <Card
hoverable hoverable
className={styles.quickCard} className={styles.quickCard}
onClick={() => navigate('/exam/new')} onClick={() => navigate('/exam/management')}
> >
<Space align="center" size="middle" style={{ width: '100%' }}> <Space align="center" size="middle" style={{ width: '100%' }}>
<div <div
@ -593,10 +593,8 @@ const Home: React.FC = () => {
<FileTextOutlined className={styles.quickIcon} style={{ color: '#fa8c16' }} /> <FileTextOutlined className={styles.quickIcon} style={{ color: '#fa8c16' }} />
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}></Title>
<Tag color="orange"></Tag> <Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>线</Paragraph>
</Title>
<Paragraph type="secondary" style={{ margin: 0, fontSize: '13px' }}>线</Paragraph>
</div> </div>
</Space> </Space>
</Card> </Card>

View File

@ -1,5 +1,149 @@
import { Question } from './question' 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 { export interface Exam {
id: number id: number
@ -48,12 +192,12 @@ export interface GetExamResponse {
// 提交考试请求 // 提交考试请求
export interface SubmitExamRequest { export interface SubmitExamRequest {
answers: Record<string, any> // question_id -> answer answers?: Record<string, any> // question_id -> answer (可选,后端会从数据库读取)
essay_choice: 'ordinary' | 'management' // 论述题选择 essay_choice?: 'ordinary' | 'management' // 论述题选择
} }
// 提交考试响应 // 提交考试响应(旧版)
export interface SubmitExamResponse { export interface SubmitExamResponseOld {
score: number score: number
detailed_results: Record<string, { detailed_results: Record<string, {
correct: boolean correct: boolean
@ -85,3 +229,4 @@ export const DEFAULT_SCORE_CONFIG: ExamScoreConfig = {
multiple_selection: 2.5, multiple_selection: 2.5,
essay: 25.0, essay: 25.0,
} }